Files
Brian 4405b817a9 refactor(skills): remove bmad-skill-manifest yaml; introduce central config.toml (#2285)
* refactor: remove bmad-skill-manifest yaml; introduce four-layer central config.toml

- Agent essence moves from per-skill bmad-skill-manifest.yaml files
  into each module.yaml's `agents:` block (code, name, title, icon,
  description). Per-agent customize.toml remains the deep-behavior
  source of truth.
- Installer emits four TOML files:
    _bmad/config.toml              team install answers + agent roster
    _bmad/config.user.toml         user install answers
    _bmad/custom/config.toml       team overrides stub
    _bmad/custom/config.user.toml  personal overrides stub
  Prompts declare scope: user to route answers to config.user.toml.
- resolve_config.py merges four layers: base-team -> base-user ->
  custom-team -> custom-user.
- Three consumer skills (party-mode, advanced-elicitation,
  retrospective) switched from agent-manifest.csv to the resolver.
- installer.js mergeModuleHelpCatalogs now takes the in-memory
  agent list from ManifestGenerator -- no CSV roundtrip.
- Deleted: 6 bmad-skill-manifest.yaml files, agent-manifest.csv
  emission, collectAgents/getAgentsFromDirRecursive,
  paths.agentManifest().

* fix(installer): strip core-key pollution from [modules.*]; soften config headers

- writeCentralConfig now always strips core-module keys from every
  [modules.<code>] bucket, even when the module's schema is not
  available in src/ (external / marketplace modules like cis, bmb).
  Core values belong in [core] only; workflows read them directly.
- When the module's own schema IS available (built-in modules),
  also drop any key it does not declare as a prompt — same
  spread-pollution filter as before, now layered on top.
- Section-aware headers on both _bmad/config.toml and
  _bmad/config.user.toml: [core] / [modules.*] values are
  editable (installer reads them as defaults on next install);
  [agents.*] is regenerated from module.yaml and will be wiped —
  overrides for agents go in _bmad/custom/config*.toml instead.

* docs: cover central config.toml + Diataxis prose pass across three files

Document the new four-file central configuration surface (_bmad/config.toml,
config.user.toml, and custom/ overrides) alongside the existing per-skill
customize.toml. Make editing rules, scope partitioning, and when-to-use-which
guidance explicit.

- customize-bmad.md: new "Central Configuration" section with editing rules,
  three worked examples (rebrand, fictional agent, module settings override),
  and a "when to use which surface" table. Converted five h4 headers to
  bold paragraph intros per style guide.
- expand-bmad-for-your-org.md: two-layer mental model extended to three;
  new Recipe 5 with three variants (rebrand, custom crew, pinned team
  settings); reinforcement table extended.
- named-agents.md: noted the dual customization surface — per-skill shapes
  behavior, central config shapes roster identity.

Diataxis prose pass applied across all three files: banned vocabulary
check, em-dash cap, hypophora / metanoia / amplificatio / stakes-inflation
cleanup, rhythm and burstiness fixes. Structural conformance verified;
markdownlint and prettier clean.

* test+docs: add central config unit tests; fix stale recipe count

- test: two new suites (35 + 36) covering writeCentralConfig and
  ensureCustomConfigStubs. Verifies scope partitioning (user_name
  lands only in config.user.toml), core-key pollution stripping
  from [modules.*], unknown-schema fallthrough (external modules
  survive without schema), agent roster baked into config.toml
  [agents.*] only, stub-preservation on re-install. 44 new
  assertions.
- docs: fixed four stale "four recipes" references to say "five"
  after Recipe 5 (Customize the Agent Roster) was added. Touches
  frontmatter, opening paragraph, Combining Recipes paragraph,
  and the named-agents cross-link blurb.

* fix: address PR review feedback on central config

- resolve_config.py argparse: three-layer → four-layer description
- SKILL/workflow/explanation docs: document all four layers including
  _bmad/config.user.toml (was missing from merge-stack descriptions)
- customize-bmad.md + installer headers: drop the false "direct edits to
  config.toml persist" claim; installer reads from per-module config.yaml,
  not central TOML, so direct edits get clobbered. Route users to
  _bmad/custom/config.toml for durable overrides
- writeCentralConfig: warn loudly when a module.yaml can't be parsed
  (previously silent — user-scoped keys could mis-file into team config)
- writeCentralConfig: preserve [agents.*] blocks for modules that didn't
  contribute fresh agents this run (e.g. quickUpdate skipping modules
  whose source is unavailable) so the roster doesn't silently shrink
- add extractAgentBlocks helper + Test Suite 37 covering preservation

Addresses comments from augmentcode and coderabbitai on PR #2285.
2026-04-19 23:11:44 -05:00

133 lines
3.6 KiB
JavaScript

const path = require('node:path');
const fs = require('../fs-native');
const { getProjectRoot } = require('../project-root');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class InstallPaths {
static async create(config) {
const srcDir = getProjectRoot();
await assertReadableDir(srcDir, 'BMAD source root');
const pkgPath = path.join(srcDir, 'package.json');
await assertReadableFile(pkgPath, 'package.json');
const version = require(pkgPath).version;
const projectRoot = path.resolve(config.directory);
await ensureWritableDir(projectRoot, 'project root');
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
const isUpdate = await fs.pathExists(bmadDir);
const configDir = path.join(bmadDir, '_config');
const coreDir = path.join(bmadDir, 'core');
const scriptsDir = path.join(bmadDir, 'scripts');
const customDir = path.join(bmadDir, 'custom');
for (const [dir, label] of [
[bmadDir, 'bmad directory'],
[configDir, 'config directory'],
[coreDir, 'core module directory'],
[scriptsDir, 'shared scripts directory'],
[customDir, 'customizations directory'],
]) {
await ensureWritableDir(dir, label);
}
return new InstallPaths({
srcDir,
version,
projectRoot,
bmadDir,
configDir,
coreDir,
scriptsDir,
customDir,
isUpdate,
});
}
constructor(props) {
Object.assign(this, props);
Object.freeze(this);
}
manifestFile() {
return path.join(this.configDir, 'manifest.yaml');
}
centralConfig() {
return path.join(this.bmadDir, 'config.toml');
}
centralUserConfig() {
return path.join(this.bmadDir, 'config.user.toml');
}
filesManifest() {
return path.join(this.configDir, 'files-manifest.csv');
}
helpCatalog() {
return path.join(this.configDir, 'bmad-help.csv');
}
moduleDir(name) {
return path.join(this.bmadDir, name);
}
moduleConfig(name) {
return path.join(this.bmadDir, name, 'config.yaml');
}
}
async function assertReadableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${dirPath}`);
}
if (!stat.isDirectory()) {
throw new Error(`${label} is not a directory: ${dirPath}`);
}
try {
await fs.access(dirPath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${dirPath}`);
}
}
async function assertReadableFile(filePath, label) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${filePath}`);
}
if (!stat.isFile()) {
throw new Error(`${label} is not a file: ${filePath}`);
}
try {
await fs.access(filePath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${filePath}`);
}
}
async function ensureWritableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (stat && !stat.isDirectory()) {
throw new Error(`${label} exists but is not a directory: ${dirPath}`);
}
try {
await fs.ensureDir(dirPath);
} catch (error) {
if (error.code === 'EACCES') {
throw new Error(`${label}: permission denied creating directory: ${dirPath}`);
}
if (error.code === 'ENOSPC') {
throw new Error(`${label}: no space left on device: ${dirPath}`);
}
throw new Error(`${label}: cannot create directory: ${dirPath} (${error.message})`);
}
try {
await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK);
} catch {
throw new Error(`${label} is not writable: ${dirPath}`);
}
}
module.exports = { InstallPaths };