feat(bmad-module): install legacy marketplace.json modules in the skill

The standalone bmad-module skill only understood the new plugin.json#bmad
spec, so installing a legacy repo (marketplace.json + module.yaml, e.g.
bmad-code-org/bmad-module-game-dev-studio) failed with exit 20. Legacy
support existed only in the full installer's PluginResolver, which the
skill can't import (it ships self-contained under .claude/skills/).

Port that resolver into lib/legacy-resolver.mjs (strategies 1-5): when a
repo has no plugin.json#bmad but has a marketplace.json, resolve it into a
synthetic manifest of the same shape readAndValidateManifest produces, then
run it through the existing buildCopyPlan -> rewrite -> stage -> swap
pipeline unchanged. buildCopyPlan already copies marketplace.json verbatim,
flattens arbitrary skill paths to skills/<basename>, and flattens
moduleDefinition/moduleHelpCsv, so almost no downstream change is needed.

- plugin-json.mjs: extract validateManifestObject(m, {allowReserved}) and
  add hasBmadPluginJson(dir). Legacy installs pass allowReserved:true so
  first-party codes (gds, bmm, ...) install; new-spec authors still get
  exit 21.
- install.mjs: detect new-spec -> legacy -> neither in §3; write
  synthesized module.yaml/module-help.csv into the temp clone for the
  strategy-5 fallback.
- cli.mjs: add --module <code> to disambiguate a multi-module marketplace
  (otherwise exit 20 lists the available codes).
- help-catalog.mjs: export MODULE_HELP_CSV_HEADER for the synthesizer.
- tests: legacy fixtures (strategy-1, reserved code, synthesize fallback)
  + integration assertions. SKILL.md/README updated.

Verified: full install of the real game-dev-studio repo resolves gds and
lands 250 files under _bmad/gds/. Integration suite 97/0, installer
component tests 374/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pbean 2026-06-06 11:49:31 -07:00
parent 08f25d0588
commit 34b2fb1c78
20 changed files with 647 additions and 19 deletions

View File

@ -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/<bmad.code>/`, 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 <source> [--ref <r>] [--channel <c>] [--dry-run]
bmad-module install <source> [--ref <r>] [--channel <c>] [--module <code>] [--dry-run]
bmad-module update <code|--all> [--ref <r>] [--channel <c>]
bmad-module remove <code> [--purge]
bmad-module list [--json]
@ -26,6 +27,7 @@ bmad-module list [--json]
- **`remove`** without `--purge` preserves `_bmad/custom/<code>/` 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 <code>`. 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/`.

View File

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

View File

@ -4,7 +4,7 @@
// setup error instead of leaking a raw ESM stack trace. See bmad-module.mjs.
//
// Usage:
// node bmad-module.mjs install <source> [--ref <r>] [--channel <c>] [--dry-run] [--project-dir <p>]
// node bmad-module.mjs install <source> [--ref <r>] [--channel <c>] [--module <code>] [--dry-run] [--project-dir <p>]
// node bmad-module.mjs update <code|--all> [--ref <r>] [--channel <c>] [--project-dir <p>]
// node bmad-module.mjs remove <code> [--purge] [--project-dir <p>]
// node bmad-module.mjs list [--json] [--project-dir <p>]
@ -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 <source> [--ref <ref>] [--channel <c>] [--set <code>.<key>=<v>] [--dry-run]
bmad-module install <source> [--ref <ref>] [--channel <c>] [--module <code>] [--set <code>.<key>=<v>] [--dry-run]
bmad-module update <code|--all> [--ref <ref>] [--channel <c>] [--set <code>.<key>=<v>]
bmad-module remove <code> [--purge]
bmad-module list [--json]
INSTALL FLAGS
--module <code> Pick one module by code when a legacy marketplace.json
repo resolves to more than one
GLOBAL FLAGS
--project-dir <path> Project root containing _bmad/ (default: cwd)
--set <code>.<key>=<v> 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

View File

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

View File

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

View File

@ -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 15; 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 <code>.`);
}
// ─── 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-]+$/, 364 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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
1 module skill display-name menu-code description action args phase preceded-by followed-by required output-location outputs
2 mlg mlg-agent-one Mini Agent ma A tiny agent for legacy-path testing. mlg-agent-one anytime
3 mlg mlg-flow Mini Flow mf A tiny workflow for legacy-path testing. mlg-flow anytime {artifacts_path} result.md

View File

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

View File

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

View File

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

View File

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

View File

@ -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,,,,,
1 module skill display-name menu-code description action args phase preceded-by followed-by required output-location outputs
2 gds gds-agent-demo Demo Agent gd A demo agent for reserved-code legacy testing. gds-agent-demo anytime

View File

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

View File

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

View File

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

View File

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