feat(bmad-module): complete custom-module install to match the installer CLI

The bmad-module skill copied a module's files and distributed skills to IDEs,
but skipped four post-copy steps the full `bmad install --custom-source` path
performs, leaving modules incompletely installed:

- Merge each module's module-help.csv into _bmad/_config/bmad-help.csv (the
  catalog bmad-help reads) — new lib/help-catalog.mjs
- Generate [modules.<code>] / [agents.<code>] blocks in config.toml /
  config.user.toml from module.yaml (defaults + --set overrides), via a
  targeted merge that preserves [core] and sibling modules — new lib/config-gen.mjs
- Create the working directories a module declares under `directories:`
  (with move-on-path-change and wds_folders) — new lib/module-dirs.mjs
- Run `npm install --omit=dev` in place when a module ships package.json
  (opt out via bmad.install.skipNpm) — new lib/npm-deps.mjs

All four run as a shared finishModuleInstall step wired into install, update,
and remove; every step is non-fatal so a module already committed to _bmad/
isn't lost to a post-copy hiccup. Adds a repeatable --set <code>.<key>=<value>
flag mirroring the installer.

Also fixes two latent issues in the manifest-driven copy that the new steps
depend on:
- moduleDefinition / moduleHelpCsv are now flattened to the module root even
  when they live inside a declared skill dir (the setup-skill assets pattern);
  previously claimedSrc dedup skipped them and the rewritten plugin.json
  pointed at a non-existent ./module.yaml.
- package.json / package-lock.json are now copied so npm deps can install.

Tests: extends the integration suite with config/agent-roster, --set,
directory-creation, help-catalog, removal-cleanup, and npm assertions
(73/73 pass); adds a minimal-npm fixture and rewrites the comprehensive
fixture's module-help.csv to the canonical schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pbean 2026-06-01 09:52:52 -07:00
parent af52c7baf9
commit 546826b451
15 changed files with 891 additions and 19 deletions

View File

@ -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/<bmad.code>/` 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/<id>/`), 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.<code>]` / `[agents.<code>]` 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 `<source>``owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--dry-run`.
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`.
- **install:** the user supplies `<source>``owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>` (override a module config answer; repeatable), `--dry-run`.
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--set <code>.<key>=<value>`.
- **remove:** the user supplies `<code>`. Use `--purge` only if they explicitly say "also remove customizations" or "purge".
- **list:** no args. Use `--json` if the user asks for machine-readable.

View File

@ -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 <code>.<key>=<value>` 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 <code>.<key>=<value>, 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 <code>.<key>=<value>, got "${spec}"`);
const code = lhs.slice(0, dot);
const key = lhs.slice(dot + 1);
if (!code || !key) throw new BmadModuleError(EXIT.USAGE, `--set expects <code>.<key>=<value>, 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 <source> [--ref <ref>] [--channel <c>] [--dry-run]
bmad-module update <code|--all> [--ref <ref>] [--channel <c>]
bmad-module install <source> [--ref <ref>] [--channel <c>] [--set <code>.<key>=<v>] [--dry-run]
bmad-module update <code|--all> [--ref <ref>] [--channel <c>] [--set <code>.<key>=<v>]
bmad-module remove <code> [--purge]
bmad-module list [--json]
GLOBAL FLAGS
--project-dir <path> Project root containing _bmad/ (default: cwd)
--set <code>.<key>=<v> Override a module config answer (repeatable)
EXAMPLES
bmad-module install acme/acme-devlog

View File

@ -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/<code>/.
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/<code>/.
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`);
}
}

View File

@ -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/<code>/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.<code>]` (team→config.toml, user→config.user.toml) and its
// `[agents.<code>]` 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 <code>.<key>=<value>`), 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.<code>]` 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.<code>] 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.<sectionKey>]), 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.<code>]` (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 = "<code>"`.
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');
}
}

View File

@ -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/<module>/` 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;
}

View File

@ -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. `<setup-skill>/assets/module.yaml`): they must also be
// flattened to the canonical `_bmad/<code>/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.

View File

@ -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 };
}

View File

@ -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/<code>/`.
//
// 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 };
}
}

View File

@ -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/<code>/` (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.<code>] + 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.

View File

@ -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 });

View File

@ -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 <range>` (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,<range>,history,,,,,summary.md
devlog,bmad-agent-historian,Clio the Historian,ch,Persona-agent for narrative recall and pattern detection.,bmad-agent-historian,,,,,,,

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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" }
}
}

View File

@ -0,0 +1,7 @@
{
"name": "acme-npmtool",
"version": "0.1.0",
"private": true,
"description": "No runtime dependencies — npm install resolves cleanly offline.",
"dependencies": {}
}

View File

@ -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.

View File

@ -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 "──────────────────────────────────────────────────────────────────────"