feat(bmad-module): distribute installed skills to the user's chosen IDEs

The bmad-module skill staged community modules under _bmad/<code>/ but never
pushed their skills out to the coding assistants the user selected at
`bmad install` time, so a freshly installed module was invisible to Claude
Code / Cursor / Copilot / etc. until a full reinstall; remove left skills
orphaned in the IDE dirs.

install/update/remove now distribute (or prune) skills to every IDE listed in
_bmad/_config/manifest.yaml and clean the redundant skill dirs from _bmad/,
matching how official modules end up.

Single engine, three callers — no fork:
- New tools/installer/core/ide-sync.js (syncIdes) wraps the real
  IdeManager.setupBatch + platform-codes engine. The full installer
  (_setupIdes/_cleanupSkillDirs), the new `bmad ide-sync` command, and the
  skill all route through it, so new IDEs and engine changes propagate
  everywhere automatically.

Local, dependency-free delivery — no npx/network at runtime:
- build-ide-sync.mjs esbuild-bundles the engine into vendor/ide-sync.mjs
  (+ platform-codes.yaml), aliasing ../prompts and ../project-root to small
  shims so @clack and the installer graph are dropped. The bundle ships inside
  the skill tree (like yaml.mjs); the skill execs it locally. It's
  generated-from-source and gated by vendor:check, refreshed on every install.

update/remove pass --prune with the module's canonicalIds so skills dropped
between versions (or on uninstall) are removed from IDE dirs + command
pointers. Graceful degradation: if the bundle is unreachable, the verb still
succeeds and points the user at `bmad ide-sync`.

Tests: new test/test-ide-sync.js drift-guard (engine == bundle, incl. prune),
integration.test.sh IDE-distribution section (offline), bundle self-check in
the build. All gates green (vendor:check, lint, format, test:install 349/349).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pbean 2026-05-29 20:30:46 -07:00
parent 6a62b2e0ba
commit af52c7baf9
20 changed files with 11532 additions and 43 deletions

View File

@ -40,17 +40,18 @@
"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 vendor:check && 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 validate:refs && npm run validate:skills && npm run docs:validate-sidebar",
"rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run vendor:check && 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 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: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",
"vendor:build": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs",
"vendor:check": "node src/core-skills/bmad-module/scripts/lib/vendor/build-vendor.mjs --check"
"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

@ -5,7 +5,7 @@ The core BMAD skill for installing, updating, removing, and listing community BM
## How it fits
- **Authors** publish a single repo with `.claude-plugin/plugin.json` that works in both Claude Code's plugin marketplace and BMAD-METHOD.
- **Users** install via this skill — no CLI required. Modules land in `_bmad/<bmad.code>/` alongside the official modules.
- **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 (with the paired `manifest-generator.js` patch).
## Verbs
@ -23,8 +23,9 @@ bmad-module list [--json]
- **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.
- **Hooks / MCP / LSP / Claude subagents** declared in the module manifest are _copied_ but NOT auto-activated by this skill. Use Claude Code's plugin manager to wire them up.
- **`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.
## Implementation

View File

@ -5,11 +5,11 @@ description: Install, update, remove, or list community BMAD modules. Use when t
# bmad-module
Manage community BMAD modules — installable packages of skills, agents, and supporting assets that ship as standalone GitHub repos. Modules land in `_bmad/<bmad.code>/` alongside official modules and are tracked in the existing manifests. The same artifact is also loadable as a Claude Code plugin via its `.claude-plugin/plugin.json` manifest.
Manage community BMAD modules — installable packages of skills, agents, and supporting assets that ship as standalone GitHub repos. Modules are staged under `_bmad/<bmad.code>/` and tracked in the existing manifests. On `install`, `update`, and `remove`, the script then distributes (or prunes) the module's skills to **every coding assistant the user selected when they ran `bmad install`** — read from the `ides:` list in `_bmad/_config/manifest.yaml` — so a community module lands in Claude Code, Cursor, Copilot, etc. exactly like an official module. As with official modules, the canonical end state is skills living in the IDE directories (e.g. `.claude/skills/<id>/`), not in `_bmad/`. The same artifact is also loadable as a Claude Code plugin via its `.claude-plugin/plugin.json` manifest.
## CRITICAL RULES
- NEVER write directly to files under `_bmad/`. All filesystem changes go through the Node script at `scripts/bmad-module.mjs` — it handles staging, atomic swaps, manifest updates, and rollback on failure.
- 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.
@ -63,7 +63,12 @@ Stream stdout and stderr verbatim. Do NOT silence or rewrite them — the script
### Step 5 — Report
On exit 0: paraphrase the script's final line(s) and note any next-step hint (e.g. "next: run the `bmad-devlog-setup` skill to finish setup").
On 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>/`").

View File

@ -6,6 +6,7 @@ import { readAndValidateManifest } from './lib/plugin-json.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';
// Run the install verb. `opts` shape:
// { source, ref, sha, channel, dryRun, projectDir }
@ -102,22 +103,36 @@ export async function runInstall(opts) {
await appendSkillManifestRows(bmadDir, code, skillDestDirs);
await appendFilesManifestRows(bmadDir, code, destPaths);
// §8. Warn about Claude-only surfaces.
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`);
// §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');
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`);
if (claudeOnly.length) {
process.stdout.write(
`[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` +
`Use Claude Code's plugin manager to wire them up.\n`,
`[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) {

View File

@ -0,0 +1,55 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
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);
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

@ -222,6 +222,20 @@ export async function appendSkillManifestRows(bmadDir, code, skillDirs) {
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);

View File

@ -19,6 +19,7 @@ Files here are imported by **relative path** (`./vendor/yaml.mjs`), which resolv
| 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.
@ -38,6 +39,15 @@ 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.

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,322 @@
# 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
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,
};

View File

@ -9,7 +9,9 @@ import {
removeSkillManifestRows,
removeFilesManifestRows,
readFileEntriesForModule,
readSkillCanonicalIdsForModule,
} from './lib/manifest-ops.mjs';
import { distributeToIdes } from './lib/ide-sync.mjs';
// Remove a module's installed files and manifest entries. With `--purge` also
// deletes `_bmad/custom/<code>/` (user customization dir). Without it, customs
@ -37,6 +39,10 @@ export async function runRemove(opts) {
);
}
// 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);
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
const fileEntries = await readFileEntriesForModule(bmadDir, code);
const moduleRoot = path.join(bmadDir, code);
@ -65,6 +71,14 @@ export async function runRemove(opts) {
await removeSkillManifestRows(bmadDir, code);
await removeModuleFromManifest(bmadDir, code);
// 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`);

View File

@ -13,7 +13,9 @@ import {
removeSkillManifestRows,
removeFilesManifestRows,
readFileEntriesForModule,
readSkillCanonicalIdsForModule,
} from './lib/manifest-ops.mjs';
import { distributeToIdes } from './lib/ide-sync.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.
@ -68,6 +70,11 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
);
}
// 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);
@ -130,6 +137,13 @@ async function updateOne(bmadDir, projectDir, entry, opts) {
`[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-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

@ -207,6 +207,44 @@ 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"
[[ "${STDOUT}" == *"claude-code"* ]] && ok "stdout reports claude-code distribution" \
|| ko "expected claude-code in stdout: ${STDOUT}"
# 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"
# ─── Summary ─────────────────────────────────────────────────────────────────
echo
echo "──────────────────────────────────────────────────────────────────────"

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

@ -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,215 @@
// 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.
if (cleanup) 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 + '/';
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);
}
}
}
/**
* 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,26 +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);
}
}
const { cleanupBmadSkillDirs } = require('./ide-sync');
await cleanupBmadSkillDirs(bmadDir);
}
async _readSkillManifestRows(bmadDir) {

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}`);
}