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:
parent
af52c7baf9
commit
546826b451
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
7
src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/package.json
vendored
Normal file
7
src/core-skills/bmad-module/tests/fixtures/examples/minimal-npm/acme-npmtool/package.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "acme-npmtool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "No runtime dependencies — npm install resolves cleanly offline.",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 "──────────────────────────────────────────────────────────────────────"
|
||||
|
|
|
|||
Loading…
Reference in New Issue