Compare commits
No commits in common. "9bc76acdcbfbe933ed1dfa4446c75d9ba69d7054" and "6a62b2e0bad55a1b335fd02ac947056f18611a8f" have entirely different histories.
9bc76acdcb
...
6a62b2e0ba
|
|
@ -40,18 +40,17 @@
|
|||
"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:ide-sync && 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: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:ide-sync && 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: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 && 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"
|
||||
"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"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,cjs,mjs}": [
|
||||
|
|
|
|||
|
|
@ -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 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.
|
||||
- **Users** install via this skill — no CLI required. Modules land in `_bmad/<bmad.code>/` alongside the official modules.
|
||||
- **BMAD-METHOD** treats community-installed modules as a new `source: 'community'` row in `manifest.yaml`; re-running `bmad install` preserves them (with the paired `manifest-generator.js` patch).
|
||||
|
||||
## Verbs
|
||||
|
|
@ -23,9 +23,8 @@ 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. 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.
|
||||
- **`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.
|
||||
|
||||
## Implementation
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +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 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.
|
||||
|
||||
To match the full `bmad install` for custom modules, the script also completes the install in place: it installs npm dependencies when the module ships a `package.json` (opt out with `bmad.install.skipNpm: true`), generates the module's `[modules.<code>]` / `[agents.<code>]` blocks in `_bmad/config.toml` and `config.user.toml` from its `module.yaml` (defaults, overridable with `--set`), creates the working directories the module declares under `directories:`, and rebuilds the merged `_bmad/_config/bmad-help.csv` so the module's skills show up in `bmad-help`. These steps are best-effort — a failure in any of them is reported as a warning, not a failed install. Interactive config refinement remains the job of the module's `postInstallSkill`, if it declares one.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- NEVER write directly to files under `_bmad/`. All filesystem changes go through the Node script at `scripts/bmad-module.mjs` — it handles staging, atomic swaps, manifest updates, and rollback on failure.
|
||||
- HALT and report cleanly if `_bmad/` is not present in the current working directory (exit code 10 from the script).
|
||||
- DO NOT execute hooks, MCP server commands, or any code shipped inside the module during install. The install copies files; activation is a separate step the user opts into via Claude Code's plugin manager.
|
||||
- If the script exits non-zero, report the exit code and stderr verbatim and stop. Do NOT retry, do NOT try a different verb. 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.
|
||||
|
|
@ -33,8 +31,8 @@ If the verb is ambiguous (e.g. the user says "manage modules"), ASK which verb t
|
|||
|
||||
### Step 2 — Parse the args
|
||||
|
||||
- **install:** the user supplies `<source>` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--set <code>.<key>=<value>` (override a module config answer; repeatable), `--dry-run`.
|
||||
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`, `--set <code>.<key>=<value>`.
|
||||
- **install:** the user supplies `<source>` — `owner/repo` (GitHub short), a full git URL (`https://…` or `git@…`), or a local path. Optional flags: `--ref <branch-tag-or-sha>`, `--channel <stable|next|pinned>`, `--dry-run`.
|
||||
- **update:** the user supplies `<code>` (the `_bmad/<code>/` folder name) or asks for "all"; in that case use `--all`. Optional `--ref`.
|
||||
- **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.
|
||||
|
||||
|
|
@ -65,12 +63,7 @@ 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"). 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 exit 0: paraphrase the script's final line(s) and note any next-step hint (e.g. "next: run the `bmad-devlog-setup` skill to finish setup").
|
||||
|
||||
On non-zero exit: print the exit code, the stderr message, and stop. Do not suggest workarounds beyond what the script's message itself suggests (e.g. "use `update` instead", "move changes into `_bmad/custom/<code>/`").
|
||||
|
||||
|
|
|
|||
|
|
@ -37,13 +37,7 @@ function parseArgs(argv) {
|
|||
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;
|
||||
}
|
||||
out.flags[key] = val;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -77,7 +71,6 @@ export async function main() {
|
|||
}
|
||||
|
||||
const projectDir = parsed.flags['project-dir'] || process.cwd();
|
||||
const setOverrides = parseSetOverrides(parsed.flags.set);
|
||||
|
||||
try {
|
||||
switch (verb) {
|
||||
|
|
@ -87,7 +80,6 @@ export async function main() {
|
|||
ref: parsed.flags['ref'] || null,
|
||||
channel: parsed.flags['channel'] || null,
|
||||
dryRun: !!parsed.flags['dry-run'],
|
||||
setOverrides,
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
|
|
@ -97,7 +89,6 @@ export async function main() {
|
|||
all: !!parsed.flags['all'],
|
||||
ref: parsed.flags['ref'] || null,
|
||||
channel: parsed.flags['channel'] || null,
|
||||
setOverrides,
|
||||
projectDir,
|
||||
});
|
||||
break;
|
||||
|
|
@ -125,38 +116,17 @@ export async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
// 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>] [--set <code>.<key>=<v>] [--dry-run]
|
||||
bmad-module update <code|--all> [--ref <ref>] [--channel <c>] [--set <code>.<key>=<v>]
|
||||
bmad-module install <source> [--ref <ref>] [--channel <c>] [--dry-run]
|
||||
bmad-module update <code|--all> [--ref <ref>] [--channel <c>]
|
||||
bmad-module remove <code> [--purge]
|
||||
bmad-module list [--json]
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,11 +6,6 @@ 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';
|
||||
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, projectDir }
|
||||
|
|
@ -107,42 +102,22 @@ export async function runInstall(opts) {
|
|||
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).
|
||||
// §8. Warn about Claude-only surfaces.
|
||||
const claudeOnly = [];
|
||||
if (manifest.hooks) claudeOnly.push('hooks');
|
||||
if (manifest.mcpServers) claudeOnly.push('mcpServers');
|
||||
if (manifest.lspServers) claudeOnly.push('lspServers');
|
||||
if (Array.isArray(manifest.agents) && manifest.agents.length) claudeOnly.push('agents');
|
||||
if (Array.isArray(manifest.commands) && manifest.commands.length) claudeOnly.push('commands');
|
||||
|
||||
process.stdout.write(
|
||||
`[bmad-module] installed ${code} (${manifest.name} ${manifest.version})${materialized.sha ? ` @ ${materialized.sha.slice(0, 7)}` : ''}\n`,
|
||||
);
|
||||
process.stdout.write(`[bmad-module] copied ${destPaths.length} file(s) to ${path.relative(projectDir, targetDir)}\n`);
|
||||
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`,
|
||||
`[bmad-module] note: ${claudeOnly.join(', ')} were copied but NOT auto-activated. ` +
|
||||
`Use Claude Code's plugin manager to wire them up.\n`,
|
||||
);
|
||||
}
|
||||
if (manifest.bmad?.install?.postInstallSkill) {
|
||||
|
|
@ -152,50 +127,3 @@ export async function runInstall(opts) {
|
|||
await materialized.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,346 +0,0 @@
|
|||
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.*] (by code).
|
||||
const kept = blocks.filter(
|
||||
(b) => b.header !== `modules.${sectionKey}` && !(b.header.startsWith('agents.') && agentCodes.has(b.header.slice('agents.'.length))),
|
||||
);
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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.
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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.',
|
||||
};
|
||||
}
|
||||
|
|
@ -121,10 +121,7 @@ export async function buildCopyList(sourceDir, ignoreMatch) {
|
|||
}
|
||||
|
||||
// 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.
|
||||
// have to declare these — they're conventional repo metadata.
|
||||
const ALWAYS_TOPLEVEL = new Set([
|
||||
'README.md',
|
||||
'README',
|
||||
|
|
@ -138,8 +135,6 @@ const ALWAYS_TOPLEVEL = new Set([
|
|||
'LICENCE.md',
|
||||
'NOTICE',
|
||||
'NOTICE.md',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
]);
|
||||
|
||||
function stripDotSlash(p) {
|
||||
|
|
@ -203,17 +198,11 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) {
|
|||
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) => {
|
||||
const addFile = (srcRel, destRel) => {
|
||||
if (!srcRel || !destRel) return;
|
||||
if (!allowDupSrc && claimedSrc.has(srcRel)) return;
|
||||
if (claimedSrc.has(srcRel)) return;
|
||||
if (claimedDest.has(destRel)) return;
|
||||
if (!allowDupSrc) claimedSrc.add(srcRel);
|
||||
claimedSrc.add(srcRel);
|
||||
claimedDest.add(destRel);
|
||||
plan.push({ srcRel, destRel });
|
||||
};
|
||||
|
|
@ -228,12 +217,12 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) {
|
|||
};
|
||||
|
||||
// Helper: if `srcRel` exists as a file in source, queue it.
|
||||
const queueFileIfExists = async (srcRel, destRel, allowDupSrc = false) => {
|
||||
const queueFileIfExists = async (srcRel, destRel) => {
|
||||
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);
|
||||
if (stat.isFile()) addFile(srcRel, destRel);
|
||||
} catch {
|
||||
/* missing — silently skip; validateDeclaredPaths surfaces declared misses */
|
||||
}
|
||||
|
|
@ -275,15 +264,11 @@ export async function buildCopyPlan(sourceDir, manifest, ignoreMatch) {
|
|||
}
|
||||
|
||||
// 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);
|
||||
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleDefinition), 'module.yaml');
|
||||
}
|
||||
if (typeof manifest.bmad?.moduleHelpCsv === 'string') {
|
||||
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv', true);
|
||||
await queueFileIfExists(stripDotSlash(manifest.bmad.moduleHelpCsv), 'module-help.csv');
|
||||
}
|
||||
|
||||
// Top-level docs declared in the manifest — keep at root by basename.
|
||||
|
|
|
|||
|
|
@ -222,20 +222,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
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) {
|
||||
const subPath = path.join(fullPath, sub);
|
||||
if (!(await pathExists(subPath))) {
|
||||
await fs.mkdir(subPath, { recursive: true });
|
||||
createdWdsFolders.push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { createdDirs, movedDirs, createdWdsFolders };
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ 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.
|
||||
|
|
@ -39,15 +38,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
#!/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
|
|
@ -1,322 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
// 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 };
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -9,11 +9,7 @@ import {
|
|||
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
|
||||
|
|
@ -41,18 +37,6 @@ 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);
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
// Delete each file tracked in files-manifest.csv; prune empty dirs after.
|
||||
const fileEntries = await readFileEntriesForModule(bmadDir, code);
|
||||
const moduleRoot = path.join(bmadDir, code);
|
||||
|
|
@ -81,22 +65,6 @@ export async function runRemove(opts) {
|
|||
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`);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ import {
|
|||
removeSkillManifestRows,
|
||||
removeFilesManifestRows,
|
||||
readFileEntriesForModule,
|
||||
readSkillCanonicalIdsForModule,
|
||||
} from './lib/manifest-ops.mjs';
|
||||
import { distributeToIdes } from './lib/ide-sync.mjs';
|
||||
import { finishModuleInstall } 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.
|
||||
|
|
@ -71,11 +68,6 @@ 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);
|
||||
|
|
@ -138,17 +130,6 @@ 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-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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +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,,,,,,,
|
||||
canonical_id,name,description,module,path,kind,team,agent,visible,deprecated,version,tags,help_text
|
||||
bmad-devlog-write,bmad-devlog-write,Write today's devlog entry from a template.,devlog,skills/bmad-devlog-write,skill,knowledge-management,,true,false,0.4.0,"devlog,write,daily",Use `/bmad-devlog-write` to create today's entry.
|
||||
bmad-devlog-summarize,bmad-devlog-summarize,Summarize devlog entries across a date range.,devlog,skills/bmad-devlog-summarize,skill,knowledge-management,,true,false,0.4.0,"devlog,summarize,history",Use `/bmad-devlog-summarize <range>` (e.g. `last-week`, `2026-05`).
|
||||
bmad-agent-historian,bmad-agent-historian,Clio the Historian — persona-agent for narrative recall and pattern detection.,devlog,skills/bmad-agent-historian,persona-agent,knowledge-management,Clio,true,false,0.4.0,"devlog,history,persona",Use `/bmad-agent-historian` or ask to "talk to Clio".
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"name": "acme-npmtool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "No runtime dependencies — npm install resolves cleanly offline.",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -97,18 +97,6 @@ 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) ─────────────────────────────────────────────────────────
|
||||
|
|
@ -173,8 +161,8 @@ 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'
|
||||
note "install examples/comprehensive/acme-devlog"
|
||||
run install "${EXAMPLES}/comprehensive/acme-devlog"
|
||||
assert_exit 0 "install comprehensive"
|
||||
assert_path_exists "_bmad/devlog/skills/bmad-devlog-write/SKILL.md"
|
||||
assert_path_exists "_bmad/devlog/skills/bmad-devlog-setup/SKILL.md"
|
||||
|
|
@ -182,10 +170,6 @@ 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"
|
||||
|
|
@ -193,33 +177,6 @@ assert_path_absent "_bmad/devlog/CHANGELOG.md"
|
|||
[[ "${STDOUT}" == *"hooks"* ]] && ok "warns about hooks not auto-activated" \
|
||||
|| ko "expected hooks warning in stdout: ${STDOUT}"
|
||||
|
||||
# ─── 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"
|
||||
head -1 _bmad/_config/bmad-help.csv | grep -q '^module,skill,display-name,' \
|
||||
&& ok "bmad-help.csv has canonical header" || ko "bmad-help.csv header wrong"
|
||||
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"
|
||||
|
||||
# ─── 10. remove minimal (no purge), preserve custom ─────────────────────────
|
||||
note "create _bmad/custom/mdlint to test preservation, then remove"
|
||||
mkdir -p _bmad/custom/mdlint
|
||||
|
|
@ -244,81 +201,12 @@ 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
|
||||
grep -q '\[modules\.devlog]' _bmad/config.toml \
|
||||
&& ko "[modules.devlog] still in config.toml" || ok "config.toml [modules.devlog] stripped"
|
||||
grep -q '\[agents\.bmad-agent-historian]' _bmad/config.toml \
|
||||
&& ko "[agents.bmad-agent-historian] still in config.toml" || ok "config.toml agent block stripped"
|
||||
grep -q '\[modules\.devlog]' _bmad/config.user.toml \
|
||||
&& ko "[modules.devlog] still in config.user.toml" || ok "config.user.toml [modules.devlog] stripped"
|
||||
grep -q '^devlog,' _bmad/_config/bmad-help.csv \
|
||||
&& ko "devlog rows still in bmad-help.csv" || ok "bmad-help.csv devlog rows removed"
|
||||
# [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"
|
||||
[[ "${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"
|
||||
|
||||
# ─── 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
|
||||
[[ "${STDOUT}" == *"installed npm dependencies for npmtool"* ]] \
|
||||
&& ok "npm dependencies installed" \
|
||||
|| ko "expected npm install confirmation in stdout: ${STDOUT}"
|
||||
# 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 "──────────────────────────────────────────────────────────────────────"
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -3238,126 +3238,6 @@ async function runTests() {
|
|||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Test Suite 45: New module system (plugin.json#bmad) in the installer
|
||||
// ============================================================
|
||||
console.log(`${colors.yellow}Test Suite 45: 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(() => {});
|
||||
}
|
||||
|
||||
// ---- 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 = [];
|
||||
const result = await om.install('devlog', bmadDir, (p) => tracked.push(p), { skipModuleInstaller: true, moduleConfig: {} });
|
||||
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 45 setup failed: ${error.message}${colors.reset}`);
|
||||
console.log(error.stack);
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// ============================================================
|
||||
// Summary
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -372,27 +372,21 @@ 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;
|
||||
}
|
||||
|
||||
// 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],
|
||||
const setupResults = await this.ideManager.setupBatch(validIdes, paths.projectRoot, paths.bmadDir, {
|
||||
selectedModules: allModules || [],
|
||||
verbose: config.verbose,
|
||||
cleanup: false,
|
||||
previousSkillIds,
|
||||
});
|
||||
|
||||
for (const setupResult of results) {
|
||||
for (const setupResult of setupResults) {
|
||||
const ide = setupResult.ide;
|
||||
if (setupResult.success) {
|
||||
addResult(ide, 'ok', setupResult.detail || '');
|
||||
|
|
@ -407,12 +401,26 @@ 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 { cleanupBmadSkillDirs } = require('./ide-sync');
|
||||
await 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _readSkillManifestRows(bmadDir) {
|
||||
|
|
|
|||
|
|
@ -2,19 +2,9 @@ const fs = require('../fs-native');
|
|||
const path = require('node:path');
|
||||
const yaml = require('yaml');
|
||||
|
||||
let _cachedPlatformCodes = null;
|
||||
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
let _cachedPlatformCodes = null;
|
||||
|
||||
/**
|
||||
* Load the platform codes configuration from YAML
|
||||
|
|
@ -25,7 +15,6 @@ 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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
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 {
|
||||
// Malformed JSON — treat as "not a new-system module" and let the legacy
|
||||
// resolver (or validateDeclaredPaths at install time) surface the problem.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { loadBmadModuleLib, readPluginManifest };
|
||||
|
|
@ -5,7 +5,6 @@ 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 = {}) {
|
||||
|
|
@ -313,14 +312,6 @@ 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)) {
|
||||
|
|
@ -387,140 +378,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -2,25 +2,17 @@ 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 by analyzing its on-disk shape.
|
||||
* Resolves how to install a plugin from marketplace.json by analyzing
|
||||
* where module.yaml and module-help.csv live relative to the listed skills.
|
||||
*
|
||||
* 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:
|
||||
* Five strategies, 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 {
|
||||
/**
|
||||
|
|
@ -35,13 +27,6 @@ class PluginResolver {
|
|||
* @returns {Promise<ResolvedModule[]>} Array of resolved module definitions
|
||||
*/
|
||||
async resolve(repoPath, plugin) {
|
||||
// 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
|
||||
|
|
@ -79,84 +64,6 @@ 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 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -180,7 +87,6 @@ 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,
|
||||
|
|
@ -218,7 +124,6 @@ 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,
|
||||
|
|
@ -258,7 +163,6 @@ 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,
|
||||
|
|
@ -297,7 +201,6 @@ 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,
|
||||
|
|
@ -354,7 +257,6 @@ class PluginResolver {
|
|||
name: moduleName,
|
||||
version: plugin.version || null,
|
||||
description: plugin.description || '',
|
||||
format: 'legacy',
|
||||
strategy: 5,
|
||||
pluginName: plugin.name,
|
||||
moduleYamlPath: null,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ 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();
|
||||
|
||||
|
|
@ -1072,7 +1071,6 @@ class UI {
|
|||
name: plugin.displayName || plugin.name,
|
||||
version: plugin.version,
|
||||
description: plugin.description,
|
||||
format: 'legacy',
|
||||
strategy: 0,
|
||||
pluginName: plugin.name,
|
||||
skillPaths: [],
|
||||
|
|
@ -1083,39 +1081,32 @@ class UI {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// 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);
|
||||
// Direct mode: no marketplace.json, scan directory for skills and resolve
|
||||
const directPlugin = {
|
||||
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||
source: '.',
|
||||
skills: [],
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (directPlugin.skills.length > 0) {
|
||||
try {
|
||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
||||
allResolved.push(...resolved);
|
||||
|
|
@ -1236,36 +1227,32 @@ class UI {
|
|||
continue;
|
||||
}
|
||||
} else {
|
||||
// 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);
|
||||
// Direct mode: scan for SKILL.md directories
|
||||
const directPlugin = {
|
||||
name: rootManifest?.name || sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||
name: sourceResult.parsed.displayName || path.basename(sourceResult.rootDir),
|
||||
source: '.',
|
||||
skills: [],
|
||||
};
|
||||
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);
|
||||
}
|
||||
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 (rootManifest || directPlugin.skills.length > 0) {
|
||||
if (directPlugin.skills.length > 0) {
|
||||
try {
|
||||
const resolved = await customMgr.resolvePlugin(sourceResult.rootDir, directPlugin, sourceResult.sourceUrl, localPath);
|
||||
allResolved.push(...resolved);
|
||||
} catch (resolveError) {
|
||||
await prompts.log.warn(` Could not resolve ${source}: ${resolveError.message}`);
|
||||
} catch {
|
||||
// Skip unresolvable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,10 +195,6 @@ 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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue