diff --git a/src/core-skills/bmad-module/README.md b/src/core-skills/bmad-module/README.md index a7682c5dd..a7da3ed84 100644 --- a/src/core-skills/bmad-module/README.md +++ b/src/core-skills/bmad-module/README.md @@ -5,13 +5,14 @@ The core BMAD skill for installing, updating, removing, and listing community BM ## 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. +- **Legacy modules** (a `.claude-plugin/marketplace.json` + `module.yaml`, the pre-`plugin.json` format) also install: `install` resolves a legacy repo into a synthetic manifest and runs it through the same pipeline. See `lib/legacy-resolver.mjs`, a self-contained port of the full installer's `PluginResolver` strategies. - **Users** install via this skill — no CLI required. Modules are staged under `_bmad//`, then their skills are distributed to the coding assistants the user chose at `bmad install` time (the `ides:` list in `_bmad/_config/manifest.yaml`), exactly like official modules. - **BMAD-METHOD** treats community-installed modules as a new `source: 'community'` row in `manifest.yaml`; re-running `bmad install` preserves them (`manifest-generator.js` carries `source: 'community'` rows through regeneration). ## Verbs ``` -bmad-module install [--ref ] [--channel ] [--dry-run] +bmad-module install [--ref ] [--channel ] [--module ] [--dry-run] bmad-module update [--ref ] [--channel ] bmad-module remove [--purge] bmad-module list [--json] @@ -26,6 +27,7 @@ bmad-module list [--json] - **`remove`** without `--purge` preserves `_bmad/custom//` so a re-install picks the customizations back up. `--purge` deletes them. Remove also prunes the module's skills from every configured IDE. - **IDE distribution** runs after every install/update/remove via a self-contained bundle of BMAD's real IDE engine, shipped at `lib/vendor/ide-sync.mjs` (built from `tools/installer/ide/*` by `lib/vendor/build-ide-sync.mjs`, gated by `vendor:check`). The skill execs it locally — no npx, no network. The same engine also backs the `bmad ide-sync` CLI command and the full installer's IDE setup, so all three stay in lockstep. If the bundle is unreachable on an older install, the skill says so and points the user at `bmad ide-sync`. - **Hooks / MCP / LSP / Claude subagents** declared in the module manifest are _copied_ but NOT auto-activated by this skill (they are Claude Code plugin surfaces, not skills). Use Claude Code's plugin manager to wire them up. +- **Legacy resolution** keys off the absence of a `plugin.json#bmad`: if `marketplace.json` is present, the skill resolves the module via `module.yaml` (or synthesizes one from SKILL.md frontmatter when none exists). A repo defining more than one module exits 20 with the available codes; re-run with `--module `. The reserved-code guard (exit 21) is relaxed on the legacy path so first-party modules (`gds`, `bmm`, …) install; current-spec `plugin.json` authors still get exit 21. ## Implementation @@ -40,4 +42,4 @@ See `SKILL.md` for the full table. The script's stderr always names the conditio ## 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/`. +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/`; legacy-format fixtures (strategy-1 module files, a reserved code, and the synthesize fallback) are under `tests/fixtures/examples/legacy/`. diff --git a/src/core-skills/bmad-module/SKILL.md b/src/core-skills/bmad-module/SKILL.md index 64a67feec..bfe83a9fe 100644 --- a/src/core-skills/bmad-module/SKILL.md +++ b/src/core-skills/bmad-module/SKILL.md @@ -5,7 +5,7 @@ description: Install, update, remove, or list community BMAD modules. Use when t # bmad-module -Manage community BMAD modules — installable packages of skills, agents, and supporting assets that ship as standalone GitHub repos. Modules are staged under `_bmad//` and tracked in the existing manifests. On `install`, `update`, and `remove`, the script distributes (or prunes) the module's skills to **every coding assistant the user selected at `bmad install`** — read from the `ides:` list in `_bmad/_config/manifest.yaml` — so the module lands in Claude Code, Cursor, Copilot, etc. The canonical end state is skills living in the IDE directories (e.g. `.claude/skills//`), not in `_bmad/`. The same artifact also loads as a Claude Code plugin via its `.claude-plugin/plugin.json` manifest. +Manage community BMAD modules — installable packages of skills, agents, and supporting assets that ship as standalone GitHub repos. Both module formats install: the current spec (a `.claude-plugin/plugin.json` with a `bmad{}` block) and the **legacy** format (a `.claude-plugin/marketplace.json` + `module.yaml`, e.g. `bmad-code-org/bmad-module-game-dev-studio`) — the script resolves a legacy repo into the same on-disk layout automatically. Modules are staged under `_bmad//` and tracked in the existing manifests. On `install`, `update`, and `remove`, the script distributes (or prunes) the module's skills to **every coding assistant the user selected at `bmad install`** — read from the `ides:` list in `_bmad/_config/manifest.yaml` — so the module lands in Claude Code, Cursor, Copilot, etc. The canonical end state is skills living in the IDE directories (e.g. `.claude/skills//`), not in `_bmad/`. The same artifact also loads as a Claude Code plugin via its `.claude-plugin/plugin.json` manifest. The script also completes the install in place, best-effort: it runs `npm install` when the module ships a `package.json` (skip with `bmad.install.skipNpm: true`), generates the module's `[modules.]` / `[agents.]` config blocks from its `module.yaml` (overridable with `--set`), creates the working directories it declares under `directories:`, and rebuilds `_bmad/_config/bmad-help.csv` so its skills appear in `bmad-help`. A failure in any of these is reported as a warning, not a failed install. Interactive config refinement remains the job of the module's `postInstallSkill`, if it declares one. @@ -33,7 +33,7 @@ If the verb is ambiguous (e.g. the user says "manage modules"), ASK which verb t ### Step 2 — Parse the args -- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref `, `--channel `, `--set .=` (override a module config answer; repeatable), `--dry-run`. +- **install:** the user supplies `` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref `, `--channel `, `--set .=` (override a module config answer; repeatable), `--module `, `--dry-run`. Use `--module ` only when a legacy marketplace.json repo defines more than one module: the script exits 20 listing the available codes, then re-run picking one. First-party legacy modules whose codes are reserved (`gds`, `bmm`, …) install on the legacy path; the same reserved code in a current-spec `plugin.json` is still rejected (exit 21). - **update:** the user supplies `` (the `_bmad//` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--channel `, `--set .=`. - **remove:** the user supplies ``. Use `--purge` only if they explicitly say "also remove customizations" or "purge". - **list:** no args. Use `--json` if the user asks for machine-readable. @@ -78,12 +78,12 @@ On non-zero exit: print the exit code, the stderr message, and stop. Do not sugg The script's stderr always names the condition, so for most non-zero exits you just relay it (see CRITICAL RULES). These few change what you tell the user next: -| Code | Meaning | What to tell the user | -| ---- | ------------------------------------------------------------------- | ----------------------------------------------------------------- | -| 5 | skill runtime files missing/corrupt — NOT a module rejection | reinstall the skill (relay the script's guidance) | -| 10 | no `_bmad/` directory in project | run `bmad install` first | -| 80 | update aborted: locally modified files would be overwritten | move overrides into `_bmad/custom//`, then retry | -| 90 | no such installed module (for `update`/`remove`) | check the code, or run `list` to see what's installed | +| Code | Meaning | What to tell the user | +| ---- | ------------------------------------------------------------ | ------------------------------------------------------ | +| 5 | skill runtime files missing/corrupt — NOT a module rejection | reinstall the skill (relay the script's guidance) | +| 10 | no `_bmad/` directory in project | run `bmad install` first | +| 80 | update aborted: locally modified files would be overwritten | move overrides into `_bmad/custom//`, then retry | +| 90 | no such installed module (for `update`/`remove`) | check the code, or run `list` to see what's installed | Any other non-zero exit: report the code and stderr verbatim and stop — stderr names the condition. For the full list of codes, run the script with `--help`. diff --git a/src/core-skills/bmad-module/scripts/cli.mjs b/src/core-skills/bmad-module/scripts/cli.mjs index 531e78d5e..293d7f49b 100644 --- a/src/core-skills/bmad-module/scripts/cli.mjs +++ b/src/core-skills/bmad-module/scripts/cli.mjs @@ -4,7 +4,7 @@ // setup error instead of leaking a raw ESM stack trace. See bmad-module.mjs. // // Usage: -// node bmad-module.mjs install [--ref ] [--channel ] [--dry-run] [--project-dir

] +// node bmad-module.mjs install [--ref ] [--channel ] [--module ] [--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

] @@ -86,6 +86,7 @@ export async function main() { source: parsed._[0], ref: parsed.flags['ref'] || null, channel: parsed.flags['channel'] || null, + module: parsed.flags['module'] || null, dryRun: !!parsed.flags['dry-run'], setOverrides, projectDir, @@ -149,11 +150,15 @@ function printUsage() { process.stderr.write(`bmad-module — install, update, remove, or list BMAD community modules. USAGE - bmad-module install [--ref ] [--channel ] [--set .=] [--dry-run] + bmad-module install [--ref ] [--channel ] [--module ] [--set .=] [--dry-run] bmad-module update [--ref ] [--channel ] [--set .=] bmad-module remove [--purge] bmad-module list [--json] +INSTALL FLAGS + --module Pick one module by code when a legacy marketplace.json + repo resolves to more than one + GLOBAL FLAGS --project-dir Project root containing _bmad/ (default: cwd) --set .= Override a module config answer (repeatable) @@ -162,6 +167,7 @@ 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 install bmad-code-org/bmad-module-game-dev-studio # legacy module bmad-module list bmad-module update devlog bmad-module remove mdlint --purge diff --git a/src/core-skills/bmad-module/scripts/install.mjs b/src/core-skills/bmad-module/scripts/install.mjs index 54e4a7fd9..53471fed7 100644 --- a/src/core-skills/bmad-module/scripts/install.mjs +++ b/src/core-skills/bmad-module/scripts/install.mjs @@ -1,8 +1,10 @@ import path from 'node:path'; import { EXIT, BmadModuleError } from './lib/exit.mjs'; import { findBmadDir, ensureConfigDir } from './lib/bmad-dir.mjs'; +import fsp from 'node:fs/promises'; import { parseSource, materializeSource } from './lib/source.mjs'; -import { readAndValidateManifest } from './lib/plugin-json.mjs'; +import { readAndValidateManifest, validateManifestObject, hasBmadPluginJson } from './lib/plugin-json.mjs'; +import { resolveLegacyModule } from './lib/legacy-resolver.mjs'; import { readUserIgnores, buildIgnoreMatcher, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs'; import { stageCopyPlan, atomicSwapDir } from './lib/fs-safe.mjs'; import { readManifestYaml, addModuleToManifest, appendSkillManifestRows, appendFilesManifestRows } from './lib/manifest-ops.mjs'; @@ -13,8 +15,9 @@ 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 } -// Returns nothing; throws BmadModuleError on failure. +// { source, ref, sha, channel, dryRun, module, setOverrides, projectDir } +// `module` selects one module by code when a legacy marketplace.json resolves to +// more than one. Returns nothing; throws BmadModuleError on failure. export async function runInstall(opts) { const projectDir = opts.projectDir || process.cwd(); @@ -30,8 +33,28 @@ export async function runInstall(opts) { const materialized = await materializeSource(descriptor, { ref: opts.ref || null }); try { - // §3. Read + validate plugin.json. - const manifest = await readAndValidateManifest(materialized.dir); + // §3. Read + validate the manifest. New-spec modules carry a + // `.claude-plugin/plugin.json#bmad`; legacy modules carry a + // `.claude-plugin/marketplace.json` + module.yaml, which we resolve into a + // synthetic manifest of the same shape. + let manifest; + let synthesized = null; + if (await hasBmadPluginJson(materialized.dir)) { + manifest = await readAndValidateManifest(materialized.dir); + } else { + const legacy = await resolveLegacyModule(materialized.dir, { selector: opts.module || null }); + if (!legacy) { + throw new BmadModuleError( + EXIT.BAD_MANIFEST, + `no .claude-plugin/plugin.json#bmad and no .claude-plugin/marketplace.json at ${materialized.dir}`, + ); + } + // Legacy first-party modules (gds, bmm, …) legitimately use reserved codes. + validateManifestObject(legacy.manifest, { allowReserved: true }); + manifest = legacy.manifest; + synthesized = legacy.synthesized; + process.stdout.write(`[bmad-module] resolved legacy module ${manifest.bmad.code} from marketplace.json\n`); + } const code = manifest.bmad.code; // §4. Collision check against installed manifest. @@ -62,6 +85,19 @@ export async function runInstall(opts) { ); } + // Strategy-5 legacy modules have no module.yaml/module-help.csv on disk — + // the resolver synthesized them. Write them into the throwaway temp source so + // buildCopyPlan/validateDeclaredPaths discover them via the normal path (the + // synthetic manifest already points moduleDefinition/moduleHelpCsv at them). + if (synthesized) { + if (synthesized['module.yaml']) { + await fsp.writeFile(path.join(materialized.dir, 'module.yaml'), synthesized['module.yaml'], 'utf8'); + } + if (synthesized['module-help.csv']) { + await fsp.writeFile(path.join(materialized.dir, 'module-help.csv'), synthesized['module-help.csv'], 'utf8'); + } + } + // §5. Build install plan. validateDeclaredPaths(materialized.dir, manifest); const userIgnores = await readUserIgnores(materialized.dir, manifest); diff --git a/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs b/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs index 0252c9f7a..60b74b1d3 100644 --- a/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs +++ b/src/core-skills/bmad-module/scripts/lib/help-catalog.mjs @@ -15,7 +15,7 @@ import path from 'node:path'; // 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 = +export 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; diff --git a/src/core-skills/bmad-module/scripts/lib/legacy-resolver.mjs b/src/core-skills/bmad-module/scripts/lib/legacy-resolver.mjs new file mode 100644 index 000000000..959cbdd8e --- /dev/null +++ b/src/core-skills/bmad-module/scripts/lib/legacy-resolver.mjs @@ -0,0 +1,384 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseYaml } from './vendor/yaml.mjs'; +import { valid as semverValid } from './semver-lite.mjs'; +import { safePathInsideRoot } from './fs-safe.mjs'; +import { MODULE_HELP_CSV_HEADER } from './help-catalog.mjs'; +import { EXIT, BmadModuleError } from './exit.mjs'; + +// Resolve a LEGACY BMAD module (marketplace.json + module.yaml) into a synthetic +// manifest of the same shape readAndValidateManifest produces, so the install +// pipeline (validateDeclaredPaths → buildCopyPlan → rewriteManifestPaths → …) +// handles it with no special-casing. This is a self-contained port of the full +// installer's PluginResolver (tools/installer/modules/plugin-resolver.js) +// strategies 1–5; the skill must not import from tools/installer (it ships +// standalone under .claude/skills/). +// +// Strategies, tried per marketplace plugin in order: +// 1. Module files (module.yaml + module-help.csv) at the skills' common parent +// or any directory between there and the repo root. +// 2. A `*-setup` skill with assets/module.yaml + assets/module-help.csv. +// 3. A single standalone skill with both files in its assets/. +// 4. Multiple standalone skills, each with both files → one module each. +// 5. Fallback: synthesize module.yaml + module-help.csv from marketplace.json +// metadata and SKILL.md frontmatter. +// +// Returns null when there is no marketplace.json (caller emits the normal +// BAD_MANIFEST). Returns { manifest, synthesized } on success, where +// `synthesized` is { 'module.yaml': string|null, 'module-help.csv': string|null } +// (non-null only for strategy 5, which the caller writes into the temp source +// dir before buildCopyPlan reads it). Throws BmadModuleError(BAD_MANIFEST) on an +// unparseable marketplace.json, when nothing resolves, or on multi-module +// ambiguity that `selector` does not disambiguate. +export async function resolveLegacyModule(sourceDir, { selector = null } = {}) { + const mpPath = path.join(sourceDir, '.claude-plugin', 'marketplace.json'); + let raw; + try { + raw = await fs.readFile(mpPath, 'utf8'); + } catch { + return null; + } + let mp; + try { + mp = JSON.parse(raw); + } catch (e) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `.claude-plugin/marketplace.json failed to parse: ${e.message}`); + } + const plugins = Array.isArray(mp.plugins) ? mp.plugins : []; + if (plugins.length === 0) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `marketplace.json declares no plugins`); + } + + const candidates = []; + for (const plugin of plugins) { + if (!plugin || typeof plugin !== 'object') continue; + const skillPaths = await resolveSkillPaths(sourceDir, plugin.skills || []); + if (skillPaths.length === 0) continue; // plugin contributes no installable skills + const resolved = + (await tryRootModuleFiles(sourceDir, plugin, skillPaths)) || + (await trySetupSkill(sourceDir, plugin, skillPaths)) || + (await trySingleStandalone(sourceDir, plugin, skillPaths)) || + (await tryMultipleStandalone(sourceDir, plugin, skillPaths)) || + (await synthesizeFallback(sourceDir, plugin, skillPaths)); + candidates.push(...resolved); + } + + if (candidates.length === 0) { + throw new BmadModuleError(EXIT.BAD_MANIFEST, `marketplace.json resolved no installable module (no skills found on disk)`); + } + + const pick = selectModule(candidates, selector); + return toSyntheticManifest(pick, sourceDir); +} + +// ─── Skill-path resolution ─────────────────────────────────────────────────── + +// Map a plugin's skills[] (repo-relative, ./-prefixed) to source-relative POSIX +// paths that exist on disk and stay inside the source root. Mirrors +// plugin-resolver.js:60-71. +async function resolveSkillPaths(sourceDir, skillRel) { + const out = []; + for (const rel of skillRel) { + if (typeof rel !== 'string') continue; + const normalized = rel.replace(/^\.\//, ''); + const abs = safePathInsideRoot(sourceDir, normalized); + if (!abs) continue; // traversal / absolute path — skip + if (await exists(abs)) out.push(toRel(sourceDir, abs)); + } + return out; +} + +// ─── Strategy 1: root module files (walk up from skills' common parent) ─────── + +async function tryRootModuleFiles(sourceDir, plugin, skillRelPaths) { + const commonParentAbs = computeCommonParent(skillRelPaths.map((r) => path.resolve(sourceDir, r))); + const candidates = await findModuleFilesUpward(commonParentAbs, sourceDir); + if (candidates.length === 0) return null; + // Deepest candidate (closest to the skills) is the safe default; a CLI has no + // interactive picker so we don't prompt between chain candidates. + const { moduleYamlAbs, moduleHelpAbs } = candidates[0]; + const data = await readModuleYaml(moduleYamlAbs); + if (!data) return null; + return [ + makeCandidate(plugin, data, skillRelPaths, { + moduleYamlRel: toRel(sourceDir, moduleYamlAbs), + moduleHelpCsvRel: toRel(sourceDir, moduleHelpAbs), + }), + ]; +} + +// ─── Strategy 2: -setup skill with assets/module.yaml ───────────────────────── + +async function trySetupSkill(sourceDir, plugin, skillRelPaths) { + for (const skillRel of skillRelPaths) { + if (!path.posix.basename(skillRel).endsWith('-setup')) continue; + const found = await skillAssets(sourceDir, skillRel); + if (!found) continue; + const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel)); + if (!data) continue; + return [makeCandidate(plugin, data, skillRelPaths, found)]; + } + return null; +} + +// ─── Strategy 3: single standalone skill ────────────────────────────────────── + +async function trySingleStandalone(sourceDir, plugin, skillRelPaths) { + if (skillRelPaths.length !== 1) return null; + const found = await skillAssets(sourceDir, skillRelPaths[0]); + if (!found) return null; + const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel)); + if (!data) return null; + return [makeCandidate(plugin, data, skillRelPaths, found)]; +} + +// ─── Strategy 4: multiple standalone skills, each its own module ─────────────── + +async function tryMultipleStandalone(sourceDir, plugin, skillRelPaths) { + if (skillRelPaths.length < 2) return null; + const resolved = []; + for (const skillRel of skillRelPaths) { + const found = await skillAssets(sourceDir, skillRel); + if (!found) continue; + const data = await readModuleYaml(path.resolve(sourceDir, found.moduleYamlRel)); + if (!data) continue; + resolved.push( + makeCandidate({ ...plugin }, data, [skillRel], found, { + fallbackCode: path.posix.basename(skillRel), + }), + ); + } + // Only use strategy 4 if EVERY skill carries module files; otherwise fall + // through to the synthesizer (mirrors plugin-resolver.js:349-355). + return resolved.length === skillRelPaths.length ? resolved : null; +} + +// ─── Strategy 5: synthesize from marketplace.json + SKILL.md frontmatter ────── + +async function synthesizeFallback(sourceDir, plugin, skillRelPaths) { + const skillInfos = []; + for (const skillRel of skillRelPaths) { + const fm = await parseSkillFrontmatter(path.resolve(sourceDir, skillRel)); + skillInfos.push({ + dirName: path.posix.basename(skillRel), + name: fm.name || path.posix.basename(skillRel), + description: fm.description || '', + }); + } + const code = plugin.name || path.posix.basename(skillRelPaths[0]); + const moduleName = formatDisplayName(code); + const synthesizedYaml = + `code: ${code}\n` + + `name: ${JSON.stringify(moduleName)}\n` + + `description: ${JSON.stringify(plugin.description || '')}\n` + + `module_version: ${plugin.version || '1.0.0'}\n`; + const synthesizedCsv = buildSynthesizedHelpCsv(moduleName, skillInfos); + return [ + { + code, + name: moduleName, + version: plugin.version || null, + description: plugin.description || '', + pluginName: plugin.name, + skillRelPaths, + moduleYamlRel: 'module.yaml', + moduleHelpCsvRel: 'module-help.csv', + synthesizedYaml, + synthesizedCsv, + }, + ]; +} + +// ─── Candidate selection ────────────────────────────────────────────────────── + +function selectModule(candidates, selector) { + if (candidates.length === 1) return candidates[0]; + const codes = candidates.map((c) => c.code); + if (selector) { + const matches = candidates.filter((c) => c.code === selector); + if (matches.length === 1) return matches[0]; + throw new BmadModuleError(EXIT.BAD_MANIFEST, `no module with code "${selector}" in this repo. Available: ${codes.join(', ')}.`); + } + throw new BmadModuleError(EXIT.BAD_MANIFEST, `this repo defines multiple modules: ${codes.join(', ')}. Re-run with --module .`); +} + +// ─── Synthetic manifest builder ─────────────────────────────────────────────── + +function toSyntheticManifest(pick, _sourceDir) { + const name = sanitizeName(pick.pluginName || pick.code); + const version = semverValid(pick.version) ? pick.version : '0.0.0'; + const manifest = { + name, + version, + description: pick.description || '', + skills: pick.skillRelPaths.map((p) => `./${p}`), + bmad: { + specVersion: '1.0.0', + code: pick.code, + compatibility: { bmadMethod: '>=6.0.0' }, + moduleVersion: pick.version || version, + moduleDefinition: `./${pick.moduleYamlRel}`, + moduleHelpCsv: `./${pick.moduleHelpCsvRel}`, + }, + }; + return { + manifest, + synthesized: { + 'module.yaml': pick.synthesizedYaml || null, + 'module-help.csv': pick.synthesizedCsv || null, + }, + }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +// Normalize a module.yaml + plugin pair into the intermediate candidate shape. +function makeCandidate(plugin, data, skillRelPaths, files, { fallbackCode } = {}) { + return { + code: data.code || fallbackCode || plugin.name, + name: data.name || plugin.name, + version: plugin.version || data.module_version || null, + description: data.description || plugin.description || '', + pluginName: plugin.name, + skillRelPaths, + moduleYamlRel: files.moduleYamlRel, + moduleHelpCsvRel: files.moduleHelpCsvRel, + synthesizedYaml: null, + synthesizedCsv: null, + }; +} + +// Return assets/module.yaml + assets/module-help.csv under a skill dir when both +// exist, as source-relative POSIX paths; else null. +async function skillAssets(sourceDir, skillRel) { + const moduleYamlRel = path.posix.join(skillRel, 'assets', 'module.yaml'); + const moduleHelpCsvRel = path.posix.join(skillRel, 'assets', 'module-help.csv'); + if (!(await exists(path.resolve(sourceDir, moduleYamlRel)))) return null; + if (!(await exists(path.resolve(sourceDir, moduleHelpCsvRel)))) return null; + return { moduleYamlRel, moduleHelpCsvRel }; +} + +// Walk from startDirAbs up to the source root, collecting dirs that contain BOTH +// module.yaml and module-help.csv. Deepest-first; bounded by sourceDir. +async function findModuleFilesUpward(startDirAbs, sourceDir) { + const root = path.resolve(sourceDir); + let dir = path.resolve(startDirAbs); + if (dir !== root && !dir.startsWith(root + path.sep)) dir = root; + const out = []; + while (true) { + const moduleYamlAbs = path.join(dir, 'module.yaml'); + const moduleHelpAbs = path.join(dir, 'module-help.csv'); + if ((await exists(moduleYamlAbs)) && (await exists(moduleHelpAbs))) { + out.push({ moduleYamlAbs, moduleHelpAbs }); + } + if (dir === root) break; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return out; +} + +// Deepest common ancestor of absolute paths. Single path → its dirname. +function computeCommonParent(absPaths) { + if (absPaths.length === 0) return '/'; + if (absPaths.length === 1) return path.dirname(absPaths[0]); + const segments = absPaths.map((p) => p.split(path.sep)); + const minLen = Math.min(...segments.map((s) => s.length)); + const common = []; + for (let i = 0; i < minLen; i++) { + const seg = segments[0][i]; + if (segments.every((s) => s[i] === seg)) common.push(seg); + else break; + } + return common.join(path.sep) || '/'; +} + +async function readModuleYaml(yamlAbs) { + try { + return parseYaml(await fs.readFile(yamlAbs, 'utf8')); + } catch { + return null; + } +} + +async function parseSkillFrontmatter(skillDirAbs) { + try { + const content = await fs.readFile(path.join(skillDirAbs, 'SKILL.md'), 'utf8'); + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) return { name: '', description: '' }; + const parsed = parseYaml(match[1]) || {}; + return { name: parsed.name || '', description: parsed.description || '' }; + } catch { + return { name: '', description: '' }; + } +} + +function buildSynthesizedHelpCsv(moduleName, skillInfos) { + const rows = [MODULE_HELP_CSV_HEADER]; + for (const info of skillInfos) { + const displayName = formatDisplayName(info.name || info.dirName); + const menuCode = generateMenuCode(info.name || info.dirName); + const description = escapeCsvField(info.description); + rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`); + } + return rows.join('\n') + '\n'; +} + +function formatDisplayName(name) { + const cleaned = String(name || '') + .replace(/^bmad-agent-/, '') + .replace(/^bmad-/, ''); + return cleaned + .split(/[-_]/) + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +function generateMenuCode(name) { + const cleaned = String(name || '') + .replace(/^bmad-agent-/, '') + .replace(/^bmad-/, ''); + return cleaned + .split(/[-_]/) + .filter((w) => w.length > 0) + .map((w) => w.charAt(0).toUpperCase()) + .join('') + .slice(0, 3); +} + +function escapeCsvField(value) { + if (!value) return ''; + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replaceAll('"', '""')}"`; + } + return value; +} + +// Coerce an arbitrary plugin/module name into a manifest `name` that passes +// NAME_REGEX (/^[a-z][a-z0-9-]+$/, 3–64 chars): lowercase, non-[a-z0-9-] → '-', +// collapse and trim dashes, ensure it starts with a letter and is ≥3 chars. +function sanitizeName(raw) { + let s = String(raw || '') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + if (!/^[a-z]/.test(s)) s = `bmad-${s}`.replace(/-+/g, '-').replace(/^-+|-+$/g, ''); + if (s.length < 3) s = `bmad-module-${s}`.replace(/-+$/g, ''); + return s.slice(0, 64).replace(/-+$/g, ''); +} + +function toRel(sourceDir, abs) { + return path.relative(sourceDir, abs).split(path.sep).join('/'); +} + +async function exists(abs) { + try { + await fs.access(abs); + return true; + } catch { + return false; + } +} diff --git a/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs b/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs index 2ff656193..998ea53ce 100644 --- a/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs +++ b/src/core-skills/bmad-module/scripts/lib/plugin-json.mjs @@ -44,7 +44,16 @@ export async function readAndValidateManifest(sourceDir) { } catch (e) { throw new BmadModuleError(EXIT.BAD_MANIFEST, `plugin.json failed to parse: ${e.message}`); } + return validateManifestObject(m); +} +// Validate an already-parsed manifest object against the install-time rules. +// Shared by readAndValidateManifest (new-spec, from disk) and the legacy +// resolver (which synthesizes a manifest from marketplace.json + module.yaml). +// `allowReserved` lets the legacy path install first-party modules whose codes +// (gds, bmm, cis, …) are reserved against new-spec community authors. Returns +// the validated object. +export function validateManifestObject(m, { allowReserved = false } = {}) { const missing = []; if (typeof m.name !== 'string') missing.push('name'); if (typeof m.version !== 'string') missing.push('version'); @@ -69,7 +78,7 @@ export async function readAndValidateManifest(sourceDir) { 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)) { + if (!allowReserved && RESERVED_CODES.has(m.bmad.code)) { throw new BmadModuleError(EXIT.RESERVED_PREFIX, `plugin.json#bmad.code "${m.bmad.code}" is reserved`); } if (!semverValidRange(m.bmad.compatibility.bmadMethod)) { @@ -81,3 +90,24 @@ export async function readAndValidateManifest(sourceDir) { return m; } + +// Probe whether a source dir carries a new-spec manifest — a parseable +// `.claude-plugin/plugin.json` with a `bmad{}` block. Returns false when the +// file is absent or has no `bmad` object (→ caller tries the legacy resolver), +// and true on parse failure so a malformed new manifest surfaces via +// readAndValidateManifest rather than being silently treated as legacy. +export async function hasBmadPluginJson(sourceDir) { + const manifestPath = path.join(sourceDir, '.claude-plugin', 'plugin.json'); + let raw; + try { + raw = await fs.readFile(manifestPath, 'utf8'); + } catch { + return false; + } + try { + const m = JSON.parse(raw); + return !!(m && typeof m === 'object' && m.bmad && typeof m.bmad === 'object'); + } catch { + return true; + } +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/.claude-plugin/marketplace.json b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/.claude-plugin/marketplace.json new file mode 100644 index 000000000..8a605b9a7 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "bmad-mini-legacy", + "owner": { "name": "Test Author" }, + "license": "MIT", + "plugins": [ + { + "name": "bmad-mini-legacy", + "source": "./", + "description": "A minimal legacy module for testing the marketplace.json install path.", + "version": "0.2.0", + "skills": ["./src/agents/mlg-agent-one", "./src/workflows/mlg-flow"] + } + ] +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/docs/guide.md b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/docs/guide.md new file mode 100644 index 000000000..58e54c5ed --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/docs/guide.md @@ -0,0 +1,4 @@ +# Mini Legacy Guide + +This file lives under `docs/` and must NOT be copied into the installed module — +it proves that undeclared top-level directories are dropped by buildCopyPlan. diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/agents/mlg-agent-one/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/agents/mlg-agent-one/SKILL.md new file mode 100644 index 000000000..4d84a1793 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/agents/mlg-agent-one/SKILL.md @@ -0,0 +1,9 @@ +--- +name: mlg-agent-one +description: A tiny agent that exists to exercise the legacy install path. Use in tests only. +--- + +# Mini Agent + +This agent does nothing useful — it only proves that a legacy skill directory +under `src/agents/` is flattened to `skills/mlg-agent-one/` on install. diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module-help.csv b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module-help.csv new file mode 100644 index 000000000..a1601dc19 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module-help.csv @@ -0,0 +1,3 @@ +module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs +mlg,mlg-agent-one,Mini Agent,ma,A tiny agent for legacy-path testing.,mlg-agent-one,,anytime,,,,, +mlg,mlg-flow,Mini Flow,mf,A tiny workflow for legacy-path testing.,mlg-flow,,anytime,,,,{artifacts_path},result.md diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module.yaml b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module.yaml new file mode 100644 index 000000000..e78cab181 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/module.yaml @@ -0,0 +1,26 @@ +code: mlg +name: "MLG: Mini Legacy" +description: "A minimal legacy module for testing the marketplace.json install path." +module_version: 0.2.0 +default_selected: false + +# Variables from Core Config available: +## output_folder + +artifacts_path: + prompt: "Where should mini-legacy artifacts be stored?" + default: "{output_folder}/mlg-artifacts" + result: "{project-root}/{value}" + +# Directories to create during installation +directories: + - "{artifacts_path}" + +# Agent roster — essence only. +agents: + - code: mlg-agent-one + name: Mini + title: Mini Agent + icon: "🧩" + team: testing + description: "A tiny agent that exists to exercise the legacy install path." diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/workflows/mlg-flow/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/workflows/mlg-flow/SKILL.md new file mode 100644 index 000000000..26e8b678e --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-mini-legacy/src/workflows/mlg-flow/SKILL.md @@ -0,0 +1,9 @@ +--- +name: mlg-flow +description: A tiny workflow that exists to exercise the legacy install path. Use in tests only. +--- + +# Mini Flow + +Proves that a legacy skill directory under `src/workflows/` is flattened to +`skills/mlg-flow/` on install. diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/.claude-plugin/marketplace.json b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/.claude-plugin/marketplace.json new file mode 100644 index 000000000..42b22b2b1 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "bmad-reserved-legacy", + "owner": { "name": "Test Author" }, + "license": "MIT", + "plugins": [ + { + "name": "bmad-reserved-legacy", + "source": "./", + "description": "A legacy module using a reserved first-party code (gds) to test the reserved relaxation.", + "version": "0.1.0", + "skills": ["./src/agents/gds-agent-demo"] + } + ] +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/agents/gds-agent-demo/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/agents/gds-agent-demo/SKILL.md new file mode 100644 index 000000000..d2a873855 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/agents/gds-agent-demo/SKILL.md @@ -0,0 +1,8 @@ +--- +name: gds-agent-demo +description: A demo agent for reserved-code legacy testing. Use in tests only. +--- + +# Demo Agent + +Proves the legacy install path accepts reserved first-party codes (gds). diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module-help.csv b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module-help.csv new file mode 100644 index 000000000..a0aa840e4 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module-help.csv @@ -0,0 +1,2 @@ +module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs +gds,gds-agent-demo,Demo Agent,gd,A demo agent for reserved-code legacy testing.,gds-agent-demo,,anytime,,,,, diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module.yaml b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module.yaml new file mode 100644 index 000000000..2a104e8c7 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-reserved-legacy/src/module.yaml @@ -0,0 +1,14 @@ +code: gds +name: "GDS: Reserved Legacy" +description: "A legacy module using a reserved first-party code to test the reserved relaxation." +module_version: 0.1.0 +default_selected: false + +# Agent roster — essence only. +agents: + - code: gds-agent-demo + name: Demo + title: Demo Agent + icon: "🎮" + team: testing + description: "Exists only to prove reserved codes install on the legacy path." diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/.claude-plugin/marketplace.json b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/.claude-plugin/marketplace.json new file mode 100644 index 000000000..95729be19 --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "bmad-synth-legacy", + "owner": { "name": "Test Author" }, + "license": "MIT", + "plugins": [ + { + "name": "synthlg", + "source": "./", + "description": "A legacy module with no module.yaml — exercises the synthesize fallback (strategy 5).", + "version": "0.3.0", + "skills": ["./src/skills/synthlg-do-thing"] + } + ] +} diff --git a/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/src/skills/synthlg-do-thing/SKILL.md b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/src/skills/synthlg-do-thing/SKILL.md new file mode 100644 index 000000000..13a0c92dd --- /dev/null +++ b/src/core-skills/bmad-module/tests/fixtures/examples/legacy/bmad-synth-legacy/src/skills/synthlg-do-thing/SKILL.md @@ -0,0 +1,8 @@ +--- +name: synthlg-do-thing +description: A standalone skill with no module.yaml, so the resolver must synthesize the module definition and help catalog from this frontmatter. +--- + +# Do Thing + +Exercises strategy 5 — the synthesize-from-frontmatter fallback. diff --git a/src/core-skills/bmad-module/tests/integration.test.sh b/src/core-skills/bmad-module/tests/integration.test.sh index 407cae458..66cfcccc9 100755 --- a/src/core-skills/bmad-module/tests/integration.test.sh +++ b/src/core-skills/bmad-module/tests/integration.test.sh @@ -220,6 +220,51 @@ 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" +# ─── 9d. legacy module (marketplace.json + module.yaml, strategy 1) ────────── +note "install examples/legacy/bmad-mini-legacy (legacy marketplace.json)" +run install "${EXAMPLES}/legacy/bmad-mini-legacy" +assert_exit 0 "install legacy mini" +[[ "${STDOUT}" == *"resolved legacy module mlg"* ]] && ok "stdout reports legacy resolution" \ + || ko "expected 'resolved legacy module mlg' in stdout: ${STDOUT}" +# Synthetic plugin.json is staged; marketplace.json is preserved verbatim. +assert_path_exists "_bmad/mlg/.claude-plugin/plugin.json" +assert_path_exists "_bmad/mlg/.claude-plugin/marketplace.json" +# Skills under src/agents and src/workflows are flattened to skills/. +assert_path_exists "_bmad/mlg/skills/mlg-agent-one/SKILL.md" +assert_path_exists "_bmad/mlg/skills/mlg-flow/SKILL.md" +# module.yaml / module-help.csv flattened from src/ to the module root. +assert_path_exists "_bmad/mlg/module.yaml" +assert_path_exists "_bmad/mlg/module-help.csv" +# Undeclared trees are dropped — src/ wrapper and docs/ must not leak. +assert_path_absent "_bmad/mlg/src" +assert_path_absent "_bmad/mlg/docs" +# The staged manifest carries canonical rewritten paths. +assert_grep '"\./skills/mlg-agent-one"' "_bmad/mlg/.claude-plugin/plugin.json" +assert_grep '"\./module\.yaml"' "_bmad/mlg/.claude-plugin/plugin.json" +# Registered and merged like any community module. The manifest `name` is the +# kebab plugin name (module.yaml#name "MLG: …" would fail NAME_REGEX). +assert_grep '^ - name: mlg' "_bmad/_config/manifest.yaml" +assert_grep 'source: community' "_bmad/_config/manifest.yaml" +assert_grep '^mlg,' "_bmad/_config/bmad-help.csv" + +# ─── 9e. legacy with a reserved first-party code (gds) ─────────────────────── +note "install examples/legacy/bmad-reserved-legacy (reserved code on legacy path)" +run install "${EXAMPLES}/legacy/bmad-reserved-legacy" +assert_exit 0 "install legacy reserved code" +assert_path_exists "_bmad/gds/module.yaml" +assert_path_exists "_bmad/gds/skills/gds-agent-demo/SKILL.md" + +# ─── 9f. legacy synthesize fallback (strategy 5, no module.yaml) ───────────── +note "install examples/legacy/bmad-synth-legacy (synthesized module.yaml)" +run install "${EXAMPLES}/legacy/bmad-synth-legacy" +assert_exit 0 "install legacy synth fallback" +# module.yaml + module-help.csv are synthesized and written into the module root. +assert_path_exists "_bmad/synthlg/module.yaml" +assert_path_exists "_bmad/synthlg/module-help.csv" +assert_path_exists "_bmad/synthlg/skills/synthlg-do-thing/SKILL.md" +assert_grep '^code: synthlg' "_bmad/synthlg/module.yaml" +assert_grep '^module,skill,display-name,' "_bmad/synthlg/module-help.csv" + # ─── 10. remove minimal (no purge), preserve custom ───────────────────────── note "create _bmad/custom/mdlint to test preservation, then remove" mkdir -p _bmad/custom/mdlint