This commit is contained in:
PinkyD 2026-06-20 18:59:44 -05:00 committed by GitHub
commit 504b76916c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 25425 additions and 122 deletions

View File

@ -103,9 +103,21 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Vendor bundle freshness (bmad-module yaml)
run: npm run vendor:check
- name: Test agent compilation components
run: npm run test:install
- name: Test IDE sync (engine/bundle parity)
run: npm run test:ide-sync
- name: Test bmad-module skill (source parsing + channels)
run: npm run test:skill-source
- name: Test bmad-module skill (end-to-end install/update/remove)
run: npm run test:skill
- name: Validate file references
run: npm run validate:refs

3
.gitignore vendored
View File

@ -50,6 +50,9 @@ CLAUDE.local.md
.analysis/
# Test fixtures need a committed .mcp.json (the rule above ignores real projects')
!src/core-skills/bmad-module/tests/fixtures/**/.mcp.json
z*/
!docs/zh-cn/

View File

@ -11,6 +11,9 @@ _bmad*/
# IDE integration folders (user-specific, not in repo)
.junie/
# Generated vendored bundles in the bmad-module skill (not authored source)
src/core-skills/bmad-module/scripts/lib/vendor/**
# Quality scan artifacts produced by bmad-workflow-builder
# (per-skill .analysis/ folders contain JSON/HTML reports that should
# not block commits with formatting checks)

View File

@ -0,0 +1,123 @@
---
title: bmad-module CLI
description: Reference for the bmad-module command — install, update, remove, and list BMad community/custom modules from inside a project.
sidebar:
order: 7
---
`bmad-module` installs, updates, removes, and lists **community and custom BMad modules** in a project that already has BMad installed (a `_bmad/` directory). It is the command behind the `bmad-module` skill, and it mirrors the official installer's behavior for custom modules so a skill-driven install lands the same on-disk state.
Run it from your project root (the directory containing `_bmad/`), or point it elsewhere with `--project-dir`.
## Commands
```
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 `<source>`
Resolve a module from `<source>`, copy it into `_bmad/<code>/`, generate its config and agent roster, create its declared working directories, and distribute its skills to the coding assistants (IDEs) you chose at `bmad install` time.
`<source>` may be:
- A GitHub shorthand — `acme/acme-devlog`
- A full Git URL — `https://github.com/acme/acme-devlog` (optionally with `@ref` or `/tree/<ref>`)
- A local path — `./examples/minimal/acme-md-lint`
- A legacy `marketplace.json` repo — `bmad-code-org/bmad-module-game-dev-studio`
| Flag | Description |
| --- | --- |
| `--ref <ref>` | Clone a specific git tag/branch/commit. Implies `--channel pinned`. |
| `--channel <c>` | Release channel: `stable`, `next`, or `pinned` (see [Channels](#channels)). |
| `--module <code>` | Pick one module by `code` when a legacy `marketplace.json` repo resolves to more than one. |
| `--set <code>.<key>=<v>` | Override a module config answer. Repeatable. |
| `--dry-run` | Print the resolved install plan without writing anything. |
### update `<code>` | `--all`
Re-resolve an installed module and atomically swap in the new version. Aborts (exit `80`) if you have locally modified tracked files, so your edits are never silently overwritten.
| Flag | Description |
| --- | --- |
| `--all` | Update every installed community/custom module instead of a single `<code>`. |
| `--ref <ref>` | Update to a specific git ref. |
| `--channel <c>` | Switch/track a release channel (`stable`, `next`, `pinned`). |
| `--set <code>.<key>=<v>` | Override a module config answer. Repeatable. |
### remove `<code>`
Remove an installed module: delete `_bmad/<code>/`, prune its entries from the central config and help catalog, and remove its skills from your IDE directories.
| Flag | Description |
| --- | --- |
| `--purge` | Also delete the module's user customizations under `_bmad/custom/` (e.g. `_bmad/custom/<skill>.toml`). Without `--purge`, customizations are left intact. |
### list
List installed community/custom modules with their code, version, channel, and source.
| Flag | Description |
| --- | --- |
| `--json` | Emit machine-readable JSON instead of a formatted table. |
## Global flags
| Flag | Description |
| --- | --- |
| `--project-dir <path>` | Project root containing `_bmad/` (default: current directory). |
## Channels
`bmad-module` mirrors the official installer's channel semantics:
- **`stable`** — the latest non-prerelease GitHub release tag. Falls back to `next` (with a note) when the repo has no tags, isn't a GitHub repo, or the tags API is unreachable.
- **`next`** — the repository's default branch (`main`).
- **`pinned`** — the exact `--ref` you supply (or an `@ref` / `/tree/<ref>` parsed from the source). `--channel pinned` without a `--ref` falls back to `next`.
An explicit `--ref` (or an `@ref` in the source) implies `pinned`. An unknown `--channel` value is rejected with a usage error rather than silently tracking `next`.
## Examples
```bash
# Install from GitHub shorthand
bmad-module install acme/acme-devlog
# Install a local module
bmad-module install ./examples/minimal/acme-md-lint
# Pin to a release tag
bmad-module install https://github.com/acme/acme-devlog --ref v0.4.0
# Install a legacy marketplace.json module
bmad-module install bmad-code-org/bmad-module-game-dev-studio
# Override a config answer at install time
bmad-module install acme/acme-devlog --set devlog.author="Ada Lovelace"
# List, update, and remove
bmad-module list
bmad-module update devlog
bmad-module update --all
bmad-module remove mdlint --purge
```
## Exit codes
| Code | Meaning |
| --- | --- |
| `0` | Success |
| `2` | Usage error (bad arguments, unknown flag/channel) |
| `5` | Skill runtime files missing/corrupt — reinstall the skill |
| `10` | No `_bmad/` directory in the project |
| `20` | Missing or invalid `plugin.json` |
| `21` | Reserved `bmad.code` |
| `30` | Prefix collision with an existing module |
| `50` | Filesystem commit failed |
| `60` | Network / git clone failed |
| `70` | Path traversal detected in a manifest path |
| `80` | Update aborted: locally modified files |
| `90` | No such installed module |

View File

@ -11,6 +11,9 @@ export default [
'dist/**',
'coverage/**',
'**/*.min.js',
// Generated, self-contained vendored bundles shipped with the bmad-module
// skill (regenerated by its build-vendor.mjs) — not authored source.
'src/core-skills/bmad-module/scripts/lib/vendor/**',
'test/template-test-generator/**',
'test/fixtures/**',
'_bmad*/**',
@ -117,6 +120,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'],

View File

@ -40,15 +40,20 @@
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
"lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar",
"quality": "npm run vendor:check && npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:skill-source && npm run test:skill && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar",
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run vendor:check && npm run test:refs && npm run test:install && npm run test:ide-sync && npm run test:urls && npm run test:channels && npm run test:skill-source && npm run test:skill && npm run lint && npm run lint:md && npm run format:check",
"test:channels": "node test/test-installer-channels.js",
"test:ide-sync": "node test/test-ide-sync.js",
"test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js",
"test:skill": "bash src/core-skills/bmad-module/tests/integration.test.sh",
"test:skill-source": "node test/test-bmad-module-source.mjs",
"test:urls": "node test/test-parse-source-urls.js",
"validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict"
"validate:skills": "node tools/validate-skills.js --strict",
"vendor:build": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs && node src/core-skills/bmad-module/scripts/lib/vendor/build-ide-sync.mjs",
"vendor:check": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs --check && node src/core-skills/bmad-module/scripts/lib/vendor/build-ide-sync.mjs --check"
},
"lint-staged": {
"*.{js,cjs,mjs}": [

View File

@ -0,0 +1,50 @@
# 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.
## 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>] [--module <code>] [--dry-run]
bmad-module update <code|--all> [--ref <r>] [--channel <c>]
bmad-module remove <code> [--purge]
bmad-module list [--json]
```
`<source>` accepts `owner/repo`, a full git URL (`https://…`, `git@…`, `ssh://`, `git://`), or a local path. A git source may carry an `@<tag-or-branch>` suffix and may be a browser-style deep link (`/tree|blob/<ref>[/<subdir>]`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`); `parseSource` in `lib/source.mjs` extracts the embedded ref and a repo subdirectory, mirroring the installer's `custom-module-manager.js`.
## Behavior notes
- **Source resolution & caching.** Git sources are cloned into a shared cache at `~/.bmad/cache/custom-modules/<host>/<owner>/<repo>/` (with `.bmad-source.json` / `.bmad-channel.json` metadata), the same cache the full installer uses; a matching ref is reused, otherwise the clone is fetched/refreshed, and a fetch failure keeps the stale copy so installs work offline. The install then copies the module root (the subdir, if the URL named one) out of the cache into a throwaway temp tree to stage from — the cache is never mutated. Local sources are copied straight to the temp tree. See `lib/cache.mjs`.
- **Channels.** `lib/channel-resolver.mjs` (a `node:`-only port of the installer's `channel-resolver.js`) resolves `--channel`: `pinned` → an explicit `--ref`/`@ref`; `stable` → the latest non-prerelease GitHub release tag (falls back to `next` when there are no tags, the URL isn't GitHub, or the tags API is unreachable); `next` (the default for a bare git source) → the default branch. `update` re-resolves the channel the module was installed with.
- **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/<code>/` and retry.
- **`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
The skill itself is a thin verb router (`SKILL.md`). `scripts/bmad-module.mjs` is a zero-import launcher that guards the import graph (a missing/corrupt runtime file becomes a documented exit code, not a raw stack trace); the verb dispatcher lives in `scripts/cli.mjs` and all filesystem work happens in the `lib/` modules. These carry **no registry dependencies** — important because the installer copies the skill into the IDE skills directories (e.g. `.claude/skills/bmad-module/`) without `node_modules` and never runs `npm install` there:
- `manifest.yaml` is read/written with a **vendored copy of the real `yaml` library** (`lib/vendor/yaml.mjs`, regenerated by `lib/vendor/build-vendor.mjs`) so it stays byte-identical to BMAD core's writer.
- `semver` validity/range checks **and** the version comparison the stable-channel resolver needs (`prerelease`, `compare`, `rcompare`) use a small `node:`-only helper (`lib/semver-lite.mjs`) instead of the `semver` package.
- The shared clone cache (`lib/cache.mjs`) and channel resolution (`lib/channel-resolver.mjs`) use only `node:child_process` / `node:https` so the skill needs no dependencies after distribution.
## 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/`; legacy-format fixtures (strategy-1 module files, a reserved code, and the synthesize fallback) are under `tests/fixtures/examples/legacy/`.
The pure (no-network) install plumbing — `parseSource` URL/`@ref`/subdir parsing, `semver-lite`, and the `channel-resolver` helpers — is unit-tested in `test/test-bmad-module-source.mjs` (run via `npm run test:skill-source`, included in `npm test`), kept at parity with the installer's `test/test-parse-source-urls.js` and `test/test-installer-channels.js`.

View File

@ -0,0 +1,104 @@
---
name: bmad-module
description: Install, update, remove, or list community BMAD modules. Use when the user says "install module <X>", "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. 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.
## CRITICAL RULES
- NEVER write directly to files under `_bmad/` or into IDE directories (`.claude/skills/`, `.agents/skills/`, etc.). All filesystem changes go through the Node script at `scripts/bmad-module.mjs` — it handles staging, atomic swaps, manifest updates, IDE distribution, and rollback on failure.
- 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. The one exception is exit code 5 (the skill's own bundled runtime files are missing/corrupt): that's a fixable setup/packaging problem, not a module rejection — relay the script's "reinstall the skill" guidance instead of reporting a failed install.
## 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 `<source>``owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. A source may carry an `@<tag-or-branch>` suffix (`owner/repo@v1.2.3`) and a git URL may be a browser-style deep link (`https://github.com/owner/repo/tree/<ref>/<subdir>`, GitLab `/-/tree/…`, Gitea `/src/branch/…`, or `?path=`): the script extracts the ref and a repo subdirectory automatically, so a module living in a monorepo subfolder installs directly. Optional flags: `--ref <branch-or-tag>`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>` (override a module config answer; repeatable), `--module <code>`, `--dry-run`. Channels: `pinned` clones an explicit `--ref`/`@ref`; `stable` resolves the latest non-prerelease GitHub release tag (falls back to the default branch when there are no tags / the host isn't GitHub / the tags API is unreachable); `next` (the default for a bare git source) tracks the default branch. 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>`. Without overrides, update re-resolves the channel the module was installed with — a `stable` module moves to the latest release tag, a `pinned` module stays put unless `--ref` moves it, and a `next` module re-pulls the default branch.
- **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.
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 <skill-dir>/scripts/bmad-module.mjs <verb> [args...]
```
`<skill-dir>` is this skill's own directory: the script ships alongside this `SKILL.md`, so resolve it relative to this file rather than assuming a fixed path — `<skill-dir>/scripts/bmad-module.mjs` (e.g. `.claude/skills/bmad-module/scripts/bmad-module.mjs` once distributed, or `src/core-skills/bmad-module/scripts/bmad-module.mjs` during development in this repo). If the script isn't found next to this `SKILL.md`, the skill's bundled runtime is missing — that's the exit-code-5 case (see CRITICAL RULES and EXIT CODES): relay the "reinstall the skill" guidance rather than guessing another location.
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"). The script also prints `[ide-sync]` lines naming each coding assistant the skills were synced to — relay them so the user knows where the module landed.
Note two non-fatal cases the script reports on exit 0:
- If the script prints `[bmad-module] note: no coding assistants are configured…`, the module is staged under `_bmad/` but no IDEs were selected at `bmad install` time — tell the user to run `bmad install` to choose their assistants.
- If it prints `[bmad-module] warning:` about IDE distribution, the module installed fine but skills may not have reached every assistant — relay the script's suggestion to run `bmad ide-sync`. Do NOT treat this as a failed install.
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/<code>/`").
## EXIT CODES
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 |
| 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`.
## 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: "Install the studio module on the stable channel" → Confirm, then run `… install acme/acme-studio --channel stable` (resolves the latest release tag).
User: "Install the linter that lives in the tools/ folder of this monorepo" → Confirm, then run `… install https://github.com/acme/monorepo/tree/main/tools/linter` (ref + subdir are parsed from the URL).
User: "Remove the mdlint module and wipe its customizations too" → Confirm, then run `… remove mdlint --purge`.

View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
// bmad-module — thin launcher (entry point).
//
// This file has NO imports on purpose. It must ALWAYS load, so that a broken or
// incomplete skill copy — e.g. a missing lib/vendor/yaml.mjs after a partial
// install — is reported as a DOCUMENTED exit code with actionable guidance,
// instead of crashing at module-load with a raw ESM resolver stack trace and a
// bare exit 1 (which is not in the exit-code table). The real CLI is cli.mjs.
//
// TOOLING must equal EXIT.TOOLING in ./lib/exit.mjs. It is duplicated here as a
// literal so this guard depends on nothing and can never itself fail to load.
const TOOLING = 5;
try {
// A failure resolving/evaluating this import graph means a runtime asset is
// missing or corrupt. Runtime and usage errors are handled INSIDE cli.mjs
// (which maps them to their own structured exit codes and calls process.exit
// before returning), so they never reach the catch below.
const { main } = await import('./cli.mjs');
await main();
} catch (err) {
const code = err && err.code;
const isLoadError =
err instanceof SyntaxError ||
code === 'ERR_MODULE_NOT_FOUND' ||
code === 'ERR_UNSUPPORTED_DIR_IMPORT' ||
code === 'ERR_UNKNOWN_FILE_EXTENSION' ||
code === 'ERR_DLOPEN_FAILED' ||
code === 'ERR_REQUIRE_ESM';
if (isLoadError) {
process.stderr.write(
`[bmad-module] the skill's bundled runtime files are missing or corrupt ` +
`(${code || err.name}: ${err.message}).\n` +
`[bmad-module] this is a setup/packaging problem, not a module-rejection ` +
`decision — do not treat it as a failed install of the target module.\n` +
`[bmad-module] fix: reinstall the skill so its scripts/ tree (including ` +
`scripts/lib/vendor/) is complete — re-run \`npx bmad-method install\`, ` +
`or re-copy the bmad-module skill folder in full.\n`,
);
process.exit(TOOLING);
}
// Anything else escaping cli.mjs is a genuine, unexpected bug.
process.stderr.write(`[bmad-module] unexpected error: ${err && (err.stack || err.message)}\n`);
process.exit(1);
}

View File

@ -0,0 +1,196 @@
// bmad-module CLI — verb dispatcher. Loaded by the thin launcher in
// bmad-module.mjs, which guards this whole import graph: if any runtime asset
// (e.g. lib/vendor/yaml.mjs) is missing, the launcher reports a documented
// 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>] [--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>]
//
// Exit codes — see SKILL.md / lib/exit.mjs. 0 = ok; everything ≥5 = 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']);
const BOOLEAN_FLAGS = new Set(['dry-run', 'purge', 'all', 'json']);
const VALUE_FLAGS = new Set(['ref', 'channel', 'module', 'set', 'project-dir']);
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);
// Reject unknown flags so typos fail fast instead of silently running
// with defaults.
if (!BOOLEAN_FLAGS.has(key) && !VALUE_FLAGS.has(key)) {
throw new BmadModuleError(EXIT.USAGE, `unknown flag --${key}`);
}
// boolean flags
if (BOOLEAN_FLAGS.has(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`);
}
// --set is repeatable; collect into an array. All other flags take the
// last value seen.
if (key === 'set') {
(out.flags.set ||= []).push(val);
} else {
out.flags[key] = val;
}
i += 2;
continue;
}
out._.push(a);
i++;
}
return out;
}
export 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();
const setOverrides = parseSetOverrides(parsed.flags.set);
try {
switch (verb) {
case 'install':
await runInstall({
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,
});
break;
case 'update':
await runUpdate({
code: parsed._[0] || null,
all: !!parsed.flags['all'],
ref: parsed.flags['ref'] || null,
channel: parsed.flags['channel'] || null,
setOverrides,
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);
}
}
// Parse repeatable `--set <code>.<key>=<value>` flags into a nested map
// { [code]: { [key]: value } }. Mirrors the full installer's --set spec.
function parseSetOverrides(rawList) {
const out = {};
if (!Array.isArray(rawList)) return out;
for (const spec of rawList) {
const eq = spec.indexOf('=');
if (eq === -1) throw new BmadModuleError(EXIT.USAGE, `--set expects <code>.<key>=<value>, got "${spec}"`);
const lhs = spec.slice(0, eq);
const value = spec.slice(eq + 1);
const dot = lhs.indexOf('.');
if (dot === -1) throw new BmadModuleError(EXIT.USAGE, `--set expects <code>.<key>=<value>, got "${spec}"`);
const code = lhs.slice(0, dot);
const key = lhs.slice(dot + 1);
if (!code || !key) throw new BmadModuleError(EXIT.USAGE, `--set expects <code>.<key>=<value>, got "${spec}"`);
(out[code] ||= {})[key] = value;
}
return out;
}
function printUsage() {
process.stderr.write(`bmad-module — install, update, remove, or list BMAD community modules.
USAGE
bmad-module install <source> [--ref <ref>] [--channel <c>] [--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)
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
EXIT CODES
0 success
2 usage error
5 skill runtime files missing/corrupt reinstall the skill
10 no _bmad/ in project
20 missing or invalid plugin.json
21 reserved bmad.code
30 prefix collision with existing module
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
`);
}

View File

@ -0,0 +1,299 @@
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 { resolveChannel } from './lib/channel-resolver.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';
import { distributeToIdes } from './lib/ide-sync.mjs';
import { installModuleDeps } from './lib/npm-deps.mjs';
import { regenerateCentralConfig, readModuleConfigValues, resolveSectionKey } from './lib/config-gen.mjs';
import { createModuleDirectories } from './lib/module-dirs.mjs';
import { regenerateHelpCatalog } from './lib/help-catalog.mjs';
// Run the install verb. `opts` shape:
// { source, ref, sha, channel, dryRun, 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();
// §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 source, resolve the channel/ref to a concrete clone target,
// then materialize (clone into the shared cache and copy out a working tree).
const descriptor = parseSource(opts.source);
const target = await resolveCloneTarget(descriptor, opts);
const materialized = await materializeSource(descriptor, { ref: target.ref });
try {
// §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.
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.`,
);
}
// 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);
const matchIgnore = buildIgnoreMatcher(userIgnores);
const { plan, skillDestDirs } = await buildCopyPlan(materialized.dir, manifest, matchIgnore);
const rewrittenManifestJson = rewriteManifestPaths(manifest);
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 (${plan.length + 1}):\n`);
process.stdout.write(` .claude-plugin/plugin.json (rewritten to canonical paths)\n`);
for (const { srcRel, destRel } of plan) {
process.stdout.write(srcRel === destRel ? ` ${destRel}\n` : ` ${destRel} (from ${srcRel})\n`);
}
return;
}
// §6. Stage to tmp/staged-out, then atomic swap.
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
await stageCopyPlan(materialized.dir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
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, {
// Git installs record the resolved channel version (tag for stable/pinned,
// 'main' for next) like the full installer; local installs keep the
// module's declared version since there is no clone ref.
version: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version,
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
sha: materialized.sha,
ref: materialized.ref,
channel: target.channel,
rawSource: descriptor.rawInput,
moduleName: manifest.name,
});
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
await appendFilesManifestRows(bmadDir, code, destPaths);
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 ${destPaths.length} file(s) to ${path.relative(projectDir, targetDir)}\n`);
// §7.5. Complete the install the way the full installer does for custom
// modules: install JS deps, generate central config + agent roster, create
// declared working directories, and rebuild the merged help catalog. All are
// non-fatal — the module is already committed to _bmad/<code>/.
await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides });
// §8. Distribute the module's skills to the coding assistants the user chose
// at `bmad install` time (read from _bmad/_config/manifest.yaml). This is the
// same distribution the full installer performs; without it the skills would
// sit in _bmad/ and never reach Claude Code / Cursor / Copilot / etc.
const ideResult = await distributeToIdes({ projectDir, bmadDir });
if (ideResult.skipped) {
process.stdout.write(
`[bmad-module] note: no coding assistants are configured in _bmad/_config/manifest.yaml — ` +
`skills are in _bmad/${code}/ only. Run \`bmad install\` to choose your IDEs.\n`,
);
} else if (!ideResult.ok) {
process.stderr.write(`[bmad-module] warning: ${ideResult.hint}\n`);
}
// §9. Warn about Claude-plugin-only surfaces (not distributed as skills).
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');
if (claudeOnly.length) {
process.stdout.write(
`[bmad-module] note: ${claudeOnly.join(', ')} are Claude Code plugin surfaces and 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();
}
}
// Resolve a parsed source + CLI flags into a concrete clone target:
// { ref, channel, version }
// ref — git ref to clone (null = default branch / local source)
// channel — manifest channel tag: 'stable' | 'pinned' | 'next' | null (local)
// version — manifest version string for git installs (tag for stable/pinned,
// 'main' for next); null for local (caller uses the module version).
//
// Mirrors the installer's channel semantics: an explicit --ref (or an @ref /
// /tree/<ref> parsed from the source) pins; --channel stable resolves the latest
// non-prerelease GitHub tag, falling back to next (with a warning) when there are
// no tags, the URL isn't a GitHub repo, or the tags API is unreachable.
const VALID_CHANNELS = new Set(['stable', 'pinned', 'next']);
export async function resolveCloneTarget(descriptor, opts) {
// Reject typo'd channels up front (e.g. `--channel stabl`) so they error
// instead of silently falling through the branches below to the `next` default.
if (opts.channel && !VALID_CHANNELS.has(opts.channel)) {
throw new BmadModuleError(EXIT.USAGE, `unknown --channel "${opts.channel}" (expected: stable, pinned, next)`);
}
if (descriptor.kind !== 'git') {
return { ref: null, channel: null, version: null };
}
const explicitRef = opts.ref ?? descriptor.ref ?? null;
let channel = opts.channel || (explicitRef ? 'pinned' : 'next');
if (channel === 'pinned') {
if (explicitRef) {
return { ref: explicitRef, channel: 'pinned', version: explicitRef };
} else {
process.stderr.write(`[bmad-module] warning: --channel pinned needs a --ref; falling back to next.\n`);
channel = 'next';
}
}
if (channel === 'stable') {
try {
const r = await resolveChannel({ channel: 'stable', repoUrl: descriptor.url });
if (r.resolvedFallback) {
process.stderr.write(
`[bmad-module] note: no stable release found for ${descriptor.displayName} (${r.reason}); tracking the default branch.\n`,
);
return { ref: null, channel: 'next', version: 'main' };
}
return { ref: r.ref, channel: 'stable', version: r.version };
} catch (e) {
process.stderr.write(`[bmad-module] warning: could not resolve stable channel (${e.message}); tracking the default branch.\n`);
return { ref: null, channel: 'next', version: 'main' };
}
}
// next
return { ref: null, channel: 'next', version: 'main' };
}
// Shared post-copy completion for install and update: install JS deps, generate
// the central config + agent roster, create declared working directories, and
// rebuild the merged help catalog. Mirrors what the full installer does for a
// custom module so a skill-driven install lands the same on-disk state. Every
// step is non-fatal — the module files are already committed under _bmad/<code>/.
export async function finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides }) {
// 1. npm deps (in place — see npm-deps.mjs for the design note).
const dep = await installModuleDeps(targetDir, manifest);
if (dep.ran && dep.ok) process.stdout.write(`[bmad-module] installed npm dependencies for ${code}\n`);
else if (dep.ran && !dep.ok) process.stderr.write(`[bmad-module] warning: npm install failed for ${code}: ${dep.error}\n`);
// 2. Capture prior config (for directory move-detection on update) before regen.
const sectionKey = await resolveSectionKey(bmadDir, code);
let existingConfig = {};
try {
existingConfig = await readModuleConfigValues(bmadDir, sectionKey);
} catch {
/* no prior config — fine */
}
// 3. Central config + agent roster.
let resolved = { values: {} };
try {
resolved = await regenerateCentralConfig(bmadDir, code, { setOverrides: setOverrides || {} });
} catch (e) {
process.stderr.write(`[bmad-module] warning: config generation failed for ${code}: ${e.message}\n`);
}
// 4. Declared working directories.
try {
const dirs = await createModuleDirectories(bmadDir, code, resolved.values, existingConfig);
const made = dirs.createdDirs.length;
const moved = dirs.movedDirs.length;
if (made) process.stdout.write(`[bmad-module] created ${made} working director${made === 1 ? 'y' : 'ies'} for ${code}\n`);
if (moved) process.stdout.write(`[bmad-module] moved ${moved} working director${moved === 1 ? 'y' : 'ies'} for ${code}\n`);
} catch (e) {
process.stderr.write(`[bmad-module] warning: directory creation failed for ${code}: ${e.message}\n`);
}
// 5. Merged help catalog.
try {
await regenerateHelpCatalog(bmadDir);
} catch (e) {
process.stderr.write(`[bmad-module] warning: help catalog rebuild failed: ${e.message}\n`);
}
}

View File

@ -0,0 +1,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
// `<projectDir>/_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 <bmadDir>/_config/.
export async function ensureConfigDir(bmadDir) {
const cfgDir = path.join(bmadDir, '_config');
await fs.mkdir(cfgDir, { recursive: true });
return cfgDir;
}

View File

@ -0,0 +1,161 @@
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 { EXIT, BmadModuleError } from './exit.mjs';
const execFileP = promisify(execFile);
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
// Shared clone cache for community modules — mirrors
// tools/installer/modules/custom-module-manager.js (getCacheDir + cloneRepo) so
// a skill-driven install reuses the same on-disk cache the CLI installer
// maintains. node:-only (execFile, not execSync+fs-extra); npm deps are NOT
// installed here — the skill installs them in _bmad/<code>/ after the copy.
export function getCacheDir() {
return path.join(os.homedir(), '.bmad', 'cache', 'custom-modules');
}
// A ref must be a tag/branch name git can take as a positional argument. Reject
// option-like values so a crafted `--upload-pack=…` ref can't reach git.
function assertSafeRef(ref) {
if (!/^[\w.+/][\w.\-+/]*$/.test(ref)) {
throw new BmadModuleError(EXIT.USAGE, `unsafe git ref: ${ref}`);
}
return ref;
}
async function pathExists(p) {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readJsonSafe(p) {
try {
return JSON.parse(await fs.readFile(p, 'utf8'));
} catch {
return null;
}
}
async function git(args, cwd) {
return execFileP('git', args, { cwd, env: GIT_ENV, timeout: 120_000 });
}
async function revParseHead(cwd) {
try {
const { stdout } = await execFileP('git', ['rev-parse', 'HEAD'], { cwd });
return stdout.trim();
} catch {
return null;
}
}
async function defaultBranch(cwd) {
try {
const { stdout } = await execFileP('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd });
return stdout.trim().replace(/^origin\//, '') || 'main';
} catch {
return 'main';
}
}
// Ensure the repo behind `descriptor` is cloned/refreshed in the shared cache at
// the requested `ref` (a tag/branch, or null for the default branch). Returns
// { repoDir, sha, ref }. Reuses an existing clone when its recorded version
// matches; re-clones on a version change; on a fetch failure against an existing
// clone, keeps the stale copy and warns (so installs work offline).
export async function ensureCachedRepo(descriptor, ref = null) {
if (descriptor.kind !== 'git') throw new BmadModuleError(EXIT.USAGE, `ensureCachedRepo requires a git source`);
if (!descriptor.cacheKey) throw new BmadModuleError(EXIT.USAGE, `git source has no cacheKey: ${descriptor.rawInput}`);
const effectiveRef = ref;
if (effectiveRef) assertSafeRef(effectiveRef);
const repoDir = path.join(getCacheDir(), ...descriptor.cacheKey.split('/'));
await fs.mkdir(path.dirname(repoDir), { recursive: true });
// Existing cache at a different version → re-clone from scratch.
if (await pathExists(repoDir)) {
const meta = await readJsonSafe(path.join(repoDir, '.bmad-source.json'));
const cachedVersion = meta?.version || null;
if (effectiveRef !== cachedVersion) {
await fs.rm(repoDir, { recursive: true, force: true });
}
}
if (await pathExists(repoDir)) {
// Refresh the existing clone (same version as before).
try {
await git(['fetch', 'origin', '--depth', '1'], repoDir);
if (effectiveRef) {
await git(['fetch', '--depth', '1', 'origin', effectiveRef, '--no-tags'], repoDir);
await git(['checkout', '--quiet', 'FETCH_HEAD'], repoDir);
} else {
const branch = await defaultBranch(repoDir);
assertSafeRef(branch);
await git(['fetch', '--depth', '1', 'origin', branch], repoDir);
await git(['reset', '--hard', `origin/${branch}`], repoDir);
}
} catch (e) {
// Remote unreachable — keep the cached copy so the install still works.
process.stderr.write(
`[bmad-module] warning: could not refresh ${descriptor.displayName} (${e.stderr || e.message}). Using cached copy.\n`,
);
}
} else {
// Fresh clone.
const args = ['clone', '--depth', '1'];
if (effectiveRef) args.push('--branch', effectiveRef);
args.push(descriptor.url, repoDir);
try {
await git(args);
} catch (e) {
await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {});
const refSuffix = effectiveRef ? `@${effectiveRef}` : '';
throw new BmadModuleError(EXIT.NETWORK_FAILURE, `git clone failed for ${descriptor.url}${refSuffix}: ${e.stderr || e.message}`);
}
}
const sha = await revParseHead(repoDir);
const branchForMeta = effectiveRef ? null : await defaultBranch(repoDir);
const now = new Date().toISOString();
await fs.writeFile(
path.join(repoDir, '.bmad-source.json'),
JSON.stringify(
{
cloneUrl: descriptor.url,
cacheKey: descriptor.cacheKey,
displayName: descriptor.displayName,
version: effectiveRef || null,
rawInput: descriptor.rawInput,
sha,
clonedAt: now,
},
null,
2,
),
'utf8',
);
await fs.writeFile(
path.join(repoDir, '.bmad-channel.json'),
JSON.stringify(
{
channel: effectiveRef ? 'pinned' : 'next',
version: effectiveRef || branchForMeta || 'main',
sha,
writtenAt: now,
},
null,
2,
),
'utf8',
);
return { repoDir, sha, ref: effectiveRef };
}

View File

@ -0,0 +1,135 @@
import https from 'node:https';
import { valid, prerelease, rcompare } from './semver-lite.mjs';
// Channel resolver for community modules — decides which ref of a module to
// clone when no explicit version is supplied:
// - stable: highest pure-semver git tag (excludes -alpha/-beta/-rc)
// - next: default-branch HEAD
// - pinned: an explicit user-supplied tag/branch
//
// node:-only port of tools/installer/modules/channel-resolver.js (which uses the
// `semver` package + node:https). Uses lib/semver-lite.mjs to stay registry-free
// — the skill ships without node_modules. Talks only to the GitHub tags API and
// does semver math; clone logic lives in source.mjs.
const GITHUB_API_BASE = 'https://api.github.com';
const DEFAULT_TIMEOUT_MS = 10_000;
const USER_AGENT = 'bmad-method-installer';
// Per-process cache: 'owner/repo' => [{ tag, version }] sorted newest-first.
const tagCache = new Map();
// Parse a GitHub repo URL into { owner, repo }, or null for non-GitHub URLs.
export function parseGitHubRepo(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url
.trim()
.replace(/\/+$/, '')
.replace(/\.git$/, '');
const httpsMatch = trimmed.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/i);
if (httpsMatch) return { owner: httpsMatch[1], repo: httpsMatch[2] };
const sshMatch = trimmed.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
if (sshMatch) return { owner: sshMatch[1], repo: sshMatch[2] };
return null;
}
function fetchJson(url, { timeout = DEFAULT_TIMEOUT_MS } = {}) {
const headers = {
'User-Agent': USER_AGENT,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
return new Promise((resolve, reject) => {
const req = https.get(url, { headers, timeout }, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(`GitHub API ${res.statusCode} for ${url}: ${body.slice(0, 200)}`);
err.statusCode = res.statusCode;
return reject(err);
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(new Error(`Failed to parse GitHub response: ${error.message}`));
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`GitHub API request timed out: ${url}`));
});
});
}
// Strip a leading 'v' and return a valid non-prerelease semver, else null.
export function normalizeStableTag(tagName) {
if (typeof tagName !== 'string') return null;
const stripped = tagName.startsWith('v') ? tagName.slice(1) : tagName;
const v = valid(stripped);
if (!v) return null;
if (prerelease(v)) return null; // exclude prereleases
return v;
}
// Fetch pure-semver tags (highest first) from a GitHub repo, cached per-process.
// Returns [{ tag, version }]: tag is the original ref (e.g. "v1.7.0"), version
// the cleaned semver ("1.7.0").
export async function fetchStableTags(owner, repo, { timeout } = {}) {
const cacheKey = `${owner}/${repo}`;
if (tagCache.has(cacheKey)) return tagCache.get(cacheKey);
const url = `${GITHUB_API_BASE}/repos/${owner}/${repo}/tags?per_page=100`;
const raw = await fetchJson(url, { timeout });
if (!Array.isArray(raw)) throw new TypeError(`Unexpected response from ${url}`);
const stable = [];
for (const entry of raw) {
const version = normalizeStableTag(entry?.name);
if (version) stable.push({ tag: entry.name, version });
}
stable.sort((a, b) => rcompare(a.version, b.version));
tagCache.set(cacheKey, stable);
return stable;
}
// Resolve a channel into a git-clonable ref.
// ref: ref for `git clone --branch`, or null for default-branch HEAD (next)
// version: tag name for stable/pinned, 'main' for next
// Falls back to next-channel semantics with resolvedFallback=true when stable
// turns up no tags or the URL isn't a GitHub repo. Throws on a pinned channel
// with no pin, or on a GitHub API error during stable resolution.
export async function resolveChannel({ channel, pin, repoUrl, timeout }) {
if (channel === 'pinned') {
if (!pin) throw new Error('resolveChannel: pinned channel requires a pin value');
return { channel: 'pinned', ref: pin, version: pin, resolvedFallback: false };
}
if (channel === 'next') {
return { channel: 'next', ref: null, version: 'main', resolvedFallback: false };
}
if (channel === 'stable') {
const parsed = parseGitHubRepo(repoUrl);
if (!parsed) {
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'not-a-github-url' };
}
const tags = await fetchStableTags(parsed.owner, parsed.repo, { timeout });
if (tags.length === 0) {
return { channel: 'next', ref: null, version: 'main', resolvedFallback: true, reason: 'no-stable-tags' };
}
const top = tags[0];
return { channel: 'stable', ref: top.tag, version: top.tag, resolvedFallback: false };
}
throw new Error(`resolveChannel: unknown channel '${channel}'`);
}
// Test-only: clear the per-process tag cache.
export function _clearTagCache() {
tagCache.clear();
}

View File

@ -0,0 +1,354 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from './vendor/yaml.mjs';
// Generate/patch the central TOML config for a community module, mirroring the
// full installer's ManifestGenerator.writeCentralConfig + collectAgentsFromModuleYaml
// (tools/installer/core/manifest-generator.js).
//
// Adaptation for the self-contained skill: the installer regenerates the WHOLE
// config from the source tree (it has core/official module.yaml on disk). The
// skill has only `_bmad/<code>/module.yaml` for the module it just installed, and
// must NOT clobber [core] or sibling modules' interactively-collected answers it
// cannot reconstruct. So we do a TARGETED merge: upsert just this module's
// `[modules.<code>]` (team→config.toml, user→config.user.toml) and its
// `[agents.<code>]` blocks, leaving every other block byte-for-byte intact.
//
// Values are non-interactive: each prompt key resolves from its module.yaml
// `default` (overridable via `--set <code>.<key>=<value>`), with the same
// `result:` template substitution the installer uses. A module's setup skill
// (postInstallSkill) can re-run this with collected answers for interactive refinement.
const TEAM_FILE = 'config.toml';
const USER_FILE = 'config.user.toml';
// ── TOML emit (port of formatTomlValue) ──────────────────────────────────────
export function formatTomlValue(value) {
if (value === null || value === undefined) return '""';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (Array.isArray(value)) return `[${value.map((v) => formatTomlValue(v)).join(', ')}]`;
const str = String(value);
const escaped = str
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('\n', '\\n')
.replaceAll('\r', '\\r')
.replaceAll('\t', '\\t');
return `"${escaped}"`;
}
// Minimal reverse of formatTomlValue for the scalars we read back (core values).
function parseTomlScalar(raw) {
const s = raw.trim();
if (s === 'true') return true;
if (s === 'false') return false;
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
if (s.startsWith('"') && s.endsWith('"')) {
return s
.slice(1, -1)
.replaceAll('\\n', '\n')
.replaceAll('\\r', '\r')
.replaceAll('\\t', '\t')
.replaceAll('\\"', '"')
.replaceAll('\\\\', '\\');
}
return s;
}
// ── TOML block model ──────────────────────────────────────────────────────────
// Split a config file into a leading preamble (the comment header before the
// first table) and an ordered list of `[header]` blocks. The file is our own
// controlled output, so a line scanner is safer than a full TOML parser.
function splitBlocks(content) {
const lines = content.split('\n');
const preamble = [];
let i = 0;
while (i < lines.length && !/^\[[^\]]+]\s*$/.test(lines[i])) {
preamble.push(lines[i]);
i++;
}
const blocks = [];
let current = null;
for (; i < lines.length; i++) {
const m = lines[i].match(/^\[([^\]]+)]\s*$/);
if (m) {
if (current) blocks.push(current);
current = { header: m[1], lines: [lines[i]] };
} else if (current) {
current.lines.push(lines[i]);
}
}
if (current) blocks.push(current);
return { preamble, blocks };
}
function blockToText(block) {
const lines = [...block.lines];
while (lines.length > 1 && lines.at(-1).trim() === '') lines.pop();
return lines.join('\n');
}
function joinFile(preamble, blocks) {
const parts = [];
const pre = [...preamble];
while (pre.length && pre.at(-1).trim() === '') pre.pop();
if (pre.length) parts.push(pre.join('\n'));
for (const b of blocks) parts.push(blockToText(b));
return parts.join('\n\n').replace(/\n+$/, '') + '\n';
}
async function readFileOrNull(p) {
try {
return await fs.readFile(p, 'utf8');
} catch {
return null;
}
}
// Read the `[core]` table from config.toml as a flat {key: value} map. Used to
// resolve `{output_folder}`-style placeholders in module defaults.
function readCoreValues(teamContent) {
if (!teamContent) return {};
const { blocks } = splitBlocks(teamContent);
const core = blocks.find((b) => b.header === 'core');
if (!core) return {};
const out = {};
for (const line of core.lines.slice(1)) {
const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
if (m) out[m[1]] = parseTomlScalar(m[2]);
}
return out;
}
// ── default/result resolution (port of processResultTemplate) ─────────────────
function applyResultTemplate(template, value, lookups) {
if (typeof template !== 'string') return value;
let result = template;
if (typeof value === 'string') {
result = result.replace('{value}', value);
} else if (typeof value === 'boolean' || typeof value === 'number') {
result = result === '{value}' ? value : result.replace('{value}', String(value));
} else {
return value;
}
if (typeof result !== 'string') return result;
return result.replaceAll(/{([^}]+)}/g, (match, key) => {
if (key === 'project-root') return '{project-root}';
if (key === 'value') return match;
let v = lookups[key];
if (typeof v === 'string' && v.includes('{project-root}/')) v = v.replace('{project-root}/', '');
return v === undefined || v === null ? match : String(v);
});
}
// Resolve a module.yaml into { values, scopes } where values are post-template
// strings and scopes maps each key to 'team' | 'user'. `overrides` supplies
// non-default values (from --set); `coreValues` feeds placeholder resolution.
function resolveModuleConfig(moduleYaml, coreValues, overrides) {
const values = {};
const scopes = {};
const lookups = { ...coreValues };
for (const [key, entry] of Object.entries(moduleYaml || {})) {
if (!entry || typeof entry !== 'object' || !('prompt' in entry)) continue;
const raw = key in overrides ? overrides[key] : entry.default;
if (raw === undefined) continue;
const resolved = 'result' in entry ? applyResultTemplate(entry.result, raw, lookups) : raw;
values[key] = resolved;
scopes[key] = entry.scope === 'user' ? 'user' : 'team';
// Make this key visible to later keys' placeholder resolution.
lookups[key] = resolved;
}
return { values, scopes };
}
function renderModuleBlock(sectionKey, kv) {
const lines = [`[modules.${sectionKey}]`];
for (const [k, v] of Object.entries(kv)) lines.push(`${k} = ${formatTomlValue(v)}`);
return { header: `modules.${sectionKey}`, lines };
}
function renderAgentBlock(agent) {
const lines = [`[agents.${agent.code}]`, `module = ${formatTomlValue(agent.module)}`, `team = ${formatTomlValue(agent.team)}`];
if (agent.name) lines.push(`name = ${formatTomlValue(agent.name)}`);
if (agent.title) lines.push(`title = ${formatTomlValue(agent.title)}`);
if (agent.icon) lines.push(`icon = ${formatTomlValue(agent.icon)}`);
if (agent.description) lines.push(`description = ${formatTomlValue(agent.description)}`);
return { header: `agents.${agent.code}`, lines };
}
const TEAM_HEADER = [
'# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on install — treat as read-only.',
'# To pin a value or add custom agents, use _bmad/custom/config.toml',
'# (team, committed) — never touched by the installer.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
const USER_HEADER = [
'# ─────────────────────────────────────────────────────────────────',
'# Installer-managed. Regenerated on install — treat as read-only.',
'# Holds install answers scoped to YOU personally.',
'# For pinned overrides use _bmad/custom/config.user.toml.',
'# ─────────────────────────────────────────────────────────────────',
'',
];
// Upsert `[modules.<code>]` and the module's `[agents.*]` blocks, dropping any
// prior copies (idempotent). When no team/user keys exist the module section is
// omitted from that file. Returns the resolved config values for downstream
// consumers (e.g. directory creation).
export async function regenerateCentralConfig(bmadDir, code, opts = {}) {
const overrides = opts.setOverrides?.[code] || {};
const moduleYamlPath = path.join(bmadDir, code, 'module.yaml');
const moduleYamlRaw = await readFileOrNull(moduleYamlPath);
const teamPath = path.join(bmadDir, TEAM_FILE);
const userPath = path.join(bmadDir, USER_FILE);
const teamContent = await readFileOrNull(teamPath);
const userContent = await readFileOrNull(userPath);
// No module.yaml → nothing module-specific to write, but still strip any stale
// blocks for this code so re-installs stay clean.
let moduleYaml = null;
if (moduleYamlRaw) {
try {
moduleYaml = parseYaml(moduleYamlRaw);
} catch (e) {
process.stderr.write(`[bmad-module] warn: could not parse ${code}/module.yaml: ${e.message}\n`);
}
}
const sectionKey = (moduleYaml && moduleYaml.code) || code;
const coreValues = readCoreValues(teamContent);
const { values, scopes } = moduleYaml ? resolveModuleConfig(moduleYaml, coreValues, overrides) : { values: {}, scopes: {} };
const teamKv = {};
const userKv = {};
for (const [k, v] of Object.entries(values)) {
if (scopes[k] === 'user') userKv[k] = v;
else teamKv[k] = v;
}
const agents = Array.isArray(moduleYaml?.agents)
? moduleYaml.agents
.filter((a) => a && typeof a.code === 'string')
.map((a) => ({
code: a.code,
name: a.name || '',
title: a.title || '',
icon: a.icon || '',
description: a.description || '',
module: code,
team: a.team || code,
}))
: [];
const agentCodes = new Set(agents.map((a) => a.code));
// ── config.toml (team) ──
{
const base = teamContent || TEAM_HEADER.join('\n') + '\n';
const { preamble, blocks } = splitBlocks(base);
// Drop this module's prior [modules.<code>] and its [agents.*] blocks. Match
// current agent codes AND, as a fallback for removed/renamed agents (or a
// missing module.yaml that leaves agentCodes empty), any [agents.*] block
// whose `module = "<code>"` line marks it as owned by this module.
const kept = blocks.filter((b) => {
if (b.header === `modules.${sectionKey}` || b.header === `modules.${code}`) return false;
if (b.header.startsWith('agents.')) {
if (agentCodes.has(b.header.slice('agents.'.length))) return false;
if (b.lines.some((l) => /^module\s*=/.test(l) && parseTomlScalar(l.split('=').slice(1).join('=')) === code)) return false;
}
return true;
});
if (Object.keys(teamKv).length) kept.push(renderModuleBlock(sectionKey, teamKv));
for (const a of agents) kept.push(renderAgentBlock(a));
await fs.writeFile(teamPath, joinFile(preamble, kept), 'utf8');
}
// ── config.user.toml (user) ──
{
const base = userContent || USER_HEADER.join('\n') + '\n';
const { preamble, blocks } = splitBlocks(base);
const kept = blocks.filter((b) => b.header !== `modules.${sectionKey}`);
if (Object.keys(userKv).length) kept.push(renderModuleBlock(sectionKey, userKv));
await fs.writeFile(userPath, joinFile(preamble, kept), 'utf8');
}
return { values, scopes, sectionKey };
}
// Read a module's currently-stored config values from config.toml +
// config.user.toml ([modules.<sectionKey>]), merged into one {key: value} map.
// Used to detect changed directory paths across updates.
export async function readModuleConfigValues(bmadDir, sectionKey) {
const out = {};
for (const file of [TEAM_FILE, USER_FILE]) {
const content = await readFileOrNull(path.join(bmadDir, file));
if (!content) continue;
const { blocks } = splitBlocks(content);
const block = blocks.find((b) => b.header === `modules.${sectionKey}`);
if (!block) continue;
for (const line of block.lines.slice(1)) {
const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
if (m) out[m[1]] = parseTomlScalar(m[2]);
}
}
return out;
}
// Resolve a module.yaml's `code` field (the TOML section key), falling back to
// the install code when module.yaml is absent/unparseable.
export async function resolveSectionKey(bmadDir, code) {
const raw = await readFileOrNull(path.join(bmadDir, code, 'module.yaml'));
if (!raw) return code;
try {
const y = parseYaml(raw);
return (y && y.code) || code;
} catch {
return code;
}
}
// Strip a module's `[modules.<code>]` (both files) and its `[agents.*]` blocks
// (team file) on removal. Agent codes come from the module's module.yaml if it
// still exists; otherwise we drop agent blocks whose `module = "<code>"`.
export async function removeModuleFromConfig(bmadDir, code) {
const moduleYamlRaw = await readFileOrNull(path.join(bmadDir, code, 'module.yaml'));
let sectionKey = code;
const agentCodes = new Set();
if (moduleYamlRaw) {
try {
const y = parseYaml(moduleYamlRaw);
if (y?.code) sectionKey = y.code;
if (Array.isArray(y?.agents)) for (const a of y.agents) if (a?.code) agentCodes.add(a.code);
} catch {
/* fall through to module= match */
}
}
const teamPath = path.join(bmadDir, TEAM_FILE);
const userPath = path.join(bmadDir, USER_FILE);
const teamContent = await readFileOrNull(teamPath);
const userContent = await readFileOrNull(userPath);
if (teamContent) {
const { preamble, blocks } = splitBlocks(teamContent);
const kept = blocks.filter((b) => {
if (b.header === `modules.${sectionKey}` || b.header === `modules.${code}`) return false;
if (b.header.startsWith('agents.')) {
const ac = b.header.slice('agents.'.length);
if (agentCodes.has(ac)) return false;
// Fallback: drop blocks whose module line names this code.
if (b.lines.some((l) => /^module\s*=/.test(l) && parseTomlScalar(l.split('=').slice(1).join('=')) === code)) return false;
}
return true;
});
await fs.writeFile(teamPath, joinFile(preamble, kept), 'utf8');
}
if (userContent) {
const { preamble, blocks } = splitBlocks(userContent);
const kept = blocks.filter((b) => b.header !== `modules.${sectionKey}` && b.header !== `modules.${code}`);
await fs.writeFile(userPath, joinFile(preamble, kept), 'utf8');
}
}

View File

@ -0,0 +1,33 @@
// 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,
// Setup/packaging problem — the skill's own bundled runtime files (e.g. the
// vendored yaml in lib/vendor/) are missing or corrupt, usually from an
// incomplete copy. Distinct from a module-rejection decision: it's fixable by
// reinstalling the skill. Emitted by the launcher in bmad-module.mjs, which
// duplicates the literal `5` so its guard can depend on nothing — keep in sync.
TOOLING: 5,
NO_BMAD_DIR: 10,
BAD_MANIFEST: 20,
RESERVED_PREFIX: 21,
PREFIX_COLLISION: 30,
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);
}

View File

@ -0,0 +1,13 @@
import { parse as parseYaml } from './vendor/yaml.mjs';
// 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;
}
}

View File

@ -0,0 +1,150 @@
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;
}
// Stage a copy plan into `destDir`: each plan entry copies one file from
// `srcRoot/srcRel` to `destDir/destRel`. `extras` is an optional map of
// `destRel → string content` for synthesized files (e.g. a rewritten plugin.json)
// that have no source-tree counterpart. Returns the union of destRels written.
export async function stageCopyPlan(srcRoot, destDir, plan, extras = {}) {
await fsp.mkdir(destDir, { recursive: true });
const written = [];
for (const { srcRel, destRel } of plan) {
const absSrc = path.join(srcRoot, srcRel);
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.copyFile(absSrc, absDest);
written.push(destRel);
}
for (const [destRel, content] of Object.entries(extras)) {
const absDest = path.join(destDir, destRel);
await fsp.mkdir(path.dirname(absDest), { recursive: true });
await fsp.writeFile(absDest, content, 'utf8');
written.push(destRel);
}
return written;
}
// Atomically replace `targetDir` with `stagedDir` contents. Best effort —
// not truly atomic, but minimizes the inconsistent window.
//
// `stagedDir` usually lives under the OS temp dir, which is frequently a
// separate filesystem (e.g. tmpfs on /tmp) from the target. rename() cannot
// move across filesystems and throws EXDEV there, so we first land the staged
// tree onto the target's own filesystem as a sibling — by rename when they
// already share a filesystem, by copy when they don't — and then rename that
// sibling into place, which is always an intra-filesystem atomic swap.
export async function atomicSwapDir(stagedDir, targetDir) {
const parent = path.dirname(targetDir);
await fsp.mkdir(parent, { recursive: true });
const suffix = crypto.randomBytes(6).toString('hex');
const sibling = path.join(parent, `.${path.basename(targetDir)}.bmad-tmp-${suffix}`);
const backup = path.join(parent, `.${path.basename(targetDir)}.bmad-old-${suffix}`);
try {
try {
await fsp.rename(stagedDir, sibling);
} catch (e) {
if (e.code !== 'EXDEV') throw e;
await copyDir(stagedDir, sibling);
await fsp.rm(stagedDir, { recursive: true, force: true });
}
// Move any existing target aside as a backup rather than deleting it
// up front, so a failed swap can be rolled back to the old install.
const hadTarget = await fsp
.stat(targetDir)
.then(() => true)
.catch(() => false);
if (hadTarget) await fsp.rename(targetDir, backup);
try {
await fsp.rename(sibling, targetDir);
} catch (e) {
if (hadTarget) await fsp.rename(backup, targetDir).catch(() => {});
throw e;
}
if (hadTarget) await fsp.rm(backup, { recursive: true, force: true });
} catch (e) {
await fsp.rm(sibling, { recursive: true, force: true });
await fsp.rm(backup, { recursive: true, force: true });
throw e;
}
}
// 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);
}
}

View File

@ -0,0 +1,133 @@
import fs from 'node:fs/promises';
import path from 'node:path';
// Regenerate the merged help catalog `_bmad/_config/bmad-help.csv` from every
// installed module's `module-help.csv`. This mirrors the full installer's
// `Installer.mergeModuleHelpCatalogs` (tools/installer/core/installer.js) so a
// module installed via this skill is visible to the `bmad-help` skill, which
// reads `_bmad/_config/bmad-help.csv` (see src/core-skills/bmad-help/SKILL.md).
//
// Self-contained note: the installer scans core from its source tree
// (`getSourcePath('core-skills')`); we instead scan `_bmad/<module>/` for every
// installed module — including core, whose `module-help.csv` is copied into
// `_bmad/core/` at `bmad install` time — so this needs no source checkout.
// Canonical per-module CSV header. Must match
// tools/installer/modules/module-help-schema.js (MODULE_HELP_CSV_HEADER). A
// per-module file whose header differs is loaded positionally with a warning.
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;
// Top-level _bmad children that are not modules and must not be scanned.
const NON_MODULE_DIRS = new Set(['_config', '_memory', 'memory', 'docs', 'scripts', 'custom']);
// Parse a single CSV line into fields. Mirrors Installer.parseCSVLine: handles
// `""`-escaped quotes inside quoted fields and unquoted commas as separators.
function parseCsvLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const next = line[i + 1];
if (char === '"') {
if (inQuotes && next === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
// Quote a field only when it contains a comma, quote, or newline. Mirrors
// Installer.escapeCSVField so the merged output is byte-compatible.
function escapeCsvField(field) {
if (field === null || field === undefined) return '';
const str = String(field);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replaceAll('"', '""')}"`;
}
return str;
}
// Read every installed module's module-help.csv, merge into the canonical
// catalog, and write `_bmad/_config/bmad-help.csv`. Returns the data-row count.
// Re-scans the whole tree each call, so it is correct after install AND remove.
export async function regenerateHelpCatalog(bmadDir) {
let entries;
try {
entries = await fs.readdir(bmadDir, { withFileTypes: true });
} catch {
return 0;
}
const moduleNames = entries
.filter((e) => e.isDirectory() && !NON_MODULE_DIRS.has(e.name) && !e.name.startsWith('.'))
.map((e) => e.name)
.sort();
const allRows = [];
for (const moduleName of moduleNames) {
const helpFilePath = path.join(bmadDir, moduleName, 'module-help.csv');
let content;
try {
content = await fs.readFile(helpFilePath, 'utf8');
} catch {
continue; // module ships no help catalog — fine
}
const lines = content.split('\n').filter((line) => line.trim() && !line.startsWith('#'));
let headerWarned = false;
for (const line of lines) {
// Canonical header row: warn on drift, then skip. (A non-canonical header
// that doesn't start with `module,` falls through and is loaded as data,
// matching the installer — author CSVs should use the canonical header.)
if (line.startsWith('module,')) {
if (!headerWarned && line.trim() !== MODULE_HELP_CSV_HEADER) {
process.stderr.write(
`[bmad-module] warn: ${moduleName}/module-help.csv header differs from canonical schema — data loaded positionally.\n`,
);
headerWarned = true;
}
continue;
}
const columns = parseCsvLine(line);
if (columns.length < COLUMN_COUNT - 1) continue;
const padded = columns.slice(0, COLUMN_COUNT);
while (padded.length < COLUMN_COUNT) padded.push('');
// Empty module column → fill with the dir name (core stays empty so its
// rows render as universal tools), matching the installer.
if ((!padded[0] || padded[0].trim() === '') && moduleName !== 'core') {
padded[0] = moduleName;
}
allRows.push(padded.map((c) => escapeCsvField(c)).join(','));
}
}
// Sort by (module, phase); stable within a phase to preserve authored order.
const decorated = allRows.map((row, index) => ({ row, index, cols: parseCsvLine(row) }));
decorated.sort((a, b) => {
const moduleA = (a.cols[0] || '').toLowerCase();
const moduleB = (b.cols[0] || '').toLowerCase();
if (moduleA !== moduleB) return moduleA.localeCompare(moduleB);
const phaseA = a.cols[PHASE_INDEX] || '';
const phaseB = b.cols[PHASE_INDEX] || '';
if (phaseA !== phaseB) return phaseA.localeCompare(phaseB);
return a.index - b.index;
});
const sortedRows = decorated.map((d) => d.row);
const outputPath = path.join(bmadDir, '_config', 'bmad-help.csv');
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, [MODULE_HELP_CSV_HEADER, ...sortedRows].join('\n'), 'utf8');
return sortedRows.length;
}

View File

@ -0,0 +1,68 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readManifestYaml } from './manifest-ops.mjs';
// Distribute the project's installed skills to the coding assistants (IDEs) the
// user chose at `bmad install` time, by running the self-contained engine bundle
// shipped beside this file (vendor/ide-sync.mjs). Pure node: + a local bundle —
// no npx, no network, no node_modules. The bundle reads the chosen IDEs from
// _bmad/_config/manifest.yaml and the skills from _config/skill-manifest.csv.
//
// `prune` is the list of canonicalIds to remove from the IDE directories (the
// skills of a module being updated or removed); pass [] for a plain install.
//
// Returns one of:
// { skipped: true } — no IDEs configured; nothing to do
// { ok: true } — bundle ran and distributed successfully
// { ok: false, hint } — bundle missing or exited non-zero; caller
// reports the hint but does NOT fail the verb
// (the _bmad/ write already succeeded).
export async function distributeToIdes({ projectDir, bmadDir, prune = [] }) {
const manifest = await readManifestYaml(bmadDir);
// readManifestYaml returns null for BOTH a missing manifest and a parse
// failure. A present-but-unreadable manifest is real config corruption — don't
// silently skip distribution; surface a repair hint. A genuinely absent
// manifest falls through to the "nothing configured" skip below.
if (manifest === null && existsSync(path.join(bmadDir, '_config', 'manifest.yaml'))) {
return {
ok: false,
hint:
'Could not read _bmad/_config/manifest.yaml (invalid YAML). Run `bmad install` to repair BMAD config, ' +
'then `bmad ide-sync` to push skills to your coding assistants.',
};
}
const ides = Array.isArray(manifest?.ides) ? manifest.ides.filter((i) => i && typeof i === 'string') : [];
if (ides.length === 0) {
return { skipped: true };
}
const bundlePath = fileURLToPath(new URL('vendor/ide-sync.mjs', import.meta.url));
if (!existsSync(bundlePath)) {
return {
ok: false,
hint:
'IDE distribution bundle is missing (older install). Run `bmad install` to refresh BMAD tooling, ' +
'or `bmad ide-sync` to push skills to your coding assistants.',
};
}
const args = [bundlePath, '-d', projectDir];
const pruneIds = (prune || []).filter(Boolean);
if (pruneIds.length) args.push('--prune', pruneIds.join(','));
const code = await new Promise((resolve) => {
const child = spawn(process.execPath, args, { stdio: 'inherit' });
child.on('error', () => resolve(-1));
child.on('close', (c) => resolve(c ?? -1));
});
if (code === 0) return { ok: true };
return {
ok: false,
hint:
`IDE distribution exited with code ${code}. Your module is installed under _bmad/, but skills may ` +
'not be in every coding assistant yet — run `bmad ide-sync` to retry.',
};
}

View File

@ -0,0 +1,403 @@
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. Declaring
// both at once is disallowed — 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/<code>/`. 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;
}
// Top-level files we always copy if present (and not ignored). Authors don't
// have to declare these — they're conventional repo metadata. package.json /
// package-lock.json are included so a module that ships JS runtime deps can have
// them installed in place post-copy (see npm-deps.mjs); node_modules itself is
// never copied (it's in DEFAULT_IGNORES) and is regenerated by npm install.
const ALWAYS_TOPLEVEL = new Set([
'README.md',
'README',
'README.rst',
'CHANGELOG.md',
'CHANGELOG',
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'LICENCE',
'LICENCE.md',
'NOTICE',
'NOTICE.md',
'package.json',
'package-lock.json',
]);
function stripDotSlash(p) {
if (typeof p !== 'string') return p;
let s = p.replaceAll('\\', '/');
if (s.startsWith('./')) s = s.slice(2);
return s;
}
// Recursively list files under `sourceDir/relDir`, returning POSIX paths
// relative to `sourceDir`. Skips symlinks and ignore-matched entries.
async function listFilesUnder(sourceDir, relDir, ignoreMatch) {
const out = [];
async function walk(rel) {
const absDir = path.join(sourceDir, rel);
let entries;
try {
entries = await fs.readdir(absDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (ignoreMatch && ignoreMatch(childRel)) continue;
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) await walk(childRel);
else if (entry.isFile()) out.push(childRel);
}
}
await walk(relDir);
return out;
}
// Build a manifest-driven copy plan. Each entry is { srcRel, destRel } in
// POSIX form, relative to the module's source root / install root respectively.
//
// The plan:
// - Each declared `skills[]` / `agents[]` / `commands[]` dir is copied
// recursively into the canonical slot (`skills/<basename>/...` etc.),
// regardless of where the author kept it in source (e.g. under `src/`).
// - `bmad.moduleDefinition` → `module.yaml`
// - `bmad.moduleHelpCsv` → `module-help.csv`
// - `bmad.docs.readme` / `bmad.docs.changelog` / `bmad.docs.homepage`
// (relative path) → preserved at module root with their basename.
// - `hooks` / `mcpServers` / `lspServers` / `settings` (when string paths) →
// canonical root slot (`hooks.json`, `.mcp.json`, etc.).
// - `.claude-plugin/plugin.json` is always kept (callers rewrite paths in it
// via `rewriteManifestPaths`).
// - `.claude-plugin/marketplace.json` is preserved if present.
// - Conventional top-level metadata files (README/CHANGELOG/LICENSE/NOTICE)
// are copied if present at source root.
//
// Anything NOT covered by the above is dropped. This means `tools/`, `website/`,
// `.github/`, `.trunk/`, etc. don't leak into the install even if the author
// forgot to list them in `bmad.install.ignore`.
//
// Returns: { plan, skillDestDirs } where skillDestDirs is the list of canonical
// skill paths (`skills/X`) for the skill-manifest writer.
export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) {
const plan = [];
const claimedSrc = new Set();
const claimedDest = new Set();
// `allowDupSrc` lets a single source file be copied to a second destination
// even if it was already claimed by an earlier (recursive) copy. Needed for
// moduleDefinition / moduleHelpCsv, which commonly live INSIDE a declared
// skill dir (e.g. `<setup-skill>/assets/module.yaml`): they must also be
// flattened to the canonical `_bmad/<code>/module.yaml` root slot that the
// rewritten plugin.json points at and that config / help-catalog read.
const addFile = (srcRel, destRel, allowDupSrc = false) => {
if (!srcRel || !destRel) return;
if (!allowDupSrc && claimedSrc.has(srcRel)) return;
if (claimedDest.has(destRel)) return;
if (!allowDupSrc) claimedSrc.add(srcRel);
claimedDest.add(destRel);
plan.push({ srcRel, destRel });
};
const addDirRecursive = async (srcRelDir, destRelDir) => {
const files = await listFilesUnder(sourceDir, srcRelDir, ignoreMatch);
for (const fileSrcRel of files) {
const rest = fileSrcRel.slice(srcRelDir.length).replace(/^\//, '');
const destRel = rest ? `${destRelDir}/${rest}` : destRelDir;
addFile(fileSrcRel, destRel);
}
};
// Helper: if `srcRel` exists as a file in source, queue it.
const queueFileIfExists = async (srcRel, destRel, allowDupSrc = false) => {
if (!srcRel) return;
if (ignoreMatch && ignoreMatch(srcRel)) return;
try {
const stat = await fs.stat(path.join(sourceDir, srcRel));
if (stat.isFile()) addFile(srcRel, destRel, allowDupSrc);
} catch {
/* missing — silently skip; validateDeclaredPaths surfaces declared misses */
}
};
// Plugin manifest itself — always kept. Path is rewritten by the caller
// before staging; here we just reserve the slot so nothing else claims it.
claimedDest.add('.claude-plugin/plugin.json');
// Optional marketplace.json — copy verbatim if present.
await queueFileIfExists('.claude-plugin/marketplace.json', '.claude-plugin/marketplace.json');
// Skills / agents / commands.
const arrCategories = [
['skills', 'skills', manifest.skills],
['agents', 'agents', manifest.agents],
['commands', 'commands', manifest.commands],
];
const skillDestDirs = [];
for (const [, destPrefix, arr] of arrCategories) {
if (!Array.isArray(arr)) continue;
for (const declared of arr) {
const srcRel = stripDotSlash(declared);
if (!srcRel) continue;
const destRel = `${destPrefix}/${path.posix.basename(srcRel)}`;
// Entries may be directories (skills, agent packs) or single files
// (e.g. a subagent declared as `./agents/foo.md`). Stat to branch;
// rewriteManifestPaths() remaps both to `<destPrefix>/<basename>`.
try {
const stat = await fs.stat(path.join(sourceDir, srcRel));
if (stat.isDirectory()) {
await addDirRecursive(srcRel, destRel);
if (destPrefix === 'skills') skillDestDirs.push(destRel);
} else if (stat.isFile() && (!ignoreMatch || !ignoreMatch(srcRel))) addFile(srcRel, destRel);
} catch {
/* missing — validateDeclaredPaths surfaces declared misses */
}
}
}
// moduleDefinition / moduleHelpCsv — flatten to canonical names at root.
// allowDupSrc: these often live inside a declared skill dir, so the source
// file may already be claimed by that skill's recursive copy; we still want
// the canonical root copy that the rewritten manifest, config-gen, and the
// help catalog all rely on.
if (typeof manifest.bmad?.moduleDefinition === 'string') {
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml', true);
}
if (typeof manifest.bmad?.moduleHelpCsv === 'string') {
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv', true);
}
// Top-level docs declared in the manifest — keep at root by basename.
const docs = manifest.bmad?.docs;
if (docs && typeof docs === 'object') {
for (const key of ['readme', 'changelog', 'homepage']) {
const v = docs[key];
if (typeof v !== 'string') continue;
if (/^https?:/i.test(v)) continue;
const srcRel = stripDotSlash(v);
await queueFileIfExists(srcRel, path.posix.basename(srcRel));
}
}
// String-typed Claude-Code surfaces — canonical root slot.
const stringSurfaces = [
['hooks', 'hooks.json'],
['mcpServers', '.mcp.json'],
['lspServers', 'lsp-servers.json'],
['settings', 'settings.json'],
];
for (const [key, destName] of stringSurfaces) {
const v = manifest[key];
if (typeof v !== 'string') continue;
const srcRel = stripDotSlash(v);
if (!srcRel) continue;
// These surfaces are single JSON files installed at a fixed canonical name
// (rewriteManifestPaths always points the manifest at `./hooks.json`,
// `./.mcp.json`, etc.). A directory source would be copied under its
// basename yet leave the manifest pointing at a file that was never written,
// so only file sources are honored here.
try {
const stat = await fs.stat(path.join(sourceDir, srcRel));
if (stat.isFile()) addFile(srcRel, destName);
} catch {
/* missing — skip */
}
}
// Conventional top-level metadata files — copy if present.
for (const name of ALWAYS_TOPLEVEL) {
await queueFileIfExists(name, name);
}
// Stable order — dest-relative sort makes diffs and dry-run output readable.
plan.sort((a, b) => a.destRel.localeCompare(b.destRel));
skillDestDirs.sort();
return { plan, skillDestDirs };
}
// Produce a rewritten plugin.json where every declared path points at its
// canonical post-install location (so the on-disk manifest stays self-consistent
// inside `_bmad/<code>/`). Returns a JSON string.
export function rewriteManifestPaths(manifest) {
const out = structuredClone(manifest);
const remapArr = (arr, destPrefix) => {
if (!Array.isArray(arr)) return arr;
return arr.map((entry) => {
if (typeof entry !== 'string') return entry;
const srcRel = stripDotSlash(entry);
return `./${destPrefix}/${path.posix.basename(srcRel)}`;
});
};
if (Array.isArray(out.skills)) out.skills = remapArr(out.skills, 'skills');
if (Array.isArray(out.agents)) out.agents = remapArr(out.agents, 'agents');
if (Array.isArray(out.commands)) out.commands = remapArr(out.commands, 'commands');
if (typeof out.hooks === 'string') out.hooks = './hooks.json';
if (typeof out.mcpServers === 'string') out.mcpServers = './.mcp.json';
if (typeof out.lspServers === 'string') out.lspServers = './lsp-servers.json';
if (typeof out.settings === 'string') out.settings = './settings.json';
if (out.bmad && typeof out.bmad === 'object') {
if (typeof out.bmad.moduleDefinition === 'string') out.bmad.moduleDefinition = './module.yaml';
if (typeof out.bmad.moduleHelpCsv === 'string') out.bmad.moduleHelpCsv = './module-help.csv';
// customize.schemas — each entry lives inside a declared skill dir, which is
// remapped to `skills/<basename>`. Anchor on the owning skill dir and keep
// every segment after it, so nested schemas (e.g. `<skill>/schemas/x.yaml`)
// land under the right skill instead of being collapsed to the last two
// segments.
const skillDirs = Array.isArray(manifest.skills)
? manifest.skills.filter((s) => typeof s === 'string').map((s) => stripDotSlash(s))
: [];
const schemas = out.bmad.customize?.schemas;
if (Array.isArray(schemas)) {
out.bmad.customize.schemas = schemas.map((entry) => {
if (typeof entry !== 'string') return entry;
const srcRel = stripDotSlash(entry);
const owner = skillDirs.find((sd) => sd && (srcRel === sd || srcRel.startsWith(sd + '/')));
if (owner) {
const remainder = srcRel.slice(owner.length + 1);
return `./skills/${path.posix.basename(owner)}/${remainder}`;
}
// Fallback when no declared skill owns the path: last two segments are
// assumed to be `<skill-name>/<filename>`.
const parts = srcRel.split('/');
if (parts.length >= 2) return `./skills/${parts.at(-2)}/${parts.at(-1)}`;
return `./${srcRel}`;
});
}
if (out.bmad.docs && typeof out.bmad.docs === 'object') {
for (const key of ['readme', 'changelog', 'homepage']) {
const v = out.bmad.docs[key];
if (typeof v !== 'string') continue;
if (/^https?:/i.test(v)) continue;
out.bmad.docs[key] = `./${path.posix.basename(stripDotSlash(v))}`;
}
}
}
return JSON.stringify(out, null, 2) + '\n';
}

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*\r?\n([\s\S]*?)\r?\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

@ -0,0 +1,299 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from './vendor/yaml.mjs';
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/<code>/` (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');
}
// Return the canonicalIds of a module's skills currently recorded in
// skill-manifest.csv. Used by update/remove to tell ide-sync which skill
// directories to prune from the IDE targets.
export async function readSkillCanonicalIdsForModule(bmadDir, code) {
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
const rows = await readCsvRows(csvPath);
if (!rows || rows.length < 2) return [];
return rows
.slice(1)
.filter((r) => r[3] === code)
.map((r) => r[0])
.filter(Boolean);
}
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/<code>/ 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');
}

View File

@ -0,0 +1,128 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from './vendor/yaml.mjs';
// Create the project working directories a module declares in its module.yaml
// `directories:` key, mirroring the full installer's
// OfficialModules.createModuleDirectories (tools/installer/modules/official-modules.js).
//
// Each entry is a `{config_key}` reference resolved against the module's config
// values (produced by config-gen). `{project-root}` is stripped to a project-
// relative path; the dir is created under the project root (the parent of
// `_bmad/`). On update, a changed path moves the old directory to the new one.
// All failures are non-fatal warnings — the module itself is already installed.
const warn = (msg) => process.stderr.write(`[bmad-module] warn: ${msg}\n`);
async function pathExists(p) {
try {
await fs.stat(p);
return true;
} catch {
return false;
}
}
// `moduleConfig` / `existingConfig`: {configKey: resolvedValue} maps, where a
// value may carry a leading `{project-root}/`. Returns a summary for display.
export async function createModuleDirectories(bmadDir, code, moduleConfig = {}, existingConfig = {}) {
const projectRoot = path.dirname(bmadDir);
const empty = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
let moduleYamlRaw;
try {
moduleYamlRaw = await fs.readFile(path.join(bmadDir, code, 'module.yaml'), 'utf8');
} catch {
return empty; // no module.yaml flattened into the install — nothing to do
}
let moduleYaml;
try {
moduleYaml = parseYaml(moduleYamlRaw);
} catch (e) {
warn(`invalid ${code}/module.yaml: ${e.message}`);
return empty;
}
if (!moduleYaml || !Array.isArray(moduleYaml.directories)) return empty;
const wdsFolders = Array.isArray(moduleYaml.wds_folders) ? moduleYaml.wds_folders : [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
const normalizedRoot = path.normalize(projectRoot);
const toRelPath = (value) => path.normalize(value.replace(/^\{project-root\}\/?/, '').replaceAll('{project-root}', ''));
for (const dirRef of moduleYaml.directories) {
const varMatch = typeof dirRef === 'string' && dirRef.match(/^\{([^}]+)\}$/);
if (!varMatch) continue; // only variable references are honored
const configKey = varMatch[1];
const dirValue = moduleConfig[configKey];
if (!dirValue || typeof dirValue !== 'string') continue;
const dirPath = toRelPath(dirValue);
const fullPath = path.join(projectRoot, dirPath);
const normalizedNewAbs = path.normalize(fullPath);
if (normalizedNewAbs !== normalizedRoot && !normalizedNewAbs.startsWith(normalizedRoot + path.sep)) {
warn(`${configKey} path escapes project root, skipping: ${dirPath}`);
continue;
}
// Detect a changed path vs the previous install for a move.
let oldFullPath = null;
let oldDirPath = null;
const oldValue = existingConfig[configKey];
if (oldValue && typeof oldValue === 'string') {
const normalizedOld = toRelPath(oldValue);
if (normalizedOld !== dirPath) {
oldDirPath = normalizedOld;
oldFullPath = path.join(projectRoot, oldDirPath);
const normalizedOldAbs = path.normalize(oldFullPath);
if (normalizedOldAbs !== normalizedRoot && !normalizedOldAbs.startsWith(normalizedRoot + path.sep)) {
oldFullPath = null; // old path escapes root — ignore
} else if (normalizedOldAbs.startsWith(normalizedNewAbs + path.sep) || normalizedNewAbs.startsWith(normalizedOldAbs + path.sep)) {
warn(`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}); creating new directory`);
oldFullPath = null;
}
}
}
const dirName = configKey.replaceAll('_', ' ');
const newExists = await pathExists(fullPath);
if (oldFullPath && (await pathExists(oldFullPath)) && !newExists) {
try {
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.rename(oldFullPath, fullPath);
movedDirs.push(`${dirName}: ${oldDirPath}${dirPath}`);
} catch (moveErr) {
warn(`failed to move ${oldDirPath}${dirPath}: ${moveErr.message}. Creating new directory; move contents manually.`);
await fs.mkdir(fullPath, { recursive: true });
createdDirs.push(`${dirName}: ${dirPath}`);
}
} else if (oldFullPath && (await pathExists(oldFullPath)) && newExists) {
warn(`${dirName}: path changed but both old (${oldDirPath}) and new (${dirPath}) exist — review/merge manually.`);
} else if (!newExists) {
await fs.mkdir(fullPath, { recursive: true });
createdDirs.push(`${dirName}: ${dirPath}`);
}
// WDS subfolders under design_artifacts.
if (configKey === 'design_artifacts' && wdsFolders.length) {
for (const sub of wdsFolders) {
if (typeof sub !== 'string' || sub === '') continue;
const subPath = path.normalize(path.join(fullPath, sub));
// `sub` is untrusted module content; reject traversal/absolute escapes
// out of the design_artifacts directory.
if (subPath !== normalizedNewAbs && !subPath.startsWith(normalizedNewAbs + path.sep)) {
warn(`wds_folders entry escapes design_artifacts, skipping: ${sub}`);
continue;
}
if (!(await pathExists(subPath))) {
await fs.mkdir(subPath, { recursive: true });
createdWdsFolders.push(sub);
}
}
}
}
return { createdDirs, movedDirs, createdWdsFolders };
}

View File

@ -0,0 +1,41 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileP = promisify(execFile);
// Install a module's runtime dependencies when it ships a package.json, mirroring
// the full installer's CustomModuleManager.cloneRepo npm step
// (tools/installer/modules/custom-module-manager.js). Unlike the installer — which
// installs into its ~/.bmad cache and copies node_modules across — the skill never
// copies node_modules (it's in DEFAULT_IGNORES), so we install in place inside the
// committed `_bmad/<code>/`.
//
// This relaxes the skill's "no npm in _bmad/" principle, but it is the only way a
// module whose skills shell out to JS deps works after a skill-driven install.
// Gated on package.json presence and opt-out via `bmad.install.skipNpm: true`.
// Always non-fatal: the module files are already committed; a failed dep install
// is a warning, not an install failure.
const NPM_ARGS = ['install', '--omit=dev', '--no-audit', '--no-fund', '--no-progress', '--legacy-peer-deps'];
const TIMEOUT_MS = 120_000;
export async function installModuleDeps(moduleDir, manifest) {
if (manifest?.bmad?.install?.skipNpm === true) return { ran: false, skipped: 'opted out (bmad.install.skipNpm)' };
const pkgPath = path.join(moduleDir, 'package.json');
try {
const stat = await fs.stat(pkgPath);
if (!stat.isFile()) return { ran: false };
} catch {
return { ran: false }; // no package.json — nothing to install
}
try {
await execFileP('npm', NPM_ARGS, { cwd: moduleDir, timeout: TIMEOUT_MS });
return { ran: true, ok: true };
} catch (e) {
return { ran: true, ok: false, error: e.shortMessage || e.message };
}
}

View File

@ -0,0 +1,113 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { valid as semverValid, validRange as semverValidRange } from './semver-lite.mjs';
import { EXIT, BmadModuleError } from './exit.mjs';
// Reserved bmad.code values — must match 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}`);
}
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');
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 364 chars`);
}
if (!semverValid(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 (!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)) {
throw new BmadModuleError(
EXIT.BAD_MANIFEST,
`plugin.json#bmad.compatibility.bmadMethod "${m.bmad.compatibility.bmadMethod}" is not a valid semver range`,
);
}
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,147 @@
// semver-lite — zero-dependency stand-ins for the two semver functions this
// skill needs. We deliberately do NOT vendor the `semver` package: its only use
// here is install-time validation of an author's plugin.json (`valid` on
// #version, `validRange` on #bmad.compatibility.bmadMethod). That is bounded,
// low-severity input checking — unlike manifest.yaml round-tripping, which is
// co-owned with BMAD core and so keeps the REAL `yaml` library (see vendor/).
//
// `node:`-only, matching every other script BMAD installs. Both functions are
// generous: a wrong *reject* would fail a legitimate install, so where semver
// is lenient we are lenient too. Parity with the real `semver` is asserted by a
// battery in the repo's skill tests; keep that battery green if you edit this.
// Official SemVer 2.0.0 grammar (semver.org), with an optional leading `v` to
// match semver's parser. Build metadata is accepted but dropped from the
// normalized return, exactly like `semver.valid()`.
const SEMVER_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
/**
* Mirror of `semver.valid()`: returns the normalized version string for a valid
* full semver, otherwise null. Build metadata is stripped from the result.
*/
export function valid(version) {
if (typeof version !== 'string') return null;
const m = SEMVER_RE.exec(version.trim());
if (!m) return null;
return `${m[1]}.${m[2]}.${m[3]}${m[4] ? `-${m[4]}` : ''}`;
}
/**
* Mirror of `semver.prerelease()`: returns the array of dot-separated
* prerelease identifiers (numeric ones coerced to Number) for a valid version,
* or null when the version is invalid or has no prerelease. Used by the channel
* resolver to exclude `-alpha`/`-rc` tags from the stable channel.
*/
export function prerelease(version) {
if (typeof version !== 'string') return null;
const m = SEMVER_RE.exec(version.trim());
if (!m || !m[4]) return null;
return m[4].split('.').map((id) => (/^\d+$/.test(id) ? Number(id) : id));
}
// Numeric compare of the three core fields, then prerelease precedence per
// SemVer §11. Returns -1, 0, or 1. Both inputs must be valid semver.
function compareValid(a, b) {
const ma = SEMVER_RE.exec(a);
const mb = SEMVER_RE.exec(b);
for (let i = 1; i <= 3; i++) {
const d = Number(ma[i]) - Number(mb[i]);
if (d !== 0) return d < 0 ? -1 : 1;
}
// A version WITH a prerelease has lower precedence than one without.
const pa = ma[4];
const pb = mb[4];
if (!pa && !pb) return 0;
if (!pa) return 1;
if (!pb) return -1;
const ia = pa.split('.');
const ib = pb.split('.');
const len = Math.min(ia.length, ib.length);
for (let i = 0; i < len; i++) {
if (ia[i] === ib[i]) continue;
const na = /^\d+$/.test(ia[i]);
const nb = /^\d+$/.test(ib[i]);
if (na && nb) return Number(ia[i]) < Number(ib[i]) ? -1 : 1;
// Numeric identifiers always have lower precedence than alphanumeric ones.
if (na) return -1;
if (nb) return 1;
return ia[i] < ib[i] ? -1 : 1;
}
// A larger set of prerelease fields wins when all preceding are equal.
return ia.length === ib.length ? 0 : ia.length < ib.length ? -1 : 1;
}
/**
* Mirror of `semver.compare()`: -1/0/1 for a<b / a==b / a>b. Returns null when
* either side is not valid semver (semver throws; callers here treat unknown as
* "don't reorder").
*/
export function compare(a, b) {
const va = valid(a);
const vb = valid(b);
if (va === null || vb === null) return null;
return compareValid(va, vb);
}
/**
* Mirror of `semver.rcompare()`: reverse of compare(), for sorting newest-first.
*/
export function rcompare(a, b) {
const c = compare(a, b);
return c === null ? 0 : -c;
}
// ---- range grammar -------------------------------------------------------
// A "partial" is a 13 segment version where each segment may be a number or an
// x-range wildcard (x/X/*), with optional prerelease/build on the full form.
const XID = '(?:0|[1-9]\\d*|\\d+|[xX*])';
const PRE = '(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
const BUILD = '(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
const PARTIAL_RE = new RegExp(`^v?${XID}(?:\\.${XID}(?:\\.${XID}${PRE}${BUILD})?)?$`);
function isPartial(v) {
return v !== '' && PARTIAL_RE.test(v);
}
// One comparator: an optional operator (>=, <=, >, <, =, ~, ^) glued to a
// partial version. Bare `*`/`x`/`X` (or empty) means "any".
function isComparator(tok) {
if (tok === '' || tok === '*' || tok === 'x' || tok === 'X') return true;
// `~>` is a semver-supported alias for `~`; match it before `~`/`>`.
const m = /^(~>|>=|<=|>|<|=|~|\^)?(.*)$/.exec(tok);
return isPartial(m[2]);
}
// One comparator set (the AND-joined part of a `||` union).
function isComparatorSet(set) {
if (set === '' || set === '*') return true;
// Hyphen range: "<partial> - <partial>" (spaces required around the dash).
const hy = /^(.+?)\s+-\s+(.+)$/.exec(set);
if (hy) return isPartial(hy[1].trim()) && isPartial(hy[2].trim());
// Collapse whitespace after an operator so ">= 1.2.3" is a single comparator,
// then split the remaining intersection on whitespace.
const tokens = set
.replace(/(~>|>=|<=|>|<|=|~|\^)\s+/g, '$1')
.split(/\s+/)
.filter(Boolean);
// `[].every()` is already true, so an empty intersection means "any".
return tokens.every((t) => isComparator(t));
}
/**
* Mirror of `semver.validRange()` as a validator: returns the input range when
* it is a syntactically valid semver range, otherwise null. (We return the
* original string rather than semver's normalized form callers here only test
* truthiness.)
*/
export function validRange(range) {
if (typeof range !== 'string') return null;
const r = range.trim();
if (r === '') return '*';
const groups = r.split(/\s*\|\|\s*/);
for (const g of groups) {
if (!isComparatorSet(g.trim())) return null;
}
return range;
}

View File

@ -0,0 +1,256 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { copyDir, safePathInsideRoot } from './fs-safe.mjs';
import { ensureCachedRepo } from './cache.mjs';
import { EXIT, BmadModuleError } from './exit.mjs';
const GH_SHORT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/;
// A `@<tag-or-branch>` tail is ref-shaped (no `:` so we never eat the auth
// segment of `git@host:…`). Raw commit SHAs are not supported — `git clone
// --branch` can't take them; pass a tag/branch or check the SHA out manually.
const REF_TAIL_RE = /^[\w.\-+/]+$/;
// Normalize a `<source>` argument from the CLI into a descriptor:
// { kind: 'local' | 'git', path?, url?, subdir, ref, cacheKey, displayName, rawInput }
// `ref` is a branch/tag extracted from an explicit `@<ref>` suffix or an
// embedded browser-URL path (`…/tree/<ref>`); `subdir` is a module location
// inside the repo extracted from a deep-path / `?path=` URL. Accepts:
// - owner/repo[@ref] → GitHub HTTPS
// - https://…, http://…, git@…, ssh://, git:// → as given (ref/subdir parsed)
// - file://path → local
// - relative or absolute path → local (if it exists on disk)
// Mirrors tools/installer/modules/custom-module-manager.js#parseSource, adapted
// to the skill's throw-on-invalid contract and node:-only deps; keeps the skill's
// `owner/repo` shorthand, which the installer (URL/marketplace-driven) lacks.
export function parseSource(input) {
if (typeof input !== 'string' || !input.trim()) {
throw new BmadModuleError(EXIT.USAGE, `source is required`);
}
const rawInput = input.trim();
// Split off an optional @<ref> suffix, but only when the part before the `@`
// looks like a complete repo reference — so we don't disturb `git@host:…` or
// an `@` that's part of the path.
let body = rawInput;
let ref = null;
const lastAt = rawInput.lastIndexOf('@');
if (lastAt > 0) {
const candidate = rawInput.slice(lastAt + 1);
const before = rawInput.slice(0, lastAt);
if (REF_TAIL_RE.test(candidate) && !candidate.includes(':')) {
const beforeLooksLikeRepo =
before.startsWith('/') ||
before.startsWith('./') ||
before.startsWith('../') ||
before.startsWith('~') ||
before.startsWith('file://') ||
/^(?:https?|ssh|git):\/\//i.test(before) ||
/^git@[^:]+:.+/.test(before) ||
GH_SHORT_RE.test(before);
if (beforeLooksLikeRepo) {
ref = candidate;
body = before;
}
}
}
if (body.startsWith('file://')) {
if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`);
const p = decodeURI(body.slice('file://'.length));
return localDescriptor(path.resolve(p), p, rawInput);
}
// Local path: starts with /, ./, ../, or ~.
if (body.startsWith('/') || body.startsWith('./') || body.startsWith('../') || body.startsWith('~')) {
if (ref) throw new BmadModuleError(EXIT.USAGE, `local paths do not support @ref suffixes`);
const expanded = body.startsWith('~') ? path.join(os.homedir(), body.slice(1)) : body;
return localDescriptor(path.resolve(expanded), body, rawInput);
}
// SSH: git@host:owner/repo[.git]
const sshMatch = body.match(/^git@([^:]+):(.+?)\/([^/.]+?)(?:\.git)?$/);
if (sshMatch) {
const [, host, owner, repo] = sshMatch;
return {
kind: 'git',
url: body,
subdir: null,
ref,
cacheKey: `${host}/${owner}/${repo}`,
displayName: `${owner}/${repo}`,
rawInput,
};
}
// HTTP(S) / ssh:// / git:// URLs — parse with the URL API so any host, nested
// group, dotted repo name, or browse-link shape is handled host-agnostically.
if (/^(?:https?|ssh|git):\/\//i.test(body)) {
return parseUrlDescriptor(body, ref, rawInput);
}
// owner/repo shorthand → GitHub HTTPS.
if (GH_SHORT_RE.test(body)) {
return {
kind: 'git',
url: `https://github.com/${body}`,
subdir: null,
ref,
cacheKey: `github.com/${body}`,
displayName: body,
rawInput,
};
}
throw new BmadModuleError(EXIT.USAGE, `not a valid module source (owner/repo, git URL, or local path): ${rawInput}`);
}
function localDescriptor(absPath, displayName, rawInput) {
return { kind: 'local', path: absPath, subdir: null, ref: null, cacheKey: null, displayName, rawInput };
}
// Browser-style deep paths that embed a ref (branch/tag/commit) and optional
// subdirectory, across hosts:
// GitHub /<repo>/tree|blob/<ref>[/<subdir>]
// GitLab /<repo>/-/tree|blob/<ref>[/<subdir>]
// Gitea /<repo>/src/[branch|commit|tag/]<ref>[/<subdir>]
// Group 1 = repo path prefix, 2 = ref, 3 = subdir (optional).
const DEEP_PATH_PATTERNS = [
/^(.+?)\/(?:-\/)?(?:tree|blob)\/([^/]+)(?:\/(.+))?$/,
/^(.+?)\/src\/(?:branch\/|commit\/|tag\/)?([^/]+)(?:\/(.+))?$/,
];
function parseUrlDescriptor(body, refFromSuffix, rawInput) {
let url;
try {
url = new URL(body);
} catch {
url = null;
}
if (!url || !url.host) {
throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`);
}
const host = url.host;
let repoPath = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '');
let subdir = null;
let urlRef = null;
for (const pattern of DEEP_PATH_PATTERNS) {
const m = repoPath.match(pattern);
if (m) {
repoPath = m[1];
if (m[2]) urlRef = m[2];
if (m[3]) {
const cleaned = m[3].replace(/\/+$/, '');
if (cleaned) subdir = cleaned;
}
break;
}
}
// Some hosts use ?path=/subdir on browse links.
if (!subdir) {
const pathParam = url.searchParams.get('path');
if (pathParam) {
const cleaned = pathParam.replace(/^\/+/, '').replace(/\/+$/, '');
if (cleaned) subdir = cleaned;
}
}
const repoPathClean = repoPath.replace(/\.git$/i, '');
if (!repoPathClean) {
throw new BmadModuleError(EXIT.USAGE, `not a valid Git URL: ${rawInput}`);
}
const segments = repoPathClean.split('/').filter(Boolean);
const displayName = segments.length >= 2 ? `${segments.at(-2)}/${segments.at(-1)}` : segments.at(-1);
return {
kind: 'git',
url: `${url.protocol}//${host}/${repoPathClean}`,
subdir,
// Explicit @ref suffix wins over an embedded /tree/<ref> path segment.
ref: refFromSuffix || urlRef || null,
cacheKey: `${host}/${repoPathClean}`,
displayName,
rawInput,
};
}
// Files that should never be staged into _bmad/<code>/ from a source tree.
const STAGE_IGNORE = (rel) =>
rel === '.git' ||
rel.startsWith('.git/') ||
rel === 'node_modules' ||
rel.startsWith('node_modules/') ||
rel === '.bmad-source.json' ||
rel === '.bmad-channel.json';
// Resolve a parsed descriptor into a usable source directory on disk.
//
// Always returns a throwaway temp working copy so the install pipeline can write
// into it (e.g. synthesized module.yaml for legacy strategy 5) and stage from it
// without mutating the user's tree or the shared clone cache.
// - local: copies the directory into the temp working copy.
// - git: ensures the repo is in the shared cache (~/.bmad/cache/custom-modules),
// then copies the module root (the subdir if the source URL named one) out of
// the cache into the temp working copy.
//
// Returns { dir, sha, ref, cleanup } where `sha`/`ref` are null for local
// sources and `cleanup()` removes the temp working copy (never the cache).
export async function materializeSource(descriptor, opts = {}) {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-'));
const dir = path.join(tmpRoot, 'src');
const cleanup = () => fs.rm(tmpRoot, { recursive: true, force: true });
if (descriptor.kind === 'local') {
const srcStat = await fs.stat(descriptor.path).catch(() => null);
if (!srcStat || !srcStat.isDirectory()) {
await cleanup();
throw new BmadModuleError(EXIT.USAGE, `local source not a directory: ${descriptor.path}`);
}
try {
await copyDir(descriptor.path, dir, STAGE_IGNORE);
} catch (e) {
await cleanup();
throw e;
}
return { dir, sha: null, ref: null, cleanup };
}
// git — explicit --ref/resolved channel wins over a ref parsed from the source.
const ref = opts.ref ?? descriptor.ref ?? null;
let cached;
try {
cached = await ensureCachedRepo(descriptor, ref);
} catch (e) {
await cleanup();
throw e;
}
// A subdir parsed from the source URL (/tree/<ref>/<subdir> or ?path=) is
// untrusted: reject `..`/absolute/symlink escapes so it can't copy out of the
// shared clone cache. safePathInsideRoot returns null on any escape.
let moduleRoot = cached.repoDir;
if (descriptor.subdir) {
moduleRoot = safePathInsideRoot(cached.repoDir, descriptor.subdir);
if (!moduleRoot) {
await cleanup();
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `subdirectory "${descriptor.subdir}" escapes the repository root`);
}
}
const rootStat = await fs.stat(moduleRoot).catch(() => null);
if (!rootStat || !rootStat.isDirectory()) {
await cleanup();
throw new BmadModuleError(EXIT.USAGE, `subdirectory "${descriptor.subdir}" not found in ${descriptor.displayName}`);
}
try {
await copyDir(moduleRoot, dir, STAGE_IGNORE);
} catch (e) {
await cleanup();
throw e;
}
return { dir, sha: cached.sha, ref: cached.ref, cleanup };
}

View File

@ -0,0 +1,55 @@
# `lib/vendor/` — vendored runtime dependencies
This directory holds **self-contained, generated** copies of third-party libraries the skill needs at runtime. They are committed on purpose.
## Why vendor at all?
The `bmad-module` skill is **copied into a user's project** — into the IDE skills directories chosen at `bmad install` time (e.g. `.claude/skills/bmad-module/`, `.agents/skills/bmad-module/`) — by `npx bmad-method install`. The installer:
- strips `node_modules` while copying (`tools/installer/core/installer.js`),
- ships **no** `package.json` under the skill, and
- never runs `npm install` inside `_bmad/`.
So a bare `import 'yaml'` cannot resolve at runtime — it throws `ERR_MODULE_NOT_FOUND` before any of the skill's exit codes can fire. Every other script BMAD installs is zero-third-party-dependency; vendoring keeps this skill self-sufficient the same way, without setup.
Files here are imported by **relative path** (`./vendor/yaml.mjs`), which resolves regardless of cwd, install location, or `node_modules` presence.
## What's vendored — and what's NOT
| Need | Strategy | Where |
|---|---|---|
| `yaml` (parse/stringify `_bmad/_config/manifest.yaml`) | **vendored, real library** | `vendor/yaml.mjs` |
| BMAD's IDE-distribution engine (push skills to `.claude/skills/` etc.) | **bundled, real engine** | `vendor/ide-sync.mjs` (+ `vendor/platform-codes.yaml`) |
| `semver` (`valid` + `validRange` on `plugin.json`) | **dropped** — hand-rolled, `node:` only | `../semver-lite.mjs` |
`manifest.yaml` is **co-owned** with BMAD core, which reads/writes it with the same `yaml` package and the same `{indent:2, lineWidth:0}` options (`tools/installer/core/manifest.js`). Hand-rolling a YAML emitter risks diverging from that on the user's live install state, so we ship the **real** library and verify byte-identical output in `build-vendor.mjs`. `semver` is only input-validation of an author's manifest, so it is safe to hand-roll.
## `yaml.mjs`
- **GENERATED — do not edit by hand.** An esbuild single-file bundle of the `yaml` npm package (eemeli/yaml), tree-shaken to just `parse` + `stringify`.
- The exact pinned version and build provenance are in the file's header.
- Upstream license is retained inline (`legalComments: 'inline'`).
### Regenerating
After bumping `yaml` (or esbuild) in the repo's **root** `package.json` + `npm install`:
```bash
npm run vendor:build # regenerate this yaml.mjs
npm run vendor:check # verify it's in sync (what CI runs)
```
## `ide-sync.mjs` (+ `platform-codes.yaml`)
- **GENERATED — do not edit by hand.** An esbuild bundle of BMAD's real IDE-distribution engine (`tools/installer/ide/*` reached via `tools/installer/core/ide-sync.js`), tree-shaken to a single dependency-free ESM file. Built by `build-ide-sync.mjs`; `../prompts` and `../project-root` are aliased to the small shims in `shims/` so the interactive `@clack/prompts` and installer-only graphs are dropped.
- After `bmad-module` stages a module under `_bmad/`, it execs this bundle to copy the module's skills into the IDE directories the user chose (read from `_bmad/_config/manifest.yaml`). Reasons it's bundled rather than imported are the same as `yaml.mjs`: the skill ships into projects without `node_modules`, and shelling out to `npx bmad-method` would reintroduce a network/npm dependency.
- `platform-codes.yaml` is copied verbatim beside the bundle (the engine reads it at runtime via `$BMAD_IDE_PLATFORM_CODES`, set by the bundle entry).
- Same engine backs `bmad ide-sync` and the full installer's IDE setup, so the three stay in lockstep; `vendor:check` byte-verifies the bundle against source and `test/test-ide-sync.js` checks engine/bundle behavior parity.
Regenerated by the same commands as above (`vendor:build` / `vendor:check` run both bundles).
The build is **deterministic** for a given `yaml` + `esbuild` version (both pinned in the lockfile) and self-checks a parse→stringify round-trip.
**You don't have to remember to do this.** `vendor:check` is wired into `npm test` (husky pre-commit) and `npm run quality` (the `validate` job in `.github/workflows/quality.yaml`). If the committed bundle drifts from the installed `yaml`/`esbuild` version, those gates fail with a message telling you to run `npm run vendor:build` — so a bump can't land with a stale bundle, and manifest writes stay byte-identical between BMAD core and this skill.
Lint/format intentionally ignore this directory (see `eslint.config.mjs` global ignores and `.prettierignore`) — it is generated, not authored.

View File

@ -0,0 +1,192 @@
#!/usr/bin/env node
// build-ide-sync — regenerates (and, with --check, verifies) the self-contained
// `ide-sync.mjs` bundle this skill ships, plus its sidecar `platform-codes.yaml`.
//
// Why this exists: after the bmad-module skill installs/updates/removes a
// community module under `_bmad/`, it must distribute that module's skills to
// exactly the coding assistants the user chose at `bmad install` time. The real
// distribution engine lives in `tools/installer/ide/*` (IdeManager /
// ConfigDrivenIdeSetup / platform-codes.yaml), but that code — and its
// dependencies (csv-parse, yaml, @clack/prompts) — is NOT present in a user's
// project (the installer ships the skill without node_modules). So we bundle the
// REAL engine into one dependency-free ESM file with esbuild (the same toolchain
// and vendoring philosophy as yaml.mjs), aliasing `../prompts` and
// `../project-root` to tiny shims so the interactive/heavy bits are dropped.
//
// The skill execs `vendor/ide-sync.mjs` from inside the user's project — no npx,
// no network, no node_modules. Because it is built from `tools/installer/ide/*`
// (not hand-forked) and verified by `vendor:check`, it can never silently drift
// from the engine the interactive installer uses.
//
// Usage (via root package.json):
// npm run vendor:build # regenerate ide-sync.mjs + platform-codes.yaml
// npm run vendor:check # fail if the committed bundle is stale (CI gate)
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const vendorDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(vendorDir, '../../../../../..'); // -> repo root
const installerDir = path.join(repoRoot, 'tools', 'installer');
const ideDir = path.join(installerDir, 'ide');
const outfile = path.join(vendorDir, 'ide-sync.mjs');
const sidecarOut = path.join(vendorDir, 'platform-codes.yaml');
const sidecarSrc = path.join(ideDir, 'platform-codes.yaml');
const promptsShim = path.join(vendorDir, 'shims', 'prompts.cjs');
const projectRootShim = path.join(vendorDir, 'shims', 'project-root.cjs');
const checkMode = process.argv.includes('--check');
const esbuild = await import('esbuild');
const esbuildVersion = require('esbuild/package.json').version;
const yamlVersion = require('yaml/package.json').version;
// csv-parse restricts ./package.json via its "exports" map, so read it directly.
const csvVersion = JSON.parse(await fs.readFile(path.join(repoRoot, 'node_modules', 'csv-parse', 'package.json'), 'utf8')).version;
// Redirect the engine's interactive/installer-only deps to lightweight shims so
// @clack/prompts and the custom-module-manager graph never enter the bundle.
const aliasPlugin = {
name: 'ide-sync-aliases',
setup(build) {
build.onResolve({ filter: /^\.\.\/prompts$/ }, () => ({ path: promptsShim }));
build.onResolve({ filter: /^\.\.\/project-root$/ }, () => ({ path: projectRootShim }));
},
};
// NOTE: no builder-specific data (node version, timestamp) in the banner — the
// output must be reproducible so --check can byte-compare.
const banner = `// ============================================================================
// GENERATED — DO NOT EDIT BY HAND. Run \`npm run vendor:build\` to regenerate.
// Self-contained bundle of BMAD's IDE-distribution engine
// (tools/installer/ide/* via tools/installer/core/ide-sync.js).
//
// bundler : esbuild ${esbuildVersion}
// yaml : ${yamlVersion}
// csv-parse : ${csvVersion}
//
// Shipped because the bmad-module skill is copied into projects without
// node_modules; see build-ide-sync.mjs and vendor/README.md for the rationale.
// Reads platform-codes.yaml from beside this file (or $BMAD_IDE_PLATFORM_CODES).
// ============================================================================
// Provide a real \`require\` so esbuild's CJS interop can load node: builtins
// (node:path/fs/os/crypto) from this ESM bundle. All third-party deps are
// inlined, so only builtins ever reach this require.
import { createRequire as __createRequire } from 'node:module';
const require = __createRequire(import.meta.url);
`;
// The entry sets the platform-codes path to the sidecar beside the bundle, then
// runs the CLI. Imports are hoisted/initialised first; the engine reads the env
// var lazily (loadPlatformCodes), by which point the body below has set it.
const entryContents = `
import { runIdeSyncCli } from './core/ide-sync.js';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dir = dirname(fileURLToPath(import.meta.url));
if (!process.env.BMAD_IDE_PLATFORM_CODES) {
process.env.BMAD_IDE_PLATFORM_CODES = join(__dir, 'platform-codes.yaml');
}
runIdeSyncCli(process.argv.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
process.stderr.write('[ide-sync] ' + ((err && err.stack) || err) + '\\n');
process.exit(1);
});
`;
const result = await esbuild.build({
stdin: {
contents: entryContents,
resolveDir: installerDir, // so './core/ide-sync.js' resolves
sourcefile: 'ide-sync-entry.mjs',
loader: 'js',
},
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
minify: false,
charset: 'utf8',
legalComments: 'inline',
banner: { js: banner },
plugins: [aliasPlugin],
write: false,
});
const built = result.outputFiles[0].text;
const sidecar = await fs.readFile(sidecarSrc, 'utf8');
// Self-check: the freshly built bundle must distribute a fixture skill to a
// selected IDE with no node_modules on its resolution path (the runtime
// condition). Build a throwaway project, run the bundle, assert the output.
await selfCheck(built, sidecar);
if (checkMode) {
const currentBundle = await fs.readFile(outfile, 'utf8').catch(() => null);
const currentSidecar = await fs.readFile(sidecarOut, 'utf8').catch(() => null);
if (currentBundle === built && currentSidecar === sidecar) {
process.stdout.write(`vendor:check OK — ide-sync.mjs matches engine (esbuild ${esbuildVersion})\n`);
process.exit(0);
}
process.stderr.write(
`vendor:check FAILED — vendor/ide-sync.mjs or platform-codes.yaml is stale or hand-edited.\n` +
` The committed bundle no longer matches tools/installer/ide/*.\n` +
` Fix: run \`npm run vendor:build\` and commit the regenerated files.\n`,
);
process.exit(1);
}
await fs.writeFile(outfile, built, 'utf8');
await fs.writeFile(sidecarOut, sidecar, 'utf8');
process.stdout.write(`built ide-sync.mjs + platform-codes.yaml (self-check OK, esbuild ${esbuildVersion})\n`);
// ---------------------------------------------------------------------------
async function selfCheck(bundleText, sidecarText) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ide-sync-check-'));
try {
// Lay down the bundle + sidecar together.
await fs.writeFile(path.join(dir, 'ide-sync.mjs'), bundleText, 'utf8');
await fs.writeFile(path.join(dir, 'platform-codes.yaml'), sidecarText, 'utf8');
// Minimal project: one community skill recorded in the manifests.
const proj = path.join(dir, 'proj');
const skillDir = path.join(proj, '_bmad', 'demo', 'skills', 'bmad-demo-skill');
await fs.mkdir(skillDir, { recursive: true });
await fs.mkdir(path.join(proj, '_bmad', '_config'), { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), '---\nname: bmad-demo-skill\ndescription: demo\n---\nbody\n', 'utf8');
await fs.writeFile(
path.join(proj, '_bmad', '_config', 'manifest.yaml'),
'installation:\n version: "0.0.0"\nmodules:\n - name: demo\n source: community\nides:\n - claude-code\n',
'utf8',
);
await fs.writeFile(
path.join(proj, '_bmad', '_config', 'skill-manifest.csv'),
'canonicalId,name,description,module,path\n"bmad-demo-skill","bmad-demo-skill","demo","demo","_bmad/demo/skills/bmad-demo-skill/SKILL.md"\n',
'utf8',
);
const { spawn } = await import('node:child_process');
const code = await new Promise((resolve) => {
const child = spawn(process.execPath, [path.join(dir, 'ide-sync.mjs'), '-d', proj], {
stdio: process.env.BMAD_IDE_SYNC_DEBUG ? 'inherit' : 'ignore',
});
child.on('close', resolve);
});
if (code !== 0) throw new Error(`ide-sync self-check: bundle exited ${code}`);
const distributed = path.join(proj, '.claude', 'skills', 'bmad-demo-skill', 'SKILL.md');
await fs.access(distributed).catch(() => {
throw new Error('ide-sync self-check FAILED: skill was not distributed to .claude/skills');
});
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}

View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
// build-vendor — regenerates (and, with --check, verifies) the vendored,
// self-contained copy of the `yaml` library this skill ships.
//
// Why this exists: the bmad-module skill is COPIED into a user's project at
// `_bmad/core/skills/bmad-module/` by `npx bmad-method install`. The installer
// strips `node_modules` (tools/installer/core/installer.js), ships no
// package.json under the skill, and never runs `npm install` in `_bmad/`. So a
// bare `import 'yaml'` cannot resolve at runtime. Every other script BMAD
// installs is zero-third-party-dep; this is the one library we cannot safely
// hand-roll, because `_bmad/_config/manifest.yaml` is CO-OWNED with BMAD core
// (tools/installer/core/manifest.js writes it with the same `yaml` package and
// the same {indent:2,lineWidth:0} options). Vendoring the REAL library is the
// only way to guarantee byte-identical round-trips.
//
// The output `yaml.mjs` is imported by RELATIVE path from manifest-ops.mjs and
// frontmatter.mjs, so it resolves regardless of cwd, install location, or
// node_modules presence.
//
// Usage (via root package.json):
// npm run vendor:build # regenerate scripts/lib/vendor/yaml.mjs
// npm run vendor:check # fail if the committed bundle is stale (CI gate)
//
// The output is DETERMINISTIC for a given yaml + esbuild version (both pinned in
// the lockfile), which is what lets `--check` byte-compare. Bumping `yaml` (or
// esbuild) makes the check fail until you re-run `npm run vendor:build` and
// commit — so the vendored copy can never silently drift from BMAD core's.
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const require = createRequire(import.meta.url);
const vendorDir = path.dirname(fileURLToPath(import.meta.url));
const outfile = path.join(vendorDir, 'yaml.mjs');
const checkMode = process.argv.includes('--check');
const esbuild = await import('esbuild');
const yamlVersion = require('yaml/package.json').version;
const esbuildVersion = require('esbuild/package.json').version;
// NOTE: intentionally no builder-specific data (node version, timestamp) in the
// banner — the output must be reproducible so --check can byte-compare.
const banner = `// ============================================================================
// GENERATED — DO NOT EDIT BY HAND. Run \`npm run vendor:build\` to regenerate.
// Vendored, self-contained bundle of the \`yaml\` npm package (eemeli/yaml).
//
// yaml : ${yamlVersion}
// bundler : esbuild ${esbuildVersion}
//
// Shipped because the skill is copied into projects without node_modules; see
// build-vendor.mjs and vendor/README.md for the rationale. Only \`parse\` and
// \`stringify\` are re-exported (tree-shaken). Upstream license retained below.
// ============================================================================
import { createRequire as __createRequire } from 'node:module';
const require = __createRequire(import.meta.url);
`;
const result = await esbuild.build({
stdin: {
contents: "export { parse, stringify } from 'yaml';",
resolveDir: vendorDir,
sourcefile: 'vendor-entry.mjs',
loader: 'js',
},
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
minify: false,
legalComments: 'inline',
charset: 'utf8',
banner: { js: banner },
write: false,
});
const built = result.outputFiles[0].text;
// Self-check: the freshly built bundle must import and round-trip without any
// node_modules on its resolution path (the runtime condition). Import it from a
// temp file so we never need to write the real output to do this.
const tmp = path.join(os.tmpdir(), `bmad-vendor-yaml-${process.pid}.mjs`);
await fs.writeFile(tmp, built, 'utf8');
try {
const { parse, stringify } = await import(pathToFileURL(tmp).href);
const sample = { modules: [{ name: 'demo', version: '1.2.3', repoUrl: 'https://example.com/x', when: '2026-05-23T00:00:00.000Z' }] };
const round = parse(stringify(sample, { indent: 2, lineWidth: 0 }));
if (JSON.stringify(round) !== JSON.stringify(sample)) {
throw new Error('vendor self-check FAILED: round-trip mismatch');
}
} finally {
await fs.rm(tmp, { force: true });
}
if (checkMode) {
const current = await fs.readFile(outfile, 'utf8').catch(() => null);
if (current === built) {
process.stdout.write(`vendor:check OK — yaml.mjs matches yaml@${yamlVersion} (esbuild ${esbuildVersion})\n`);
process.exit(0);
}
process.stderr.write(
`vendor:check FAILED — scripts/lib/vendor/yaml.mjs is stale or hand-edited.\n` +
` Expected the bundle for yaml@${yamlVersion} (esbuild ${esbuildVersion}).\n` +
` Fix: run \`npm run vendor:build\` and commit the regenerated yaml.mjs.\n` +
` (This guards manifest.yaml fidelity: the vendored yaml must match the\n` +
` one BMAD core writes manifests with — see vendor/README.md.)\n`,
);
process.exit(1);
}
await fs.writeFile(outfile, built, 'utf8');
process.stdout.write(`vendored yaml@${yamlVersion} -> ${path.relative(process.cwd(), outfile)} (self-check OK)\n`);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,329 @@
# BMAD Platform Codes Configuration
#
# Each platform entry has:
# name: Display name shown to users
# preferred: Whether shown as a recommended option on install
# suspended: (optional) Message explaining why install is blocked
# installer:
# target_dir: Directory where skill directories are installed (project/workspace)
# global_target_dir: (optional) User-home directory for global install
# ancestor_conflict_check: (optional) Refuse install when ancestor dir has BMAD files
#
# Multiple platforms may share the same target_dir or global_target_dir — many tools
# read from the shared `.agents/skills/` and `~/.agents/skills/` cross-tool standard.
# Paths verified against each tool's primary docs as of 2026-04-25.
platforms:
adal:
name: "AdaL"
preferred: false
installer:
target_dir: .adal/skills
global_target_dir: ~/.adal/skills
amp:
name: "Sourcegraph Amp"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.config/agents/skills
antigravity:
name: "Google Antigravity"
preferred: false
installer:
target_dir: .agent/skills
global_target_dir: ~/.gemini/antigravity/skills
auggie:
name: "Auggie"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
bob:
name: "IBM Bob"
preferred: false
installer:
target_dir: .bob/skills
global_target_dir: ~/.bob/skills
claude-code:
name: "Claude Code"
preferred: true
installer:
target_dir: .claude/skills
global_target_dir: ~/.claude/skills
cline:
name: "Cline"
preferred: false
installer:
target_dir: .cline/skills
global_target_dir: ~/.cline/skills
codex:
name: "Codex"
preferred: true
installer:
target_dir: .agents/skills
global_target_dir: ~/.codex/skills
codewhale:
name: "CodeWhale"
preferred: false
installer:
target_dir: .codewhale/skills
global_target_dir: ~/.codewhale/skills
codebuddy:
name: "CodeBuddy"
preferred: false
installer:
target_dir: .codebuddy/skills
global_target_dir: ~/.codebuddy/skills
command-code:
name: "Command Code"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
cortex:
name: "Snowflake Cortex Code"
preferred: false
installer:
target_dir: .cortex/skills
global_target_dir: ~/.snowflake/cortex/skills
crush:
name: "Crush"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.config/agents/skills
cursor:
name: "Cursor"
preferred: true
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
droid:
name: "Factory Droid"
preferred: false
installer:
target_dir: .factory/skills
global_target_dir: ~/.factory/skills
firebender:
name: "Firebender"
preferred: false
installer:
target_dir: .firebender/skills
global_target_dir: ~/.agents/skills
gemini:
name: "Gemini CLI"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
github-copilot:
name: "GitHub Copilot"
preferred: true
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
commands_target_dir: .github/agents
commands_extension: .agent.md
commands_body_template: "LOAD the FULL {project-root}/{target_dir}/{canonicalId}/SKILL.md, READ its entire contents and follow its directions exactly!"
# The Custom Agents picker should only show persona agents (not
# workflows/tools). Detected by reading each skill's source
# `customize.toml` and checking for an `[agent]` section — that's
# the actual configuration source of truth: every BMAD persona is
# configured under `[agent]`, every workflow under `[workflow]`,
# every standalone skill has no customize.toml. This signal is
# naming-independent, so personas like `bmad-tea` (which doesn't
# follow the `-agent-` convention) are still included, and
# meta-skills like `bmad-agent-builder` (which contains `-agent-`
# but is a skill-builder workflow, not a persona) are correctly
# excluded.
commands_filter: agents-only
goose:
name: "Block Goose"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.config/agents/skills
iflow:
name: "iFlow"
preferred: false
installer:
target_dir: .iflow/skills
global_target_dir: ~/.iflow/skills
junie:
name: "Junie"
preferred: false
installer:
target_dir: .junie/skills
global_target_dir: ~/.junie/skills
kilo:
name: "KiloCoder"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.kilocode/skills
kimi-code:
name: "Kimi Code"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
kiro:
name: "Kiro"
preferred: false
installer:
target_dir: .kiro/skills
global_target_dir: ~/.kiro/skills
kode:
name: "Kode"
preferred: false
installer:
target_dir: .kode/skills
global_target_dir: ~/.kode/skills
mistral-vibe:
name: "Mistral Vibe"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.vibe/skills
mux:
name: "Mux"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
neovate:
name: "Neovate"
preferred: false
installer:
target_dir: .neovate/skills
global_target_dir: ~/.neovate/skills
ona:
name: "Ona"
preferred: false
installer:
target_dir: .ona/skills
openclaw:
name: "OpenClaw"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
opencode:
name: "OpenCode"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
commands_target_dir: .opencode/commands
openhands:
name: "OpenHands"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
pi:
name: "Pi"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
pochi:
name: "Pochi"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
qoder:
name: "Qoder"
preferred: false
installer:
target_dir: .qoder/skills
global_target_dir: ~/.qoder/skills
qwen:
name: "QwenCoder"
preferred: false
installer:
target_dir: .qwen/skills
global_target_dir: ~/.qwen/skills
replit:
name: "Replit Agent"
preferred: false
installer:
target_dir: .agents/skills
roo:
name: "Roo Code"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
rovo-dev:
name: "Rovo Dev"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
trae:
name: "Trae"
preferred: false
installer:
target_dir: .trae/skills
warp:
name: "Warp"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
windsurf:
name: "Windsurf"
preferred: false
installer:
target_dir: .agents/skills
global_target_dir: ~/.agents/skills
zencoder:
name: "Zencoder"
preferred: false
installer:
target_dir: .zencoder/skills
global_target_dir: ~/.zencoder/skills

View File

@ -0,0 +1,18 @@
// Build-time shim for tools/installer/project-root.js, injected into the
// ide-sync bundle. The full module reaches into custom-module-manager and the
// rest of the installer; the IDE engine only needs getProjectRoot() (to locate
// an optional project-level removals.txt). In the bundle, the project root is
// the cwd the bmad-module skill runs the bundle from.
'use strict';
const path = require('node:path');
function getProjectRoot() {
return process.cwd();
}
function getSourcePath(...segments) {
return path.join(process.cwd(), ...segments);
}
module.exports = { getProjectRoot, getSourcePath };

View File

@ -0,0 +1,53 @@
// Build-time shim for tools/installer/prompts.js, injected into the ide-sync
// bundle so the heavyweight interactive @clack/prompts dependency is never
// pulled in. The IDE engine only uses `prompts.log.*` for status output; that
// maps to plain stdout/stderr here. Interactive helpers throw if reached (they
// must not be during non-interactive distribution).
'use strict';
const out = (m) => process.stdout.write(`${m}\n`);
const err = (m) => process.stderr.write(`${m}\n`);
const log = {
info: async (m) => out(m),
success: async (m) => out(m),
message: async (m) => out(m),
step: async (m) => out(m),
warn: async (m) => err(m),
error: async (m) => err(m),
};
const notInteractive = () => {
throw new Error('interactive prompt is not available in the ide-sync bundle');
};
// Identity color helper: every method returns its input unchanged.
const identityColor = new Proxy(
{},
{
get: () => (s) => s,
},
);
module.exports = {
log,
getColor: async () => identityColor,
spinner: () => ({ start() {}, stop() {}, message() {} }),
tasks: async () => {},
note: async (m) => out(m),
box: async (m) => out(m),
intro: async () => {},
outro: async () => {},
cancel: async () => {},
handleCancel: async () => {},
getClack: notInteractive,
select: notInteractive,
multiselect: notInteractive,
autocomplete: notInteractive,
autocompleteMultiselect: notInteractive,
directory: notInteractive,
confirm: notInteractive,
text: notInteractive,
password: notInteractive,
prompt: notInteractive,
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,125 @@
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, safePathInsideRoot } from './lib/fs-safe.mjs';
import {
readManifestYaml,
removeModuleFromManifest,
removeSkillManifestRows,
removeFilesManifestRows,
readFileEntriesForModule,
readSkillCanonicalIdsForModule,
} from './lib/manifest-ops.mjs';
import { distributeToIdes } from './lib/ide-sync.mjs';
import { removeModuleFromConfig } from './lib/config-gen.mjs';
import { regenerateHelpCatalog } from './lib/help-catalog.mjs';
// Remove a module's installed files and manifest entries. With `--purge` also
// deletes `_bmad/custom/<code>/` (user customization dir). Without it, customs
// 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 <code> 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\`).`,
);
}
// Capture the module's distributed skill ids before dropping its manifest
// rows, so we can prune them from the IDE directories afterward.
const removedSkillIds = await readSkillCanonicalIdsForModule(bmadDir, code);
// Strip the module's central-config blocks ([modules.<code>] + its [agents.*])
// while its module.yaml still exists on disk to identify the agent codes.
try {
await removeModuleFromConfig(bmadDir, code);
} catch (e) {
process.stderr.write(`[bmad-module] warning: failed to update config for removal of ${code}: ${e.message}\n`);
}
// Resolve the module root with a containment check, so a traversal-tainted
// code (e.g. from a hand-edited manifest) can never delete outside _bmad/.
const moduleRoot = safePathInsideRoot(bmadDir, code);
if (!moduleRoot) {
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `module code "${code}" escapes _bmad/`);
}
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
const fileEntries = await readFileEntriesForModule(bmadDir, code);
for (const fe of fileEntries) {
const abs = safePathInsideRoot(bmadDir, fe.path);
if (!abs) {
process.stderr.write(`[bmad-module] warn: skipping files-manifest path that escapes _bmad/: ${fe.path}\n`);
continue;
}
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 = safePathInsideRoot(path.join(bmadDir, 'custom'), code);
if (customDir) await fs.rm(customDir, { recursive: true, force: true });
}
// Drop manifest rows.
await removeFilesManifestRows(bmadDir, code);
await removeSkillManifestRows(bmadDir, code);
await removeModuleFromManifest(bmadDir, code);
// Rebuild the merged help catalog now that the module's module-help.csv is
// gone, so its skills disappear from `bmad-help`.
try {
await regenerateHelpCatalog(bmadDir);
} catch (e) {
process.stderr.write(`[bmad-module] warning: help catalog rebuild failed: ${e.message}\n`);
}
// Prune the module's skills from every configured coding assistant. The
// manifest no longer lists the module, so ide-sync removes its skill dirs +
// command pointers and re-syncs the rest.
const ideResult = await distributeToIdes({ projectDir, bmadDir, prune: removedSkillIds });
if (!ideResult.skipped && !ideResult.ok) {
process.stderr.write(`[bmad-module] warning: ${ideResult.hint}\n`);
}
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;
}
}

View File

@ -0,0 +1,166 @@
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, buildCopyPlan, rewriteManifestPaths, validateDeclaredPaths } from './lib/install-plan.mjs';
import { stageCopyPlan, atomicSwapDir, sha256File, pruneEmptyDirs, safePathInsideRoot } from './lib/fs-safe.mjs';
import {
readManifestYaml,
addModuleToManifest,
appendSkillManifestRows,
appendFilesManifestRows,
removeSkillManifestRows,
removeFilesManifestRows,
readFileEntriesForModule,
readSkillCanonicalIdsForModule,
} from './lib/manifest-ops.mjs';
import { distributeToIdes } from './lib/ide-sync.mjs';
import { finishModuleInstall, resolveCloneTarget } from './install.mjs';
// Update one installed module (or all when opts.all is true). v1 semantics:
// - Re-resolves the original source (or new --ref) and re-clones.
// - 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 <code|--all> 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);
// Re-resolve against the channel/ref the module was installed with, unless the
// CLI overrides either. A `stable` entry re-resolves to the latest tag; a
// pinned entry stays put unless `--ref` moves it.
const target = await resolveCloneTarget(descriptor, {
ref: opts.ref ?? entry.ref ?? null,
channel: opts.channel ?? entry.channel ?? null,
});
const materialized = await materializeSource(descriptor, { ref: target.ref });
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}"`,
);
}
// Capture the currently-distributed skill ids before we rewrite the
// manifest, so any skill dropped between versions is pruned from the IDE
// directories (and re-distributed ones are refreshed).
const oldSkillIds = await readSkillCanonicalIdsForModule(bmadDir, 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 = safePathInsideRoot(bmadDir, fe.path);
if (!abs) continue; // an entry that escapes _bmad/ is not a tracked file
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 plan, stage, swap.
validateDeclaredPaths(materialized.dir, manifest);
const userIgnores = await readUserIgnores(materialized.dir, manifest);
const matchIgnore = buildIgnoreMatcher(userIgnores);
const { plan, skillDestDirs } = await buildCopyPlan(materialized.dir, manifest, matchIgnore);
const rewrittenManifestJson = rewriteManifestPaths(manifest);
const stagedDir = path.join(path.dirname(materialized.dir), 'staged-out');
await stageCopyPlan(materialized.dir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
const targetDir = safePathInsideRoot(bmadDir, code);
if (!targetDir) {
throw new BmadModuleError(EXIT.PATH_TRAVERSAL, `module code "${code}" escapes _bmad/`);
}
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: descriptor.kind === 'git' ? target.version : manifest.bmad.moduleVersion || manifest.version,
repoUrl: descriptor.kind === 'git' ? descriptor.url : null,
sha: materialized.sha,
ref: materialized.ref,
channel: target.channel,
rawSource: descriptor.rawInput,
moduleName: manifest.name,
});
const destPaths = ['.claude-plugin/plugin.json', ...plan.map((p) => p.destRel)];
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
await appendFilesManifestRows(bmadDir, code, destPaths);
// 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 ${destPaths.length} file(s)\n`);
// Re-run the same post-copy completion as install: deps, config + agent
// roster, working directories (moves if a path changed), and help catalog.
await finishModuleInstall({ bmadDir, code, targetDir, manifest, setOverrides: opts.setOverrides });
// Re-distribute to the configured coding assistants: prune skills that no
// longer exist in this version, refresh the rest.
const ideResult = await distributeToIdes({ projectDir, bmadDir, prune: oldSkillIds });
if (!ideResult.skipped && !ideResult.ok) {
process.stderr.write(`[bmad-module] warning: ${ideResult.hint}\n`);
}
} finally {
await materialized.cleanup();
}
}

View File

@ -0,0 +1,48 @@
{
"name": "acme-devlog",
"version": "0.4.0",
"displayName": "Devlog",
"description": "Daily engineering devlog: write entries, summarize history, and consult Clio the Historian.",
"author": {
"name": "Acme Corp",
"email": "team@acme.example",
"url": "https://acme.example"
},
"repository": "https://github.com/acme/acme-devlog",
"license": "MIT",
"homepage": "https://acme.example/devlog",
"keywords": ["devlog", "history", "summarization", "engineering-log"],
"skills": ["./skills/bmad-devlog-setup", "./skills/bmad-devlog-write", "./skills/bmad-devlog-summarize", "./skills/bmad-agent-historian"],
"agents": ["./agents/changelog-archivist.md"],
"hooks": "./hooks/hooks.json",
"mcpServers": "./.mcp.json",
"bmad": {
"specVersion": "1.0.0",
"code": "devlog",
"category": "knowledge-management",
"subcategory": "history",
"compatibility": {
"bmadMethod": ">=6.6.0 <7.0.0"
},
"setupSkill": "bmad-devlog-setup",
"moduleDefinition": "skills/bmad-devlog-setup/assets/module.yaml",
"moduleHelpCsv": "skills/bmad-devlog-setup/assets/module-help.csv",
"customize": {
"schemas": ["./skills/bmad-agent-historian/customize.toml"]
},
"dependencies": {
"modules": [{ "code": "bmm", "version": ">=6.6.0" }]
},
"install": {
"ignore": ["docs/**", "tests/**", "*.test.*", "README.md", "CHANGELOG.md"],
"postInstallSkill": "bmad-devlog-setup"
},
"docs": {
"readme": "./README.md",
"changelog": "./CHANGELOG.md",
"homepage": "./docs/index.md"
}
}
}

View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"devlog-history": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.js"],
"env": {
"DEVLOG_PATH": "${CLAUDE_PLUGIN_DATA}/devlog"
}
}
}
}

View File

@ -0,0 +1,24 @@
# Changelog
All notable changes to this module will be documented here. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] — 2026-05-21
### Added
- Clio the Historian persona-agent (`bmad-agent-historian`) with three menu actions: summarize range, recall topic context, identify patterns.
- `changelog-archivist` Claude subagent for fan-out summarization across long date ranges.
- MCP server stub (`devlog-history`) for programmatic queries against past entries.
- `SessionStart` hook surfaces today's entry (or yesterday's if today is empty).
### Changed
- Devlog entry template now includes "Open questions" and "Blockers" sections.
- Setup skill records `devlog_path` separately from `output_folder` so entries are addressable independently of other BMAD output.
## [0.3.0] — 2026-04-30
### Added
- Initial implementation: `bmad-devlog-write` and `bmad-devlog-summarize` skills.
- Setup skill scaffolding.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Acme Corp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,43 @@
# acme-devlog
A daily engineering devlog for BMAD-driven projects.
This module demonstrates **every supported surface** of the BMAD Module Manifest Specification — skills, a persona-agent with a `customize.toml`, a Claude subagent, a SessionStart hook, an MCP server stub, install-time configuration via `module.yaml`, and a setup skill. Use it as a reference when authoring a real module.
## What it does
- **Write** a structured daily entry (template-driven) with `/bmad-devlog-write`.
- **Summarize** a date range with `/bmad-devlog-summarize`.
- **Consult Clio**, a persona-agent historian, with `/bmad-agent-historian`. Clio narrates patterns across entries, surfaces forgotten context, and routes you to the right write/summarize skill.
- **Show today's entry** at session start (via the `SessionStart` hook).
- **Query history programmatically** via the bundled MCP server stub (`devlog-history`).
## Install
```
bmad-module install acme/acme-devlog
```
Installs to `_bmad/devlog/`. The setup skill (`bmad-devlog-setup`) runs automatically and prompts for the devlog output path (default: `_bmad-output/devlog`).
## Configuration
After install, `_bmad/devlog/config.yaml` records your devlog path. Override defaults per skill via `_bmad/custom/`:
- Team: `_bmad/custom/bmad-agent-historian.toml`
- Personal: `_bmad/custom/bmad-agent-historian.user.toml`
See `docs/index.md` (in this repo) for the customization recipe.
## Uninstall
```
bmad-module remove devlog # leaves _bmad/custom/bmad-agent-historian*.toml intact
bmad-module remove devlog --purge # removes user customizations too
```
Devlog entries written to `_bmad-output/devlog/` are **never** deleted by uninstall.
## License
MIT. See `LICENSE`.

View File

@ -0,0 +1,47 @@
---
name: changelog-archivist
description: Summarizes a single week of devlog entries into a tight changelog-style brief. Used as a fan-out subagent by /bmad-devlog-summarize for long date ranges.
model: sonnet
---
# Changelog Archivist
You are a focused subagent invoked by the `bmad-devlog-summarize` skill to compress a single week's devlog entries into a brief paragraph.
## Inputs
You receive:
- A list of devlog entry file paths covering one ISO week.
- The week label (e.g. `2026-W21`).
## Task
For each entry, extract:
- What shipped (verbatim where possible).
- Recurring blockers.
- Decisions visible in the entry.
Produce a single section in this exact shape:
```
### <week-label>
**Shipped.** <one paragraph, 2-4 sentences, prose not bullets>
**Blockers.** <one sentence; "none recurring" if absent>
**Decisions.** <one sentence; "none" if absent>
```
## Rules
- Do not invent content. If the week has no entries, output the week label and `_no entries this week_` and stop.
- Do not editorialize beyond what's in the entries.
- Cite dates inline (e.g. `On 2026-05-14, …`) only when a single day's content dominates.
- Keep the section under 120 words.
## Why a subagent
For ranges longer than two weeks, the parent skill fans out one subagent per week so each archivist operates with a small, focused context. The parent then concatenates the sections and adds a top-level intro.

View File

@ -0,0 +1,56 @@
# acme-devlog — Authoring & Customization
This doc lives in the module source. It is **excluded** from install via `bmad.install.ignore` (see `.claude-plugin/plugin.json`). Read it on GitHub or in a clone of the module repo, not under `_bmad/devlog/`.
## Customizing Clio
Clio's defaults live in `_bmad/devlog/skills/bmad-agent-historian/customize.toml`. You should not edit that file directly — the installer overwrites it on `bmad-module update`.
Instead, drop overrides into one of:
| File | Scope | Git status |
| --------------------------------------------- | ---------------- | ----------- |
| `_bmad/custom/bmad-agent-historian.toml` | Team (committed) | tracked |
| `_bmad/custom/bmad-agent-historian.user.toml` | Personal | git-ignored |
Both files use the same TOML shape as the base `customize.toml`. The `bmad-customize` core skill merges them in this order: base → team → personal.
**Merge rules:**
- Scalars: override wins.
- Tables: deep merge.
- Arrays of tables keyed by `code` (e.g. `[[agent.menu]]`): matching codes replace, new codes append.
- Other arrays (`persistent_facts`, `principles`, `activation_steps_*`): append.
### Example — add a menu item
`_bmad/custom/bmad-agent-historian.user.toml`:
```toml
[[agent.menu]]
code = "TODAY"
description = "Read today's entry aloud"
prompt = """
Read the file at `{devlog_path}/$(date +%F).md` if it exists. If not,
say "No entry yet for today — try the WRT menu item."
"""
```
### Example — change communication style
`_bmad/custom/bmad-agent-historian.toml`:
```toml
[agent]
communication_style = "Crisp, dry, footnoted. Cites entries like a historian writing for The Economist."
```
## Why a SessionStart hook?
The hook is convenience, not requirement. Disable it by removing the `hooks` field from `_bmad/devlog/.claude-plugin/plugin.json` (or by uninstalling). It surfaces the current entry so you have context the moment a session starts; for some teams that's noise — opt out freely.
## Why `module.yaml` lives under `skills/bmad-devlog-setup/assets/`?
This module uses PluginResolver Strategy 2 (the `-setup` skill carries the module definition in `assets/`). The advantage is that `bmad-devlog-setup` can be re-run any time to reconfigure without touching the rest of the install. The `bmad.moduleDefinition` field in `plugin.json` points the installer at the right file.
A simpler module can put `module.yaml` at `skills/module.yaml` (the default) and skip `bmad.setupSkill`.

View File

@ -0,0 +1,15 @@
{
"description": "Surface today's (or most recent) devlog entry at session start.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/fetch-git-history.sh\""
}
]
}
]
}
}

View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# SessionStart hook: print today's devlog entry if it exists, else the most
# recent entry. Reads devlog_path from _bmad/devlog/config.yaml.
#
# Bound by Claude Code's SessionStart event via hooks/hooks.json. Exits 0
# silently when there's nothing useful to surface.
set -eu
config="${PWD}/_bmad/devlog/config.yaml"
[ -f "$config" ] || exit 0
devlog_path=$(awk -F': *' '/^devlog_path:/ {print $2; exit}' "$config" | tr -d '"')
[ -n "$devlog_path" ] && [ -d "$devlog_path" ] || exit 0
today="$(date +%F)"
today_file="${devlog_path}/${today}.md"
if [ -f "$today_file" ]; then
echo "=== Devlog — ${today} ==="
cat "$today_file"
exit 0
fi
# Fall back to the most recent .md by mtime. Glob + `-nt` instead of parsing
# `ls` so filenames with spaces/newlines are handled safely (and ShellCheck
# stays happy).
latest=""
for f in "${devlog_path}"/*.md; do
[ -e "$f" ] || continue
if [ -z "$latest" ] || [ "$f" -nt "$latest" ]; then
latest="$f"
fi
done
if [ -n "$latest" ]; then
echo "=== Most recent devlog ($(basename "$latest" .md)) ==="
cat "$latest"
fi
exit 0

View File

@ -0,0 +1,60 @@
---
name: bmad-agent-historian
description: Clio, the Devlog Historian. A persona-agent for narrative recall, pattern detection across entries, and routing to the right devlog action. Use when the user asks to talk to Clio or requests the historian.
---
# Clio — Devlog Historian
## Overview
You are Clio, named for the muse of history. You read the project's devlog with an archivist's care and a journalist's nose for the story underneath the entries. You don't invent context — every observation you make is grounded in a specific entry, cited by date.
## Conventions
- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives).
- `{project-root}` resolves to the project working directory.
- `{skill-name}` resolves to this skill's directory basename.
## On Activation
### Step 1: Resolve the Agent Block
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key agent`
If the script fails, resolve the `agent` block yourself by reading these three files in base → team → user order and applying BMad's structural merge rules:
1. `{skill-root}/customize.toml` — defaults
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append.
### Step 2: Adopt Persona
Adopt the Clio identity from the Overview. Layer on `{agent.role}`, `{agent.identity}`, `{agent.communication_style}`, and `{agent.principles}` from the resolved block.
Do not break character until the user dismisses the persona.
### Step 3: Load Persistent Facts
Treat every entry in `{agent.persistent_facts}` as foundational context. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts.
### Step 4: Load Devlog Config
Load `{project-root}/_bmad/devlog/config.yaml`. Note `devlog_path` and `entry_format`. If the config is missing, tell the user kindly:
> "I can't find the devlog config yet — run `/bmad-devlog-setup` first and call me back."
…then stand down.
### Step 5: Greet and Present Menu
Greet using `{user_name}` from `{project-root}/_bmad/bmm/config.yaml` (fallback: "friend"). Then present the menu (`{agent.menu}`) as a numbered list. Each item has a `code`, `description`, and either a `skill` or a `prompt`.
### Step 6: Handle Selection
When the user picks a menu code, invoke its `skill` or execute its `prompt`. The persona carries through; stay in character.
## Notes for Authors
This is a reference persona-agent. The `customize.toml` next door defines Clio's defaults — copy and adapt the structure for your own persona-agent skills.

View File

@ -0,0 +1,80 @@
# DO NOT EDIT -- overwritten on every update.
#
# Clio, the Devlog Historian, is the hardcoded identity of this agent.
# Customize the persona and menu below to shape behavior without changing
# who the agent is. Override in _bmad/custom/bmad-agent-historian.toml
# (team) or _bmad/custom/bmad-agent-historian.user.toml (personal).
[agent]
# Non-configurable skill frontmatter. Create a custom persona-agent skill
# if you need a new name/title.
name = "Clio"
title = "Devlog Historian"
# --- Configurable below. Overrides merge per BMad structural rules: ---
# scalars: override wins • arrays (persistent_facts, principles, activation_steps_*): append
# arrays-of-tables with `code`/`id`: replace matching items, append new ones.
icon = "🕰️"
activation_steps_prepend = []
activation_steps_append = []
# Persistent facts the agent keeps in mind for the whole session.
# `file:` entries are loaded as content; literal entries are facts verbatim.
# Note: the devlog config is intentionally NOT loaded here as an always-on
# `file:` fact — it may not exist on first run, which would fail activation
# before the `/bmad-devlog-setup` fallback can create it. Skills read the
# config after checking it exists.
persistent_facts = [
"The devlog is a primary source. Never invent context that isn't written down.",
]
role = "Help the user recall, analyze, and narrate the project's history through the devlog."
identity = "Channels the patient eye of an archivist and the narrative nose of a journalist. Treats every entry as primary source material."
communication_style = "Calm and measured, with date markers (`On 2026-05-14, …`). Tells the story underneath the entries; never editorializes beyond what's written."
principles = [
"Every observation cites at least one entry by date.",
"Patterns over events: surface what recurs, not what's loudest.",
"When the record is silent, say so — do not extrapolate.",
"Route the user to write/summarize skills when they need to act, not reminisce.",
]
# Capabilities menu. Overrides merge by `code`: matching codes replace the
# item in place; new codes append. Each item has exactly one of `skill`
# (invokes a registered skill) or `prompt` (executes the prompt text directly).
[[agent.menu]]
code = "SUM"
description = "Summarize a date range"
skill = "bmad-devlog-summarize"
[[agent.menu]]
code = "REC"
description = "Recall context for a topic"
prompt = """
Ask the user for a topic or keyword. Search every devlog entry under
`{devlog_path}` for matches. For each match, cite the entry date and quote
the relevant lines. End with a one-paragraph narrative tying the matches
together. If no matches, say so plainly.
"""
[[agent.menu]]
code = "PAT"
description = "Identify recurring patterns"
prompt = """
Read every entry in the last 30 days. Identify themes that appear in 3
entries (recurring blockers, repeated decisions, drifting open questions).
Present each pattern as: name, evidence (3+ cited entries), implication.
"""
[[agent.menu]]
code = "WRT"
description = "Write today's entry"
skill = "bmad-devlog-write"
[[agent.menu]]
code = "EXIT"
description = "Dismiss Clio"
prompt = "Acknowledge the dismissal in character ('Until the next entry, then.'), break persona, and return control."

View File

@ -0,0 +1,45 @@
---
name: bmad-devlog-setup
description: One-time setup for the acme-devlog module. Use after `bmad-module install acme/acme-devlog`, or when the user says "configure devlog" or "re-run devlog setup".
---
# Devlog Setup
Installs and configures the devlog module by reading `assets/module.yaml`, collecting answers to its prompts, and writing `_bmad/devlog/config.yaml`.
This skill is invoked automatically by `bmad-module install` (via `bmad.install.postInstallSkill`). It is also safe to re-run any time — it merges over the existing config without losing prior answers.
## EXECUTION
### Step 1: Load the module definition
Read `./assets/module.yaml` from the skill root. Parse its prompt entries (e.g. `devlog_path`, `entry_format`).
### Step 2: Collect answers
For each prompt, ask the user. Show the default in parens. Accept the default when the user replies with empty input or "use default".
If `_bmad/devlog/config.yaml` already exists, load existing answers and pre-fill them as the prompt default.
### Step 3: Apply variable substitution
Resolve `{value}`, `{project-root}`, and `{output_folder}` placeholders using each prompt's `result:` template.
### Step 4: Write config
Write the resolved key/value map to `_bmad/devlog/config.yaml` (YAML, 2-space indent, keys sorted).
### Step 5: Create directories
Ensure `{devlog_path}` exists on disk. Create it if absent.
### Step 6: Confirm
Print:
```
Devlog configured.
devlog_path: <resolved>
entry_format: <resolved>
Try `/bmad-devlog-write` to create today's entry.
```

View File

@ -0,0 +1,4 @@
module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs
devlog,bmad-devlog-write,Write Devlog Entry,dw,Write today's devlog entry from a template.,bmad-devlog-write,,daily,,,,{devlog_path},entry.md
devlog,bmad-devlog-summarize,Summarize Devlog,ds,Summarize devlog entries across a date range.,bmad-devlog-summarize,<range>,history,,,,,summary.md
devlog,bmad-agent-historian,Clio the Historian,ch,Persona-agent for narrative recall and pattern detection.,bmad-agent-historian,,,,,,,
1 module skill display-name menu-code description action args phase preceded-by followed-by required output-location outputs
2 devlog bmad-devlog-write Write Devlog Entry dw Write today's devlog entry from a template. bmad-devlog-write daily {devlog_path} entry.md
3 devlog bmad-devlog-summarize Summarize Devlog ds Summarize devlog entries across a date range. bmad-devlog-summarize <range> history summary.md
4 devlog bmad-agent-historian Clio the Historian ch Persona-agent for narrative recall and pattern detection. bmad-agent-historian

View File

@ -0,0 +1,41 @@
code: devlog
name: "Devlog"
description: "Daily engineering devlog: write entries, summarize history, and consult Clio the Historian."
default_selected: false
# Variables from Core Config available:
## user_name
## output_folder
devlog_path:
prompt: "Where should daily devlog entries be stored?"
default: "{output_folder}/devlog"
result: "{project-root}/{value}"
entry_format:
prompt:
- "How should entry filenames be formatted?"
- "Affects how the summarize skill walks history."
scope: user
default: "iso"
result: "{value}"
single-select:
- value: "iso"
label: "ISO 8601 — 2026-05-21.md"
- value: "weekly"
label: "ISO week — 2026-W21.md (one file per week)"
- value: "monthly"
label: "Year-month — 2026-05.md (one file per month, append entries)"
# Directories to create during installation
directories:
- "{devlog_path}"
# Agent roster — essence only. Persona and behavior live in customize.toml.
agents:
- code: bmad-agent-historian
name: Clio
title: Devlog Historian
icon: "🕰️"
team: knowledge-management
description: "Channels the patient eye of an archivist and the narrative nose of a journalist. Surfaces patterns across entries, never invents context, always cites the date."

View File

@ -0,0 +1,63 @@
---
name: bmad-devlog-summarize
description: Summarize devlog entries across a date range. Use when the user says "summarize devlog", "what happened last week", or "devlog summary <range>".
---
# Devlog Summarize
Walks devlog entries in a date range and produces a structured summary: themes, recurring blockers, decisions, open questions.
## EXECUTION
### Step 1: Resolve config
Read `{project-root}/_bmad/devlog/config.yaml`. If missing, invoke `/bmad-devlog-setup` first.
### Step 2: Parse the range argument
Accept these forms:
- `today` / `yesterday`
- `last-week` / `last-month` / `last-quarter`
- ISO date: `2026-05-21`
- ISO range: `2026-05-01..2026-05-21`
- ISO week: `2026-W21`
- ISO month: `2026-05`
If no argument, ask the user.
### Step 3: Collect entries
Enumerate all entries under `devlog_path` whose date falls in the range. For `weekly`/`monthly` formats, parse sub-sections by their date heading.
If zero entries match, report "No entries in <range>." and stop.
### Step 4: Summarize
For long ranges (>14 days), delegate per-week summarization to the `changelog-archivist` Claude subagent (fan-out). For short ranges, summarize inline.
Produce:
```
# Devlog summary — <range>
## Themes
- _patterns across entries_
## Blockers (recurring)
- _what came up more than once_
## Decisions
- _commitments visible in entries_
## Open questions
- _still unresolved at end of range_
## By the numbers
- Entries: <N>
- Days with no entry: <M>
```
### Step 5: Optionally save
Ask: "Save to `<devlog_path>/_summaries/<range>.md`?" Write if yes.

View File

@ -0,0 +1,50 @@
---
name: bmad-devlog-write
description: Write today's devlog entry from the bundled template. Use when the user says "write devlog", "today's entry", or "log this".
---
# Devlog Write
Creates or appends today's devlog entry under the configured devlog path.
## EXECUTION
### Step 1: Resolve config
Read `{project-root}/_bmad/devlog/config.yaml`. Expect:
- `devlog_path` (absolute path)
- `entry_format` (`iso` | `weekly` | `monthly`)
If config is missing, invoke `/bmad-devlog-setup` first.
### Step 2: Determine the entry file
- `iso``<devlog_path>/<YYYY-MM-DD>.md`
- `weekly``<devlog_path>/<YYYY>-W<NN>.md`
- `monthly``<devlog_path>/<YYYY-MM>.md`
### Step 3: Initialize if absent
If the file doesn't exist, copy `./assets/template.md` to the target path. Substitute `{{date}}` and `{{author}}` (from `user_name`). For `weekly`/`monthly`, render the `{{date}}` heading from the period value (e.g. `2026-W21` or `2026-05`) instead of reusing the daily date.
### Step 4: Collect entry content
Ask the user:
1. **What did you ship today?** (bullet list)
2. **What blocked you?** (bullet list; "nothing" is valid)
3. **Open questions?** (bullet list; "none" is valid)
4. **One sentence summary.**
For `weekly`/`monthly` formats, append a dated sub-section (e.g. `## 2026-05-21`) rather than overwriting.
### Step 5: Write and confirm
Write the entry, print:
```
Wrote <devlog_path>/<filename>
```
If the file existed and you appended, print "Appended to …" instead.

View File

@ -0,0 +1,19 @@
# Devlog — {{date}}
**Author:** {{author}}
## Shipped
- _what landed today_
## Blockers
- _what got in the way (or "nothing")_
## Open questions
- _decisions deferred, things to chase tomorrow (or "none")_
## Summary
_one sentence_

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

@ -0,0 +1,14 @@
{
"name": "acme-npmtool",
"version": "0.1.0",
"description": "A module that ships a package.json so install exercises npm dependency setup.",
"license": "MIT",
"author": { "name": "Acme Corp" },
"skills": ["./skills/acme-npmtool"],
"bmad": {
"specVersion": "1.0.0",
"code": "npmtool",
"category": "developer-tools",
"compatibility": { "bmadMethod": ">=6.6.0" }
}
}

View File

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

View File

@ -0,0 +1,9 @@
---
name: acme-npmtool
description: A trivial skill whose module ships a package.json to exercise npm dependency install.
---
# Acme NPM Tool
This skill exists only so its module can carry a `package.json`, exercising the
installer's npm dependency setup step. It has no runtime dependencies.

View File

@ -0,0 +1,14 @@
{
"name": "acme-md-lint",
"version": "0.1.0",
"description": "Lints markdown headings and link rot in BMad projects.",
"license": "MIT",
"author": { "name": "Acme Corp" },
"skills": ["./skills/acme-md-lint"],
"bmad": {
"specVersion": "1.0.0",
"code": "mdlint",
"category": "developer-tools",
"compatibility": { "bmadMethod": ">=6.6.0" }
}
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Acme Corp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,37 @@
# acme-md-lint
A minimal BMAD module: one skill that lints markdown files under `_bmad/` for heading-hierarchy mistakes and broken relative links.
This is the **smallest valid module** that conforms to the BMAD Module Manifest Specification. Use it as a starting template.
## Install
```
bmad-module install acme/acme-md-lint
```
Installs to `_bmad/mdlint/`.
## Use
After install, invoke from any Claude Code session:
```
/acme-md-lint
```
The skill walks every `.md` file under `_bmad/` and reports:
- Heading-level skips (e.g. `##``####`)
- Missing H1
- Relative links whose targets don't exist on disk
## Uninstall
```
bmad-module remove mdlint
```
## License
MIT. See `LICENSE`.

View File

@ -0,0 +1,61 @@
---
name: acme-md-lint
description: Lints all markdown files under _bmad/ for heading hierarchy errors and broken relative links. Use when the user says "lint markdown", "check links", or "audit docs".
---
# acme-md-lint
A small lint skill for the markdown content shipped with installed BMAD modules.
## CRITICAL RULES
- DO NOT modify any file — this skill is read-only.
- DO NOT follow external URLs; only check relative paths on disk.
- HALT and report cleanly if `_bmad/` is not present in the current working directory.
## EXECUTION
### Step 1: Locate the BMAD tree
Confirm `_bmad/` exists under the current working directory. If not, report:
> No `_bmad/` directory found. Run `bmad install` first.
…and stop.
### Step 2: Enumerate markdown files
Walk `_bmad/` recursively. Collect every `.md` file path. Skip files under `_bmad/_config/` (those are CSV/YAML generated by the installer).
### Step 3: Check heading hierarchy
For each file:
1. Parse headings line-by-line (`#`, `##`, `###`, …).
2. Require an H1 as the first heading (warn if absent).
3. Flag any heading that jumps more than one level deeper than the previous heading (e.g. `##` followed by `####`).
Report each violation with `file:line — message`.
### Step 4: Check relative links
For each file, find Markdown links `[text](path)` where `path` does not start with `http://`, `https://`, `mailto:`, or `#`. Resolve the path relative to the file's directory and verify it exists on disk. If not, report:
> `file:line — broken link → path`
External URLs are skipped entirely.
### Step 5: Summarize
Print:
```
Scanned N files. M heading issues, K broken links.
```
If `M + K == 0`, end with "All clean." If non-zero, end with "Review the issues above."
## Notes
- The skill operates on installed `_bmad/` content, not on the module source. It is intended as a post-install or pre-PR sanity check.
- This is a pedagogical reference. Production lint behavior would warrant a dedicated tool (e.g. `markdownlint`); this skill demonstrates the smallest valid module shape.

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
---
name: skill-a
description: Stub skill for the reserved-code negative fixture. Never installed.
---
Stub.

View File

@ -0,0 +1,391 @@
#!/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 vendored reference modules
# (tests/fixtures/examples/) and negative fixtures. Does NOT require
# BMAD-METHOD's installer; installer integration 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)"
MODULE_JS="${SKILL_DIR}/scripts/bmad-module.mjs"
# Reference modules are vendored under tests/fixtures/examples/ so the suite is
# self-contained — the fixtures travel with the test, no external checkout.
EXAMPLES="${SCRIPT_DIR}/fixtures/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() {
local stderr_file
stderr_file="$(mktemp)"
set +e
STDOUT="$(node "${MODULE_JS}" "$@" 2>"${stderr_file}")"
EXIT=$?
STDERR="$(cat "${stderr_file}")"
rm -f "${stderr_file}"
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
# Central config as `bmad install` would leave it: [core] supplies output_folder
# so module defaults that reference {output_folder} resolve during config-gen.
cat > _bmad/config.toml <<'TOML'
# Installer-managed. Regenerated on install.
[core]
user_name = "Tester"
output_folder = "{project-root}/_bmad-output"
TOML
printf '# Installer-managed.\n\n[core]\ncommunication_language = "English"\n' > _bmad/config.user.toml
# Core ships a canonical module-help.csv so the merged catalog has a baseline row.
printf 'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n,bmad-help,Help,h,Show the BMAD help catalog,bmad-help,,,,,,,\n' > _bmad/core/module-help.csv
ok "skeleton seeded at ${WORKDIR}/_bmad/"
# ─── 1. list (empty) ─────────────────────────────────────────────────────────
note "list (no modules)"
run list
assert_exit 0 "list empty"
if [[ "${STDOUT}" == *"no modules installed"* ]]; then ok "stdout reports empty"
else ko "expected 'no modules installed' in stdout: ${STDOUT}"; fi
# ─── 1a. usage errors: unknown flag / invalid channel reject early ───────────
note "unknown flag and invalid channel → exit 2"
run install "${EXAMPLES}/minimal/acme-md-lint" --bogus
assert_exit 2 "unknown flag rejected"
run install "${EXAMPLES}/minimal/acme-md-lint" --channel stabl --dry-run
assert_exit 2 "invalid channel rejected"
# ─── 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"
if [[ "${STDOUT}" == *"dry-run"* ]]; then ok "stdout mentions dry-run"
else ko "expected 'dry-run' in stdout: ${STDOUT}"; fi
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"
if [[ "${STDOUT}" == *"mdlint"* ]]; then ok "stdout includes mdlint"
else ko "expected 'mdlint' in stdout: ${STDOUT}"; fi
run list --json
assert_exit 0 "list --json"
if [[ "${STDOUT}" == *"\"name\": \"mdlint\""* ]]; then ok "json includes mdlint name"
else ko "expected mdlint in JSON: ${STDOUT}"; fi
# ─── 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 (with --set override)"
run install "${EXAMPLES}/comprehensive/acme-devlog" --set devlog.devlog_path='{output_folder}/journal'
assert_exit 0 "install comprehensive"
assert_path_exists "_bmad/devlog/skills/bmad-devlog-write/SKILL.md"
assert_path_exists "_bmad/devlog/skills/bmad-devlog-setup/SKILL.md"
assert_path_exists "_bmad/devlog/agents/changelog-archivist.md"
# hooks/mcpServers are flattened to canonical root slots (see rewriteManifestPaths)
assert_path_exists "_bmad/devlog/hooks.json"
assert_path_exists "_bmad/devlog/.mcp.json"
# moduleDefinition / moduleHelpCsv are also flattened to the module root even
# though they live inside the setup skill's assets/ dir.
assert_path_exists "_bmad/devlog/module.yaml"
assert_path_exists "_bmad/devlog/module-help.csv"
# install.ignore excludes docs/ and tests/ and README.md / CHANGELOG.md
assert_path_absent "_bmad/devlog/docs"
assert_path_absent "_bmad/devlog/README.md"
assert_path_absent "_bmad/devlog/CHANGELOG.md"
if [[ "${STDOUT}" == *"hooks"* ]]; then ok "warns about hooks not auto-activated"
else ko "expected hooks warning in stdout: ${STDOUT}"; fi
# ─── 9a. parity: central config + agent roster (gap #3) ──────────────────────
note "config generation + agent roster"
assert_grep '^\[modules\.devlog]' "_bmad/config.toml"
# --set override resolves {output_folder} from [core] and applies the result template
assert_grep 'devlog_path = "\{project-root}/_bmad-output/journal"' "_bmad/config.toml"
assert_grep '^\[agents\.bmad-agent-historian]' "_bmad/config.toml"
assert_grep 'module = "devlog"' "_bmad/config.toml"
# [core] is preserved untouched
assert_grep '^user_name = "Tester"' "_bmad/config.toml"
# user-scoped answer lands in config.user.toml, not config.toml
assert_grep '^\[modules\.devlog]' "_bmad/config.user.toml"
assert_grep 'entry_format = "iso"' "_bmad/config.user.toml"
# ─── 9b. parity: module working directories (gap #2) ─────────────────────────
note "module directory creation"
assert_path_exists "_bmad-output/journal"
# ─── 9c. parity: merged help catalog (gap #1) ────────────────────────────────
note "bmad-help.csv merge"
assert_path_exists "_bmad/_config/bmad-help.csv"
if head -1 _bmad/_config/bmad-help.csv | grep -q '^module,skill,display-name,'; then
ok "bmad-help.csv has canonical header"
else ko "bmad-help.csv header wrong"; fi
assert_grep '^devlog,bmad-devlog-write,' "_bmad/_config/bmad-help.csv"
assert_grep '^devlog,bmad-agent-historian,' "_bmad/_config/bmad-help.csv"
# the core baseline row is still present
assert_grep ',bmad-help,Help,' "_bmad/_config/bmad-help.csv"
# ─── 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"
if [[ "${STDOUT}" == *"resolved legacy module mlg"* ]]; then ok "stdout reports legacy resolution"
else ko "expected 'resolved legacy module mlg' in stdout: ${STDOUT}"; fi
# 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
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"
if [[ "${STDOUT}" == *"preserved"* ]]; then ok "stdout mentions preserved customs"
else ko "expected 'preserved' in stdout: ${STDOUT}"; fi
# manifest rows for mdlint should be gone
if grep -q ',"mdlint",' _bmad/_config/files-manifest.csv; then
ko "mdlint rows still in files-manifest.csv"
else ok "files-manifest.csv pruned"; fi
if grep -q '"acme-md-lint"' _bmad/_config/skill-manifest.csv; then
ko "acme-md-lint row still in skill-manifest.csv"
else ok "skill-manifest.csv pruned"; fi
# ─── 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"
# config blocks and help rows for devlog are stripped on removal
if grep -q '\[modules\.devlog]' _bmad/config.toml; then
ko "[modules.devlog] still in config.toml"
else ok "config.toml [modules.devlog] stripped"; fi
if grep -q '\[agents\.bmad-agent-historian]' _bmad/config.toml; then
ko "[agents.bmad-agent-historian] still in config.toml"
else ok "config.toml agent block stripped"; fi
if grep -q '\[modules\.devlog]' _bmad/config.user.toml; then
ko "[modules.devlog] still in config.user.toml"
else ok "config.user.toml [modules.devlog] stripped"; fi
if grep -q '^devlog,' _bmad/_config/bmad-help.csv; then
ko "devlog rows still in bmad-help.csv"
else ok "bmad-help.csv devlog rows removed"; fi
# [core] survives the removal
assert_grep '^user_name = "Tester"' "_bmad/config.toml"
# ─── 12. remove unknown ──────────────────────────────────────────────────────
note "remove unknown code"
run remove nope
assert_exit 90 "remove unknown"
# ─── 13. IDE distribution into the user's chosen coding assistants ───────────
# Uses a SEPARATE project whose manifest lists two IDEs, so install/remove must
# push skills to (and prune them from) those IDE dirs via the vendored ide-sync
# bundle. Fully offline — no npx, no network, no node_modules.
note "IDE distribution: install/remove sync to configured assistants"
IDEPROJ="${WORKDIR}/ideproj"
mkdir -p "${IDEPROJ}/_bmad/_config"
cat > "${IDEPROJ}/_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: []
ides:
- claude-code
- cursor
YAML
printf 'canonicalId,name,description,module,path\n' > "${IDEPROJ}/_bmad/_config/skill-manifest.csv"
printf 'type,name,module,path,hash\n' > "${IDEPROJ}/_bmad/_config/files-manifest.csv"
run install "${EXAMPLES}/minimal/acme-md-lint" --project-dir "${IDEPROJ}"
assert_exit 0 "install into IDE project"
assert_path_exists "${IDEPROJ}/.claude/skills/acme-md-lint/SKILL.md"
assert_path_exists "${IDEPROJ}/.agents/skills/acme-md-lint/SKILL.md"
if [[ "${STDOUT}" == *"claude-code"* ]]; then ok "stdout reports claude-code distribution"
else ko "expected claude-code in stdout: ${STDOUT}"; fi
# Canonical end-state: skill source dirs removed from _bmad/ after distribution.
if find "${IDEPROJ}/_bmad" -name SKILL.md | grep -q .; then
ko "SKILL.md still under _bmad after distribution"
else
ok "_bmad skill dirs cleaned after distribution"
fi
run remove mdlint --project-dir "${IDEPROJ}"
assert_exit 0 "remove from IDE project"
assert_path_absent "${IDEPROJ}/.claude/skills/acme-md-lint"
assert_path_absent "${IDEPROJ}/.agents/skills/acme-md-lint"
# ─── 14. npm dependency install (gap #4) ─────────────────────────────────────
# A module shipping package.json. package.json/package-lock.json are copied to
# the module root; if npm is available, deps are installed in place. The fixture
# has no dependencies, so npm resolves offline. Guarded on npm availability so
# CI sandboxes without npm still pass.
note "npm fixture: package.json copied + deps installed in place"
run install "${EXAMPLES}/minimal-npm/acme-npmtool"
assert_exit 0 "install npm fixture"
assert_path_exists "_bmad/npmtool/package.json"
if command -v npm >/dev/null 2>&1; then
if [[ "${STDOUT}" == *"installed npm dependencies for npmtool"* ]]; then
ok "npm dependencies installed"
else ko "expected npm install confirmation in stdout: ${STDOUT}"; fi
# The fixture has zero deps, so npm writes package-lock.json (not node_modules);
# its presence proves npm actually ran inside the installed module dir.
assert_path_exists "_bmad/npmtool/package-lock.json"
else
ok "npm not on PATH — skipping dependency-install assertion"
fi
# ─── Summary ─────────────────────────────────────────────────────────────────
echo
echo "──────────────────────────────────────────────────────────────────────"
printf ' %d pass · %d fail\n' "${pass}" "${fail}"
if [[ "${fail}" -gt 0 ]]; then
echo " FAIL"
exit 1
fi
echo " OK"

View File

@ -11,3 +11,4 @@ Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assu
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,,
Core,bmad-spec,Spec,SP,"Use to distill any intent input (brief, PRD, transcript, brain dump, design folder, mixed multi-source) into a succinct, no-fluff SPEC.md contract + companions that downstream work derives from. Locks the WHAT before the HOW. Works for software, game design, research, editorial, policy, business, anything intent-bearing. Validation mode also available.",,[path],anytime,,,false,{output_folder}/specs/spec-{slug},SPEC.md + companion files
Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files
Core,bmad-module,Manage Modules,MM,"Install, update, remove, or list community BMAD modules (standalone GitHub repos installed by URL or local path). Use when the user says install/update/remove/uninstall/list module.",,[verb] [source],anytime,,,false,{project-root}/_bmad,

1 module skill display-name menu-code description action args phase preceded-by followed-by required output-location outputs
11 Core bmad-review-edge-case-hunter Edge Case Hunter Review ECH Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven. [path] anytime false
12 Core bmad-spec Spec SP Use to distill any intent input (brief, PRD, transcript, brain dump, design folder, mixed multi-source) into a succinct, no-fluff SPEC.md contract + companions that downstream work derives from. Locks the WHAT before the HOW. Works for software, game design, research, editorial, policy, business, anything intent-bearing. Validation mode also available. [path] anytime false {output_folder}/specs/spec-{slug} SPEC.md + companion files
13 Core bmad-customize BMad Customize BC Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required. anytime false {project-root}/_bmad/custom TOML override files
14 Core bmad-module Manage Modules MM Install, update, remove, or list community BMAD modules (standalone GitHub repos installed by URL or local path). Use when the user says install/update/remove/uninstall/list module. [verb] [source] anytime false {project-root}/_bmad

View File

@ -0,0 +1,157 @@
/**
* bmad-module skill parseSource / semver-lite / channel-resolver unit tests.
*
* Covers the skill's pure (no-network, no-filesystem) install plumbing so the
* @ref / deep-path-URL / subdir parsing, the registry-free semver math, and the
* channel resolver stay at parity with the canonical installer
* (tools/installer/modules/custom-module-manager.js + channel-resolver.js, whose
* own behavior is pinned by test/test-parse-source-urls.js and
* test/test-installer-channels.js).
*
* Usage: node test/test-bmad-module-source.mjs
*/
import { parseSource } from '../src/core-skills/bmad-module/scripts/lib/source.mjs';
import { valid, prerelease, compare, rcompare, validRange } from '../src/core-skills/bmad-module/scripts/lib/semver-lite.mjs';
import { parseGitHubRepo, normalizeStableTag } from '../src/core-skills/bmad-module/scripts/lib/channel-resolver.mjs';
const colors = { reset: '', green: '', red: '', cyan: '', dim: '' };
let passed = 0;
let failed = 0;
function assert(cond, name, detail = '') {
if (cond) {
console.log(`${colors.green}${colors.reset} ${name}`);
passed++;
} else {
console.log(`${colors.red}${colors.reset} ${name}`);
if (detail) console.log(` ${colors.dim}${detail}${colors.reset}`);
failed++;
}
}
const eq = (got, want, name) =>
assert(JSON.stringify(got) === JSON.stringify(want), name, `got ${JSON.stringify(got)} want ${JSON.stringify(want)}`);
function throws(fn, name) {
try {
fn();
assert(false, name, 'expected a throw');
} catch {
assert(true, name);
}
}
// ─── parseSource ────────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}parseSource${colors.reset}\n`);
{
const r = parseSource('owner/repo');
eq(
[r.kind, r.url, r.ref, r.subdir, r.cacheKey],
['git', 'https://github.com/owner/repo', null, null, 'github.com/owner/repo'],
'owner/repo shorthand → GitHub HTTPS',
);
}
{
const r = parseSource('acme/devlog@v1.2.3');
eq([r.url, r.ref], ['https://github.com/acme/devlog', 'v1.2.3'], 'owner/repo@ref strips suffix');
}
{
const r = parseSource('https://github.com/owner/repo/tree/main/pkg/foo');
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', 'pkg/foo'], 'GitHub /tree/<ref>/<subdir>');
}
{
const r = parseSource('https://github.com/owner/repo/tree/main');
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'main', null], 'GitHub /tree/<ref> without subdir strips ref');
}
{
const r = parseSource('https://github.com/owner/repo/blob/v2.0.0/src');
eq([r.url, r.ref, r.subdir], ['https://github.com/owner/repo', 'v2.0.0', 'src'], 'GitHub /blob/<ref>/<subdir>');
}
{
const r = parseSource('https://gitlab.com/group/subgroup/repo/-/tree/main/src/module');
eq([r.url, r.ref, r.subdir], ['https://gitlab.com/group/subgroup/repo', 'main', 'src/module'], 'GitLab nested-group /-/tree');
}
{
const r = parseSource('https://gitea.example.com/owner/repo/src/branch/main');
eq([r.url, r.subdir], ['https://gitea.example.com/owner/repo', null], 'Gitea /src/branch/<ref> without subdir strips ref');
}
{
const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module?path=/src/skills');
eq(
[r.url, r.subdir, r.cacheKey],
['https://dev.azure.com/myorg/MyProject/_git/my-module', 'src/skills', 'dev.azure.com/myorg/MyProject/_git/my-module'],
'Azure DevOps ?path= + full-path cacheKey',
);
}
{
const r = parseSource('https://dev.azure.com/myorg/MyProject/_git/my-module.git');
eq(r.url, 'https://dev.azure.com/myorg/MyProject/_git/my-module', 'trailing .git stripped from cloneUrl');
}
{
const r = parseSource('git@github.com:owner/repo.git');
eq(
[r.kind, r.url, r.cacheKey, r.displayName],
['git', 'git@github.com:owner/repo.git', 'github.com/owner/repo', 'owner/repo'],
'SSH URL preserved, @ not consumed',
);
}
{
const r = parseSource('https://git.example.com/owner/my.repo.name');
eq([r.url, r.displayName], ['https://git.example.com/owner/my.repo.name', 'owner/my.repo.name'], 'dotted repo name preserved');
}
{
const r = parseSource('https://git.example.com/myorg/MyProject/_git/my-module');
eq(
[r.cacheKey, r.displayName],
['git.example.com/myorg/MyProject/_git/my-module', '_git/my-module'],
'nested path cacheKey + last-two-segment displayName',
);
}
throws(() => parseSource('./local@v1'), 'local path + @ref throws');
throws(() => parseSource(' '), 'empty source throws');
throws(() => parseSource('not a source'), 'garbage source throws');
// ─── semver-lite ──────────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}semver-lite${colors.reset}\n`);
eq(valid('v1.2.3'), '1.2.3', 'valid() strips leading v');
eq(valid('1.2'), null, 'valid() rejects partial');
eq(prerelease('1.0.0-rc.1'), ['rc', 1], 'prerelease() parses identifiers');
eq(prerelease('1.0.0'), null, 'prerelease() null for release');
eq(compare('1.0.0-alpha', '1.0.0'), -1, 'prerelease < release');
eq(compare('1.0.0-alpha.1', '1.0.0-alpha.beta'), -1, 'numeric id < alphanumeric id');
eq(compare('1.2.0', '1.10.0'), -1, 'numeric (not lexical) field compare');
eq(compare('2.0.0', '2.0.0'), 0, 'equal versions');
eq(compare('bad', '1.0.0'), null, 'compare() null on invalid');
{
const tags = [
{ tag: 'v1.0.0', version: '1.0.0' },
{ tag: 'v1.7.0', version: '1.7.0' },
{ tag: 'v1.2.0', version: '1.2.0' },
];
tags.sort((a, b) => rcompare(a.version, b.version));
eq(
tags.map((t) => t.tag),
['v1.7.0', 'v1.2.0', 'v1.0.0'],
'rcompare() sorts newest-first',
);
}
assert(validRange('>=6.0.0') !== null, 'validRange() accepts a real range');
// ─── channel-resolver (pure helpers) ──────────────────────────────────────────
console.log(`\n${colors.cyan}channel-resolver${colors.reset}\n`);
eq(parseGitHubRepo('https://github.com/o/r/tree/main'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from deep URL');
eq(parseGitHubRepo('git@github.com:o/r'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo from SSH');
eq(parseGitHubRepo('https://gitlab.com/o/r'), null, 'parseGitHubRepo null for non-GitHub');
eq(parseGitHubRepo('https://github.com/o/r.git'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo strips .git');
eq(parseGitHubRepo('https://github.com/o/r.git/'), { owner: 'o', repo: 'r' }, 'parseGitHubRepo strips .git before trailing slash');
eq(
[normalizeStableTag('v1.7.0'), normalizeStableTag('1.0.0-rc.1'), normalizeStableTag('nope')],
['1.7.0', null, null],
'normalizeStableTag excludes prereleases/invalid',
);
// ─── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${colors.cyan}Results: ${passed} passed, ${failed} failed${colors.reset}\n`);
process.exit(failed > 0 ? 1 : 0);

123
test/test-ide-sync.js Normal file
View File

@ -0,0 +1,123 @@
// test-ide-sync — behavioral drift guard for the IDE-distribution path.
//
// The bmad-module skill runs a self-contained esbuild bundle
// (src/core-skills/bmad-module/scripts/lib/vendor/ide-sync.mjs) built FROM the
// real engine (tools/installer/ide/* via core/ide-sync.js). vendor:check already
// byte-verifies the bundle matches its source. This test verifies the two
// delivery vehicles behave IDENTICALLY at runtime:
// 1. `bmad ide-sync` — the engine, run directly from the package
// 2. `vendor/ide-sync.mjs` — the shipped, dependency-free bundle
// Both must produce the same IDE skill trees for the same project, including
// `--prune`. If the engine changes without rebuilding the bundle, the outputs
// diverge and this fails (complementing the byte-level vendor:check).
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const repoRoot = path.resolve(__dirname, '..');
const CLI = path.join(repoRoot, 'tools', 'installer', 'bmad-cli.js');
const BUNDLE = path.join(repoRoot, 'src', 'core-skills', 'bmad-module', 'scripts', 'lib', 'vendor', 'ide-sync.mjs');
let passed = 0;
let failed = 0;
function check(label, fn) {
try {
fn();
passed++;
process.stdout.write(`${label}\n`);
} catch (error) {
failed++;
process.stdout.write(`${label}\n ${error.message}\n`);
}
}
// Build a fresh project with two skills recorded for two IDEs.
function makeProject(skillIds) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-ide-sync-'));
fs.mkdirSync(path.join(dir, '_bmad', '_config'), { recursive: true });
fs.writeFileSync(
path.join(dir, '_bmad', '_config', 'manifest.yaml'),
'installation:\n version: "0.0.0"\nmodules:\n - name: demo\n source: community\nides:\n - claude-code\n - cursor\n',
);
let csv = 'canonicalId,name,description,module,path\n';
for (const id of skillIds) {
const sd = path.join(dir, '_bmad', 'demo', 'skills', id);
fs.mkdirSync(sd, { recursive: true });
fs.writeFileSync(path.join(sd, 'SKILL.md'), `---\nname: ${id}\ndescription: ${id} demo\n---\nbody ${id}\n`);
csv += `"${id}","${id}","${id} demo","demo","_bmad/demo/skills/${id}/SKILL.md"\n`;
}
fs.writeFileSync(path.join(dir, '_bmad', '_config', 'skill-manifest.csv'), csv);
return dir;
}
// Snapshot the IDE skill trees (relative path -> file contents) for comparison.
function snapshotIdeDirs(projectDir) {
const snap = {};
for (const rel of ['.claude/skills', '.agents/skills']) {
const base = path.join(projectDir, rel);
if (!fs.existsSync(base)) continue;
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) walk(full);
else snap[path.relative(projectDir, full)] = fs.readFileSync(full, 'utf8');
}
};
walk(base);
}
return snap;
}
function runEngine(projectDir, prune) {
const args = [CLI, 'ide-sync', '-d', projectDir];
if (prune) args.push('--prune', prune);
const r = spawnSync(process.execPath, args, { encoding: 'utf8' });
assert.strictEqual(r.status, 0, `engine ide-sync exited ${r.status}: ${r.stderr}`);
}
function runBundle(projectDir, prune) {
const args = [BUNDLE, '-d', projectDir];
if (prune) args.push('--prune', prune);
const r = spawnSync(process.execPath, args, { encoding: 'utf8' });
assert.strictEqual(r.status, 0, `bundle ide-sync exited ${r.status}: ${r.stderr}`);
}
process.stdout.write('IDE-sync engine/bundle parity\n');
check('bundle exists (run `npm run vendor:build` if missing)', () => {
assert.ok(fs.existsSync(BUNDLE), `missing ${BUNDLE}`);
});
const cleanup = [];
try {
// Distribute: engine vs bundle must yield identical IDE trees.
check('distribute: engine == bundle', () => {
const a = makeProject(['sk-a', 'sk-b']);
const b = makeProject(['sk-a', 'sk-b']);
cleanup.push(a, b);
runEngine(a);
runBundle(b);
assert.deepStrictEqual(snapshotIdeDirs(a), snapshotIdeDirs(b));
assert.ok(fs.existsSync(path.join(a, '.claude', 'skills', 'sk-a', 'SKILL.md')), 'engine did not distribute');
});
// Prune one skill (the remove path): engine vs bundle must agree.
check('prune: engine == bundle and removes pruned skill', () => {
const a = makeProject(['sk-a']); // sk-b dropped from manifest
const b = makeProject(['sk-a']);
cleanup.push(a, b);
runEngine(a, 'sk-b');
runBundle(b, 'sk-b');
assert.deepStrictEqual(snapshotIdeDirs(a), snapshotIdeDirs(b));
assert.ok(!fs.existsSync(path.join(a, '.claude', 'skills', 'sk-b')), 'pruned skill should be gone');
assert.ok(fs.existsSync(path.join(a, '.claude', 'skills', 'sk-a')), 'kept skill should remain');
});
} finally {
for (const d of cleanup) fs.rmSync(d, { recursive: true, force: true });
}
process.stdout.write(`\n ${passed} pass · ${failed} fail\n`);
process.exit(failed > 0 ? 1 : 0);

View File

@ -3575,6 +3575,217 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 48: New module system (plugin.json#bmad) in the installer
// ============================================================
console.log(`${colors.yellow}Test Suite 48: New module system + legacy detection in custom-module install${colors.reset}\n`);
try {
const { PluginResolver } = require('../tools/installer/modules/plugin-resolver');
const { CustomModuleManager } = require('../tools/installer/modules/custom-module-manager');
const { readPluginManifest } = require('../tools/installer/modules/bmad-module-lib');
const acmeDevlog = path.resolve(__dirname, '../src/core-skills/bmad-module/tests/fixtures/examples/comprehensive/acme-devlog');
// ---- readPluginManifest: detects bmad block, ignores non-bmad plugin.json ----
{
const m = await readPluginManifest(acmeDevlog);
assert(m && m.bmad && m.bmad.code === 'devlog', 'readPluginManifest reads .claude-plugin/plugin.json#bmad');
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-nobmad-'));
await fs.ensureDir(path.join(tmp, '.claude-plugin'));
await fs.writeFile(path.join(tmp, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'x' }), 'utf8');
const none = await readPluginManifest(tmp);
assert(none === null, 'readPluginManifest returns null for plugin.json without a bmad block');
await fs.remove(tmp).catch(() => {});
}
// ---- Strategy 0: plugin.json#bmad resolves as format 'plugin-json' ----
{
const resolved = await new PluginResolver().resolve(acmeDevlog, { name: 'acme-devlog', source: '.', skills: [] });
assert(resolved.length === 1, 'Strategy 0 resolves a single new-system module');
const r = resolved[0];
assert(r.format === 'plugin-json', 'new-system module carries format: plugin-json');
assert(r.code === 'devlog', 'new-system module code comes from bmad.code (not plugin name)');
assert(r.version === '0.4.0' && !!r.manifest && !!r.sourceDir, 'new-system module carries version + manifest + sourceDir');
assert(!!r.moduleYamlPath && r.moduleYamlPath.endsWith('assets/module.yaml'), 'moduleYamlPath points at source moduleDefinition');
}
// ---- Legacy structure still resolves as format 'legacy' ----
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-legacy-'));
const skill = path.join(tmp, 'skills', 'foo-setup');
await fs.ensureDir(path.join(skill, 'assets'));
await fs.writeFile(path.join(skill, 'SKILL.md'), '---\nname: foo-setup\ndescription: x\n---\n', 'utf8');
await fs.writeFile(path.join(skill, 'assets', 'module.yaml'), 'code: foo\nname: Foo\ndescription: legacy\n', 'utf8');
await fs.writeFile(
path.join(skill, 'assets', 'module-help.csv'),
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n',
'utf8',
);
const resolved = await new PluginResolver().resolve(tmp, { name: 'foo', skills: ['./skills/foo-setup'] });
assert(
resolved.length === 1 && resolved[0].format === 'legacy' && resolved[0].strategy === 2,
'legacy setup-skill resolves as format: legacy (strategy 2)',
);
// A plugin.json WITHOUT a bmad block must NOT hijack legacy detection.
await fs.ensureDir(path.join(tmp, '.claude-plugin'));
await fs.writeFile(
path.join(tmp, '.claude-plugin', 'plugin.json'),
JSON.stringify({ name: 'foo', skills: ['./skills/foo-setup'] }),
'utf8',
);
const resolved2 = await new PluginResolver().resolve(tmp, { name: 'foo', source: '.', skills: ['./skills/foo-setup'] });
assert(resolved2[0].format === 'legacy', 'plugin.json without bmad block falls through to legacy strategies');
await fs.remove(tmp).catch(() => {});
}
// ---- Strategy 1: module files ABOVE the skills' common parent ----
// Mirrors the bmad-creative-intelligence-suite layout: module.yaml +
// module-help.csv at src/, skills nested under src/skills/<name>/. The
// skills' common parent is src/skills (no module files), so the resolver
// must walk up to src/ to find them — otherwise it synthesizes a degenerate
// module named after the plugin and loses the real code/agents roster.
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-srcmod-'));
const srcDir = path.join(tmp, 'src');
await fs.ensureDir(path.join(srcDir, 'skills'));
await fs.writeFile(
path.join(srcDir, 'module.yaml'),
'code: cis\nname: "CIS: Creative Innovation Suite"\ndescription: legacy at src\n',
'utf8',
);
await fs.writeFile(
path.join(srcDir, 'module-help.csv'),
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n',
'utf8',
);
for (const name of ['bmad-cis-storytelling', 'bmad-cis-design-thinking']) {
const skill = path.join(srcDir, 'skills', name);
await fs.ensureDir(skill);
await fs.writeFile(path.join(skill, 'SKILL.md'), `---\nname: ${name}\ndescription: x\n---\n`, 'utf8');
}
const resolved = await new PluginResolver().resolve(tmp, {
name: 'bmad-creative-intelligence-suite',
source: '.',
skills: ['./src/skills/bmad-cis-storytelling', './src/skills/bmad-cis-design-thinking'],
});
assert(
resolved.length === 1 && resolved[0].strategy === 1,
'module files above the skills common parent resolve via strategy 1 (not synthesized strategy 5)',
);
assert(
resolved[0].code === 'cis' && resolved[0].name === 'CIS: Creative Innovation Suite',
'code/name come from src/module.yaml, not the marketplace plugin name',
);
assert(
resolved[0].moduleYamlPath && resolved[0].moduleYamlPath.endsWith(path.join('src', 'module.yaml')),
'moduleYamlPath points at src/module.yaml',
);
await fs.remove(tmp).catch(() => {});
}
// ---- Multiple module definitions: deepest-first default + chooser ----
// Both src/ and src/skills/ carry module.yaml + module-help.csv. Headless
// resolution must take the deepest (src/skills); an interactive caller can
// override via chooseModuleDefinition, which receives enriched metadata.
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-multimod-'));
const csv =
'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs\n';
const srcDir = path.join(tmp, 'src');
const skillsDir = path.join(srcDir, 'skills');
await fs.ensureDir(skillsDir);
await fs.writeFile(path.join(srcDir, 'module.yaml'), 'code: outer\nname: Outer\ndescription: at src\n', 'utf8');
await fs.writeFile(path.join(srcDir, 'module-help.csv'), csv, 'utf8');
await fs.writeFile(path.join(skillsDir, 'module.yaml'), 'code: inner\nname: Inner\ndescription: at src/skills\n', 'utf8');
await fs.writeFile(path.join(skillsDir, 'module-help.csv'), csv, 'utf8');
for (const name of ['skill-a', 'skill-b']) {
const skill = path.join(skillsDir, name);
await fs.ensureDir(skill);
await fs.writeFile(path.join(skill, 'SKILL.md'), `---\nname: ${name}\ndescription: x\n---\n`, 'utf8');
}
const plugin = { name: 'multi', source: '.', skills: ['./src/skills/skill-a', './src/skills/skill-b'] };
const def = await new PluginResolver().resolve(tmp, plugin);
assert(def[0].code === 'inner', 'with no chooser, the deepest module definition (src/skills) is used by default');
let seen = null;
const picked = await new PluginResolver().resolve(tmp, plugin, {
chooseModuleDefinition: async (candidates) => {
seen = candidates;
return candidates.find((c) => c.code === 'outer');
},
});
assert(Array.isArray(seen) && seen.length === 2, 'chooser receives all candidates when more than one is found');
assert(
seen.every((c) => typeof c.relativePath === 'string' && 'name' in c && 'description' in c),
'chooser candidates are enriched with relativePath + module.yaml metadata',
);
assert(picked[0].code === 'outer', "chooser's selection (src) overrides the deepest default");
await fs.remove(tmp).catch(() => {});
}
// ---- End-to-end install of a new-system module via OfficialModules ----
{
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pj-install-'));
const bmadDir = path.join(tmp, '_bmad');
await fs.ensureDir(path.join(bmadDir, '_config'));
await fs.writeFile(path.join(bmadDir, '_config', 'manifest.yaml'), 'modules: []\n', 'utf8');
const [resolved] = await new PluginResolver().resolve(acmeDevlog, { name: 'acme-devlog', source: '.', skills: [] });
resolved.localPath = acmeDevlog;
CustomModuleManager._resolutionCache.set(resolved.code, resolved);
const om = new OfficialModules();
const tracked = [];
let result;
try {
result = await om.install('devlog', bmadDir, (p) => tracked.push(p), { skipModuleInstaller: true, moduleConfig: {} });
} finally {
CustomModuleManager._resolutionCache.delete('devlog');
}
assert(result.success === true && result.module === 'devlog', 'install() routes plugin-json resolution and succeeds');
assert(
await fs.pathExists(path.join(bmadDir, 'devlog', 'module.yaml')),
'install flattens moduleDefinition → _bmad/devlog/module.yaml',
);
assert(
await fs.pathExists(path.join(bmadDir, 'devlog', 'module-help.csv')),
'install flattens moduleHelpCsv → _bmad/devlog/module-help.csv',
);
assert(
await fs.pathExists(path.join(bmadDir, 'devlog', '.claude-plugin', 'plugin.json')),
'install keeps .claude-plugin/plugin.json',
);
assert(
await fs.pathExists(path.join(bmadDir, 'devlog', 'skills', 'bmad-devlog-setup', 'SKILL.md')),
'install copies declared skills under skills/',
);
assert(!(await fs.pathExists(path.join(bmadDir, 'devlog', 'tests'))), 'install honors bmad.install.ignore (tests/ excluded)');
const rewritten = JSON.parse(await fs.readFile(path.join(bmadDir, 'devlog', '.claude-plugin', 'plugin.json'), 'utf8'));
assert(rewritten.bmad.moduleDefinition === './module.yaml', 'plugin.json is rewritten to canonical paths');
assert(tracked.some((p) => p.endsWith('plugin.json')) && tracked.length > 5, 'install tracks copied files for the files manifest');
const yamlLib = require('yaml');
const mani = yamlLib.parse(await fs.readFile(path.join(bmadDir, '_config', 'manifest.yaml'), 'utf8'));
const entry = mani.modules.find((m) => m.name === 'devlog');
assert(
entry && entry.source === 'custom' && entry.localPath === acmeDevlog,
'install registers the module in manifest.yaml (source: custom)',
);
await fs.remove(tmp).catch(() => {});
}
} catch (error) {
console.log(`${colors.red}Test Suite 48 setup failed: ${error.message}${colors.reset}`);
console.log(error.stack);
failed++;
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -0,0 +1,34 @@
const { runIdeSync } = require('../core/ide-sync');
// `bmad ide-sync` — distribute the skills recorded in _config/skill-manifest.csv
// to every coding assistant listed under `ides:` in _config/manifest.yaml, then
// reach the canonical end-state (skills in IDE dirs, removed from _bmad/).
//
// Non-interactive by design: it reads the existing manifest rather than
// prompting, so it is safe to run from scripts and without a TTY. It is the same
// distribution the full `bmad install` performs (both route through
// core/ide-sync.js → IdeManager.setupBatch), exposed as a standalone step. The
// bmad-module skill invokes the bundled equivalent after install/update/remove.
module.exports = {
command: 'ide-sync',
description: "Sync installed skills to the coding assistants configured in this project's manifest",
options: [
['-d, --directory <path>', 'Project directory containing _bmad/', '.'],
['--prune <ids>', 'Comma-separated canonicalIds to remove from IDE directories'],
['-v, --verbose', 'Verbose output'],
],
action: async (options) => {
try {
const code = await runIdeSync({
directory: options.directory || '.',
prune: options.prune || '',
verbose: !!options.verbose,
});
process.exit(code);
} catch (error) {
process.stderr.write(`[ide-sync] failed: ${error.message}\n`);
if (process.env.BMAD_DEBUG) process.stderr.write(`${error.stack}\n`);
process.exit(1);
}
},
};

View File

@ -0,0 +1,249 @@
// ide-sync — the single, non-interactive primitive for distributing installed
// BMAD skills to the coding assistants (IDEs) recorded in a project's manifest.
//
// This is the ONE implementation of "push skills to the chosen IDEs". Three
// callers route through it so they can never diverge:
// 1. The interactive installer (`Installer._setupIdes` → syncIdes).
// 2. The `bmad ide-sync` CLI command (commands/ide-sync.js → runIdeSync).
// 3. The self-contained bundle shipped into projects at install time and
// invoked by the bmad-module skill (build target wraps runIdeSyncCli).
//
// It reuses the real config-driven IDE engine (IdeManager / ConfigDrivenIdeSetup
// / platform-codes.yaml), so new platforms and handler changes flow here for
// free. The engine is bundleable (fs-native is zero-dep; yaml/csv-parse inline;
// `../prompts` and `../project-root` are aliased to small shims at bundle time).
const path = require('node:path');
const fs = require('../fs-native');
const { IdeManager } = require('../ide/manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
const writeOut = (m) => process.stdout.write(`${m}\n`);
const writeErr = (m) => process.stderr.write(`${m}\n`);
const DEFAULT_LOGGER = { info: writeOut, warn: writeErr, error: writeErr };
/**
* Distribute the skills currently listed in _config/skill-manifest.csv to each
* selected IDE, prune any `previousSkillIds` no longer present, then remove the
* now-redundant skill source dirs from _bmad/ (canonical end-state: skills live
* in IDE dirs).
*
* @param {Object} args
* @param {string} args.projectRoot Project root (contains _bmad/).
* @param {string} args.bmadDir Path to the _bmad/ directory.
* @param {string[]} args.ides Platform codes to set up (from manifest.yaml `ides`).
* @param {string[]} [args.previousSkillIds] canonicalIds to remove from IDE dirs.
* @param {boolean} [args.verbose]
* @param {boolean} [args.cleanup] Remove _bmad/ skill source dirs afterward (default true).
* The interactive installer passes false and runs its own
* unconditional cleanup step.
* @returns {Promise<{skipped: boolean, results: Array}>}
*/
async function syncIdes({ projectRoot, bmadDir, ides, previousSkillIds = [], verbose = false, cleanup = true, silent = false }) {
const validIdes = (ides || []).filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) return { skipped: true, results: [] };
const ideManager = new IdeManager();
ideManager.setBmadFolderName(path.basename(bmadDir));
await ideManager.ensureInitialized();
const results = await ideManager.setupBatch(validIdes, projectRoot, bmadDir, {
previousSkillIds: new Set(previousSkillIds),
verbose,
silent,
});
// Mirror Installer._cleanupSkillDirs: skills are self-contained in IDE dirs,
// so _bmad/ only needs module-level files. Only clean up when every IDE
// synced successfully — otherwise the source skill dirs are still needed to
// retry the failed targets.
const allSucceeded = results.every((r) => r && r.success);
if (cleanup && allSucceeded) await cleanupBmadSkillDirs(bmadDir);
return { skipped: false, results };
}
/**
* Remove skill source directories from _bmad/ after IDE distribution. Reads
* _config/skill-manifest.csv and removes the parent dir of each listed SKILL.md
* (skipping any already gone). Non-skill module files are left untouched.
* Shared with Installer._cleanupSkillDirs so there is one implementation.
* @param {string} bmadDir
*/
async function cleanupBmadSkillDirs(bmadDir) {
const csv = require('csv-parse/sync');
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return;
const csvContent = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
const bmadRoot = path.resolve(bmadDir);
for (const record of records) {
if (!record.path) continue;
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const skillFilePath = path.resolve(bmadDir, relativePath);
// Containment guard: a malformed CSV row (absolute path or `../`) must not
// let cleanup escape _bmad/ and remove arbitrary directories.
if (skillFilePath !== bmadRoot && !skillFilePath.startsWith(bmadRoot + path.sep)) continue;
const sourceDir = path.dirname(skillFilePath);
if (sourceDir === bmadRoot) continue;
if (await fs.pathExists(sourceDir)) {
await fs.remove(sourceDir);
await removeEmptyParents(path.dirname(sourceDir), bmadDir);
}
}
}
/**
* Remove now-empty parent directories left behind after skill dir cleanup.
* Walks up from dir, stopping at (and never removing) bmadDir. Best-effort:
* a directory that vanishes or fills in mid-walk just ends the walk.
* @param {string} dir - Directory to start walking up from
* @param {string} bmadDir - BMAD installation directory (boundary)
*/
async function removeEmptyParents(dir, bmadDir) {
let current = dir;
while (true) {
// Path-boundary check (not a string prefix, so siblings like _bmad2 don't match).
const rel = path.relative(bmadDir, current);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break;
try {
const entries = await fs.readdir(current);
if (entries.length > 0) break;
await fs.rmdir(current);
} catch {
break;
}
current = path.dirname(current);
}
}
/**
* Read the selected IDE platform codes from _config/manifest.yaml.
* @param {string} bmadDir
* @returns {Promise<string[]>}
*/
async function readSelectedIdes(bmadDir) {
const yaml = require('yaml');
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
if (!(await fs.pathExists(manifestPath))) return [];
try {
const parsed = yaml.parse(await fs.readFile(manifestPath, 'utf8'));
return Array.isArray(parsed?.ides) ? parsed.ides.filter((i) => i && typeof i === 'string') : [];
} catch {
return [];
}
}
/**
* End-to-end run used by the CLI command and the shipped bundle: resolve paths,
* read the chosen IDEs from the manifest, distribute, and report. Returns a
* process exit code (0 ok, 1 failure, 2 no install).
*
* @param {Object} opts
* @param {string} [opts.directory] Project dir (default '.').
* @param {string|string[]} [opts.prune] canonicalIds to remove (CSV string or array).
* @param {boolean} [opts.verbose]
* @param {Object} [opts.logger] { info, warn, error }
* @returns {Promise<number>} exit code
*/
async function runIdeSync(opts = {}) {
const logger = opts.logger || DEFAULT_LOGGER;
const projectRoot = path.resolve(opts.directory || '.');
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
if (!(await fs.pathExists(bmadDir))) {
logger.error(`[ide-sync] no BMAD installation (_bmad/) found in ${projectRoot}. Run \`bmad install\` first.`);
return 2;
}
const ides = await readSelectedIdes(bmadDir);
if (ides.length === 0) {
logger.info('[ide-sync] no IDEs configured in manifest.yaml — nothing to distribute.');
return 0;
}
const previousSkillIds = normalizeIdList(opts.prune);
const { results } = await syncIdes({
projectRoot,
bmadDir,
ides,
previousSkillIds,
verbose: !!opts.verbose,
// Standalone path prints its own concise [ide-sync] lines; suppress the
// engine's interactive-style status output (errors still surface).
silent: true,
});
let failed = 0;
for (const r of results) {
if (r.success) {
logger.info(`[ide-sync] ${r.ide}: ${r.detail || 'configured'}`);
} else {
failed++;
logger.error(`[ide-sync] ${r.ide}: FAILED — ${r.error || 'unknown error'}`);
}
}
return failed > 0 ? 1 : 0;
}
/** Parse a comma-separated string or array of canonicalIds into a clean array. */
function normalizeIdList(value) {
if (!value) return [];
const arr = Array.isArray(value) ? value : String(value).split(',');
return arr.map((s) => String(s).trim()).filter(Boolean);
}
/**
* argv entry point for the shipped bundle. Parses a tiny flag set and calls
* runIdeSync. Intentionally dependency-free (no commander) so the bundle stays
* small and self-contained.
* @param {string[]} argv process.argv.slice(2)
* @returns {Promise<number>} exit code
*/
async function runIdeSyncCli(argv = []) {
const opts = { directory: '.', prune: '', verbose: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--directory=')) {
opts.directory = a.slice('--directory='.length);
continue;
}
if (a.startsWith('--prune=')) {
opts.prune = a.slice('--prune='.length);
continue;
}
switch (a) {
case '-d':
case '--directory': {
opts.directory = argv[++i] ?? '.';
break;
}
case '--prune': {
opts.prune = argv[++i] ?? '';
break;
}
case '-v':
case '--verbose': {
opts.verbose = true;
break;
}
default: {
break;
}
}
}
return runIdeSync(opts);
}
module.exports = {
syncIdes,
cleanupBmadSkillDirs,
readSelectedIdes,
runIdeSync,
runIdeSyncCli,
};

View File

@ -372,21 +372,27 @@ class Installer {
async _setupIdes(config, allModules, paths, addResult, previousSkillIds = new Set()) {
if (config.skipIde || !config.ides || config.ides.length === 0) return;
await this.ideManager.ensureInitialized();
const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string');
if (validIdes.length === 0) {
addResult('IDE configuration', 'warn', 'no valid IDEs selected');
return;
}
const setupResults = await this.ideManager.setupBatch(validIdes, paths.projectRoot, paths.bmadDir, {
selectedModules: allModules || [],
// Route through the shared distribution primitive so the interactive
// installer and the standalone `bmad ide-sync` command can never diverge.
// cleanup:false — the install flow runs its own unconditional
// _cleanupSkillDirs afterward (it must run even when no IDEs are selected).
const { syncIdes } = require('./ide-sync');
const { results } = await syncIdes({
projectRoot: paths.projectRoot,
bmadDir: paths.bmadDir,
ides: validIdes,
previousSkillIds: [...previousSkillIds],
verbose: config.verbose,
previousSkillIds,
cleanup: false,
});
for (const setupResult of setupResults) {
for (const setupResult of results) {
const ide = setupResult.ide;
if (setupResult.success) {
addResult(ide, 'ok', setupResult.detail || '');
@ -401,51 +407,12 @@ class Installer {
* Skills are self-contained in IDE directories, so _bmad/ only needs
* module-level files (config.yaml, _config/, etc.).
* Also cleans up skill dirs left by older installer versions.
* Delegates to the shared implementation so there is one copy of this logic.
* @param {string} bmadDir - BMAD installation directory
*/
async _cleanupSkillDirs(bmadDir) {
const csv = require('csv-parse/sync');
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
if (!(await fs.pathExists(csvPath))) return;
const csvContent = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true });
const bmadFolderName = path.basename(bmadDir);
const bmadPrefix = bmadFolderName + '/';
for (const record of records) {
if (!record.path) continue;
const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path;
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
if (await fs.pathExists(sourceDir)) {
await fs.remove(sourceDir);
await this._removeEmptyParents(path.dirname(sourceDir), bmadDir);
}
}
}
/**
* Remove now-empty parent directories left behind after skill dir cleanup.
* Walks up from dir, stopping at (and never removing) bmadDir. Best-effort:
* a directory that vanishes or fills in mid-walk just ends the walk.
* @param {string} dir - Directory to start walking up from
* @param {string} bmadDir - BMAD installation directory (boundary)
*/
async _removeEmptyParents(dir, bmadDir) {
let current = dir;
while (true) {
// Path-boundary check (not a string prefix, so siblings like _bmad2 don't match).
const rel = path.relative(bmadDir, current);
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break;
try {
const entries = await fs.readdir(current);
if (entries.length > 0) break;
await fs.rmdir(current);
} catch {
break;
}
current = path.dirname(current);
}
const { cleanupBmadSkillDirs } = require('./ide-sync');
await cleanupBmadSkillDirs(bmadDir);
}
async _readSkillManifestRows(bmadDir) {

View File

@ -401,23 +401,141 @@ class ManifestGenerator {
const csvPath = path.join(cfgDir, 'skill-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
let csvContent = 'canonicalId,name,description,module,path\n';
// Preserve rows for modules installed via the bmad-module skill —
// those rows are not in this.skills (the installer doesn't walk
// community sources), so without this they'd be silently dropped on
// every regeneration.
const preserved = await this._readPreservedCommunityCsvRows(csvPath);
const rows = [];
for (const skill of this.skills) {
const row = [
escapeCsv(skill.canonicalId),
escapeCsv(skill.name),
escapeCsv(skill.description),
escapeCsv(skill.module),
escapeCsv(skill.path),
].join(',');
csvContent += row + '\n';
rows.push([skill.canonicalId, skill.name, skill.description, skill.module, skill.path]);
}
for (const r of preserved) rows.push(r);
rows.sort((a, b) => {
if (a[3] !== b[3]) return a[3].localeCompare(b[3]);
return a[0].localeCompare(b[0]);
});
let csvContent = 'canonicalId,name,description,module,path\n';
for (const r of rows) {
csvContent += r.map(escapeCsv).join(',') + '\n';
}
await fs.writeFile(csvPath, csvContent);
return csvPath;
}
/**
* Load the set of module codes installed via the bmad-module skill
* (source: 'community' in manifest.yaml). Empty set if no manifest or none.
* @returns {Promise<Set<string>>}
*/
async _loadCommunityModuleCodes() {
if (!this.bmadDir) return new Set();
const manifestPath = path.join(this.bmadDir, '_config', 'manifest.yaml');
if (!(await fs.pathExists(manifestPath))) return new Set();
try {
const parsed = yaml.parse(await fs.readFile(manifestPath, 'utf8'));
const modules = Array.isArray(parsed?.modules) ? parsed.modules : [];
return new Set(
modules.filter((m) => m && typeof m === 'object' && m.source === 'community' && typeof m.name === 'string').map((m) => m.name),
);
} catch {
return new Set();
}
}
/**
* Read rows from a previously-generated CSV that belong to community-source
* modules. Used by writeSkillManifest and writeFilesManifest to keep
* community entries across installer regenerations.
* @param {string} csvPath
* @returns {Promise<Array<Array<string>>>}
*/
async _readPreservedCommunityCsvRows(csvPath) {
if (!(await fs.pathExists(csvPath))) return [];
const communityCodes = await this._loadCommunityModuleCodes();
if (communityCodes.size === 0) return [];
let text;
try {
text = await fs.readFile(csvPath, 'utf8');
} catch {
return [];
}
const rows = ManifestGenerator._parseCsv(text);
if (rows.length < 2) return [];
// The `module` column lives at a different index in each CSV:
// skill-manifest.csv: canonicalId,name,description,module,path → 3
// files-manifest.csv: type,name,module,path,hash → 2
// Look it up from the header rather than hardcoding the index.
const header = rows[0];
const moduleIdx = header.indexOf('module');
if (moduleIdx === -1) return [];
return rows.slice(1).filter((r) => r.length > moduleIdx && communityCodes.has(r[moduleIdx]));
}
/**
* Minimal CSV parser for the shapes this generator writes: header +
* records with `"…"` fields, quotes escaped as `""`.
* @param {string} text
* @returns {Array<Array<string>>}
*/
static _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;
}
/**
* Write central _bmad/config.toml with [core], [modules.<code>], [agents.<code>] tables.
* Install-owned. Team-scope answers config.toml; user-scope answers config.user.toml.
@ -681,6 +799,12 @@ class ManifestGenerator {
async writeFilesManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'files-manifest.csv');
// Preserve rows for modules installed via the bmad-module skill —
// those files are not in this.allInstalledFiles or this.files (the
// installer doesn't walk community sources), so without this they'd be
// silently dropped on every regeneration.
const preserved = await this._readPreservedCommunityCsvRows(csvPath);
// Create CSV header with hash column
let csv = 'type,name,module,path,hash\n';
@ -724,6 +848,12 @@ class ManifestGenerator {
}
}
// Merge in preserved community rows before sorting so they interleave
// with the freshly-generated rows in stable (module, type, name) order.
for (const r of preserved) {
allFiles.push({ type: r[0], name: r[1], module: r[2], path: r[3], hash: r[4] || '' });
}
// Sort files by module, then type, then name
allFiles.sort((a, b) => {
if (a.module !== b.module) return a.module.localeCompare(b.module);

View File

@ -2,10 +2,20 @@ const fs = require('../fs-native');
const path = require('node:path');
const yaml = require('yaml');
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
let _cachedPlatformCodes = null;
/**
* Resolve the platform-codes.yaml path. Defaults to the copy beside this file,
* but honors BMAD_IDE_PLATFORM_CODES so the self-contained bundle the
* bmad-module skill ships can point at the YAML beside it (esbuild output does
* not preserve this file's original __dirname). Resolved lazily so the env var
* can be set before the first load.
* @returns {string}
*/
function resolvePlatformCodesPath() {
return process.env.BMAD_IDE_PLATFORM_CODES || path.join(__dirname, 'platform-codes.yaml');
}
/**
* Load the platform codes configuration from YAML
* @returns {Object} Platform codes configuration
@ -15,6 +25,7 @@ async function loadPlatformCodes() {
return _cachedPlatformCodes;
}
const PLATFORM_CODES_PATH = resolvePlatformCodesPath();
if (!(await fs.pathExists(PLATFORM_CODES_PATH))) {
throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`);
}

View File

@ -0,0 +1,93 @@
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { getSourcePath } = require('../project-root');
/**
* Bridge to the bmad-module skill's ESM libraries.
*
* The installer is CommonJS; the new module system's install logic lives as
* zero-dependency ESM under `src/core-skills/bmad-module/scripts/lib/`. Rather
* than reimplement (and risk drifting from) the spec, the installer reuses the
* exact same functions the runtime `bmad-module` skill uses to validate a
* `.claude-plugin/plugin.json#bmad` manifest and lay a module out on disk.
*
* This file is the single place that knows the `src/` layout. It lazily
* `import()`s each lib once and caches the namespace. `pathToFileURL` makes the
* dynamic-import specifier valid on Windows (bare absolute paths are rejected
* there).
*/
const LIB_REL = ['core-skills', 'bmad-module', 'scripts', 'lib'];
function libUrl(file) {
return pathToFileURL(getSourcePath(...LIB_REL, file)).href;
}
const _cache = new Map();
async function load(file) {
if (!_cache.has(file)) {
_cache.set(file, await import(libUrl(file)));
}
return _cache.get(file);
}
/**
* Load the subset of skill libs the installer needs to detect, validate, copy,
* and finalize a new-system (`plugin.json#bmad`) module. Returns a flat object
* of the named exports.
*/
async function loadBmadModuleLib() {
const [pluginJson, installPlan, fsSafe, npmDeps] = await Promise.all([
load('plugin-json.mjs'),
load('install-plan.mjs'),
load('fs-safe.mjs'),
load('npm-deps.mjs'),
]);
return {
// plugin-json.mjs
readAndValidateManifest: pluginJson.readAndValidateManifest,
RESERVED_CODES: pluginJson.RESERVED_CODES,
CODE_REGEX: pluginJson.CODE_REGEX,
// install-plan.mjs
readUserIgnores: installPlan.readUserIgnores,
buildIgnoreMatcher: installPlan.buildIgnoreMatcher,
buildCopyPlan: installPlan.buildCopyPlan,
rewriteManifestPaths: installPlan.rewriteManifestPaths,
validateDeclaredPaths: installPlan.validateDeclaredPaths,
// fs-safe.mjs
stageCopyPlan: fsSafe.stageCopyPlan,
atomicSwapDir: fsSafe.atomicSwapDir,
// npm-deps.mjs
installModuleDeps: npmDeps.installModuleDeps,
};
}
/**
* Read `.claude-plugin/plugin.json` from a directory and return the parsed
* object only when it carries a `bmad` block (i.e. it's a new-system module
* manifest). Returns null when the file is absent, unparseable, or lacks a
* `bmad` key callers then fall back to the legacy marketplace.json path.
* No validation here; resolution validates via readAndValidateManifest.
*
* @param {string} dir - Absolute path to a candidate module root
* @returns {Promise<Object|null>}
*/
async function readPluginManifest(dir) {
const fs = require('../fs-native');
const manifestPath = path.join(dir, '.claude-plugin', 'plugin.json');
if (!(await fs.pathExists(manifestPath))) return null;
try {
const parsed = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.bmad && typeof parsed.bmad === 'object') {
return parsed;
}
} catch (error) {
// Malformed JSON — fall back to the legacy resolver (or validateDeclaredPaths
// at install time) rather than hard-failing, but warn so the corruption is
// not swallowed silently and looks indistinguishable from a missing file.
process.stderr.write(`[bmad-module] warning: ignoring invalid JSON in ${manifestPath}: ${error.message}\n`);
}
return null;
}
module.exports = { loadBmadModuleLib, readPluginManifest };

View File

@ -572,12 +572,14 @@ class CustomModuleManager {
* @param {Object} plugin - Raw plugin object from marketplace.json
* @param {string} [sourceUrl] - Original URL for manifest tracking (null for local)
* @param {string} [localPath] - Local source path for manifest tracking (null for URLs)
* @param {Object} [options] - Forwarded to PluginResolver.resolve (e.g.
* chooseModuleDefinition for interactive disambiguation).
* @returns {Promise<Array<Object>>} Array of ResolvedModule objects
*/
async resolvePlugin(repoPath, plugin, sourceUrl, localPath) {
async resolvePlugin(repoPath, plugin, sourceUrl, localPath, options = {}) {
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
const resolved = await resolver.resolve(repoPath, plugin);
const resolved = await resolver.resolve(repoPath, plugin, options);
// Read clone metadata (written by cloneRepo) so we can pick up the
// resolved git ref + SHA for manifest recording.

View File

@ -5,6 +5,7 @@ const prompts = require('../prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
const { CLIUtils } = require('../cli-utils');
const { ExternalModuleManager } = require('./external-manager');
const { loadBmadModuleLib } = require('./bmad-module-lib');
class OfficialModules {
constructor(options = {}) {
@ -312,6 +313,14 @@ class OfficialModules {
* @param {Object} options - Installation options
*/
async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
// New module system: a .claude-plugin/plugin.json#bmad manifest drives the
// copy. Reuse the bmad-module skill's libs so the on-disk result matches a
// skill-driven install exactly. Legacy (marketplace.json + module.yaml)
// resolutions fall through to the original path below.
if (resolved.format === 'plugin-json') {
return this._installFromPluginJson(resolved, bmadDir, fileTrackingCallback, options);
}
const targetPath = path.join(bmadDir, resolved.code);
if (await fs.pathExists(targetPath)) {
@ -378,6 +387,140 @@ class OfficialModules {
};
}
/**
* Install a new-module-system module (resolved from .claude-plugin/plugin.json#bmad).
*
* Reuses the bmad-module skill's ESM libs (via bmad-module-lib) to build the
* curated copy plan, flatten moduleDefinition/moduleHelpCsv to the module root,
* rewrite plugin.json to canonical paths, and install npm deps in place so the
* on-disk layout is byte-identical to a `bmad-module install`. Downstream installer
* steps (config generation, directory creation, help-catalog merge, manifest/skill
* discovery, IDE distribution) then treat it exactly like any other module, since
* module.yaml + module-help.csv now sit at the module root.
*
* @param {Object} resolved - ResolvedModule with format 'plugin-json' (sourceDir, manifest)
* @param {string} bmadDir - Target _bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Installation options
*/
async _installFromPluginJson(resolved, bmadDir, fileTrackingCallback = null, options = {}) {
const crypto = require('node:crypto');
const lib = await loadBmadModuleLib();
const { sourceDir, manifest, code } = { sourceDir: resolved.sourceDir, manifest: resolved.manifest, code: resolved.code };
const targetPath = path.join(bmadDir, code);
// Validate declared paths (throws on traversal / escapes) and build the plan.
lib.validateDeclaredPaths(sourceDir, manifest);
const userIgnores = await lib.readUserIgnores(sourceDir, manifest);
const matchIgnore = lib.buildIgnoreMatcher(userIgnores);
const { plan } = await lib.buildCopyPlan(sourceDir, manifest, matchIgnore);
const rewrittenManifestJson = lib.rewriteManifestPaths(manifest);
// Stage on the same filesystem as the target, then atomically swap in.
const stagedDir = path.join(bmadDir, `.${code}.bmad-stage-${crypto.randomBytes(6).toString('hex')}`);
try {
await lib.stageCopyPlan(sourceDir, stagedDir, plan, {
'.claude-plugin/plugin.json': rewrittenManifestJson,
});
await lib.atomicSwapDir(stagedDir, targetPath);
} catch (error) {
await fs.remove(stagedDir).catch(() => {});
throw new Error(`Failed to install ${code}: ${error.message}`);
}
// Track every installed file for the files manifest.
if (fileTrackingCallback) {
fileTrackingCallback(path.join(targetPath, '.claude-plugin', 'plugin.json'));
for (const { destRel } of plan) {
fileTrackingCallback(path.join(targetPath, destRel));
}
}
// npm deps in place (honors bmad.install.skipNpm; non-fatal).
try {
const dep = await lib.installModuleDeps(targetPath, manifest);
if (dep.ran && dep.ok) await prompts.log.info(` Installed npm dependencies for ${code}`);
else if (dep.ran && !dep.ok) await prompts.log.warn(` npm install failed for ${code}: ${dep.error}`);
} catch (error) {
await prompts.log.warn(` npm install failed for ${code}: ${error.message}`);
}
// Warn about unmet declared module dependencies (best-effort, non-fatal).
await this._warnUnmetModuleDeps(manifest, bmadDir, code);
// Create directories declared in module.yaml (now flattened at module root).
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(code, bmadDir, options);
}
// Register in the manifest. Keep the installer's existing custom tagging so
// its own update / quick-update path is unaffected — the new-module-system
// compliance is about on-disk layout + which manifest format is recognized,
// not the _bmad/_config/manifest.yaml source tag.
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
const hasGitClone = !!resolved.repoUrl;
const manifestEntry = {
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: 'custom',
npmPackage: null,
repoUrl: resolved.repoUrl || null,
};
if (hasGitClone) {
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
}
if (resolved.localPath) manifestEntry.localPath = resolved.localPath;
await manifestObj.addModule(bmadDir, code, manifestEntry);
// Surface install hints the way the bmad-module skill does.
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 > 0) claudeOnly.push('agents');
if (Array.isArray(manifest.commands) && manifest.commands.length > 0) claudeOnly.push('commands');
if (claudeOnly.length > 0) {
await prompts.log.info(
` ${code}: ${claudeOnly.join(', ')} are Claude Code plugin surfaces — copied but not auto-activated. Wire them up via Claude Code's plugin manager.`,
);
}
if (manifest.bmad?.install?.postInstallSkill) {
await prompts.log.info(` ${code}: next, run the \`${manifest.bmad.install.postInstallSkill}\` skill to finish setup.`);
}
return {
success: true,
module: code,
path: targetPath,
versionInfo: { version: manifestEntry.version || '' },
};
}
/**
* Best-effort, non-fatal warning for declared bmad.dependencies.modules that
* are not present on disk and not selected for install in this run.
* @param {Object} manifest - The module's plugin.json manifest
* @param {string} bmadDir - Target _bmad directory
* @param {string} code - The installing module's code (skip self-reference)
*/
async _warnUnmetModuleDeps(manifest, bmadDir, code) {
const deps = manifest?.bmad?.dependencies?.modules;
if (!Array.isArray(deps) || deps.length === 0) return;
const { CustomModuleManager } = require('./custom-module-manager');
for (const dep of deps) {
const depCode = typeof dep === 'string' ? dep : dep?.code;
if (!depCode || depCode === code) continue;
const onDisk = await fs.pathExists(path.join(bmadDir, depCode));
const selectedThisRun = CustomModuleManager._resolutionCache.has(depCode);
if (onDisk || selectedThisRun) continue;
const versionStr = typeof dep === 'object' && dep.version ? ` (${dep.version})` : '';
await prompts.log.warn(` ${code} declares a dependency on module '${depCode}'${versionStr} — ensure it is installed.`);
}
}
/**
* Update an existing module
* @param {string} moduleName - Name of the module to update
@ -551,21 +694,29 @@ class OfficialModules {
const projectRoot = path.dirname(bmadDir);
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Special handling for core module - it's in src/core-skills not src/modules
let sourcePath;
if (moduleName === 'core') {
sourcePath = getSourcePath('core-skills');
} else {
sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) {
return emptyResult; // No source found, skip
}
}
// Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
// Prefer the flattened installed module.yaml. buildCopyPlan() copies a
// new-spec module's moduleDefinition to _bmad/<code>/module.yaml, where it
// carries the canonical `directories` declarations — but its source tree may
// keep module.yaml under a skill asset path that findModuleSource() can't
// locate, which would otherwise skip the declared working dirs.
let moduleYamlPath = path.join(bmadDir, moduleName, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) {
return emptyResult; // No module.yaml, skip
// Special handling for core module - it's in src/core-skills not src/modules
let sourcePath;
if (moduleName === 'core') {
sourcePath = getSourcePath('core-skills');
} else {
sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) {
return emptyResult; // No source found, skip
}
}
// Read module.yaml to find the `directories` key
moduleYamlPath = path.join(sourcePath, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) {
return emptyResult; // No module.yaml, skip
}
}
let moduleYaml;

View File

@ -2,17 +2,25 @@ const fs = require('../fs-native');
const path = require('node:path');
const yaml = require('yaml');
const { MODULE_HELP_CSV_HEADER } = require('./module-help-schema');
const { loadBmadModuleLib, readPluginManifest } = require('./bmad-module-lib');
/**
* Resolves how to install a plugin from marketplace.json by analyzing
* where module.yaml and module-help.csv live relative to the listed skills.
* Resolves how to install a plugin by analyzing its on-disk shape.
*
* Five strategies, tried in order:
* Strategy 0 (new module system), tried first:
* 0. A `.claude-plugin/plugin.json` carrying a `bmad{}` block at the module
* root. Resolved + validated via the bmad-module skill's own libs so the
* installer and the runtime skill agree on what a module is.
*
* Legacy strategies (marketplace.json + module.yaml), tried in order:
* 1. Root module files at the common parent of all skills
* 2. A -setup skill with assets/module.yaml + assets/module-help.csv
* 3. Single standalone skill with both files in its assets/
* 4. Multiple standalone skills, each with both files in assets/
* 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter
*
* Every resolved module carries a `format` discriminator ('plugin-json' or
* 'legacy') so the installer can pick the matching install path.
*/
class PluginResolver {
/**
@ -24,9 +32,22 @@ class PluginResolver {
* @param {string} [plugin.version] - Semantic version
* @param {string} [plugin.description] - Plugin description
* @param {string[]} [plugin.skills] - Relative paths to skill directories
* @param {Object} [options] - Resolution options
* @param {function} [options.chooseModuleDefinition] - Async selector invoked
* when more than one module.yaml + module-help.csv pair is found between the
* skills' common parent and the repo root. Receives (candidates, context)
* and returns the chosen candidate. When omitted (headless / non-interactive
* / CLI), the deepest candidate is used so resolution never blocks.
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
*/
async resolve(repoPath, plugin) {
async resolve(repoPath, plugin, options = {}) {
// Strategy 0: new module system. Tried before everything else — and before
// the no-skills early return below — because new-system modules declare
// their skills inside plugin.json rather than via marketplace.json's
// skills[] array.
const pluginJsonResult = await this._tryPluginJson(repoPath, plugin);
if (pluginJsonResult) return pluginJsonResult;
const skillRelPaths = plugin.skills || [];
// No skills array: legacy behavior - caller should use existing findModuleSource
@ -55,7 +76,7 @@ class PluginResolver {
// Try each strategy in order
const result =
(await this._tryRootModuleFiles(repoPath, plugin, skillPaths)) ||
(await this._tryRootModuleFiles(repoPath, plugin, skillPaths, options)) ||
(await this._trySetupSkill(repoPath, plugin, skillPaths)) ||
(await this._trySingleStandalone(repoPath, plugin, skillPaths)) ||
(await this._tryMultipleStandalone(repoPath, plugin, skillPaths)) ||
@ -64,20 +85,130 @@ class PluginResolver {
return result;
}
// ─── Strategy 0: New Module System (plugin.json#bmad) ───────────────────────
/**
* Detect a `.claude-plugin/plugin.json` carrying a `bmad{}` block at the
* module root and resolve it via the bmad-module skill's own validator.
*
* The module root is `plugin.source` (relative to the repo) when given, else
* the repo root. Returns a single-element array of a new-format ResolvedModule
* on success, or null to fall through to the legacy strategies. Throws when a
* plugin.json#bmad is present but invalid a malformed new-system manifest
* should surface, not silently install via the legacy synthesizer.
*/
async _tryPluginJson(repoPath, plugin) {
const repoRoot = path.resolve(repoPath);
let moduleRoot = repoRoot;
if (plugin.source) {
const normalized = String(plugin.source).replace(/^\.\//, '');
const abs = path.resolve(repoPath, normalized);
// Guard against path traversal out of the repo root.
if (abs !== repoRoot && !abs.startsWith(repoRoot + path.sep)) {
return null;
}
moduleRoot = abs;
}
const rawManifest = await readPluginManifest(moduleRoot);
if (!rawManifest) return null;
// Validate with the skill's install-time validator (throws BmadModuleError
// with a descriptive .message on a bad manifest).
const { readAndValidateManifest } = await loadBmadModuleLib();
const manifest = await readAndValidateManifest(moduleRoot);
// Resolve declared skill dirs to absolute existing paths for display only;
// the install copy is plan-driven (buildCopyPlan), not skillPaths-driven.
const skillPaths = [];
if (Array.isArray(manifest.skills)) {
for (const rel of manifest.skills) {
if (typeof rel !== 'string') continue;
const abs = path.resolve(moduleRoot, rel.replace(/^\.\//, ''));
if (abs.startsWith(moduleRoot + path.sep) && (await fs.pathExists(abs))) {
skillPaths.push(abs);
}
}
}
// Point moduleYamlPath at the source moduleDefinition so the installer's
// source-resolution helpers (findModuleSourceByCode → createModuleDirectories,
// resolveInstalledModuleYaml) can read the module's declared `directories`.
// Install itself flattens this to `_bmad/<code>/module.yaml` via buildCopyPlan.
let moduleYamlPath = null;
if (typeof manifest.bmad?.moduleDefinition === 'string') {
const abs = path.resolve(moduleRoot, manifest.bmad.moduleDefinition.replace(/^\.\//, ''));
if (abs.startsWith(moduleRoot + path.sep) && (await fs.pathExists(abs))) {
moduleYamlPath = abs;
}
}
return [
{
code: manifest.bmad.code,
name: manifest.displayName || manifest.name,
version: manifest.bmad.moduleVersion || manifest.version || null,
description: manifest.description || plugin.description || '',
format: 'plugin-json',
strategy: 'plugin-json',
pluginName: plugin.name,
sourceDir: moduleRoot,
manifest,
skillPaths,
moduleYamlPath,
moduleHelpCsvPath: null,
synthesizedModuleYaml: null,
synthesizedHelpCsv: null,
},
];
}
// ─── Strategy 1: Root Module Files ──────────────────────────────────────────
/**
* Check if module.yaml + module-help.csv exist at the common parent of all skills.
* Check if module.yaml + module-help.csv exist at the common parent of all
* skills, or in any directory between there and the repo root.
*
* The canonical BMad layout puts module.yaml + module-help.csv at the repo
* root or under src/, while skills live in src/skills/<name>/ i.e. one or
* more levels ABOVE the skills' common parent. We therefore start at the
* common parent and walk up to the repo root, using the first (deepest)
* directory that has both files. This catches the common case where, e.g.,
* module.yaml sits at src/module.yaml but skills are in src/skills/.
*/
async _tryRootModuleFiles(repoPath, plugin, skillPaths) {
async _tryRootModuleFiles(repoPath, plugin, skillPaths, options = {}) {
const commonParent = this._computeCommonParent(skillPaths);
const moduleYamlPath = path.join(commonParent, 'module.yaml');
const moduleHelpPath = path.join(commonParent, 'module-help.csv');
if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) {
const candidates = await this._findModuleFileCandidatesUpward(commonParent, repoPath);
if (candidates.length === 0) {
return null;
}
// Deepest candidate (closest to the skills) is the safe default. When more
// than one directory in the chain carries both files, give an interactive
// caller the chance to pick — enriching each option with its module.yaml
// metadata so the choice is meaningful. Headless callers fall through to
// the deepest candidate without prompting.
let chosen = candidates[0];
if (candidates.length > 1 && typeof options.chooseModuleDefinition === 'function') {
const enriched = [];
for (const candidate of candidates) {
const data = await this._readModuleYaml(candidate.moduleYamlPath);
enriched.push({
...candidate,
relativePath: path.relative(path.resolve(repoPath), candidate.moduleYamlPath),
code: data?.code || null,
name: data?.name || null,
description: data?.description || null,
});
}
const picked = await options.chooseModuleDefinition(enriched, { plugin });
if (picked && picked.moduleYamlPath && picked.moduleHelpPath) {
chosen = picked;
}
}
const { moduleYamlPath, moduleHelpPath } = chosen;
const moduleData = await this._readModuleYaml(moduleYamlPath);
if (!moduleData) return null;
@ -87,6 +218,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 1,
pluginName: plugin.name,
moduleYamlPath,
@ -124,6 +256,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 2,
pluginName: plugin.name,
moduleYamlPath,
@ -163,6 +296,7 @@ class PluginResolver {
name: moduleData.name || plugin.name,
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || plugin.description || '',
format: 'legacy',
strategy: 3,
pluginName: plugin.name,
moduleYamlPath,
@ -201,6 +335,7 @@ class PluginResolver {
name: moduleData.name || path.basename(skillPath),
version: plugin.version || moduleData.module_version || null,
description: moduleData.description || '',
format: 'legacy',
strategy: 4,
pluginName: plugin.name,
moduleYamlPath,
@ -257,6 +392,7 @@ class PluginResolver {
name: moduleName,
version: plugin.version || null,
description: plugin.description || '',
format: 'legacy',
strategy: 5,
pluginName: plugin.name,
moduleYamlPath: null,
@ -270,6 +406,40 @@ class PluginResolver {
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Walk up from startDir to the repo root, collecting every directory that
* contains BOTH module.yaml and module-help.csv. Bounded by repoRoot so we
* never escape the cloned repository. Results are ordered deepest-first
* (closest to startDir), so candidates[0] is the safe default.
* @param {string} startDir - Directory to start searching from (inclusive)
* @param {string} repoPath - Repository root (upper bound, inclusive)
* @returns {Promise<Array<{moduleYamlPath: string, moduleHelpPath: string}>>}
*/
async _findModuleFileCandidatesUpward(startDir, repoPath) {
const repoRoot = path.resolve(repoPath);
let dir = path.resolve(startDir);
// If startDir somehow falls outside the repo, only consider the repo root.
if (dir !== repoRoot && !dir.startsWith(repoRoot + path.sep)) {
dir = repoRoot;
}
const candidates = [];
while (true) {
const moduleYamlPath = path.join(dir, 'module.yaml');
const moduleHelpPath = path.join(dir, 'module-help.csv');
if ((await fs.pathExists(moduleYamlPath)) && (await fs.pathExists(moduleHelpPath))) {
candidates.push({ moduleYamlPath, moduleHelpPath });
}
if (dir === repoRoot) break;
const parent = path.dirname(dir);
if (parent === dir) break; // filesystem root — stop defensively
dir = parent;
}
return candidates;
}
/**
* Compute the deepest common ancestor directory of an array of absolute paths.
* @param {string[]} absPaths - Absolute directory paths

View File

@ -171,7 +171,19 @@ async function resolveInstalledModuleYaml(moduleName) {
try {
const { CustomModuleManager } = require('./modules/custom-module-manager');
for (const [, mod] of CustomModuleManager._resolutionCache) {
if ((mod.code === moduleName || mod.name === moduleName) && mod.localPath) {
// Match on code, display name, OR the marketplace plugin name. A legacy
// module whose module.yaml `code`/`name` (e.g. cis / "CIS: …") diverges
// from its marketplace plugin name (e.g. bmad-creative-intelligence-suite)
// can be tracked downstream under any of the three — match all of them.
const matches = mod.code === moduleName || mod.name === moduleName || mod.pluginName === moduleName;
if (!matches) continue;
// Prefer the resolution's exact module.yaml — searchRoot(localPath) returns
// the FIRST module.yaml under the root, which can be the wrong one in a
// multi-module/multi-plugin repo resolved by pluginName.
if (mod.moduleYamlPath && (await fs.pathExists(mod.moduleYamlPath))) {
return mod.moduleYamlPath;
}
if (mod.localPath) {
const found = await searchRoot(mod.localPath);
if (found) return found;
}

View File

@ -17,6 +17,7 @@ const {
const channelResolver = require('./modules/channel-resolver');
const prompts = require('./prompts');
const { parseSetEntries } = require('./set-overrides');
const { readPluginManifest } = require('./modules/bmad-module-lib');
const manifest = new Manifest();
@ -1017,6 +1018,25 @@ class UI {
const customMgr = new CustomModuleManager();
const selectedModules = [];
// Interactive disambiguation: when a plugin has more than one module.yaml +
// module-help.csv pair between its skills and the repo root, let the user
// pick which one defines the module. `activeSpinner` is paused around the
// prompt so the choice renders cleanly, then resumed by the caller.
let activeSpinner = null;
const chooseModuleDefinition = async (candidates, { plugin }) => {
activeSpinner?.stop('Multiple module definitions found');
const choice = await prompts.select({
message: `"${plugin.name}" declares multiple module definitions — choose which to install:`,
choices: candidates.map((c) => ({
name: `${c.code || '(no code)'}${c.name ? `${c.name}` : ''} [${c.relativePath}]`,
value: c,
})),
default: candidates[0],
});
activeSpinner?.start('Analyzing plugin structure...');
return choice;
};
let addMore = true;
while (addMore) {
const sourceInput = await prompts.text({
@ -1030,6 +1050,7 @@ class UI {
});
const s = await prompts.spinner();
activeSpinner = s;
s.start('Resolving source...');
let sourceResult;
@ -1071,7 +1092,9 @@ class UI {
const effectiveRepoPath = sourceResult.repoPath || sourceResult.rootDir;
for (const plugin of plugins) {
try {
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath);
const resolved = await customMgr.resolvePlugin(effectiveRepoPath, plugin.rawPlugin, sourceResult.sourceUrl, localPath, {
chooseModuleDefinition,
});
if (resolved.length > 0) {
allResolved.push(...resolved);
} else {
@ -1081,6 +1104,7 @@ class UI {
name: plugin.displayName || plugin.name,
version: plugin.version,
description: plugin.description,
format: 'legacy',
strategy: 0,
pluginName: plugin.name,
skillPaths: [],
@ -1091,34 +1115,43 @@ class UI {
}
}
} else {
// Direct mode: no marketplace.json, scan directory for skills and resolve
// Direct mode: no marketplace.json. Prefer a new-system module manifest
// at the root (.claude-plugin/plugin.json#bmad); otherwise scan for
// SKILL.md directories (legacy direct mode).
const rootManifest = await readPluginManifest(sourceResult.rootDir);
const directPlugin = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
// Scan for SKILL.md directories to populate skills array
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
if (!rootManifest) {
// Scan for SKILL.md directories to populate skills array
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
}
}
}
} catch (scanError) {
s.error('Failed to scan directory');
await prompts.log.error(` ${scanError.message}`);
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
} catch (scanError) {
s.error('Failed to scan directory');
await prompts.log.error(` ${scanError.message}`);
addMore = await prompts.confirm({ message: 'Try another source?', default: false });
continue;
}
if (directPlugin.skills.length > 0) {
// New-system modules resolve from plugin.json (skills declared inside it,
// so an empty skills[] here is expected); legacy modules need ≥1 skill.
if (rootManifest || directPlugin.skills.length > 0) {
try {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath, {
chooseModuleDefinition,
});
allResolved.push(...resolved);
} catch (resolveError) {
await prompts.log.warn(` Could not resolve: ${resolveError.message}`);
@ -1198,6 +1231,12 @@ class UI {
.map((s) => s.trim())
.filter(Boolean);
// Non-interactive mode: a source the user explicitly asked for must not be
// silently dropped. Collect failures and throw after attempting every source
// so the install fails (non-zero exit) instead of completing with the
// requested module missing.
const failures = [];
for (const source of sources) {
const s = await prompts.spinner();
s.start(`Resolving ${source}...`);
@ -1209,6 +1248,7 @@ class UI {
} catch (error) {
s.error(`Failed to resolve ${source}`);
await prompts.log.error(` ${error.message}`);
failures.push(`${source}: ${error.message}`);
continue;
}
@ -1234,40 +1274,53 @@ class UI {
} catch (discoverError) {
s2.error('Failed to discover modules');
await prompts.log.error(` ${discoverError.message}`);
failures.push(`${source}: ${discoverError.message}`);
continue;
}
} else {
// Direct mode: scan for SKILL.md directories
// Direct mode: prefer a new-system manifest at the root, else scan for
// SKILL.md directories (legacy direct mode).
const rootManifest = await readPluginManifest(sourceResult.rootDir);
const directPlugin = {
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
source: '.',
skills: [],
};
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
if (!rootManifest) {
try {
const entries = await fs.readdir(sourceResult.rootDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const skillMd = path.join(sourceResult.rootDir, entry.name, 'SKILL.md');
if (await fs.pathExists(skillMd)) {
directPlugin.skills.push(entry.name);
}
}
}
} catch {
// Skip unreadable directories
}
} catch {
// Skip unreadable directories
}
if (directPlugin.skills.length > 0) {
if (rootManifest || directPlugin.skills.length > 0) {
try {
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
allResolved.push(...resolved);
} catch {
// Skip unresolvable
} catch (resolveError) {
s2.error(`Failed to resolve ${source}`);
await prompts.log.error(` ${resolveError.message}`);
failures.push(`${source}: ${resolveError.message}`);
continue;
}
}
}
s2.stop(`Found ${allResolved.length} module${allResolved.length === 1 ? '' : 's'}`);
if (allResolved.length === 0) {
failures.push(`${source}: no installable module found at this source`);
continue;
}
for (const mod of allResolved) {
allCodes.push(mod.code);
const versionStr = mod.version ? ` v${mod.version}` : '';
@ -1275,6 +1328,10 @@ class UI {
}
}
if (failures.length > 0) {
throw new Error(`Could not resolve ${failures.length} custom source(s):\n - ${failures.join('\n - ')}`);
}
return allCodes;
}

View File

@ -195,6 +195,10 @@ function discoverSkillDirs(rootDirs) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git') continue;
// Skip `tests/fixtures/` trees: these hold reference and deliberately-
// malformed example modules (e.g. third-party `acme-*` skills and negative
// fixtures) that intentionally don't follow the production SKILL rules.
if (entry.name === 'fixtures' && path.basename(dir) === 'tests') continue;
const fullPath = path.join(dir, entry.name);
const skillMd = path.join(fullPath, 'SKILL.md');