diff --git a/src/core-skills/bmad-module/SKILL.md b/src/core-skills/bmad-module/SKILL.md index 97bfee5cc..6c646c953 100644 --- a/src/core-skills/bmad-module/SKILL.md +++ b/src/core-skills/bmad-module/SKILL.md @@ -7,6 +7,8 @@ description: Install, update, remove, or list community BMAD modules. Use when t Manage community BMAD modules — installable packages of skills, agents, and supporting assets that ship as standalone GitHub repos. Modules are staged under `_bmad//` and tracked in the existing manifests. On `install`, `update`, and `remove`, the script then distributes (or prunes) the module's skills to **every coding assistant the user selected when they ran `bmad install`** — read from the `ides:` list in `_bmad/_config/manifest.yaml` — so a community module lands in Claude Code, Cursor, Copilot, etc. exactly like an official module. As with official modules, the canonical end state is skills living in the IDE directories (e.g. `.claude/skills//`), not in `_bmad/`. The same artifact is also loadable as a Claude Code plugin via its `.claude-plugin/plugin.json` manifest. +To match the full `bmad install` for custom modules, the script also completes the install in place: it installs npm dependencies when the module ships a `package.json` (opt out with `bmad.install.skipNpm: true`), generates the module's `[modules.]` / `[agents.]` blocks in `_bmad/config.toml` and `config.user.toml` from its `module.yaml` (defaults, overridable with `--set`), creates the working directories the module declares under `directories:`, and rebuilds the merged `_bmad/_config/bmad-help.csv` so the module's skills show up in `bmad-help`. These steps are best-effort — a failure in any of them is reported as a warning, not a failed install. Interactive config refinement remains the job of the module's `postInstallSkill`, if it declares one. + ## CRITICAL RULES - NEVER write directly to files under `_bmad/` or into IDE directories (`.claude/skills/`, `.agents/skills/`, etc.). All filesystem changes go through the Node script at `scripts/bmad-module.mjs` — it handles staging, atomic swaps, manifest updates, IDE distribution, and rollback on failure. @@ -31,8 +33,8 @@ If the verb is ambiguous (e.g. the user says "manage modules"), ASK which verb t ### Step 2 — Parse the args -- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref `, `--channel `, `--dry-run`. -- **update:** the user supplies `` (the `_bmad//` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`. +- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref `, `--channel `, `--set .=` (override a module config answer; repeatable), `--dry-run`. +- **update:** the user supplies `` (the `_bmad//` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--set .=`. - **remove:** the user supplies ``. Use `--purge` only if they explicitly say "also remove customizations" or "purge". - **list:** no args. Use `--json` if the user asks for machine-readable. diff --git a/src/core-skills/bmad-module/scripts/cli.mjs b/src/core-skills/bmad-module/scripts/cli.mjs index 24e437611..1293a3b7d 100644 --- a/src/core-skills/bmad-module/scripts/cli.mjs +++ b/src/core-skills/bmad-module/scripts/cli.mjs @@ -37,7 +37,13 @@ function parseArgs(argv) { if (val === undefined || val.startsWith('--')) { throw new BmadModuleError(EXIT.USAGE, `flag --${key} requires a value`); } - out.flags[key] = val; + // --set is repeatable; collect into an array. All other flags take the + // last value seen. + if (key === 'set') { + (out.flags.set ||= []).push(val); + } else { + out.flags[key] = val; + } i += 2; continue; } @@ -71,6 +77,7 @@ export async function main() { } const projectDir = parsed.flags['project-dir'] || process.cwd(); + const setOverrides = parseSetOverrides(parsed.flags.set); try { switch (verb) { @@ -80,6 +87,7 @@ export async function main() { ref: parsed.flags['ref'] || null, channel: parsed.flags['channel'] || null, dryRun: !!parsed.flags['dry-run'], + setOverrides, projectDir, }); break; @@ -89,6 +97,7 @@ export async function main() { all: !!parsed.flags['all'], ref: parsed.flags['ref'] || null, channel: parsed.flags['channel'] || null, + setOverrides, projectDir, }); break; @@ -116,17 +125,38 @@ export async function main() { } } +// Parse repeatable `--set .=` flags into a nested map +// { [code]: { [key]: value } }. Mirrors the full installer's --set spec. +function parseSetOverrides(rawList) { + const out = {}; + if (!Array.isArray(rawList)) return out; + for (const spec of rawList) { + const eq = spec.indexOf('='); + if (eq === -1) throw new BmadModuleError(EXIT.USAGE, `--set expects .=, got "${spec}"`); + const lhs = spec.slice(0, eq); + const value = spec.slice(eq + 1); + const dot = lhs.indexOf('.'); + if (dot === -1) throw new BmadModuleError(EXIT.USAGE, `--set expects .=, got "${spec}"`); + const code = lhs.slice(0, dot); + const key = lhs.slice(dot + 1); + if (!code || !key) throw new BmadModuleError(EXIT.USAGE, `--set expects .=, got "${spec}"`); + (out[code] ||= {})[key] = value; + } + return out; +} + function printUsage() { process.stderr.write(`bmad-module — install, update, remove, or list BMAD community modules. USAGE - bmad-module install [--ref ] [--channel ] [--dry-run] - bmad-module update [--ref ] [--channel ] + bmad-module install [--ref ] [--channel ] [--set .=] [--dry-run] + bmad-module update [--ref ] [--channel ] [--set .=] bmad-module remove [--purge] bmad-module list [--json] GLOBAL FLAGS --project-dir Project root containing _bmad/ (default: cwd) + --set .= Override a module config answer (repeatable) EXAMPLES bmad-module install acme/acme-devlog diff --git a/src/core-skills/bmad-module/scripts/install.mjs b/src/core-skills/bmad-module/scripts/install.mjs index 4d7ff7b32..ff0e28211 100644 --- a/src/core-skills/bmad-module/scripts/install.mjs +++ b/src/core-skills/bmad-module/scripts/install.mjs @@ -7,6 +7,10 @@ import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPath import { stageCopyPlan, atomicSwapDir } from './lib/fs-safe.mjs'; import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs'; import { distributeToIdes } from './lib/ide-sync.mjs'; +import { installModuleDeps } from './lib/npm-deps.mjs'; +import { regenerateCentralConfig, readModuleConfigValues, resolveSectionKey } from './lib/config-gen.mjs'; +import { createModuleDirectories } from './lib/module-dirs.mjs'; +import { regenerateHelpCatalog } from './lib/help-catalog.mjs'; // Run the install verb. `opts` shape: // { source, ref, sha, channel, dryRun, projectDir } @@ -108,6 +112,12 @@ export async function runInstall(opts) { ); process.stdout.write(`[bmad-module] copied ${destPaths.length} file(s) to ${path.relative(projectDir, targetDir)}\n`); + // §7.5. Complete the install the way the full installer does for custom + // modules: install JS deps, generate central config + agent roster, create + // declared working directories, and rebuild the merged help catalog. All are + // non-fatal — the module is already committed to _bmad//. + await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides }); + // §8. Distribute the module's skills to the coding assistants the user chose // at `bmad install` time (read from _bmad/_config/manifest.yaml). This is the // same distribution the full installer performs; without it the skills would @@ -142,3 +152,50 @@ export async function runInstall(opts) { await materialized.cleanup(); } } + +// Shared post-copy completion for install and update: install JS deps, generate +// the central config + agent roster, create declared working directories, and +// rebuild the merged help catalog. Mirrors what the full installer does for a +// custom module so a skill-driven install lands the same on-disk state. Every +// step is non-fatal — the module files are already committed under _bmad//. +export async function finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides }) { + // 1. npm deps (in place — see npm-deps.mjs for the design note). + const dep = await installModuleDeps(targetDir, manifest); + if (dep.ran && dep.ok) process.stdout.write(`[bmad-module] installed npm dependencies for ${code}\n`); + else if (dep.ran && !dep.ok) process.stderr.write(`[bmad-module] warning: npm install failed for ${code}: ${dep.error}\n`); + + // 2. Capture prior config (for directory move-detection on update) before regen. + const sectionKey = await resolveSectionKey(bmadDir, code); + let existingConfig = {}; + try { + existingConfig = await readModuleConfigValues(bmadDir, sectionKey); + } catch { + /* no prior config — fine */ + } + + // 3. Central config + agent roster. + let resolved = { values: {} }; + try { + resolved = await regenerateCentralConfig(bmadDir, code, { setOverrides: setOverrides || {} }); + } catch (e) { + process.stderr.write(`[bmad-module] warning: config generation failed for ${code}: ${e.message}\n`); + } + + // 4. Declared working directories. + try { + const dirs = await createModuleDirectories(bmadDir, code, resolved.values, existingConfig); + const made = dirs.createdDirs.length; + const moved = dirs.movedDirs.length; + if (made) process.stdout.write(`[bmad-module] created ${made} working director${made === 1 ? 'y' : 'ies'} for ${code}\n`); + if (moved) process.stdout.write(`[bmad-module] moved ${moved} working director${moved === 1 ? 'y' : 'ies'} for ${code}\n`); + } catch (e) { + process.stderr.write(`[bmad-module] warning: directory creation failed for ${code}: ${e.message}\n`); + } + + // 5. Merged help catalog. + try { + await regenerateHelpCatalog(bmadDir); + } catch (e) { + process.stderr.write(`[bmad-module] warning: help catalog rebuild failed: ${e.message}\n`); + } +} diff --git a/src/core-skills/bmad-module/scripts/lib/config-gen.mjs b/src/core-skills/bmad-module/scripts/lib/config-gen.mjs new file mode 100644 index 000000000..a735976d8 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/config-gen.mjs @@ -0,0 +1,346 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseYaml } from './vendor/yaml.mjs'; + +// Generate/patch the central TOML config for a community module, mirroring the +// full installer's ManifestGenerator.writeCentralConfig + collectAgentsFromModuleYaml +// (tools/installer/core/manifest-generator.js). +// +// Adaptation for the self-contained skill: the installer regenerates the WHOLE +// config from the source tree (it has core/official module.yaml on disk). The +// skill has only `_bmad//module.yaml` for the module it just installed, and +// must NOT clobber [core] or sibling modules' interactively-collected answers it +// cannot reconstruct. So we do a TARGETED merge: upsert just this module's +// `[modules.]` (team→config.toml, user→config.user.toml) and its +// `[agents.]` blocks, leaving every other block byte-for-byte intact. +// +// Values are non-interactive: each prompt key resolves from its module.yaml +// `default` (overridable via `--set .=`), with the same +// `result:` template substitution the installer uses. A module's setup skill +// (postInstallSkill) can re-run this with collected answers for interactive refinement. + +const TEAM_FILE = 'config.toml'; +const USER_FILE = 'config.user.toml'; + +// ── TOML emit (port of formatTomlValue) ────────────────────────────────────── +export function formatTomlValue(value) { + if (value === null || value === undefined) return '""'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (Array.isArray(value)) return `[${value.map((v) => formatTomlValue(v)).join(', ')}]`; + const str = String(value); + const escaped = str + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t'); + return `"${escaped}"`; +} + +// Minimal reverse of formatTomlValue for the scalars we read back (core values). +function parseTomlScalar(raw) { + const s = raw.trim(); + if (s === 'true') return true; + if (s === 'false') return false; + if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s); + if (s.startsWith('"') && s.endsWith('"')) { + return s + .slice(1, -1) + .replaceAll('\\n', '\n') + .replaceAll('\\r', '\r') + .replaceAll('\\t', '\t') + .replaceAll('\\"', '"') + .replaceAll('\\\\', '\\'); + } + return s; +} + +// ── TOML block model ────────────────────────────────────────────────────────── +// Split a config file into a leading preamble (the comment header before the +// first table) and an ordered list of `[header]` blocks. The file is our own +// controlled output, so a line scanner is safer than a full TOML parser. +function splitBlocks(content) { + const lines = content.split('\n'); + const preamble = []; + let i = 0; + while (i < lines.length && !/^\[[^\]]+]\s*$/.test(lines[i])) { + preamble.push(lines[i]); + i++; + } + const blocks = []; + let current = null; + for (; i < lines.length; i++) { + const m = lines[i].match(/^\[([^\]]+)]\s*$/); + if (m) { + if (current) blocks.push(current); + current = { header: m[1], lines: [lines[i]] }; + } else if (current) { + current.lines.push(lines[i]); + } + } + if (current) blocks.push(current); + return { preamble, blocks }; +} + +function blockToText(block) { + const lines = [...block.lines]; + while (lines.length > 1 && lines.at(-1).trim() === '') lines.pop(); + return lines.join('\n'); +} + +function joinFile(preamble, blocks) { + const parts = []; + const pre = [...preamble]; + while (pre.length && pre.at(-1).trim() === '') pre.pop(); + if (pre.length) parts.push(pre.join('\n')); + for (const b of blocks) parts.push(blockToText(b)); + return parts.join('\n\n').replace(/\n+$/, '') + '\n'; +} + +async function readFileOrNull(p) { + try { + return await fs.readFile(p, 'utf8'); + } catch { + return null; + } +} + +// Read the `[core]` table from config.toml as a flat {key: value} map. Used to +// resolve `{output_folder}`-style placeholders in module defaults. +function readCoreValues(teamContent) { + if (!teamContent) return {}; + const { blocks } = splitBlocks(teamContent); + const core = blocks.find((b) => b.header === 'core'); + if (!core) return {}; + const out = {}; + for (const line of core.lines.slice(1)) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/); + if (m) out[m[1]] = parseTomlScalar(m[2]); + } + return out; +} + +// ── default/result resolution (port of processResultTemplate) ───────────────── +function applyResultTemplate(template, value, lookups) { + if (typeof template !== 'string') return value; + let result = template; + if (typeof value === 'string') { + result = result.replace('{value}', value); + } else if (typeof value === 'boolean' || typeof value === 'number') { + result = result === '{value}' ? value : result.replace('{value}', String(value)); + } else { + return value; + } + if (typeof result !== 'string') return result; + return result.replaceAll(/{([^}]+)}/g, (match, key) => { + if (key === 'project-root') return '{project-root}'; + if (key === 'value') return match; + let v = lookups[key]; + if (typeof v === 'string' && v.includes('{project-root}/')) v = v.replace('{project-root}/', ''); + return v === undefined || v === null ? match : String(v); + }); +} + +// Resolve a module.yaml into { values, scopes } where values are post-template +// strings and scopes maps each key to 'team' | 'user'. `overrides` supplies +// non-default values (from --set); `coreValues` feeds placeholder resolution. +function resolveModuleConfig(moduleYaml, coreValues, overrides) { + const values = {}; + const scopes = {}; + const lookups = { ...coreValues }; + for (const [key, entry] of Object.entries(moduleYaml || {})) { + if (!entry || typeof entry !== 'object' || !('prompt' in entry)) continue; + const raw = key in overrides ? overrides[key] : entry.default; + if (raw === undefined) continue; + const resolved = 'result' in entry ? applyResultTemplate(entry.result, raw, lookups) : raw; + values[key] = resolved; + scopes[key] = entry.scope === 'user' ? 'user' : 'team'; + // Make this key visible to later keys' placeholder resolution. + lookups[key] = resolved; + } + return { values, scopes }; +} + +function renderModuleBlock(sectionKey, kv) { + const lines = [`[modules.${sectionKey}]`]; + for (const [k, v] of Object.entries(kv)) lines.push(`${k} = ${formatTomlValue(v)}`); + return { header: `modules.${sectionKey}`, lines }; +} + +function renderAgentBlock(agent) { + const lines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`]; + if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`); + if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`); + if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`); + if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`); + return { header: `agents.${agent.code}`, lines }; +} + +const TEAM_HEADER = [ + '# ─────────────────────────────────────────────────────────────────', + '# Installer-managed. Regenerated on install — treat as read-only.', + '# To pin a value or add custom agents, use _bmad/custom/config.toml', + '# (team, committed) — never touched by the installer.', + '# ─────────────────────────────────────────────────────────────────', + '', +]; +const USER_HEADER = [ + '# ─────────────────────────────────────────────────────────────────', + '# Installer-managed. Regenerated on install — treat as read-only.', + '# Holds install answers scoped to YOU personally.', + '# For pinned overrides use _bmad/custom/config.user.toml.', + '# ─────────────────────────────────────────────────────────────────', + '', +]; + +// Upsert `[modules.]` and the module's `[agents.*]` blocks, dropping any +// prior copies (idempotent). When no team/user keys exist the module section is +// omitted from that file. Returns the resolved config values for downstream +// consumers (e.g. directory creation). +export async function regenerateCentralConfig(bmadDir, code, opts = {}) { + const overrides = opts.setOverrides?.[code] || {}; + const moduleYamlPath = path.join(bmadDir, code, 'module.yaml'); + const moduleYamlRaw = await readFileOrNull(moduleYamlPath); + + const teamPath = path.join(bmadDir, TEAM_FILE); + const userPath = path.join(bmadDir, USER_FILE); + const teamContent = await readFileOrNull(teamPath); + const userContent = await readFileOrNull(userPath); + + // No module.yaml → nothing module-specific to write, but still strip any stale + // blocks for this code so re-installs stay clean. + let moduleYaml = null; + if (moduleYamlRaw) { + try { + moduleYaml = parseYaml(moduleYamlRaw); + } catch (e) { + process.stderr.write(`[bmad-module] warn: could not parse ${code}/module.yaml: ${e.message}\n`); + } + } + + const sectionKey = (moduleYaml && moduleYaml.code) || code; + const coreValues = readCoreValues(teamContent); + const { values, scopes } = moduleYaml ? resolveModuleConfig(moduleYaml, coreValues, overrides) : { values: {}, scopes: {} }; + + const teamKv = {}; + const userKv = {}; + for (const [k, v] of Object.entries(values)) { + if (scopes[k] === 'user') userKv[k] = v; + else teamKv[k] = v; + } + + const agents = Array.isArray(moduleYaml?.agents) + ? moduleYaml.agents + .filter((a) => a && typeof a.code === 'string') + .map((a) => ({ + code: a.code, + name: a.name || '', + title: a.title || '', + icon: a.icon || '', + description: a.description || '', + module: code, + team: a.team || code, + })) + : []; + const agentCodes = new Set(agents.map((a) => a.code)); + + // ── config.toml (team) ── + { + const base = teamContent || TEAM_HEADER.join('\n') + '\n'; + const { preamble, blocks } = splitBlocks(base); + // Drop this module's prior [modules.] and its [agents.*] (by code). + const kept = blocks.filter( + (b) => b.header !== `modules.${sectionKey}` && !(b.header.startsWith('agents.') && agentCodes.has(b.header.slice('agents.'.length))), + ); + if (Object.keys(teamKv).length) kept.push(renderModuleBlock(sectionKey, teamKv)); + for (const a of agents) kept.push(renderAgentBlock(a)); + await fs.writeFile(teamPath, joinFile(preamble, kept), 'utf8'); + } + + // ── config.user.toml (user) ── + { + const base = userContent || USER_HEADER.join('\n') + '\n'; + const { preamble, blocks } = splitBlocks(base); + const kept = blocks.filter((b) => b.header !== `modules.${sectionKey}`); + if (Object.keys(userKv).length) kept.push(renderModuleBlock(sectionKey, userKv)); + await fs.writeFile(userPath, joinFile(preamble, kept), 'utf8'); + } + + return { values, scopes, sectionKey }; +} + +// Read a module's currently-stored config values from config.toml + +// config.user.toml ([modules.]), merged into one {key: value} map. +// Used to detect changed directory paths across updates. +export async function readModuleConfigValues(bmadDir, sectionKey) { + const out = {}; + for (const file of [TEAM_FILE, USER_FILE]) { + const content = await readFileOrNull(path.join(bmadDir, file)); + if (!content) continue; + const { blocks } = splitBlocks(content); + const block = blocks.find((b) => b.header === `modules.${sectionKey}`); + if (!block) continue; + for (const line of block.lines.slice(1)) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/); + if (m) out[m[1]] = parseTomlScalar(m[2]); + } + } + return out; +} + +// Resolve a module.yaml's `code` field (the TOML section key), falling back to +// the install code when module.yaml is absent/unparseable. +export async function resolveSectionKey(bmadDir, code) { + const raw = await readFileOrNull(path.join(bmadDir, code, 'module.yaml')); + if (!raw) return code; + try { + const y = parseYaml(raw); + return (y && y.code) || code; + } catch { + return code; + } +} + +// Strip a module's `[modules.]` (both files) and its `[agents.*]` blocks +// (team file) on removal. Agent codes come from the module's module.yaml if it +// still exists; otherwise we drop agent blocks whose `module = ""`. +export async function removeModuleFromConfig(bmadDir, code) { + const moduleYamlRaw = await readFileOrNull(path.join(bmadDir, code, 'module.yaml')); + let sectionKey = code; + const agentCodes = new Set(); + if (moduleYamlRaw) { + try { + const y = parseYaml(moduleYamlRaw); + if (y?.code) sectionKey = y.code; + if (Array.isArray(y?.agents)) for (const a of y.agents) if (a?.code) agentCodes.add(a.code); + } catch { + /* fall through to module= match */ + } + } + + const teamPath = path.join(bmadDir, TEAM_FILE); + const userPath = path.join(bmadDir, USER_FILE); + const teamContent = await readFileOrNull(teamPath); + const userContent = await readFileOrNull(userPath); + + if (teamContent) { + const { preamble, blocks } = splitBlocks(teamContent); + const kept = blocks.filter((b) => { + if (b.header === `modules.${sectionKey}` || b.header === `modules.${code}`) return false; + if (b.header.startsWith('agents.')) { + const ac = b.header.slice('agents.'.length); + if (agentCodes.has(ac)) return false; + // Fallback: drop blocks whose module line names this code. + if (b.lines.some((l) => /^module\s*=/.test(l) && parseTomlScalar(l.split('=').slice(1).join('=')) === code)) return false; + } + return true; + }); + await fs.writeFile(teamPath, joinFile(preamble, kept), 'utf8'); + } + if (userContent) { + const { preamble, blocks } = splitBlocks(userContent); + const kept = blocks.filter((b) => b.header !== `modules.${sectionKey}` && b.header !== `modules.${code}`); + await fs.writeFile(userPath, joinFile(preamble, kept), 'utf8'); + } +} diff --git a/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs b/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs new file mode 100644 index 000000000..0252c9f7a --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs @@ -0,0 +1,133 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Regenerate the merged help catalog `_bmad/_config/bmad-help.csv` from every +// installed module's `module-help.csv`. This mirrors the full installer's +// `Installer.mergeModuleHelpCatalogs` (tools/installer/core/installer.js) so a +// module installed via this skill is visible to the `bmad-help` skill, which +// reads `_bmad/_config/bmad-help.csv` (see src/core-skills/bmad-help/SKILL.md). +// +// Self-contained note: the installer scans core from its source tree +// (`getSourcePath('core-skills')`); we instead scan `_bmad//` for every +// installed module — including core, whose `module-help.csv` is copied into +// `_bmad/core/` at `bmad install` time — so this needs no source checkout. + +// Canonical per-module CSV header. Must match +// tools/installer/modules/module-help-schema.js (MODULE_HELP_CSV_HEADER). A +// per-module file whose header differs is loaded positionally with a warning. +const MODULE_HELP_CSV_HEADER = + 'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs'; +const COLUMN_COUNT = 13; +const PHASE_INDEX = 7; + +// Top-level _bmad children that are not modules and must not be scanned. +const NON_MODULE_DIRS = new Set(['_config', '_memory', 'memory', 'docs', 'scripts', 'custom']); + +// Parse a single CSV line into fields. Mirrors Installer.parseCSVLine: handles +// `""`-escaped quotes inside quoted fields and unquoted commas as separators. +function parseCsvLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const next = line[i + 1]; + if (char === '"') { + if (inQuotes && next === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += char; + } + } + result.push(current); + return result; +} + +// Quote a field only when it contains a comma, quote, or newline. Mirrors +// Installer.escapeCSVField so the merged output is byte-compatible. +function escapeCsvField(field) { + if (field === null || field === undefined) return ''; + const str = String(field); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replaceAll('"', '""')}"`; + } + return str; +} + +// Read every installed module's module-help.csv, merge into the canonical +// catalog, and write `_bmad/_config/bmad-help.csv`. Returns the data-row count. +// Re-scans the whole tree each call, so it is correct after install AND remove. +export async function regenerateHelpCatalog(bmadDir) { + let entries; + try { + entries = await fs.readdir(bmadDir, { withFileTypes: true }); + } catch { + return 0; + } + const moduleNames = entries + .filter((e) => e.isDirectory() && !NON_MODULE_DIRS.has(e.name) && !e.name.startsWith('.')) + .map((e) => e.name) + .sort(); + + const allRows = []; + for (const moduleName of moduleNames) { + const helpFilePath = path.join(bmadDir, moduleName, 'module-help.csv'); + let content; + try { + content = await fs.readFile(helpFilePath, 'utf8'); + } catch { + continue; // module ships no help catalog — fine + } + const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#')); + let headerWarned = false; + for (const line of lines) { + // Canonical header row: warn on drift, then skip. (A non-canonical header + // that doesn't start with `module,` falls through and is loaded as data, + // matching the installer — author CSVs should use the canonical header.) + if (line.startsWith('module,')) { + if (!headerWarned && line.trim() !== MODULE_HELP_CSV_HEADER) { + process.stderr.write( + `[bmad-module] warn: ${moduleName}/module-help.csv header differs from canonical schema — data loaded positionally.\n`, + ); + headerWarned = true; + } + continue; + } + const columns = parseCsvLine(line); + if (columns.length < COLUMN_COUNT - 1) continue; + const padded = columns.slice(0, COLUMN_COUNT); + while (padded.length < COLUMN_COUNT) padded.push(''); + // Empty module column → fill with the dir name (core stays empty so its + // rows render as universal tools), matching the installer. + if ((!padded[0] || padded[0].trim() === '') && moduleName !== 'core') { + padded[0] = moduleName; + } + allRows.push(padded.map((c) => escapeCsvField(c)).join(',')); + } + } + + // Sort by (module, phase); stable within a phase to preserve authored order. + const decorated = allRows.map((row, index) => ({ row, index, cols: parseCsvLine(row) })); + decorated.sort((a, b) => { + const moduleA = (a.cols[0] || '').toLowerCase(); + const moduleB = (b.cols[0] || '').toLowerCase(); + if (moduleA !== moduleB) return moduleA.localeCompare(moduleB); + const phaseA = a.cols[PHASE_INDEX] || ''; + const phaseB = b.cols[PHASE_INDEX] || ''; + if (phaseA !== phaseB) return phaseA.localeCompare(phaseB); + return a.index - b.index; + }); + const sortedRows = decorated.map((d) => d.row); + + const outputPath = path.join(bmadDir, '_config', 'bmad-help.csv'); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, [MODULE_HELP_CSV_HEADER, ...sortedRows].join('\n'), 'utf8'); + return sortedRows.length; +} diff --git a/src/core-skills/bmad-module/scripts/lib/install-plan.mjs b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs index 485dad460..2d039ffe2 100644 --- a/src/core-skills/bmad-module/scripts/lib/install-plan.mjs +++ b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs @@ -121,7 +121,10 @@ export async function buildCopyList(sourceDir, ignoreMatch) { } // Top-level files we always copy if present (and not ignored). Authors don't -// have to declare these — they're conventional repo metadata. +// have to declare these — they're conventional repo metadata. package.json / +// package-lock.json are included so a module that ships JS runtime deps can have +// them installed in place post-copy (see npm-deps.mjs); node_modules itself is +// never copied (it's in DEFAULT_IGNORES) and is regenerated by npm install. const ALWAYS_TOPLEVEL = new Set([ 'README.md', 'README', @@ -135,6 +138,8 @@ const ALWAYS_TOPLEVEL = new Set([ 'LICENCE.md', 'NOTICE', 'NOTICE.md', + 'package.json', + 'package-lock.json', ]); function stripDotSlash(p) { @@ -198,11 +203,17 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) { const claimedSrc = new Set(); const claimedDest = new Set(); - const addFile = (srcRel, destRel) => { + // `allowDupSrc` lets a single source file be copied to a second destination + // even if it was already claimed by an earlier (recursive) copy. Needed for + // moduleDefinition / moduleHelpCsv, which commonly live INSIDE a declared + // skill dir (e.g. `/assets/module.yaml`): they must also be + // flattened to the canonical `_bmad//module.yaml` root slot that the + // rewritten plugin.json points at and that config / help-catalog read. + const addFile = (srcRel, destRel, allowDupSrc = false) => { if (!srcRel || !destRel) return; - if (claimedSrc.has(srcRel)) return; + if (!allowDupSrc && claimedSrc.has(srcRel)) return; if (claimedDest.has(destRel)) return; - claimedSrc.add(srcRel); + if (!allowDupSrc) claimedSrc.add(srcRel); claimedDest.add(destRel); plan.push({ srcRel, destRel }); }; @@ -217,12 +228,12 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) { }; // Helper: if `srcRel` exists as a file in source, queue it. - const queueFileIfExists = async (srcRel, destRel) => { + const queueFileIfExists = async (srcRel, destRel, allowDupSrc = false) => { if (!srcRel) return; if (ignoreMatch && ignoreMatch(srcRel)) return; try { const stat = await fs.stat(path.join(sourceDir, srcRel)); - if (stat.isFile()) addFile(srcRel, destRel); + if (stat.isFile()) addFile(srcRel, destRel, allowDupSrc); } catch { /* missing — silently skip; validateDeclaredPaths surfaces declared misses */ } @@ -264,11 +275,15 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) { } // moduleDefinition / moduleHelpCsv — flatten to canonical names at root. + // allowDupSrc: these often live inside a declared skill dir, so the source + // file may already be claimed by that skill's recursive copy; we still want + // the canonical root copy that the rewritten manifest, config-gen, and the + // help catalog all rely on. if (typeof manifest.bmad?.moduleDefinition === 'string') { - await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml'); + await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml', true); } if (typeof manifest.bmad?.moduleHelpCsv === 'string') { - await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv'); + await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv', true); } // Top-level docs declared in the manifest — keep at root by basename. diff --git a/src/core-skills/bmad-module/scripts/lib/module-dirs.mjs b/src/core-skills/bmad-module/scripts/lib/module-dirs.mjs new file mode 100644 index 000000000..47e42c918 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/module-dirs.mjs @@ -0,0 +1,121 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseYaml } from './vendor/yaml.mjs'; + +// Create the project working directories a module declares in its module.yaml +// `directories:` key, mirroring the full installer's +// OfficialModules.createModuleDirectories (tools/installer/modules/official-modules.js). +// +// Each entry is a `{config_key}` reference resolved against the module's config +// values (produced by config-gen). `{project-root}` is stripped to a project- +// relative path; the dir is created under the project root (the parent of +// `_bmad/`). On update, a changed path moves the old directory to the new one. +// All failures are non-fatal warnings — the module itself is already installed. + +const warn = (msg) => process.stderr.write(`[bmad-module] warn: ${msg}\n`); + +async function pathExists(p) { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +// `moduleConfig` / `existingConfig`: {configKey: resolvedValue} maps, where a +// value may carry a leading `{project-root}/`. Returns a summary for display. +export async function createModuleDirectories(bmadDir, code, moduleConfig = {}, existingConfig = {}) { + const projectRoot = path.dirname(bmadDir); + const empty = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + + let moduleYamlRaw; + try { + moduleYamlRaw = await fs.readFile(path.join(bmadDir, code, 'module.yaml'), 'utf8'); + } catch { + return empty; // no module.yaml flattened into the install — nothing to do + } + let moduleYaml; + try { + moduleYaml = parseYaml(moduleYamlRaw); + } catch (e) { + warn(`invalid ${code}/module.yaml: ${e.message}`); + return empty; + } + if (!moduleYaml || !Array.isArray(moduleYaml.directories)) return empty; + + const wdsFolders = Array.isArray(moduleYaml.wds_folders) ? moduleYaml.wds_folders : []; + const createdDirs = []; + const movedDirs = []; + const createdWdsFolders = []; + const normalizedRoot = path.normalize(projectRoot); + + const toRelPath = (value) => path.normalize(value.replace(/^\{project-root\}\/?/, '').replaceAll('{project-root}', '')); + + for (const dirRef of moduleYaml.directories) { + const varMatch = typeof dirRef === 'string' && dirRef.match(/^\{([^}]+)\}$/); + if (!varMatch) continue; // only variable references are honored + const configKey = varMatch[1]; + const dirValue = moduleConfig[configKey]; + if (!dirValue || typeof dirValue !== 'string') continue; + + const dirPath = toRelPath(dirValue); + const fullPath = path.join(projectRoot, dirPath); + const normalizedNewAbs = path.normalize(fullPath); + if (normalizedNewAbs !== normalizedRoot && !normalizedNewAbs.startsWith(normalizedRoot + path.sep)) { + warn(`${configKey} path escapes project root, skipping: ${dirPath}`); + continue; + } + + // Detect a changed path vs the previous install for a move. + let oldFullPath = null; + let oldDirPath = null; + const oldValue = existingConfig[configKey]; + if (oldValue && typeof oldValue === 'string') { + const normalizedOld = toRelPath(oldValue); + if (normalizedOld !== dirPath) { + oldDirPath = normalizedOld; + oldFullPath = path.join(projectRoot, oldDirPath); + const normalizedOldAbs = path.normalize(oldFullPath); + if (normalizedOldAbs !== normalizedRoot && !normalizedOldAbs.startsWith(normalizedRoot + path.sep)) { + oldFullPath = null; // old path escapes root — ignore + } else if (normalizedOldAbs.startsWith(normalizedNewAbs + path.sep) || normalizedNewAbs.startsWith(normalizedOldAbs + path.sep)) { + warn(`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}); creating new directory`); + oldFullPath = null; + } + } + } + + const dirName = configKey.replaceAll('_', ' '); + const newExists = await pathExists(fullPath); + if (oldFullPath && (await pathExists(oldFullPath)) && !newExists) { + try { + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.rename(oldFullPath, fullPath); + movedDirs.push(`${dirName}: ${oldDirPath} → ${dirPath}`); + } catch (moveErr) { + warn(`failed to move ${oldDirPath} → ${dirPath}: ${moveErr.message}. Creating new directory; move contents manually.`); + await fs.mkdir(fullPath, { recursive: true }); + createdDirs.push(`${dirName}: ${dirPath}`); + } + } else if (oldFullPath && (await pathExists(oldFullPath)) && newExists) { + warn(`${dirName}: path changed but both old (${oldDirPath}) and new (${dirPath}) exist — review/merge manually.`); + } else if (!newExists) { + await fs.mkdir(fullPath, { recursive: true }); + createdDirs.push(`${dirName}: ${dirPath}`); + } + + // WDS subfolders under design_artifacts. + if (configKey === 'design_artifacts' && wdsFolders.length) { + for (const sub of wdsFolders) { + const subPath = path.join(fullPath, sub); + if (!(await pathExists(subPath))) { + await fs.mkdir(subPath, { recursive: true }); + createdWdsFolders.push(sub); + } + } + } + } + + return { createdDirs, movedDirs, createdWdsFolders }; +} diff --git a/src/core-skills/bmad-module/scripts/lib/npm-deps.mjs b/src/core-skills/bmad-module/scripts/lib/npm-deps.mjs new file mode 100644 index 000000000..81e5546b5 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/npm-deps.mjs @@ -0,0 +1,41 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); + +// Install a module's runtime dependencies when it ships a package.json, mirroring +// the full installer's CustomModuleManager.cloneRepo npm step +// (tools/installer/modules/custom-module-manager.js). Unlike the installer — which +// installs into its ~/.bmad cache and copies node_modules across — the skill never +// copies node_modules (it's in DEFAULT_IGNORES), so we install in place inside the +// committed `_bmad//`. +// +// This relaxes the skill's "no npm in _bmad/" principle, but it is the only way a +// module whose skills shell out to JS deps works after a skill-driven install. +// Gated on package.json presence and opt-out via `bmad.install.skipNpm: true`. +// Always non-fatal: the module files are already committed; a failed dep install +// is a warning, not an install failure. + +const NPM_ARGS = ['install', '--omit=dev', '--no-audit', '--no-fund', '--no-progress', '--legacy-peer-deps']; +const TIMEOUT_MS = 120_000; + +export async function installModuleDeps(moduleDir, manifest) { + if (manifest?.bmad?.install?.skipNpm === true) return { ran: false, skipped: 'opted out (bmad.install.skipNpm)' }; + + const pkgPath = path.join(moduleDir, 'package.json'); + try { + const stat = await fs.stat(pkgPath); + if (!stat.isFile()) return { ran: false }; + } catch { + return { ran: false }; // no package.json — nothing to install + } + + try { + await execFileP('npm', NPM_ARGS, { cwd: moduleDir, timeout: TIMEOUT_MS }); + return { ran: true, ok: true }; + } catch (e) { + return { ran: true, ok: false, error: e.shortMessage || e.message }; + } +} diff --git a/src/core-skills/bmad-module/scripts/remove.mjs b/src/core-skills/bmad-module/scripts/remove.mjs index 787173daa..d9a544a2e 100644 --- a/src/core-skills/bmad-module/scripts/remove.mjs +++ b/src/core-skills/bmad-module/scripts/remove.mjs @@ -12,6 +12,8 @@ import { readSkillCanonicalIdsForModule, } from './lib/manifest-ops.mjs'; import { distributeToIdes } from './lib/ide-sync.mjs'; +import { removeModuleFromConfig } from './lib/config-gen.mjs'; +import { regenerateHelpCatalog } from './lib/help-catalog.mjs'; // Remove a module's installed files and manifest entries. With `--purge` also // deletes `_bmad/custom//` (user customization dir). Without it, customs @@ -43,6 +45,14 @@ export async function runRemove(opts) { // rows, so we can prune them from the IDE directories afterward. const removedSkillIds = await readSkillCanonicalIdsForModule(bmadDir, code); + // Strip the module's central-config blocks ([modules.] + its [agents.*]) + // while its module.yaml still exists on disk to identify the agent codes. + try { + await removeModuleFromConfig(bmadDir, code); + } catch (e) { + process.stderr.write(`[bmad-module] warning: failed to update config for removal of ${code}: ${e.message}\n`); + } + // Delete each file tracked in files-manifest.csv; prune empty dirs after. const fileEntries = await readFileEntriesForModule(bmadDir, code); const moduleRoot = path.join(bmadDir, code); @@ -71,6 +81,14 @@ export async function runRemove(opts) { await removeSkillManifestRows(bmadDir, code); await removeModuleFromManifest(bmadDir, code); + // Rebuild the merged help catalog now that the module's module-help.csv is + // gone, so its skills disappear from `bmad-help`. + try { + await regenerateHelpCatalog(bmadDir); + } catch (e) { + process.stderr.write(`[bmad-module] warning: help catalog rebuild failed: ${e.message}\n`); + } + // Prune the module's skills from every configured coding assistant. The // manifest no longer lists the module, so ide-sync removes its skill dirs + // command pointers and re-syncs the rest. diff --git a/src/core-skills/bmad-module/scripts/update.mjs b/src/core-skills/bmad-module/scripts/update.mjs index ffe634356..f7bb2d936 100644 --- a/src/core-skills/bmad-module/scripts/update.mjs +++ b/src/core-skills/bmad-module/scripts/update.mjs @@ -16,6 +16,7 @@ import { readSkillCanonicalIdsForModule, } from './lib/manifest-ops.mjs'; import { distributeToIdes } from './lib/ide-sync.mjs'; +import { finishModuleInstall } from './install.mjs'; // Update one installed module (or all when opts.all is true). v1 semantics: // - Re-resolves the original source (or new --ref) and re-clones. @@ -138,6 +139,10 @@ async function updateOne(bmadDir, projectDir, entry, opts) { ); process.stdout.write(`[bmad-module] previous ${oldEntries.length} file(s) → new ${destPaths.length} file(s)\n`); + // Re-run the same post-copy completion as install: deps, config + agent + // roster, working directories (moves if a path changed), and help catalog. + await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides }); + // Re-distribute to the configured coding assistants: prune skills that no // longer exist in this version, refresh the rest. const ideResult = await distributeToIdes({ projectDir, bmadDir, prune: oldSkillIds }); diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog/skills/bmad-devlog-setup/assets/module-help.csv b/src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog/skills/bmad-devlog-setup/assets/module-help.csv index fa8fef8e8..6bf829b6c 100644 --- a/src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog/skills/bmad-devlog-setup/assets/module-help.csv +++ b/src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog/skills/bmad-devlog-setup/assets/module-help.csv @@ -1,4 +1,4 @@ -canonical_id,name,description,module,path,kind,team,agent,visible,deprecated,version,tags,help_text -bmad-devlog-write,bmad-devlog-write,Write today's devlog entry from a template.,devlog,skills/bmad-devlog-write,skill,knowledge-management,,true,false,0.4.0,"devlog,write,daily",Use `/bmad-devlog-write` to create today's entry. -bmad-devlog-summarize,bmad-devlog-summarize,Summarize devlog entries across a date range.,devlog,skills/bmad-devlog-summarize,skill,knowledge-management,,true,false,0.4.0,"devlog,summarize,history",Use `/bmad-devlog-summarize ` (e.g. `last-week`, `2026-05`). -bmad-agent-historian,bmad-agent-historian,Clio the Historian — persona-agent for narrative recall and pattern detection.,devlog,skills/bmad-agent-historian,persona-agent,knowledge-management,Clio,true,false,0.4.0,"devlog,history,persona",Use `/bmad-agent-historian` or ask to "talk to Clio". +module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs +devlog,bmad-devlog-write,Write Devlog Entry,dw,Write today's devlog entry from a template.,bmad-devlog-write,,daily,,,,{devlog_path},entry.md +devlog,bmad-devlog-summarize,Summarize Devlog,ds,Summarize devlog entries across a date range.,bmad-devlog-summarize,,history,,,,,summary.md +devlog,bmad-agent-historian,Clio the Historian,ch,Persona-agent for narrative recall and pattern detection.,bmad-agent-historian,,,,,,, diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/.claude-plugin/plugin.json b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/.claude-plugin/plugin.json new file mode 100644 index 000000000..026f0a3c0 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "acme-npmtool", + "version": "0.1.0", + "description": "A module that ships a package.json so install exercises npm dependency setup.", + "license": "MIT", + "author": { "name": "Acme Corp" }, + "skills": ["./skills/acme-npmtool"], + "bmad": { + "specVersion": "1.0.0", + "code": "npmtool", + "category": "developer-tools", + "compatibility": { "bmadMethod": ">=6.6.0" } + } +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/package.json b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/package.json new file mode 100644 index 000000000..f20b23a43 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/package.json @@ -0,0 +1,7 @@ +{ + "name": "acme-npmtool", + "version": "0.1.0", + "private": true, + "description": "No runtime dependencies — npm install resolves cleanly offline.", + "dependencies": {} +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/skills/acme-npmtool/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/skills/acme-npmtool/SKILL.md new file mode 100644 index 000000000..6f82a3172 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/skills/acme-npmtool/SKILL.md @@ -0,0 +1,9 @@ +--- +name: acme-npmtool +description: A trivial skill whose module ships a package.json to exercise npm dependency install. +--- + +# Acme NPM Tool + +This skill exists only so its module can carry a `package.json`, exercising the +installer's npm dependency setup step. It has no runtime dependencies. diff --git a/src/core-skills/bmad-module/tests/integration.test.sh b/src/core-skills/bmad-module/tests/integration.test.sh index 7ca2cc611..5dc9dcf18 100755 --- a/src/core-skills/bmad-module/tests/integration.test.sh +++ b/src/core-skills/bmad-module/tests/integration.test.sh @@ -97,6 +97,18 @@ ides: [] YAML printf 'canonicalId,name,description,module,path\n' > _bmad/_config/skill-manifest.csv printf 'type,name,module,path,hash\n' > _bmad/_config/files-manifest.csv +# Central config as `bmad install` would leave it: [core] supplies output_folder +# so module defaults that reference {output_folder} resolve during config-gen. +cat > _bmad/config.toml <<'TOML' +# Installer-managed. Regenerated on install. + +[core] +user_name = "Tester" +output_folder = "{project-root}/_bmad-output" +TOML +printf '# Installer-managed.\n\n[core]\ncommunication_language = "English"\n' > _bmad/config.user.toml +# Core ships a canonical module-help.csv so the merged catalog has a baseline row. +printf 'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n,bmad-help,Help,h,Show the BMAD help catalog,bmad-help,,,,,,,\n' > _bmad/core/module-help.csv ok "skeleton seeded at ${WORKDIR}/_bmad/" # ─── 1. list (empty) ───────────────────────────────────────────────────────── @@ -161,8 +173,8 @@ run install "${FIXTURES}/module-bad-missing-fields" assert_exit 20 "missing required fields" # ─── 9. comprehensive module install ───────────────────────────────────────── -note "install examples/comprehensive/acme-devlog" -run install "${EXAMPLES}/comprehensive/acme-devlog" +note "install examples/comprehensive/acme-devlog (with --set override)" +run install "${EXAMPLES}/comprehensive/acme-devlog" --set devlog.devlog_path='{output_folder}/journal' assert_exit 0 "install comprehensive" assert_path_exists "_bmad/devlog/skills/bmad-devlog-write/SKILL.md" assert_path_exists "_bmad/devlog/skills/bmad-devlog-setup/SKILL.md" @@ -170,6 +182,10 @@ assert_path_exists "_bmad/devlog/agents/changelog-archivist.md" # hooks/mcpServers are flattened to canonical root slots (see rewriteManifestPaths) assert_path_exists "_bmad/devlog/hooks.json" assert_path_exists "_bmad/devlog/.mcp.json" +# moduleDefinition / moduleHelpCsv are also flattened to the module root even +# though they live inside the setup skill's assets/ dir. +assert_path_exists "_bmad/devlog/module.yaml" +assert_path_exists "_bmad/devlog/module-help.csv" # install.ignore excludes docs/ and tests/ and README.md / CHANGELOG.md assert_path_absent "_bmad/devlog/docs" assert_path_absent "_bmad/devlog/README.md" @@ -177,6 +193,33 @@ assert_path_absent "_bmad/devlog/CHANGELOG.md" [[ "${STDOUT}" == *"hooks"* ]] && ok "warns about hooks not auto-activated" \ || ko "expected hooks warning in stdout: ${STDOUT}" +# ─── 9a. parity: central config + agent roster (gap #3) ────────────────────── +note "config generation + agent roster" +assert_grep '^\[modules\.devlog]' "_bmad/config.toml" +# --set override resolves {output_folder} from [core] and applies the result template +assert_grep 'devlog_path = "\{project-root}/_bmad-output/journal"' "_bmad/config.toml" +assert_grep '^\[agents\.bmad-agent-historian]' "_bmad/config.toml" +assert_grep 'module = "devlog"' "_bmad/config.toml" +# [core] is preserved untouched +assert_grep '^user_name = "Tester"' "_bmad/config.toml" +# user-scoped answer lands in config.user.toml, not config.toml +assert_grep '^\[modules\.devlog]' "_bmad/config.user.toml" +assert_grep 'entry_format = "iso"' "_bmad/config.user.toml" + +# ─── 9b. parity: module working directories (gap #2) ───────────────────────── +note "module directory creation" +assert_path_exists "_bmad-output/journal" + +# ─── 9c. parity: merged help catalog (gap #1) ──────────────────────────────── +note "bmad-help.csv merge" +assert_path_exists "_bmad/_config/bmad-help.csv" +head -1 _bmad/_config/bmad-help.csv | grep -q '^module,skill,display-name,' \ + && ok "bmad-help.csv has canonical header" || ko "bmad-help.csv header wrong" +assert_grep '^devlog,bmad-devlog-write,' "_bmad/_config/bmad-help.csv" +assert_grep '^devlog,bmad-agent-historian,' "_bmad/_config/bmad-help.csv" +# the core baseline row is still present +assert_grep ',bmad-help,Help,' "_bmad/_config/bmad-help.csv" + # ─── 10. remove minimal (no purge), preserve custom ───────────────────────── note "create _bmad/custom/mdlint to test preservation, then remove" mkdir -p _bmad/custom/mdlint @@ -201,6 +244,17 @@ run remove devlog --purge assert_exit 0 "remove --purge" assert_path_absent "_bmad/devlog" assert_path_absent "_bmad/custom/devlog" +# config blocks and help rows for devlog are stripped on removal +grep -q '\[modules\.devlog]' _bmad/config.toml \ + && ko "[modules.devlog] still in config.toml" || ok "config.toml [modules.devlog] stripped" +grep -q '\[agents\.bmad-agent-historian]' _bmad/config.toml \ + && ko "[agents.bmad-agent-historian] still in config.toml" || ok "config.toml agent block stripped" +grep -q '\[modules\.devlog]' _bmad/config.user.toml \ + && ko "[modules.devlog] still in config.user.toml" || ok "config.user.toml [modules.devlog] stripped" +grep -q '^devlog,' _bmad/_config/bmad-help.csv \ + && ko "devlog rows still in bmad-help.csv" || ok "bmad-help.csv devlog rows removed" +# [core] survives the removal +assert_grep '^user_name = "Tester"' "_bmad/config.toml" # ─── 12. remove unknown ────────────────────────────────────────────────────── note "remove unknown code" @@ -245,6 +299,26 @@ assert_exit 0 "remove from IDE project" assert_path_absent "${IDEPROJ}/.claude/skills/acme-md-lint" assert_path_absent "${IDEPROJ}/.agents/skills/acme-md-lint" +# ─── 14. npm dependency install (gap #4) ───────────────────────────────────── +# A module shipping package.json. package.json/package-lock.json are copied to +# the module root; if npm is available, deps are installed in place. The fixture +# has no dependencies, so npm resolves offline. Guarded on npm availability so +# CI sandboxes without npm still pass. +note "npm fixture: package.json copied + deps installed in place" +run install "${EXAMPLES}/minimal-npm/acme-npmtool" +assert_exit 0 "install npm fixture" +assert_path_exists "_bmad/npmtool/package.json" +if command -v npm >/dev/null 2>&1; then + [[ "${STDOUT}" == *"installed npm dependencies for npmtool"* ]] \ + && ok "npm dependencies installed" \ + || ko "expected npm install confirmation in stdout: ${STDOUT}" + # The fixture has zero deps, so npm writes package-lock.json (not node_modules); + # its presence proves npm actually ran inside the installed module dir. + assert_path_exists "_bmad/npmtool/package-lock.json" +else + ok "npm not on PATH — skipping dependency-install assertion" +fi + # ─── Summary ───────────────────────────────────────────────────────────────── echo echo "──────────────────────────────────────────────────────────────────────"