diff --git a/eslint.config.mjs b/eslint.config.mjs index 1bf3e270e..e82b4233b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -117,6 +117,29 @@ export default [ }, }, + // bmad-module core skill: self-contained ESM CLI support scripts. + // Same internal-script relaxations as tools/** and src/scripts/** above, + // plus a few cosmetic rules. The code is reviewed and integration-tested + // as-is (the exit-code contract relies on process.exit). + { + files: ['src/core-skills/bmad-module/scripts/**/*.mjs', 'src/core-skills/bmad-module/scripts/**/*.js'], + rules: { + 'n/hashbang': 'off', + 'n/no-process-exit': 'off', + 'unicorn/no-process-exit': 'off', + 'unicorn/prefer-top-level-await': 'off', + 'no-unused-vars': 'off', + 'unicorn/no-array-reduce': 'off', + 'unicorn/no-array-callback-reference': 'off', + 'unicorn/no-array-for-each': 'off', + 'unicorn/catch-error-name': 'off', + 'unicorn/switch-case-braces': 'off', + 'unicorn/explicit-length-check': 'off', + 'unicorn/prefer-string-replace-all': 'off', + 'unicorn/prefer-string-raw': 'off', + }, + }, + // ESLint config file should not be checked for publish-related Node rules { files: ['eslint.config.mjs'], diff --git a/src/core-skills/bmad-module/README.md b/src/core-skills/bmad-module/README.md new file mode 100644 index 000000000..c2087ba5b --- /dev/null +++ b/src/core-skills/bmad-module/README.md @@ -0,0 +1,59 @@ +# bmad-module + +The core BMAD skill for installing, updating, removing, and listing community +BMAD modules. Modules are standalone GitHub repos that conform to the BMAD +Module Manifest Spec (see `docs/spec.md` in `bmad-marketplace`). + +## How it fits + +- **Authors** publish a single repo with `.claude-plugin/plugin.json` that + works in both Claude Code's plugin marketplace and BMAD-METHOD. +- **Users** install via this skill — no CLI required. Modules land in + `_bmad//` alongside the official modules. +- **BMAD-METHOD** treats community-installed modules as a new `source: 'community'` + row in `manifest.yaml`; re-running `bmad install` preserves them (with the + paired `manifest-generator.js` patch). + +## Verbs + +``` +bmad-module install [--ref ] [--channel ] [--dry-run] +bmad-module update [--ref ] [--channel ] +bmad-module remove [--purge] +bmad-module list [--json] +``` + +`` accepts `owner/repo`, a full git URL, or a local path. + +## Behavior notes + +- **Source of truth** for what was installed is `_bmad/_config/files-manifest.csv` + (per-file hashes) and `_bmad/_config/skill-manifest.csv` (one row per + shipped skill). `manifest.yaml` carries the source/version/sha tuple. +- **`update`** refuses to overwrite locally-modified files (hash mismatch + against the recorded hash). Move overrides into `_bmad/custom//` + and retry. +- **`remove`** without `--purge` preserves `_bmad/custom//` so a + re-install picks the customizations back up. `--purge` deletes them. +- **Hooks / MCP / LSP / Claude subagents** declared in the module manifest + are *copied* but NOT auto-activated by this skill. Use Claude Code's + plugin manager to wire them up. + +## Implementation + +The skill itself is a thin verb router (`SKILL.md`). All filesystem work +happens in `scripts/bmad-module.mjs` and the `lib/` modules, which are +self-contained (only `yaml` and `semver` as runtime deps). They re-use no +BMAD-METHOD internal modules — the same code runs during development inside +`bmad-marketplace` and after the skill is PR'd into BMAD-METHOD core. + +## Exit codes + +See `SKILL.md` for the full table. The script's stderr always names the +condition; the codes are stable so tooling can branch. + +## Tests + +Integration tests live in `tests/integration.test.sh` and run end-to-end on +a fresh BMAD install. Fixtures for negative cases (collisions, path +traversal, reserved codes) are under `tests/fixtures/`. diff --git a/src/core-skills/bmad-module/SKILL.md b/src/core-skills/bmad-module/SKILL.md new file mode 100644 index 000000000..74b99a49b --- /dev/null +++ b/src/core-skills/bmad-module/SKILL.md @@ -0,0 +1,127 @@ +--- +name: bmad-module +description: Install, update, remove, or list community BMAD modules. Use when the user says "install module ", "install bmad module", "update module", "remove module", "uninstall module", or "list modules". +--- + +# bmad-module + +Manage community BMAD modules — installable packages of skills, agents, and +supporting assets that ship as standalone GitHub repos. Modules land in +`_bmad//` alongside official modules and are tracked in the +existing manifests. The same artifact is also loadable as a Claude Code +plugin via its `.claude-plugin/plugin.json` manifest. + +## CRITICAL RULES + +- NEVER write directly to files under `_bmad/`. All filesystem changes go + through the Node script at `scripts/bmad-module.mjs` — it handles staging, + atomic swaps, manifest updates, and rollback on failure. +- HALT and report cleanly if `_bmad/` is not present in the current working + directory (exit code 10 from the script). +- DO NOT execute hooks, MCP server commands, or any code shipped inside the + module during install. The install copies files; activation is a separate + step the user opts into via Claude Code's plugin manager. +- If the script exits non-zero, report the exit code and stderr verbatim and + stop. Do NOT retry, do NOT try a different verb. + +## EXECUTION + +### Step 1 — Identify the verb + +The user's request maps to exactly one of: + +| Verb | Phrasing | +|---|---| +| `install` | "install module X", "add the X module", "set up X" | +| `update` | "update module X", "upgrade X", "pull the latest X" | +| `remove` | "remove module X", "uninstall X", "delete X module" | +| `list` | "list modules", "what modules are installed", "show installed modules" | + +If the verb is ambiguous (e.g. the user says "manage modules"), ASK which +verb they want before continuing. + +### Step 2 — Parse the args + +- **install:** the user supplies `` — `owner/repo` (GitHub short), + a full git URL (`https://…` or `git@…`), or a local path. Optional flags: + `--ref `, `--channel `, `--dry-run`. +- **update:** the user supplies `` (the `_bmad//` folder name) + or asks for "all"; in that case use `--all`. Optional `--ref`. +- **remove:** the user supplies ``. Use `--purge` only if they + explicitly say "also remove customizations" or "purge". +- **list:** no args. Use `--json` if the user asks for machine-readable. + +If anything is missing or ambiguous, ASK before invoking. + +### Step 3 — Confirm before destructive verbs + +For `install`, `update`, and `remove`, summarize what will happen and confirm +once with the user: + +> About to **install** `acme/acme-devlog` (will create `_bmad/devlog/`). +> Proceed? [y/N] + +For `install` you may run a dry-run first (`--dry-run`) and show the file +plan; that counts as the summary — still confirm before the real run. + +Skip the confirmation step only if the user has already pre-authorized in +this turn (e.g. "go ahead and install acme-md-lint without asking"). + +### Step 4 — Invoke the Node script + +Run from the project root (the dir containing `_bmad/`): + +``` +node /scripts/bmad-module.mjs [args...] +``` + +`` is wherever the skill files live in the current install. After +this skill ships into BMAD-METHOD that's `_bmad/core/skills/bmad-module/`; +during development it's this repo's `src/core-skills/bmad-module/`. + +Stream stdout and stderr verbatim. Do NOT silence or rewrite them — the +script's own messages are designed for end-user consumption. + +### Step 5 — Report + +On exit 0: paraphrase the script's final line(s) and note any next-step hint +(e.g. "next: run the `bmad-devlog-setup` skill to finish setup"). + +On non-zero exit: print the exit code, the stderr message, and stop. Do not +suggest workarounds beyond what the script's message itself suggests +(e.g. "use `update` instead", "move changes into `_bmad/custom//`"). + +## EXIT CODES + +| Code | Meaning | +|---|---| +| 0 | success | +| 2 | usage error (bad/missing args or flags) | +| 10 | no `_bmad/` directory in project — run `bmad install` first | +| 20 | missing or invalid `.claude-plugin/plugin.json` in source | +| 21 | module uses a reserved `bmad.code` | +| 30 | prefix collision with an already-installed module | +| 40 | module would write outside its `_bmad//` root | +| 50 | filesystem commit (atomic swap) failed | +| 60 | network or `git clone` failed | +| 70 | path traversal detected in manifest | +| 80 | update aborted: locally modified files would be overwritten | +| 90 | no such installed module (for `update`/`remove`) | + +## EXAMPLES + +User: "Install the devlog module from acme/acme-devlog" +→ Confirm, then run: + `node …/scripts/bmad-module.mjs install acme/acme-devlog` + +User: "Try installing examples/minimal/acme-md-lint first as a dry-run" +→ Run with `--dry-run`, show the plan, then ask whether to proceed for real. + +User: "What modules do I have installed?" +→ Run `… list`. No confirmation needed (read-only). + +User: "Update the devlog module to v0.5.0" +→ Confirm, then run `… update devlog --ref v0.5.0`. + +User: "Remove the mdlint module and wipe its customizations too" +→ Confirm, then run `… remove mdlint --purge`. diff --git a/src/core-skills/bmad-module/scripts/bmad-module.mjs b/src/core-skills/bmad-module/scripts/bmad-module.mjs new file mode 100755 index 000000000..98bec865b --- /dev/null +++ b/src/core-skills/bmad-module/scripts/bmad-module.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// bmad-module — verb dispatcher. +// +// Usage: +// node bmad-module.mjs install [--ref ] [--channel ] [--dry-run] [--project-dir

] +// node bmad-module.mjs update [--ref ] [--channel ] [--project-dir

] +// node bmad-module.mjs remove [--purge] [--project-dir

] +// node bmad-module.mjs list [--json] [--project-dir

] +// +// Exit codes — see SKILL.md / lib/exit.js. 0 = ok; everything ≥10 = structured error. + +import { runInstall } from './install.mjs'; +import { runUpdate } from './update.mjs'; +import { runRemove } from './remove.mjs'; +import { runList } from './list.mjs'; +import { EXIT, BmadModuleError } from './lib/exit.mjs'; + +const VERBS = new Set(['install', 'update', 'remove', 'list']); + +function parseArgs(argv) { + const out = { _: [], flags: {} }; + let i = 0; + while (i < argv.length) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + // boolean flags + if (['dry-run', 'purge', 'all', 'json'].includes(key)) { + out.flags[key] = true; + i++; + continue; + } + // value flags + const val = argv[i + 1]; + if (val === undefined || val.startsWith('--')) { + throw new BmadModuleError(EXIT.USAGE, `flag --${key} requires a value`); + } + out.flags[key] = val; + i += 2; + continue; + } + out._.push(a); + i++; + } + return out; +} + +async function main() { + const argv = process.argv.slice(2); + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printUsage(); + process.exit(EXIT.USAGE); + } + const verb = argv[0]; + if (!VERBS.has(verb)) { + process.stderr.write(`[bmad-module] unknown verb "${verb}". Valid: install, update, remove, list.\n`); + process.exit(EXIT.USAGE); + } + + let parsed; + try { + parsed = parseArgs(argv.slice(1)); + } catch (e) { + if (e instanceof BmadModuleError) { + process.stderr.write(`[bmad-module] ${e.message}\n`); + process.exit(e.code); + } + throw e; + } + + const projectDir = parsed.flags['project-dir'] || process.cwd(); + + try { + switch (verb) { + case 'install': + await runInstall({ + source: parsed._[0], + ref: parsed.flags['ref'] || null, + channel: parsed.flags['channel'] || null, + dryRun: !!parsed.flags['dry-run'], + projectDir, + }); + break; + case 'update': + await runUpdate({ + code: parsed._[0] || null, + all: !!parsed.flags['all'], + ref: parsed.flags['ref'] || null, + channel: parsed.flags['channel'] || null, + projectDir, + }); + break; + case 'remove': + await runRemove({ + code: parsed._[0], + purge: !!parsed.flags['purge'], + projectDir, + }); + break; + case 'list': + await runList({ + json: !!parsed.flags['json'], + projectDir, + }); + break; + } + } catch (e) { + if (e instanceof BmadModuleError) { + process.stderr.write(`[bmad-module] ${e.message}\n`); + process.exit(e.code); + } + process.stderr.write(`[bmad-module] unexpected error: ${e.stack || e.message}\n`); + process.exit(1); + } +} + +function printUsage() { + process.stderr.write(`bmad-module — install, update, remove, or list BMAD community modules. + +USAGE + bmad-module install [--ref ] [--channel ] [--dry-run] + bmad-module update [--ref ] [--channel ] + bmad-module remove [--purge] + bmad-module list [--json] + +GLOBAL FLAGS + --project-dir Project root containing _bmad/ (default: cwd) + +EXAMPLES + bmad-module install acme/acme-devlog + bmad-module install ./examples/minimal/acme-md-lint + bmad-module install https://github.com/acme/acme-devlog --ref v0.4.0 + bmad-module list + bmad-module update devlog + bmad-module remove mdlint --purge + +EXIT CODES + 0 success + 2 usage error + 10 no _bmad/ in project + 20 missing or invalid plugin.json + 21 reserved bmad.code + 30 prefix collision with existing module + 40 file overlap outside the module root + 50 filesystem commit failed + 60 network/git clone failed + 70 path traversal in manifest + 80 update aborted: locally modified files + 90 no such installed module +`); +} + +main(); diff --git a/src/core-skills/bmad-module/scripts/install.mjs b/src/core-skills/bmad-module/scripts/install.mjs new file mode 100644 index 000000000..4d9c2cb26 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/install.mjs @@ -0,0 +1,143 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { EXIT, BmadModuleError } from './lib/exit.mjs'; +import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs'; +import { parseSource, materializeSource } from './lib/source.mjs'; +import { readAndValidateManifest } from './lib/plugin-json.mjs'; +import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs'; +import { copyDir, atomicSwapDir } from './lib/fs-safe.mjs'; +import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs'; + +// Run the install verb. `opts` shape: +// { source, ref, sha, channel, dryRun, projectDir } +// Returns nothing; throws BmadModuleError on failure. +export async function runInstall(opts) { + const projectDir = opts.projectDir || process.cwd(); + + // §1. Resolve _bmad/ first — fail fast if BMAD is not installed. + const bmadDir = await findBmadDir(projectDir); + if (!bmadDir) { + throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}. Run \`bmad install\` first.`); + } + await ensureConfigDir(bmadDir); + + // §2. Normalize + materialize source. + const descriptor = parseSource(opts.source); + const materialized = await materializeSource(descriptor, { ref: opts.ref || null }); + + try { + // §3. Read + validate plugin.json. + const manifest = await readAndValidateManifest(materialized.dir); + const code = manifest.bmad.code; + + // §4. Collision check against installed manifest. + const existing = await readManifestYaml(bmadDir); + const existingEntry = existing?.modules?.find((m) => m && m.name === code); + if (existingEntry) { + const sameSource = + (existingEntry.rawSource && existingEntry.rawSource === descriptor.rawInput) || + (existingEntry.repoUrl && descriptor.kind === 'git' && existingEntry.repoUrl === descriptor.url); + const sameSha = materialized.sha && existingEntry.sha === materialized.sha; + if (sameSource && sameSha) { + process.stdout.write(`[bmad-module] ${code} ${existingEntry.version} already installed at this sha — no-op.\n`); + return; + } + if (existingEntry.source === 'community' && sameSource) { + // Same module, different sha — user should use `update`. + throw new BmadModuleError( + EXIT.PREFIX_COLLISION, + `${code} already installed from this source at sha ${existingEntry.sha || '?'}. ` + + `Run \`bmad-module update ${code}\` to change version.`, + ); + } + throw new BmadModuleError( + EXIT.PREFIX_COLLISION, + `code "${code}" already used by ${existingEntry.source} module ` + + `${existingEntry.repoUrl || existingEntry.rawSource || existingEntry.npmPackage || '(local)'}. ` + + `Module authors should pick a unique bmad.code (spec §7.1).`, + ); + } + + // §5. Build install plan. + validateDeclaredPaths(materialized.dir, manifest); + const userIgnores = await readUserIgnores(materialized.dir, manifest); + const matchIgnore = buildIgnoreMatcher(userIgnores); + const copyList = await buildCopyList(materialized.dir, matchIgnore); + + if (opts.dryRun) { + process.stdout.write(`[bmad-module] dry-run: would install ${code} (${manifest.name} ${manifest.version})\n`); + process.stdout.write(`[bmad-module] target: ${path.join(bmadDir, code)}\n`); + process.stdout.write(`[bmad-module] files (${copyList.length}):\n`); + for (const rel of copyList) process.stdout.write(` ${rel}\n`); + return; + } + + // §6. Stage to tmp/staged-out, then atomic swap. + const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out'); + await copyDir(materialized.dir, stagedDir, (rel) => !copyList.includes(rel) && !isAncestorOfAny(rel, copyList)); + const targetDir = path.join(bmadDir, code); + try { + await atomicSwapDir(stagedDir, targetDir); + } catch (e) { + throw new BmadModuleError(EXIT.COMMIT_FAILURE, `failed to swap into ${targetDir}: ${e.message}`); + } + + // §7. Register in manifests. + await addModuleToManifest(bmadDir, code, { + version: manifest.bmad.moduleVersion || manifest.version, + repoUrl: descriptor.kind === 'git' ? descriptor.url : null, + sha: materialized.sha, + ref: materialized.ref, + channel: opts.channel || (opts.ref ? 'pinned' : descriptor.kind === 'git' ? 'next' : null), + rawSource: descriptor.rawInput, + moduleName: manifest.name, + }); + + const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => normalizeSkillDirRelToCode(s)) : []; + await appendSkillManifestRows(bmadDir, code, skillDirs); + await appendFilesManifestRows(bmadDir, code, copyList); + + // §8. Warn about Claude-only surfaces. + const claudeOnly = []; + if (manifest.hooks) claudeOnly.push('hooks'); + if (manifest.mcpServers) claudeOnly.push('mcpServers'); + if (manifest.lspServers) claudeOnly.push('lspServers'); + if (Array.isArray(manifest.agents) && manifest.agents.length) claudeOnly.push('agents'); + if (Array.isArray(manifest.commands) && manifest.commands.length) claudeOnly.push('commands'); + + process.stdout.write( + `[bmad-module] installed ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`, + ); + process.stdout.write(`[bmad-module] copied ${copyList.length} file(s) to ${path.relative(projectDir, targetDir)}\n`); + if (claudeOnly.length) { + process.stdout.write( + `[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` + + `Use Claude Code's plugin manager to wire them up.\n`, + ); + } + if (manifest.bmad?.install?.postInstallSkill) { + process.stdout.write(`[bmad-module] next: run the \`${manifest.bmad.install.postInstallSkill}\` skill to finish setup.\n`); + } + } finally { + await materialized.cleanup(); + } +} + +// Strip leading `./` and split the declared skill path. Modules use paths +// relative to the module root (e.g. `./skills/bmad-devlog-write`). Within +// `_bmad//` the same relative layout is preserved, so we just strip +// the dot-slash. +function normalizeSkillDirRelToCode(skillPath) { + let p = String(skillPath); + if (p.startsWith('./')) p = p.slice(2); + return p; +} + +// During staging we filter the source tree to just the files we plan to copy. +// `copyList` is a list of files; we also need to allow their ancestor dirs to +// be walked. This returns true iff `rel` is a prefix of some path in list. +function isAncestorOfAny(rel, list) { + const prefix = rel + '/'; + for (const p of list) if (p.startsWith(prefix)) return true; + return false; +} diff --git a/src/core-skills/bmad-module/scripts/lib/bmad-dir.mjs b/src/core-skills/bmad-module/scripts/lib/bmad-dir.mjs new file mode 100644 index 000000000..708f2f3a9 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/bmad-dir.mjs @@ -0,0 +1,23 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Locate the _bmad/ directory for a project. Matches BMAD-METHOD's +// Installer.findBmadDir semantics exactly: no upward search — always +// `/_bmad`. Returns absolute path or null if absent. +export async function findBmadDir(projectDir) { + const candidate = path.join(path.resolve(projectDir), '_bmad'); + try { + const stat = await fs.stat(candidate); + return stat.isDirectory() ? candidate : null; + } catch { + return null; + } +} + +// Resolve a writable _config dir, ensuring it exists. Modules always +// register into /_config/. +export async function ensureConfigDir(bmadDir) { + const cfgDir = path.join(bmadDir, '_config'); + await fs.mkdir(cfgDir, { recursive: true }); + return cfgDir; +} diff --git a/src/core-skills/bmad-module/scripts/lib/exit.mjs b/src/core-skills/bmad-module/scripts/lib/exit.mjs new file mode 100644 index 000000000..316ba773b --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/exit.mjs @@ -0,0 +1,28 @@ +// Exit codes for bmad-module verbs. Documented in SKILL.md and README.md so +// callers (Claude, CI, humans) can branch on them programmatically. +export const EXIT = { + OK: 0, + USAGE: 2, + NO_BMAD_DIR: 10, + BAD_MANIFEST: 20, + RESERVED_PREFIX: 21, + PREFIX_COLLISION: 30, + FILE_OVERLAP: 40, + COMMIT_FAILURE: 50, + NETWORK_FAILURE: 60, + PATH_TRAVERSAL: 70, + MODIFIED_FILES: 80, + NOT_INSTALLED: 90, +}; + +export class BmadModuleError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +export function die(code, message) { + process.stderr.write(`[bmad-module] ${message}\n`); + process.exit(code); +} diff --git a/src/core-skills/bmad-module/scripts/lib/frontmatter.mjs b/src/core-skills/bmad-module/scripts/lib/frontmatter.mjs new file mode 100644 index 000000000..4eb6e6f7b --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/frontmatter.mjs @@ -0,0 +1,13 @@ +import { parse as parseYaml } from 'yaml'; + +// Parse YAML frontmatter from a markdown string. Returns the parsed object, +// or null if no frontmatter block is present / it failed to parse. +export function parseFrontmatter(content) { + const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + if (!m) return null; + try { + return parseYaml(m[1]); + } catch { + return null; + } +} diff --git a/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs b/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs new file mode 100644 index 000000000..5b57e8fe4 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/fs-safe.mjs @@ -0,0 +1,92 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; + +// Resolve `declared` as a path inside `rootAbs`. Rejects absolute paths, +// `..` segments, and symlink escapes. Returns the absolute path or null. +export function safePathInsideRoot(rootAbs, declared) { + if (typeof declared !== 'string' || declared === '') return null; + if (path.isAbsolute(declared)) return null; + if (declared.split(/[\\/]/).includes('..')) return null; + const resolved = path.resolve(rootAbs, declared); + if (resolved !== rootAbs && !resolved.startsWith(rootAbs + path.sep)) return null; + if (fs.existsSync(resolved)) { + try { + const real = fs.realpathSync(resolved); + const realRoot = fs.realpathSync(rootAbs); + if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null; + } catch { + return null; + } + } + return resolved; +} + +// SHA-256 of a file's bytes, returned as a hex string. Returns null on +// I/O failure — callers should treat a null hash as "file unreadable". +export async function sha256File(filePath) { + try { + const buf = await fsp.readFile(filePath); + return crypto.createHash('sha256').update(buf).digest('hex'); + } catch { + return null; + } +} + +// Recursively copy `srcDir` into `destDir`, creating destDir first. +// Returns an array of relative file paths (POSIX-style) actually copied. +// Skips entries matched by `shouldSkip(relPath)` which receives a POSIX +// path relative to srcDir. +export async function copyDir(srcDir, destDir, shouldSkip = () => false) { + await fsp.mkdir(destDir, { recursive: true }); + const copied = []; + async function walk(rel) { + const absSrc = path.join(srcDir, rel); + const entries = await fsp.readdir(absSrc, { withFileTypes: true }); + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (shouldSkip(childRel)) continue; + const childSrc = path.join(srcDir, childRel); + const childDest = path.join(destDir, childRel); + if (entry.isDirectory()) { + await fsp.mkdir(childDest, { recursive: true }); + await walk(childRel); + } else if (entry.isFile()) { + await fsp.mkdir(path.dirname(childDest), { recursive: true }); + await fsp.copyFile(childSrc, childDest); + copied.push(childRel); + } + // Symlinks are skipped — install staging never preserves them. + } + } + await walk(''); + return copied; +} + +// Atomically replace `targetDir` with `stagedDir` contents. If `targetDir` +// exists it's removed first; then `stagedDir` is renamed in. Best effort — +// not truly atomic across filesystems but minimizes the inconsistent window. +export async function atomicSwapDir(stagedDir, targetDir) { + await fsp.rm(targetDir, { recursive: true, force: true }); + await fsp.mkdir(path.dirname(targetDir), { recursive: true }); + await fsp.rename(stagedDir, targetDir); +} + +// Remove empty parent directories upward until a non-empty one is hit, +// stopping at `stopAt` (exclusive). Used after file deletion. +export async function pruneEmptyDirs(startDir, stopAt) { + let dir = path.resolve(startDir); + const stop = path.resolve(stopAt); + while (dir !== stop && dir.startsWith(stop + path.sep)) { + let entries; + try { + entries = await fsp.readdir(dir); + } catch { + return; + } + if (entries.length > 0) return; + await fsp.rmdir(dir); + dir = path.dirname(dir); + } +} diff --git a/src/core-skills/bmad-module/scripts/lib/install-plan.mjs b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs new file mode 100644 index 000000000..69ec7bfc3 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/install-plan.mjs @@ -0,0 +1,121 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { EXIT, BmadModuleError } from './exit.mjs'; +import { safePathInsideRoot } from './fs-safe.mjs'; + +// Default ignore patterns always applied on top of user ignores. +// `.claude-plugin/` is intentionally NOT ignored — the manifest is needed +// post-install for `update` and `list` to re-resolve the module. +const DEFAULT_IGNORES = ['.git/**', '.git', 'node_modules/**', 'node_modules', '.bmadignore', '.DS_Store', '**/.DS_Store']; + +// Compile one ignore pattern (gitignore-lite: supports `*`, `**`, `?`, and +// trailing `/`; no negation, no leading `/` anchoring) into a RegExp matched +// against a POSIX-style relative path. +function compilePattern(pattern) { + let p = pattern.trim(); + if (!p || p.startsWith('#')) return null; + // Treat trailing slash as "directory" — match the dir and its contents. + const dirOnly = p.endsWith('/'); + if (dirOnly) p = p.slice(0, -1); + // Anchor by default (gitignore semantics): if no slash in pattern, match + // basename anywhere; else anchor to root. + const anchored = p.includes('/'); + let body = p + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '\uFFFF') + .replace(/\*/g, '[^/]*') + .replace(/\uFFFF/g, '.*') + .replace(/\?/g, '[^/]'); + const re = anchored ? new RegExp(`^${body}(/.*)?$`) : new RegExp(`(^|/)${body}(/.*)?$`); + return re; +} + +export function buildIgnoreMatcher(userPatterns) { + const patterns = [...DEFAULT_IGNORES, ...(userPatterns || [])]; + const compiled = patterns.map(compilePattern).filter(Boolean); + return (relPath) => { + const posix = relPath.replaceAll('\\', '/'); + return compiled.some((re) => re.test(posix)); + }; +} + +// Load user ignore patterns from manifest first, then .bmadignore. Spec §15 +// disallows both at once — readUserIgnores enforces it. +export async function readUserIgnores(sourceDir, manifest) { + const fromManifest = manifest?.bmad?.install?.ignore; + const ignoreFilePath = path.join(sourceDir, '.bmadignore'); + let fromFile = null; + try { + const buf = await fs.readFile(ignoreFilePath, 'utf8'); + fromFile = buf + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean); + } catch { + /* no .bmadignore — fine */ + } + if (Array.isArray(fromManifest) && fromFile) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `both .bmadignore and bmad.install.ignore are present — pick one`); + } + if (Array.isArray(fromManifest)) return fromManifest; + if (fromFile) return fromFile; + return []; +} + +// Validate that every declared path in the manifest exists inside the source +// tree and resolves safely (no traversal, no symlink escape). Declared paths +// double as documentation; they do NOT drive the copy list, but if they are +// broken the install would land a non-functional module. +export function validateDeclaredPaths(sourceDir, manifest) { + const declared = []; + const arr = (key, val) => Array.isArray(val) && val.forEach((v) => declared.push({ key, val: v })); + const str = (key, val) => typeof val === 'string' && declared.push({ key, val }); + arr('skills', manifest.skills); + arr('agents', manifest.agents); + arr('commands', manifest.commands); + str('hooks', manifest.hooks); + if (typeof manifest.mcpServers === 'string') str('mcpServers', manifest.mcpServers); + str('lspServers', manifest.lspServers); + str('settings', manifest.settings); + str('bmad.moduleDefinition', manifest.bmad?.moduleDefinition); + str('bmad.moduleHelpCsv', manifest.bmad?.moduleHelpCsv); + arr('bmad.customize.schemas', manifest.bmad?.customize?.schemas); + if (manifest.bmad?.docs) { + str('bmad.docs.readme', manifest.bmad.docs.readme); + str('bmad.docs.changelog', manifest.bmad.docs.changelog); + if (typeof manifest.bmad.docs.homepage === 'string' && !/^https?:/.test(manifest.bmad.docs.homepage)) { + str('bmad.docs.homepage', manifest.bmad.docs.homepage); + } + } + for (const { key, val } of declared) { + const safe = safePathInsideRoot(sourceDir, val); + if (safe === null) { + throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `manifest ${key}: "${val}" escapes module root`); + } + } + // Existence is enforced by the validator pre-publish; at install time we + // surface missing declared paths as PATH_TRAVERSAL-class problems too, so + // the user gets a single failure mode for "manifest doesn't match tree". + return declared; +} + +// Walk the module source tree and return the list of POSIX-relative file +// paths that should be copied into `_bmad//`. Honors ignore patterns +// and skips symlinks (they're not preserved in the install). +export async function buildCopyList(sourceDir, ignoreMatch) { + const out = []; + async function walk(rel) { + const absDir = path.join(sourceDir, rel); + const entries = await fs.readdir(absDir, { withFileTypes: true }); + for (const entry of entries) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + if (ignoreMatch(childRel)) continue; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) await walk(childRel); + else if (entry.isFile()) out.push(childRel); + } + } + await walk(''); + out.sort(); + return out; +} diff --git a/src/core-skills/bmad-module/scripts/lib/manifest-ops.mjs b/src/core-skills/bmad-module/scripts/lib/manifest-ops.mjs new file mode 100644 index 000000000..96a3cb118 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/manifest-ops.mjs @@ -0,0 +1,285 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { parseFrontmatter } from './frontmatter.mjs'; +import { sha256File } from './fs-safe.mjs'; +import { EXIT, BmadModuleError } from './exit.mjs'; + +// ============================================================================= +// manifest.yaml — read/write/addModule/removeModule +// ============================================================================= + +const MANIFEST_YAML_OPTS = { indent: 2, lineWidth: 0, sortKeys: false }; + +export async function readManifestYaml(bmadDir) { + const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml'); + try { + const content = await fs.readFile(yamlPath, 'utf8'); + return parseYaml(content); + } catch { + return null; + } +} + +async function writeManifestYaml(bmadDir, data) { + const yamlPath = path.join(bmadDir, '_config', 'manifest.yaml'); + await fs.mkdir(path.dirname(yamlPath), { recursive: true }); + const yamlContent = stringifyYaml(structuredClone(data), MANIFEST_YAML_OPTS); + const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n'; + await fs.writeFile(yamlPath, content, 'utf8'); +} + +// Add or update a community module entry in manifest.yaml. Mirrors BMAD-METHOD's +// Manifest.addModule() entry shape exactly (source: 'community') so the +// upstream installer recognizes community rows during regeneration. +export async function addModuleToManifest(bmadDir, code, options) { + let manifest = await readManifestYaml(bmadDir); + if (!manifest) { + throw new BmadModuleError(EXIT.NO_BMAD_DIR, `manifest.yaml not found — _bmad/_config/ missing. Run \`bmad install\` first.`); + } + if (!Array.isArray(manifest.modules)) manifest.modules = []; + + const now = new Date().toISOString(); + const idx = manifest.modules.findIndex((m) => m && m.name === code); + if (idx === -1) { + const entry = { + name: code, + version: options.version || null, + installDate: now, + lastUpdated: now, + source: 'community', + npmPackage: null, + repoUrl: options.repoUrl || null, + }; + if (options.channel) entry.channel = options.channel; + if (options.sha) entry.sha = options.sha; + if (options.ref) entry.ref = options.ref; + if (options.rawSource) entry.rawSource = options.rawSource; + if (options.moduleName) entry.moduleName = options.moduleName; + manifest.modules.push(entry); + } else { + const existing = manifest.modules[idx]; + manifest.modules[idx] = { + ...existing, + version: options.version ?? existing.version, + source: 'community', + repoUrl: options.repoUrl ?? existing.repoUrl, + channel: options.channel ?? existing.channel, + sha: options.sha ?? existing.sha, + ref: options.ref ?? existing.ref, + rawSource: options.rawSource ?? existing.rawSource, + moduleName: options.moduleName ?? existing.moduleName, + lastUpdated: now, + }; + } + + await writeManifestYaml(bmadDir, manifest); +} + +export async function removeModuleFromManifest(bmadDir, code) { + const manifest = await readManifestYaml(bmadDir); + if (!manifest || !Array.isArray(manifest.modules)) return false; + const before = manifest.modules.length; + manifest.modules = manifest.modules.filter((m) => !(m && m.name === code)); + if (manifest.modules.length === before) return false; + await writeManifestYaml(bmadDir, manifest); + return true; +} + +export async function listModuleEntries(bmadDir) { + const manifest = await readManifestYaml(bmadDir); + if (!manifest || !Array.isArray(manifest.modules)) return []; + return manifest.modules.filter((m) => m && m.source === 'community'); +} + +// ============================================================================= +// CSV helpers — used by both skill-manifest.csv and files-manifest.csv +// ============================================================================= + +function escapeCsv(value) { + return `"${String(value ?? '').replaceAll('"', '""')}"`; +} + +// Tiny CSV parser sufficient for the shapes BMAD-METHOD writes: header line + +// records with `"…"` fields, quotes escaped as `""`. No commas-in-fields +// outside quotes. Returns array of arrays. +function parseCsv(text) { + const rows = []; + let row = []; + let field = ''; + let i = 0; + let inQuotes = false; + while (i < text.length) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"'; + i += 2; + continue; + } + inQuotes = false; + i++; + continue; + } + field += c; + i++; + } else { + if (c === '"') { + inQuotes = true; + i++; + continue; + } + if (c === ',') { + row.push(field); + field = ''; + i++; + continue; + } + if (c === '\n' || c === '\r') { + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + row = []; + field = ''; + if (c === '\r' && text[i + 1] === '\n') i += 2; + else i++; + continue; + } + field += c; + i++; + } + } + if (field !== '' || row.length > 0) { + row.push(field); + rows.push(row); + } + return rows; +} + +async function readCsvRows(filePath) { + try { + const text = await fs.readFile(filePath, 'utf8'); + return parseCsv(text); + } catch { + return null; + } +} + +function rowsToCsv(header, rows) { + let csv = header.join(',') + '\n'; + for (const r of rows) { + csv += r.map(escapeCsv).join(',') + '\n'; + } + return csv; +} + +// ============================================================================= +// skill-manifest.csv — header: canonicalId,name,description,module,path +// ============================================================================= + +const SKILL_HEADER = ['canonicalId', 'name', 'description', 'module', 'path']; + +// Append rows for a module's skills, parsed from each SKILL.md's frontmatter. +// `skillDirs` is an array of POSIX-relative dirs inside `_bmad//` (e.g. +// `["bmad-devlog-write", "bmad-devlog-summarize"]`). +export async function appendSkillManifestRows(bmadDir, code, skillDirs) { + const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + const existingRaw = await readCsvRows(csvPath); + const rows = existingRaw && existingRaw.length > 0 ? existingRaw.slice(1) : []; + + for (const skillRel of skillDirs) { + const skillMdPath = path.join(bmadDir, code, skillRel, 'SKILL.md'); + let canonicalId = path.basename(skillRel); + let name = canonicalId; + let description = ''; + try { + const md = await fs.readFile(skillMdPath, 'utf8'); + const fm = parseFrontmatter(md); + if (fm) { + if (typeof fm.name === 'string') { + canonicalId = fm.name; + name = fm.name; + } + if (typeof fm.description === 'string') { + description = fm.description.replaceAll(/\s+/g, ' ').trim(); + } + } + } catch { + /* SKILL.md unreadable — degrade gracefully with basename */ + } + rows.push([canonicalId, name, description, code, `_bmad/${code}/${skillRel}/SKILL.md`]); + } + + // Sort by (module, canonicalId) for stable diffs. Don't sort the header. + rows.sort((a, b) => { + if (a[3] !== b[3]) return a[3].localeCompare(b[3]); + return a[0].localeCompare(b[0]); + }); + + await fs.mkdir(path.dirname(csvPath), { recursive: true }); + await fs.writeFile(csvPath, rowsToCsv(SKILL_HEADER, rows), 'utf8'); +} + +export async function removeSkillManifestRows(bmadDir, code) { + const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + const existingRaw = await readCsvRows(csvPath); + if (!existingRaw || existingRaw.length < 1) return; + const rows = existingRaw.slice(1).filter((r) => r[3] !== code); + await fs.writeFile(csvPath, rowsToCsv(SKILL_HEADER, rows), 'utf8'); +} + +// ============================================================================= +// files-manifest.csv — header: type,name,module,path,hash +// ============================================================================= + +const FILES_HEADER = ['type', 'name', 'module', 'path', 'hash']; + +// Append rows for every file copied during install. `copiedRelPaths` is the +// POSIX-relative path list returned by buildCopyList, paths relative to the +// staged module root (which == _bmad// after commit). +export async function appendFilesManifestRows(bmadDir, code, copiedRelPaths) { + const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv'); + const existingRaw = await readCsvRows(csvPath); + const rows = existingRaw && existingRaw.length > 0 ? existingRaw.slice(1) : []; + + const newRows = []; + for (const rel of copiedRelPaths) { + const absPath = path.join(bmadDir, code, rel); + const ext = path.extname(rel).slice(1).toLowerCase(); + const base = path.basename(rel, path.extname(rel)); + const hash = await sha256File(absPath); + newRows.push([ext || 'file', base, code, `${code}/${rel}`, hash || '']); + } + + const merged = [...rows, ...newRows]; + merged.sort((a, b) => { + if (a[2] !== b[2]) return a[2].localeCompare(b[2]); + if (a[0] !== b[0]) return a[0].localeCompare(b[0]); + return a[1].localeCompare(b[1]); + }); + + await fs.mkdir(path.dirname(csvPath), { recursive: true }); + await fs.writeFile(csvPath, rowsToCsv(FILES_HEADER, merged), 'utf8'); +} + +// Return the existing rows for this module code as { path, hash } pairs. +// Used by update to diff old-vs-new and by remove to know what to delete. +export async function readFileEntriesForModule(bmadDir, code) { + const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv'); + const rows = await readCsvRows(csvPath); + if (!rows || rows.length < 2) return []; + return rows + .slice(1) + .filter((r) => r[2] === code) + .map((r) => ({ type: r[0], name: r[1], module: r[2], path: r[3], hash: r[4] })); +} + +export async function removeFilesManifestRows(bmadDir, code) { + const csvPath = path.join(bmadDir, '_config', 'files-manifest.csv'); + const rows = await readCsvRows(csvPath); + if (!rows || rows.length < 1) return; + const kept = rows.slice(1).filter((r) => r[2] !== code); + await fs.writeFile(csvPath, rowsToCsv(FILES_HEADER, kept), 'utf8'); +} diff --git a/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs b/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs new file mode 100644 index 000000000..586528001 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import semver from 'semver'; +import { EXIT, BmadModuleError } from './exit.mjs'; + +// Reserved bmad.code values — must match docs/spec.md §7.1 and the +// validator's RESERVED_CODES set. Single source of truth for the runtime. +export const RESERVED_CODES = new Set([ + 'core', + 'bmm', + 'bmb', + 'cis', + 'gds', + 'tea', + 'wds', + 'automator', + '_config', + '_memory', + 'custom', + 'agents', + 'hooks', + 'config', + 'commands', + 'skills', +]); + +export const CODE_REGEX = /^[a-z][a-z0-9-]{1,31}$/; +export const NAME_REGEX = /^[a-z][a-z0-9-]+$/; + +// Read and install-time-validate a module manifest. Install-time checks are +// intentionally narrower than the author validator (scripts/validate-module.mjs) +// — we only block things that would corrupt _bmad/ or cause data loss. +export async function readAndValidateManifest(sourceDir) { + const manifestPath = path.join(sourceDir, '.claude-plugin', 'plugin.json'); + let raw; + try { + raw = await fs.readFile(manifestPath, 'utf8'); + } catch { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `missing .claude-plugin/plugin.json at ${sourceDir}`); + } + let m; + try { + m = JSON.parse(raw); + } catch (e) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json failed to parse: ${e.message}`); + } + + const missing = []; + if (typeof m.name !== 'string') missing.push('name'); + if (typeof m.version !== 'string') missing.push('version'); + if (typeof m.description !== 'string') missing.push('description'); + if (!m.bmad || typeof m.bmad !== 'object') { + missing.push('bmad'); + } else { + if (typeof m.bmad.specVersion !== 'string') missing.push('bmad.specVersion'); + if (typeof m.bmad.code !== 'string') missing.push('bmad.code'); + if (typeof m.bmad.compatibility?.bmadMethod !== 'string') missing.push('bmad.compatibility.bmadMethod'); + } + if (missing.length) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json missing required fields: ${missing.join(', ')}`); + } + + if (!NAME_REGEX.test(m.name) || m.name.length < 3 || m.name.length > 64) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#name "${m.name}" must match ${NAME_REGEX} and be 3–64 chars`); + } + if (!semver.valid(m.version)) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#version "${m.version}" is not valid semver`); + } + if (!CODE_REGEX.test(m.bmad.code)) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json#bmad.code "${m.bmad.code}" must match ${CODE_REGEX}`); + } + if (RESERVED_CODES.has(m.bmad.code)) { + throw new BmadModuleError(EXIT.RESERVED_PREFIX, `plugin.json#bmad.code "${m.bmad.code}" is reserved (spec §7.1)`); + } + if (!semver.validRange(m.bmad.compatibility.bmadMethod)) { + throw new BmadModuleError( + EXIT.BAD_MANIFEST, + `plugin.json#bmad.compatibility.bmadMethod "${m.bmad.compatibility.bmadMethod}" is not a valid semver range`, + ); + } + + return m; +} diff --git a/src/core-skills/bmad-module/scripts/lib/source.mjs b/src/core-skills/bmad-module/scripts/lib/source.mjs new file mode 100644 index 000000000..84cbe3942 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/source.mjs @@ -0,0 +1,107 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { copyDir } from './fs-safe.mjs'; +import { EXIT, BmadModuleError } from './exit.mjs'; + +const execFileP = promisify(execFile); + +const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/; + +// Normalize a `` argument from the CLI into a descriptor: +// { kind: 'local' | 'git', path?, url?, displayName, rawInput } +// Accepts: +// - owner/repo → GitHub HTTPS +// - https://… or git@… URL → as given +// - file://path → local +// - relative or absolute path → local (if it exists on disk) +export function parseSource(input) { + if (typeof input !== 'string' || !input.trim()) { + throw new BmadModuleError(EXIT.USAGE, `source is required`); + } + const raw = input.trim(); + + if (raw.startsWith('file://')) { + const p = decodeURI(raw.slice('file://'.length)); + return { kind: 'local', path: path.resolve(p), displayName: p, rawInput: raw }; + } + + if ( + raw.startsWith('https://') || + raw.startsWith('http://') || + raw.startsWith('git@') || + raw.startsWith('ssh://') || + raw.startsWith('git://') + ) { + return { kind: 'git', url: raw, displayName: raw, rawInput: raw }; + } + + if (GH_SHORT_RE.test(raw)) { + const url = `https://github.com/${raw}`; + return { kind: 'git', url, displayName: raw, rawInput: raw }; + } + + // Treat anything else as a local path. + return { kind: 'local', path: path.resolve(raw), displayName: raw, rawInput: raw }; +} + +// Resolve a parsed descriptor into a usable source directory on disk. +// For local sources, copies into a fresh temp dir so installation can stage +// without touching the user's working tree. For git, shallow-clones into a +// temp dir (no shared cache for v1 — keeps install deterministic and avoids +// stale checkouts). +// +// Returns { dir, sha, ref, cleanup } where `sha` and `ref` are null for +// local sources and `cleanup()` removes the temp dir. +export async function materializeSource(descriptor, opts = {}) { + const { ref = null } = opts; + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-')); + + if (descriptor.kind === 'local') { + const srcStat = await fs.stat(descriptor.path).catch(() => null); + if (!srcStat || !srcStat.isDirectory()) { + throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`); + } + const dir = path.join(tmpRoot, 'src'); + await copyDir( + descriptor.path, + dir, + (rel) => rel === '.git' || rel.startsWith('.git/') || rel === 'node_modules' || rel.startsWith('node_modules/'), + ); + return { + dir, + sha: null, + ref: null, + cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + }; + } + + // git + const dir = path.join(tmpRoot, 'src'); + const args = ['clone', '--depth', '1']; + if (ref) args.push('--branch', ref); + args.push(descriptor.url, dir); + try { + await execFileP('git', args, { timeout: 120_000 }); + } catch (e) { + await fs.rm(tmpRoot, { recursive: true, force: true }); + throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed: ${e.stderr || e.message}`); + } + + let sha = null; + try { + const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd: dir }); + sha = stdout.trim(); + } catch { + // sha unknown — non-fatal, manifest will show null + } + + return { + dir, + sha, + ref, + cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + }; +} diff --git a/src/core-skills/bmad-module/scripts/list.mjs b/src/core-skills/bmad-module/scripts/list.mjs new file mode 100644 index 000000000..13fe89196 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/list.mjs @@ -0,0 +1,43 @@ +import { EXIT, BmadModuleError } from './lib/exit.mjs'; +import { findBmadDir } from './lib/bmad-dir.mjs'; +import { listModuleEntries } from './lib/manifest-ops.mjs'; + +// List community-source modules from manifest.yaml. Output is a fixed-width +// table; `--json` swaps in JSON for programmatic callers. +export async function runList(opts) { + const projectDir = opts.projectDir || process.cwd(); + const bmadDir = await findBmadDir(projectDir); + if (!bmadDir) { + throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}`); + } + + const modules = await listModuleEntries(bmadDir); + + if (opts.json) { + process.stdout.write(JSON.stringify({ modules }, null, 2) + '\n'); + return; + } + + if (modules.length === 0) { + process.stdout.write(`[bmad-module] no modules installed.\n`); + return; + } + + const rows = modules.map((p) => ({ + code: p.name, + name: p.moduleName || '-', + version: p.version || '-', + sha: p.sha ? p.sha.slice(0, 7) : '-', + source: p.repoUrl || p.rawSource || '-', + installed: p.installDate ? p.installDate.slice(0, 10) : '-', + })); + const cols = ['code', 'name', 'version', 'sha', 'source', 'installed']; + const widths = cols.reduce((acc, c) => { + acc[c] = Math.max(c.length, ...rows.map((r) => String(r[c]).length)); + return acc; + }, {}); + const fmt = (r) => cols.map((c) => String(r[c]).padEnd(widths[c])).join(' '); + process.stdout.write(fmt(Object.fromEntries(cols.map((c) => [c, c.toUpperCase()]))) + '\n'); + process.stdout.write(cols.map((c) => '-'.repeat(widths[c])).join(' ') + '\n'); + for (const r of rows) process.stdout.write(fmt(r) + '\n'); +} diff --git a/src/core-skills/bmad-module/scripts/remove.mjs b/src/core-skills/bmad-module/scripts/remove.mjs new file mode 100644 index 000000000..e03bdb558 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/remove.mjs @@ -0,0 +1,83 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { EXIT, BmadModuleError } from './lib/exit.mjs'; +import { findBmadDir } from './lib/bmad-dir.mjs'; +import { pruneEmptyDirs } from './lib/fs-safe.mjs'; +import { + readManifestYaml, + removeModuleFromManifest, + removeSkillManifestRows, + removeFilesManifestRows, + readFileEntriesForModule, +} from './lib/manifest-ops.mjs'; + +// Remove a module's installed files and manifest entries. With `--purge` also +// deletes `_bmad/custom//` (user customization dir). Without it, customs +// are preserved so a re-install picks them back up. +export async function runRemove(opts) { + const projectDir = opts.projectDir || process.cwd(); + const code = opts.code; + if (!code) throw new BmadModuleError(EXIT.USAGE, `bmad-module remove is required`); + + const bmadDir = await findBmadDir(projectDir); + if (!bmadDir) { + throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}`); + } + + const manifest = await readManifestYaml(bmadDir); + const entry = manifest?.modules?.find((m) => m && m.name === code); + if (!entry) { + throw new BmadModuleError(EXIT.NOT_INSTALLED, `no module "${code}" in manifest.yaml`); + } + if (entry.source !== 'community') { + throw new BmadModuleError( + EXIT.PREFIX_COLLISION, + `module "${code}" was installed as source="${entry.source}", not "community". ` + + `Use the appropriate uninstaller (e.g. \`bmad-method uninstall\`).`, + ); + } + + // Delete each file tracked in files-manifest.csv; prune empty dirs after. + const fileEntries = await readFileEntriesForModule(bmadDir, code); + const moduleRoot = path.join(bmadDir, code); + for (const fe of fileEntries) { + const abs = path.join(bmadDir, fe.path); + try { + await fs.rm(abs, { force: true }); + await pruneEmptyDirs(path.dirname(abs), moduleRoot); + } catch (e) { + process.stderr.write(`[bmad-module] warn: failed to remove ${fe.path}: ${e.message}\n`); + } + } + + // Remove the module root if it still exists (in case files-manifest was + // incomplete or empty). Safe — at this point we've confirmed source=community. + await fs.rm(moduleRoot, { recursive: true, force: true }); + + // Optionally purge custom overrides. + if (opts.purge) { + const customDir = path.join(bmadDir, 'custom', code); + await fs.rm(customDir, { recursive: true, force: true }); + } + + // Drop manifest rows. + await removeFilesManifestRows(bmadDir, code); + await removeSkillManifestRows(bmadDir, code); + await removeModuleFromManifest(bmadDir, code); + + process.stdout.write(`[bmad-module] removed ${code} (${fileEntries.length} file(s))\n`); + if (opts.purge) { + process.stdout.write(`[bmad-module] purged _bmad/custom/${code}/\n`); + } else if (await dirExists(path.join(bmadDir, 'custom', code))) { + process.stdout.write(`[bmad-module] preserved _bmad/custom/${code}/ (use --purge to remove)\n`); + } +} + +async function dirExists(p) { + try { + const s = await fs.stat(p); + return s.isDirectory(); + } catch { + return false; + } +} diff --git a/src/core-skills/bmad-module/scripts/update.mjs b/src/core-skills/bmad-module/scripts/update.mjs new file mode 100644 index 000000000..1eb6cc398 --- /dev/null +++ b/src/core-skills/bmad-module/scripts/update.mjs @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { EXIT, BmadModuleError } from './lib/exit.mjs'; +import { findBmadDir } from './lib/bmad-dir.mjs'; +import { parseSource, materializeSource } from './lib/source.mjs'; +import { readAndValidateManifest } from './lib/plugin-json.mjs'; +import { readUserIgnores, buildIgnoreMatcher, buildCopyList, validateDeclaredPaths } from './lib/install-plan.mjs'; +import { copyDir, atomicSwapDir, sha256File, pruneEmptyDirs } from './lib/fs-safe.mjs'; +import { + readManifestYaml, + addModuleToManifest, + appendSkillManifestRows, + appendFilesManifestRows, + removeSkillManifestRows, + removeFilesManifestRows, + readFileEntriesForModule, +} from './lib/manifest-ops.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. +// - Same sha → no-op. +// - Different sha → diff files-manifest rows; abort if any tracked file has +// been modified locally; otherwise install-over-top and prune removed. +export async function runUpdate(opts) { + const projectDir = opts.projectDir || process.cwd(); + const bmadDir = await findBmadDir(projectDir); + if (!bmadDir) { + throw new BmadModuleError(EXIT.NO_BMAD_DIR, `no _bmad/ found in ${projectDir}`); + } + + const manifest = await readManifestYaml(bmadDir); + const allModules = (manifest?.modules || []).filter((m) => m && m.source === 'community'); + + let targets; + if (opts.all) { + targets = allModules; + } else { + if (!opts.code) throw new BmadModuleError(EXIT.USAGE, `bmad-module update is required`); + const t = allModules.find((m) => m.name === opts.code); + if (!t) throw new BmadModuleError(EXIT.NOT_INSTALLED, `no module "${opts.code}" in manifest.yaml`); + targets = [t]; + } + + for (const entry of targets) { + await updateOne(bmadDir, projectDir, entry, opts); + } +} + +async function updateOne(bmadDir, projectDir, entry, opts) { + const code = entry.name; + if (!entry.rawSource) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `module ${code} has no rawSource in manifest.yaml — cannot re-resolve`); + } + const descriptor = parseSource(entry.rawSource); + const materialized = await materializeSource(descriptor, { ref: opts.ref || entry.ref || null }); + + try { + // No-op fast path. + if (materialized.sha && materialized.sha === entry.sha) { + process.stdout.write(`[bmad-module] ${code} already at ${materialized.sha.slice(0, 7)} — no-op.\n`); + return; + } + + const manifest = await readAndValidateManifest(materialized.dir); + if (manifest.bmad.code !== code) { + throw new BmadModuleError( + EXIT.PREFIX_COLLISION, + `source manifest declares bmad.code "${manifest.bmad.code}" but installed code is "${code}"`, + ); + } + + // Modified-file check: any tracked file whose on-disk hash diverges from + // the recorded one is treated as user-modified. Abort rather than clobber. + const oldEntries = await readFileEntriesForModule(bmadDir, code); + const modified = []; + for (const fe of oldEntries) { + const abs = path.join(bmadDir, fe.path); + const current = await sha256File(abs); + if (current === null) continue; + if (fe.hash && current !== fe.hash) modified.push(fe.path); + } + if (modified.length) { + throw new BmadModuleError( + EXIT.MODIFIED_FILES, + `update would overwrite ${modified.length} locally-modified file(s):\n ` + + modified.join('\n ') + + `\nMove your changes into _bmad/custom/${code}/ and re-run.`, + ); + } + + // Build new copy list, stage, swap. + validateDeclaredPaths(materialized.dir, manifest); + const userIgnores = await readUserIgnores(materialized.dir, manifest); + const matchIgnore = buildIgnoreMatcher(userIgnores); + const copyList = await buildCopyList(materialized.dir, matchIgnore); + + const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out'); + await copyDir(materialized.dir, stagedDir, (rel) => !copyList.includes(rel) && !isAncestorOfAny(rel, copyList)); + const targetDir = path.join(bmadDir, code); + try { + await atomicSwapDir(stagedDir, targetDir); + } catch (e) { + throw new BmadModuleError(EXIT.COMMIT_FAILURE, `failed to swap into ${targetDir}: ${e.message}`); + } + + // Manifest rewrites: remove old rows for this code, then re-append. + await removeSkillManifestRows(bmadDir, code); + await removeFilesManifestRows(bmadDir, code); + await addModuleToManifest(bmadDir, code, { + version: manifest.bmad.moduleVersion || manifest.version, + repoUrl: descriptor.kind === 'git' ? descriptor.url : null, + sha: materialized.sha, + ref: opts.ref || entry.ref, + channel: opts.channel || (opts.ref ? 'pinned' : entry.channel || (descriptor.kind === 'git' ? 'next' : null)), + rawSource: descriptor.rawInput, + moduleName: manifest.name, + }); + const skillDirs = Array.isArray(manifest.skills) ? manifest.skills.map((s) => (s.startsWith('./') ? s.slice(2) : s)) : []; + await appendSkillManifestRows(bmadDir, code, skillDirs); + await appendFilesManifestRows(bmadDir, code, copyList); + + // Prune empty dirs left behind from removed files. (The atomic swap of + // the module root already replaced everything; this is a no-op guard for + // the edge case where rm-then-mkdir leaves stale parents.) + await pruneEmptyDirs(targetDir, bmadDir); + + process.stdout.write( + `[bmad-module] updated ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`, + ); + process.stdout.write(`[bmad-module] previous ${oldEntries.length} file(s) → new ${copyList.length} file(s)\n`); + } finally { + await materialized.cleanup(); + } +} + +function isAncestorOfAny(rel, list) { + const prefix = rel + '/'; + for (const p of list) if (p.startsWith(prefix)) return true; + return false; +} diff --git a/src/core-skills/bmad-module/tests/fixtures/module-bad-missing-fields/.claude-plugin/plugin.json b/src/core-skills/bmad-module/tests/fixtures/module-bad-missing-fields/.claude-plugin/plugin.json new file mode 100644 index 000000000..d85aeda4e --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/module-bad-missing-fields/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "fixture-missing-fields", + "version": "0.1.0", + "description": "Negative fixture — missing the entire bmad object, so install-time validation fails." +} diff --git a/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/.claude-plugin/plugin.json b/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/.claude-plugin/plugin.json new file mode 100644 index 000000000..54d4e1210 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "fixture-bad-traversal", + "version": "0.1.0", + "description": "Negative fixture — declares a skill path that escapes the module root via '..'.", + "skills": ["../escape"], + "bmad": { + "specVersion": "1.0.0", + "code": "badpath", + "compatibility": { "bmadMethod": ">=6.6.0" } + } +} diff --git a/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/skills/skill-a/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/skills/skill-a/SKILL.md new file mode 100644 index 000000000..c95441743 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/module-bad-traversal/skills/skill-a/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-a +description: Stub skill that exists so the path-traversal check is exercised against the manifest's bogus entry, not a missing file. +--- + +Stub. diff --git a/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/.claude-plugin/plugin.json b/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/.claude-plugin/plugin.json new file mode 100644 index 000000000..6fe39cb2c --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "fixture-reserved-code", + "version": "0.1.0", + "description": "Negative fixture — declares bmad.code that collides with the reserved BMAD module 'bmm'.", + "skills": ["./skills/skill-a"], + "bmad": { + "specVersion": "1.0.0", + "code": "bmm", + "compatibility": { "bmadMethod": ">=6.6.0" } + } +} diff --git a/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/skills/skill-a/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/skills/skill-a/SKILL.md new file mode 100644 index 000000000..deb92ad8a --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/module-reserved-code/skills/skill-a/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-a +description: Stub skill for the reserved-code negative fixture. Never installed. +--- + +Stub. diff --git a/src/core-skills/bmad-module/tests/integration.test.sh b/src/core-skills/bmad-module/tests/integration.test.sh new file mode 100755 index 000000000..120fb94b4 --- /dev/null +++ b/src/core-skills/bmad-module/tests/integration.test.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# integration.test.sh — end-to-end smoke test for the bmad-module skill. +# +# Hermetic: fabricates a minimal _bmad/_config/manifest.yaml skeleton in a +# tmp dir and exercises every verb against the local reference modules and +# negative fixtures. Does NOT require BMAD-METHOD's installer; the upstream +# patch (§5) is verified separately. +# +# Run from anywhere: +# bash src/core-skills/bmad-module/tests/integration.test.sh +# +# Exit 0 on full pass; non-zero on first failed assertion (set -e). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_DIR="$(cd "${SKILL_DIR}/../../.." && pwd)" +MODULE_JS="${SKILL_DIR}/scripts/bmad-module.mjs" +EXAMPLES="${REPO_DIR}/examples" +FIXTURES="${SCRIPT_DIR}/fixtures" + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT +cd "${WORKDIR}" + +pass=0 +fail=0 + +note() { printf '\n\033[1m── %s\033[0m\n' "$*"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; pass=$((pass+1)); } +ko() { printf ' \033[31m✗\033[0m %s\n' "$*"; fail=$((fail+1)); } + +# Wrapper that captures stdout/stderr/exit code into globals. +run() { + set +e + STDOUT="$(node "${MODULE_JS}" "$@" 2>/tmp/bmad-module-stderr.$$)" + EXIT=$? + STDERR="$(cat /tmp/bmad-module-stderr.$$)" + rm -f /tmp/bmad-module-stderr.$$ + set -e +} + +assert_exit() { + local want=$1; local label=$2 + if [[ "${EXIT}" -eq "${want}" ]]; then ok "${label} → exit ${want}" + else ko "${label} → expected exit ${want}, got ${EXIT}. stderr: ${STDERR}" + fi +} + +assert_path_exists() { + if [[ -e "$1" ]]; then ok "exists: $1" + else ko "missing: $1" + fi +} + +assert_path_absent() { + if [[ ! -e "$1" ]]; then ok "absent: $1" + else ko "should be gone: $1" + fi +} + +assert_grep() { + local pat=$1; local file=$2 + if grep -q -E "$pat" "$file"; then ok "grep '$pat' in $(basename "$file")" + else ko "grep '$pat' NOT in $(basename "$file"); contents:\n$(cat "$file")" + fi +} + +# ─── Setup: fabricate _bmad/_config/manifest.yaml ──────────────────────────── + +note "setup: minimal _bmad/ skeleton" +mkdir -p _bmad/_config +mkdir -p _bmad/core _bmad/bmm +cat > _bmad/_config/manifest.yaml <<'YAML' +installation: + version: "v6.7.1" + installDate: "2026-05-21T00:00:00.000Z" + lastUpdated: "2026-05-21T00:00:00.000Z" +modules: + - name: core + version: "v6.7.1" + installDate: "2026-05-21T00:00:00.000Z" + lastUpdated: "2026-05-21T00:00:00.000Z" + source: built-in + npmPackage: null + repoUrl: null + - name: bmm + version: "v6.7.1" + installDate: "2026-05-21T00:00:00.000Z" + lastUpdated: "2026-05-21T00:00:00.000Z" + source: built-in + npmPackage: null + repoUrl: null +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 +ok "skeleton seeded at ${WORKDIR}/_bmad/" + +# ─── 1. list (empty) ───────────────────────────────────────────────────────── +note "list (no modules)" +run list +assert_exit 0 "list empty" +[[ "${STDOUT}" == *"no modules installed"* ]] && ok "stdout reports empty" \ + || ko "expected 'no modules installed' in stdout: ${STDOUT}" + +# ─── 2. dry-run install of minimal module ──────────────────────────────────── +note "install --dry-run examples/minimal/acme-md-lint" +run install "${EXAMPLES}/minimal/acme-md-lint" --dry-run +assert_exit 0 "dry-run install" +[[ "${STDOUT}" == *"dry-run"* ]] && ok "stdout mentions dry-run" \ + || ko "expected 'dry-run' in stdout: ${STDOUT}" +assert_path_absent "_bmad/mdlint" + +# ─── 3. real install of minimal module ─────────────────────────────────────── +note "install examples/minimal/acme-md-lint" +run install "${EXAMPLES}/minimal/acme-md-lint" +assert_exit 0 "install minimal" +assert_path_exists "_bmad/mdlint/.claude-plugin/plugin.json" +assert_path_exists "_bmad/mdlint/skills/acme-md-lint/SKILL.md" +assert_grep '^ - name: mdlint' "_bmad/_config/manifest.yaml" +assert_grep 'source: community' "_bmad/_config/manifest.yaml" +assert_grep '"acme-md-lint","acme-md-lint"' "_bmad/_config/skill-manifest.csv" +assert_grep ',"mdlint",' "_bmad/_config/files-manifest.csv" + +# ─── 4. list (one module) ──────────────────────────────────────────────────── +note "list (after minimal install)" +run list +assert_exit 0 "list one" +[[ "${STDOUT}" == *"mdlint"* ]] && ok "stdout includes mdlint" \ + || ko "expected 'mdlint' in stdout: ${STDOUT}" + +run list --json +assert_exit 0 "list --json" +[[ "${STDOUT}" == *"\"name\": \"mdlint\""* ]] && ok "json includes mdlint name" \ + || ko "expected mdlint in JSON: ${STDOUT}" + +# ─── 5. idempotent re-install ──────────────────────────────────────────────── +note "install acme-md-lint again (idempotent / collision)" +# Local sources have no sha, so the no-op fast path can't trigger — we hit +# the collision branch instead. Asserting exit 30 documents the v1 behavior: +# local re-installs require `update`. +run install "${EXAMPLES}/minimal/acme-md-lint" +assert_exit 30 "re-install collision" + +# ─── 6. negative: reserved-code fixture ────────────────────────────────────── +note "install module-reserved-code → exit 21" +run install "${FIXTURES}/module-reserved-code" +assert_exit 21 "reserved code" + +# ─── 7. negative: bad-traversal fixture ────────────────────────────────────── +note "install module-bad-traversal → exit 70" +run install "${FIXTURES}/module-bad-traversal" +assert_exit 70 "path traversal" + +# ─── 8. negative: missing-fields fixture ───────────────────────────────────── +note "install module-bad-missing-fields → exit 20" +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" +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" +assert_path_exists "_bmad/devlog/agents/changelog-archivist.md" +assert_path_exists "_bmad/devlog/hooks/hooks.json" +assert_path_exists "_bmad/devlog/.mcp.json" +# install.ignore excludes docs/ and tests/ and README.md / CHANGELOG.md +assert_path_absent "_bmad/devlog/docs" +assert_path_absent "_bmad/devlog/README.md" +assert_path_absent "_bmad/devlog/CHANGELOG.md" +[[ "${STDOUT}" == *"hooks"* ]] && ok "warns about hooks not auto-activated" \ + || ko "expected hooks warning in stdout: ${STDOUT}" + +# ─── 10. remove minimal (no purge), preserve custom ───────────────────────── +note "create _bmad/custom/mdlint to test preservation, then remove" +mkdir -p _bmad/custom/mdlint +echo "user override" > _bmad/custom/mdlint/override.md +run remove mdlint +assert_exit 0 "remove mdlint" +assert_path_absent "_bmad/mdlint" +assert_path_exists "_bmad/custom/mdlint/override.md" +[[ "${STDOUT}" == *"preserved"* ]] && ok "stdout mentions preserved customs" \ + || ko "expected 'preserved' in stdout: ${STDOUT}" +# manifest rows for mdlint should be gone +grep -q ',"mdlint",' _bmad/_config/files-manifest.csv && \ + ko "mdlint rows still in files-manifest.csv" || ok "files-manifest.csv pruned" +grep -q '"acme-md-lint"' _bmad/_config/skill-manifest.csv && \ + ko "acme-md-lint row still in skill-manifest.csv" || ok "skill-manifest.csv pruned" + +# ─── 11. remove --purge ────────────────────────────────────────────────────── +note "remove devlog --purge" +mkdir -p _bmad/custom/devlog +echo "user override" > _bmad/custom/devlog/override.md +run remove devlog --purge +assert_exit 0 "remove --purge" +assert_path_absent "_bmad/devlog" +assert_path_absent "_bmad/custom/devlog" + +# ─── 12. remove unknown ────────────────────────────────────────────────────── +note "remove unknown code" +run remove nope +assert_exit 90 "remove unknown" + +# ─── Summary ───────────────────────────────────────────────────────────────── +echo +echo "──────────────────────────────────────────────────────────────────────" +printf ' %d pass · %d fail\n' "${pass}" "${fail}" +if [[ "${fail}" -gt 0 ]]; then + echo " FAIL" + exit 1 +fi +echo " OK"