refactor(installer): restructure installer with clean separation of concerns

Move tools/cli/ to tools/installer/ with major structural cleanup:

- InstallPaths async factory for path resolution and directory creation
- Config value object (frozen) replaces mutable config bag
- ExistingInstall value object replaces stateful Detector class
- OfficialModules + CustomModules + ExternalModuleManager replace monolithic ModuleManager
- install() is prompt-free; all user interaction in ui.js
- Update state returned explicitly instead of mutating customConfig
- Delete dead code: dependency-resolver, _base-ide, IdeConfigManager,
  platform-codes helpers, npx wrapper, xml-utils
- Flatten directory structure: custom/handler → custom-handler,
  tools/cli/ → tools/installer/, lib/ directories removed
- Update all path references in package.json, tests, CI, and docs
This commit is contained in:
Alex Verkhovsky 2026-03-25 22:34:24 -06:00
parent 819d373e2e
commit f0e81fd308
85 changed files with 3703 additions and 7715 deletions

View File

@ -5,7 +5,7 @@ on:
branches: [main] branches: [main]
paths: paths:
- "src/**" - "src/**"
- "tools/cli/**" - "tools/installer/**"
- "package.json" - "package.json"
workflow_dispatch: workflow_dispatch:
inputs: inputs:

View File

@ -61,7 +61,7 @@ IDs d'outils disponibles pour loption `--tools` :
**Recommandés :** `claude-code`, `cursor` **Recommandés :** `claude-code`, `cursor`
Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml). Exécutez `npx bmad-method install` de manière interactive une fois pour voir la liste complète actuelle des outils pris en charge, ou consultez la [configuration des codes de la plateforme](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml).
## Modes d'installation ## Modes d'installation

View File

@ -61,7 +61,7 @@ Available tool IDs for the `--tools` flag:
**Preferred:** `claude-code`, `cursor` **Preferred:** `claude-code`, `cursor`
Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml). Run `npx bmad-method install` interactively once to see the full current list of supported tools, or check the [platform codes configuration](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml).
## Installation Modes ## Installation Modes

View File

@ -61,7 +61,7 @@ sidebar:
**推荐:** `claude-code`、`cursor` **推荐:** `claude-code`、`cursor`
运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/cli/installers/lib/ide/platform-codes.yaml)。 运行一次 `npx bmad-method install` 交互式安装以查看完整的当前支持工具列表,或查看 [平台代码配置](https://github.com/bmad-code-org/BMAD-METHOD/blob/main/tools/installer/ide/platform-codes.yaml)。
## 安装模式 ## 安装模式

View File

@ -18,14 +18,14 @@
}, },
"license": "MIT", "license": "MIT",
"author": "Brian (BMad) Madison", "author": "Brian (BMad) Madison",
"main": "tools/cli/bmad-cli.js", "main": "tools/installer/bmad-cli.js",
"bin": { "bin": {
"bmad": "tools/bmad-npx-wrapper.js", "bmad": "tools/installer/bmad-cli.js",
"bmad-method": "tools/bmad-npx-wrapper.js" "bmad-method": "tools/installer/bmad-cli.js"
}, },
"scripts": { "scripts": {
"bmad:install": "node tools/cli/bmad-cli.js install", "bmad:install": "node tools/installer/bmad-cli.js install",
"bmad:uninstall": "node tools/cli/bmad-cli.js uninstall", "bmad:uninstall": "node tools/installer/bmad-cli.js uninstall",
"docs:build": "node tools/build-docs.mjs", "docs:build": "node tools/build-docs.mjs",
"docs:dev": "astro dev --root website", "docs:dev": "astro dev --root website",
"docs:fix-links": "node tools/fix-doc-links.js", "docs:fix-links": "node tools/fix-doc-links.js",
@ -34,13 +34,13 @@
"format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"", "format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"",
"format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"", "format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"",
"format:fix:staged": "prettier --write", "format:fix:staged": "prettier --write",
"install:bmad": "node tools/cli/bmad-cli.js install", "install:bmad": "node tools/installer/bmad-cli.js install",
"lint": "eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0", "lint": "eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0",
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
"lint:md": "markdownlint-cli2 \"**/*.md\"", "lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills", "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check", "test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
"test:install": "node test/test-installation-components.js", "test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js", "test:refs": "node test/test-file-refs-csv.js",

View File

@ -172,7 +172,7 @@ parts: 1
- Deferred: CI/CD integration, telemetry for module authors, air-gapped enterprise install, zip bundle integrity verification (checksums/signing), deeper non-technical platform integrations - Deferred: CI/CD integration, telemetry for module authors, air-gapped enterprise install, zip bundle integrity verification (checksums/signing), deeper non-technical platform integrations
## Current Installer (migration context) ## Current Installer (migration context)
- Entry: `tools/cli/bmad-cli.js` (Commander.js) → `tools/cli/installers/lib/core/installer.js` - Entry: `tools/installer/bmad-cli.js` (Commander.js) → `tools/installer/core/installer.js`
- Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags) - Platforms: `platform-codes.yaml` (~20 platforms with target dirs, legacy dirs, template types, special flags)
- Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON - Manifests: CSV files (skill/workflow/agent-manifest.csv) are current source of truth, not JSON
- External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver - External modules: `external-official-modules.yaml` (CIS, GDS, TEA, WDS) from npm with semver

View File

@ -15,7 +15,7 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { loadSkillManifest, getInstallToBmad } = require('../tools/cli/installers/lib/ide/shared/skill-manifest'); const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest');
// ANSI colors // ANSI colors
const colors = { const colors = {

View File

@ -14,10 +14,9 @@
const path = require('node:path'); const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { ConfigCollector } = require('../tools/cli/installers/lib/core/config-collector'); const { ManifestGenerator } = require('../tools/installer/core/manifest-generator');
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); const { IdeManager } = require('../tools/installer/ide/manager');
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager'); const { clearCache, loadPlatformCodes } = require('../tools/installer/ide/platform-codes');
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
// ANSI colors // ANSI colors
const colors = { const colors = {
@ -149,8 +148,6 @@ async function runTests() {
assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path'); assert(windsurfInstaller?.target_dir === '.windsurf/skills', 'Windsurf target_dir uses native skills path');
assert(windsurfInstaller?.skill_format === true, 'Windsurf installer enables native skill output');
assert( assert(
Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'), Array.isArray(windsurfInstaller?.legacy_targets) && windsurfInstaller.legacy_targets.includes('.windsurf/workflows'),
'Windsurf installer cleans legacy workflow output', 'Windsurf installer cleans legacy workflow output',
@ -197,8 +194,6 @@ async function runTests() {
assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path'); assert(kiroInstaller?.target_dir === '.kiro/skills', 'Kiro target_dir uses native skills path');
assert(kiroInstaller?.skill_format === true, 'Kiro installer enables native skill output');
assert( assert(
Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'), Array.isArray(kiroInstaller?.legacy_targets) && kiroInstaller.legacy_targets.includes('.kiro/steering'),
'Kiro installer cleans legacy steering output', 'Kiro installer cleans legacy steering output',
@ -245,8 +240,6 @@ async function runTests() {
assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path'); assert(antigravityInstaller?.target_dir === '.agent/skills', 'Antigravity target_dir uses native skills path');
assert(antigravityInstaller?.skill_format === true, 'Antigravity installer enables native skill output');
assert( assert(
Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'), Array.isArray(antigravityInstaller?.legacy_targets) && antigravityInstaller.legacy_targets.includes('.agent/workflows'),
'Antigravity installer cleans legacy workflow output', 'Antigravity installer cleans legacy workflow output',
@ -293,8 +286,6 @@ async function runTests() {
assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path'); assert(auggieInstaller?.target_dir === '.augment/skills', 'Auggie target_dir uses native skills path');
assert(auggieInstaller?.skill_format === true, 'Auggie installer enables native skill output');
assert( assert(
Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'), Array.isArray(auggieInstaller?.legacy_targets) && auggieInstaller.legacy_targets.includes('.augment/commands'),
'Auggie installer cleans legacy command output', 'Auggie installer cleans legacy command output',
@ -346,8 +337,6 @@ async function runTests() {
assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path'); assert(opencodeInstaller?.target_dir === '.opencode/skills', 'OpenCode target_dir uses native skills path');
assert(opencodeInstaller?.skill_format === true, 'OpenCode installer enables native skill output');
assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks'); assert(opencodeInstaller?.ancestor_conflict_check === true, 'OpenCode installer enables ancestor conflict checks');
assert( assert(
@ -412,8 +401,6 @@ async function runTests() {
assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path'); assert(claudeInstaller?.target_dir === '.claude/skills', 'Claude Code target_dir uses native skills path');
assert(claudeInstaller?.skill_format === true, 'Claude Code installer enables native skill output');
assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks'); assert(claudeInstaller?.ancestor_conflict_check === true, 'Claude Code installer enables ancestor conflict checks');
assert( assert(
@ -505,8 +492,6 @@ async function runTests() {
assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path'); assert(codexInstaller?.target_dir === '.agents/skills', 'Codex target_dir uses native skills path');
assert(codexInstaller?.skill_format === true, 'Codex installer enables native skill output');
assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks'); assert(codexInstaller?.ancestor_conflict_check === true, 'Codex installer enables ancestor conflict checks');
assert( assert(
@ -595,8 +580,6 @@ async function runTests() {
assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path'); assert(cursorInstaller?.target_dir === '.cursor/skills', 'Cursor target_dir uses native skills path');
assert(cursorInstaller?.skill_format === true, 'Cursor installer enables native skill output');
assert( assert(
Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'), Array.isArray(cursorInstaller?.legacy_targets) && cursorInstaller.legacy_targets.includes('.cursor/commands'),
'Cursor installer cleans legacy command output', 'Cursor installer cleans legacy command output',
@ -649,8 +632,6 @@ async function runTests() {
assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path'); assert(rooInstaller?.target_dir === '.roo/skills', 'Roo target_dir uses native skills path');
assert(rooInstaller?.skill_format === true, 'Roo installer enables native skill output');
assert( assert(
Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'), Array.isArray(rooInstaller?.legacy_targets) && rooInstaller.legacy_targets.includes('.roo/commands'),
'Roo installer cleans legacy command output', 'Roo installer cleans legacy command output',
@ -757,8 +738,6 @@ async function runTests() {
assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path'); assert(copilotInstaller?.target_dir === '.github/skills', 'GitHub Copilot target_dir uses native skills path');
assert(copilotInstaller?.skill_format === true, 'GitHub Copilot installer enables native skill output');
assert( assert(
Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'), Array.isArray(copilotInstaller?.legacy_targets) && copilotInstaller.legacy_targets.includes('.github/agents'),
'GitHub Copilot installer cleans legacy agents output', 'GitHub Copilot installer cleans legacy agents output',
@ -839,8 +818,6 @@ async function runTests() {
assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path'); assert(clineInstaller?.target_dir === '.cline/skills', 'Cline target_dir uses native skills path');
assert(clineInstaller?.skill_format === true, 'Cline installer enables native skill output');
assert( assert(
Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'), Array.isArray(clineInstaller?.legacy_targets) && clineInstaller.legacy_targets.includes('.clinerules/workflows'),
'Cline installer cleans legacy workflow output', 'Cline installer cleans legacy workflow output',
@ -901,8 +878,6 @@ async function runTests() {
assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path'); assert(codebuddyInstaller?.target_dir === '.codebuddy/skills', 'CodeBuddy target_dir uses native skills path');
assert(codebuddyInstaller?.skill_format === true, 'CodeBuddy installer enables native skill output');
assert( assert(
Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'), Array.isArray(codebuddyInstaller?.legacy_targets) && codebuddyInstaller.legacy_targets.includes('.codebuddy/commands'),
'CodeBuddy installer cleans legacy command output', 'CodeBuddy installer cleans legacy command output',
@ -961,8 +936,6 @@ async function runTests() {
assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path'); assert(crushInstaller?.target_dir === '.crush/skills', 'Crush target_dir uses native skills path');
assert(crushInstaller?.skill_format === true, 'Crush installer enables native skill output');
assert( assert(
Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'), Array.isArray(crushInstaller?.legacy_targets) && crushInstaller.legacy_targets.includes('.crush/commands'),
'Crush installer cleans legacy command output', 'Crush installer cleans legacy command output',
@ -1021,8 +994,6 @@ async function runTests() {
assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path'); assert(traeInstaller?.target_dir === '.trae/skills', 'Trae target_dir uses native skills path');
assert(traeInstaller?.skill_format === true, 'Trae installer enables native skill output');
assert( assert(
Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'), Array.isArray(traeInstaller?.legacy_targets) && traeInstaller.legacy_targets.includes('.trae/rules'),
'Trae installer cleans legacy rules output', 'Trae installer cleans legacy rules output',
@ -1138,8 +1109,6 @@ async function runTests() {
assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path'); assert(geminiInstaller?.target_dir === '.gemini/skills', 'Gemini target_dir uses native skills path');
assert(geminiInstaller?.skill_format === true, 'Gemini installer enables native skill output');
assert( assert(
Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'), Array.isArray(geminiInstaller?.legacy_targets) && geminiInstaller.legacy_targets.includes('.gemini/commands'),
'Gemini installer cleans legacy commands output', 'Gemini installer cleans legacy commands output',
@ -1196,7 +1165,6 @@ async function runTests() {
const iflowInstaller = platformCodes24.platforms.iflow?.installer; const iflowInstaller = platformCodes24.platforms.iflow?.installer;
assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path'); assert(iflowInstaller?.target_dir === '.iflow/skills', 'iFlow target_dir uses native skills path');
assert(iflowInstaller?.skill_format === true, 'iFlow installer enables native skill output');
assert( assert(
Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'), Array.isArray(iflowInstaller?.legacy_targets) && iflowInstaller.legacy_targets.includes('.iflow/commands'),
'iFlow installer cleans legacy commands output', 'iFlow installer cleans legacy commands output',
@ -1246,7 +1214,6 @@ async function runTests() {
const qwenInstaller = platformCodes25.platforms.qwen?.installer; const qwenInstaller = platformCodes25.platforms.qwen?.installer;
assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path'); assert(qwenInstaller?.target_dir === '.qwen/skills', 'QwenCoder target_dir uses native skills path');
assert(qwenInstaller?.skill_format === true, 'QwenCoder installer enables native skill output');
assert( assert(
Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'), Array.isArray(qwenInstaller?.legacy_targets) && qwenInstaller.legacy_targets.includes('.qwen/commands'),
'QwenCoder installer cleans legacy commands output', 'QwenCoder installer cleans legacy commands output',
@ -1296,7 +1263,6 @@ async function runTests() {
const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer; const rovoInstaller = platformCodes26.platforms['rovo-dev']?.installer;
assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path'); assert(rovoInstaller?.target_dir === '.rovodev/skills', 'Rovo Dev target_dir uses native skills path');
assert(rovoInstaller?.skill_format === true, 'Rovo Dev installer enables native skill output');
assert( assert(
Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'), Array.isArray(rovoInstaller?.legacy_targets) && rovoInstaller.legacy_targets.includes('.rovodev/workflows'),
'Rovo Dev installer cleans legacy workflows output', 'Rovo Dev installer cleans legacy workflows output',
@ -1432,8 +1398,6 @@ async function runTests() {
const piInstaller = platformCodes28.platforms.pi?.installer; const piInstaller = platformCodes28.platforms.pi?.installer;
assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path'); assert(piInstaller?.target_dir === '.pi/skills', 'Pi target_dir uses native skills path');
assert(piInstaller?.skill_format === true, 'Pi installer enables native skill output');
assert(piInstaller?.template_type === 'default', 'Pi installer uses default skill template');
tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-')); tempProjectDir28 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-pi-test-'));
installedBmadDir28 = await createTestBmadFixture(); installedBmadDir28 = await createTestBmadFixture();
@ -1648,93 +1612,6 @@ async function runTests() {
// skill-manifest.csv should include the native agent entrypoint // skill-manifest.csv should include the native agent entrypoint
const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8'); const skillManifestCsv29 = await fs.readFile(path.join(tempFixture29, '_config', 'skill-manifest.csv'), 'utf8');
assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint'); assert(skillManifestCsv29.includes('bmad-tea'), 'skill-manifest.csv includes native type:agent SKILL.md entrypoint');
// --- Agents at non-agents/ paths (regression test for BMM/CIS layouts) ---
// Create a second fixture with agents at paths like bmm/1-analysis/bmad-agent-analyst/
const tempFixture29b = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-agent-paths-'));
await fs.ensureDir(path.join(tempFixture29b, '_config'));
// Agent at bmm-style path: bmm/1-analysis/bmad-agent-analyst/
const bmmAgentDir = path.join(tempFixture29b, 'bmm', '1-analysis', 'bmad-agent-analyst');
await fs.ensureDir(bmmAgentDir);
await fs.writeFile(
path.join(bmmAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-agent-analyst',
'displayName: Mary',
'title: Business Analyst',
'role: Strategic Business Analyst',
'module: bmm',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(bmmAgentDir, 'SKILL.md'),
'---\nname: bmad-agent-analyst\ndescription: Business Analyst agent\n---\n\nAnalyst agent.\n',
);
// Agent at cis-style path: cis/skills/bmad-cis-agent-brainstorming-coach/
const cisAgentDir = path.join(tempFixture29b, 'cis', 'skills', 'bmad-cis-agent-brainstorming-coach');
await fs.ensureDir(cisAgentDir);
await fs.writeFile(
path.join(cisAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: bmad-cis-agent-brainstorming-coach',
'displayName: Carson',
'title: Brainstorming Specialist',
'role: Master Facilitator',
'module: cis',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(cisAgentDir, 'SKILL.md'),
'---\nname: bmad-cis-agent-brainstorming-coach\ndescription: Brainstorming coach\n---\n\nCoach.\n',
);
// Agent at standard agents/ path (GDS-style): gds/agents/gds-agent-game-dev/
const gdsAgentDir = path.join(tempFixture29b, 'gds', 'agents', 'gds-agent-game-dev');
await fs.ensureDir(gdsAgentDir);
await fs.writeFile(
path.join(gdsAgentDir, 'bmad-skill-manifest.yaml'),
[
'type: agent',
'name: gds-agent-game-dev',
'displayName: Link',
'title: Game Developer',
'role: Senior Game Dev',
'module: gds',
].join('\n') + '\n',
);
await fs.writeFile(
path.join(gdsAgentDir, 'SKILL.md'),
'---\nname: gds-agent-game-dev\ndescription: Game developer agent\n---\n\nGame dev.\n',
);
const generator29b = new ManifestGenerator();
await generator29b.generateManifests(tempFixture29b, ['bmm', 'cis', 'gds'], [], { ides: [] });
// All three agents should appear in agents[] regardless of directory layout
const bmmAgent = generator29b.agents.find((a) => a.name === 'bmad-agent-analyst');
assert(bmmAgent !== undefined, 'Agent at bmm/1-analysis/ path appears in agents[]');
assert(bmmAgent && bmmAgent.module === 'bmm', 'BMM agent module field comes from manifest file');
assert(bmmAgent && bmmAgent.path.includes('bmm/1-analysis/bmad-agent-analyst'), 'BMM agent path reflects actual directory layout');
const cisAgent = generator29b.agents.find((a) => a.name === 'bmad-cis-agent-brainstorming-coach');
assert(cisAgent !== undefined, 'Agent at cis/skills/ path appears in agents[]');
assert(cisAgent && cisAgent.module === 'cis', 'CIS agent module field comes from manifest file');
const gdsAgent = generator29b.agents.find((a) => a.name === 'gds-agent-game-dev');
assert(gdsAgent !== undefined, 'Agent at gds/agents/ path appears in agents[]');
assert(gdsAgent && gdsAgent.module === 'gds', 'GDS agent module field comes from manifest file');
// agent-manifest.csv should contain all three
const agentCsv29b = await fs.readFile(path.join(tempFixture29b, '_config', 'agent-manifest.csv'), 'utf8');
assert(agentCsv29b.includes('bmad-agent-analyst'), 'agent-manifest.csv includes BMM-layout agent');
assert(agentCsv29b.includes('bmad-cis-agent-brainstorming-coach'), 'agent-manifest.csv includes CIS-layout agent');
assert(agentCsv29b.includes('gds-agent-game-dev'), 'agent-manifest.csv includes GDS-layout agent');
await fs.remove(tempFixture29b).catch(() => {});
} catch (error) { } catch (error) {
assert(false, 'Unified skill scanner test succeeds', error.message); assert(false, 'Unified skill scanner test succeeds', error.message);
} finally { } finally {
@ -1861,8 +1738,6 @@ async function runTests() {
const onaInstaller = platformCodes32.platforms.ona?.installer; const onaInstaller = platformCodes32.platforms.ona?.installer;
assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path'); assert(onaInstaller?.target_dir === '.ona/skills', 'Ona target_dir uses native skills path');
assert(onaInstaller?.skill_format === true, 'Ona installer enables native skill output');
assert(onaInstaller?.template_type === 'default', 'Ona installer uses default skill template');
tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-')); tempProjectDir32 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-ona-test-'));
installedBmadDir32 = await createTestBmadFixture(); installedBmadDir32 = await createTestBmadFixture();
@ -1941,93 +1816,6 @@ async function runTests() {
console.log(''); console.log('');
// ============================================================
// Test Suite 33: ConfigCollector Prompt Normalization
// ============================================================
console.log(`${colors.yellow}Test Suite 33: ConfigCollector Prompt Normalization${colors.reset}\n`);
try {
const teaModuleConfig33 = {
test_artifacts: {
default: '_bmad-output/test-artifacts',
},
test_design_output: {
prompt: 'Where should test design documents be stored?',
default: 'test-design',
result: '{test_artifacts}/{value}',
},
test_review_output: {
prompt: 'Where should test review reports be stored?',
default: 'test-reviews',
result: '{test_artifacts}/{value}',
},
trace_output: {
prompt: 'Where should traceability reports be stored?',
default: 'traceability',
result: '{test_artifacts}/{value}',
},
};
const collector33 = new ConfigCollector();
collector33.currentProjectDir = path.join(os.tmpdir(), 'bmad-config-normalization');
collector33.allAnswers = {};
collector33.collectedConfig = {
tea: {
test_artifacts: '_bmad-output/test-artifacts',
},
};
collector33.existingConfig = {
tea: {
test_artifacts: '_bmad-output/test-artifacts',
test_design_output: '_bmad-output/test-artifacts/test-design',
test_review_output: '_bmad-output/test-artifacts/test-reviews',
trace_output: '_bmad-output/test-artifacts/traceability',
},
};
const testDesignQuestion33 = await collector33.buildQuestion(
'tea',
'test_design_output',
teaModuleConfig33.test_design_output,
teaModuleConfig33,
);
const testReviewQuestion33 = await collector33.buildQuestion(
'tea',
'test_review_output',
teaModuleConfig33.test_review_output,
teaModuleConfig33,
);
const traceQuestion33 = await collector33.buildQuestion('tea', 'trace_output', teaModuleConfig33.trace_output, teaModuleConfig33);
assert(testDesignQuestion33.default === 'test-design', 'ConfigCollector normalizes existing test_design_output prompt default');
assert(testReviewQuestion33.default === 'test-reviews', 'ConfigCollector normalizes existing test_review_output prompt default');
assert(traceQuestion33.default === 'traceability', 'ConfigCollector normalizes existing trace_output prompt default');
collector33.allAnswers = {
tea_test_artifacts: '_bmad-output/test-artifacts',
};
assert(
collector33.processResultTemplate(teaModuleConfig33.test_design_output.result, testDesignQuestion33.default) ===
'_bmad-output/test-artifacts/test-design',
'ConfigCollector re-applies test_design_output template without duplicating prefix',
);
assert(
collector33.processResultTemplate(teaModuleConfig33.test_review_output.result, testReviewQuestion33.default) ===
'_bmad-output/test-artifacts/test-reviews',
'ConfigCollector re-applies test_review_output template without duplicating prefix',
);
assert(
collector33.processResultTemplate(teaModuleConfig33.trace_output.result, traceQuestion33.default) ===
'_bmad-output/test-artifacts/traceability',
'ConfigCollector re-applies trace_output template without duplicating prefix',
);
} catch (error) {
assert(false, 'ConfigCollector prompt normalization test succeeds', error.message);
}
console.log('');
// ============================================================ // ============================================================
// Summary // Summary
// ============================================================ // ============================================================

View File

@ -34,7 +34,7 @@ function assert(condition, testName, errorMessage = '') {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// These regexes are extracted from ModuleManager.vendorWorkflowDependencies() // These regexes are extracted from ModuleManager.vendorWorkflowDependencies()
// in tools/cli/installers/lib/modules/manager.js // in tools/installer/modules/manager.js
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Source regex (line ~1081) — uses non-capturing group for _bmad // Source regex (line ~1081) — uses non-capturing group for _bmad

View File

@ -1,38 +0,0 @@
#!/usr/bin/env node
/**
* BMad Method CLI - Direct execution wrapper for npx
* This file ensures proper execution when run via npx from GitHub or npm registry
*/
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
// Check if we're running in an npx temporary directory
const isNpxExecution = __dirname.includes('_npx') || __dirname.includes('.npm');
if (isNpxExecution) {
// Running via npx - spawn child process to preserve user's working directory
const args = process.argv.slice(2);
const bmadCliPath = path.join(__dirname, 'cli', 'bmad-cli.js');
if (!fs.existsSync(bmadCliPath)) {
console.error('Error: Could not find bmad-cli.js at', bmadCliPath);
console.error('Current directory:', __dirname);
process.exit(1);
}
try {
// Execute CLI from user's working directory (process.cwd()), not npm cache
execFileSync('node', [bmadCliPath, ...args], {
stdio: 'inherit',
cwd: process.cwd(), // This preserves the user's working directory
});
} catch (error) {
process.exit(error.status || 1);
}
} else {
// Local execution - use require
require('./cli/bmad-cli.js');
}

View File

@ -1,743 +0,0 @@
const fs = require('fs-extra');
const path = require('node:path');
const glob = require('glob');
const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
/**
* Dependency Resolver for BMAD modules
* Handles cross-module dependencies and ensures all required files are included
*/
class DependencyResolver {
constructor() {
this.dependencies = new Map();
this.resolvedFiles = new Set();
this.missingDependencies = new Set();
}
/**
* Resolve all dependencies for selected modules
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Modules explicitly selected by user
* @param {Object} options - Resolution options
* @returns {Object} Resolution results with all required files
*/
async resolve(bmadDir, selectedModules = [], options = {}) {
if (options.verbose) {
await prompts.log.info('Resolving module dependencies...');
}
// Always include core as base
const modulesToProcess = new Set(['core', ...selectedModules]);
// First pass: collect all explicitly selected files
const primaryFiles = await this.collectPrimaryFiles(bmadDir, modulesToProcess, options);
// Second pass: parse and resolve dependencies
const allDependencies = await this.parseDependencies(primaryFiles);
// Third pass: resolve dependency paths and collect files
const resolvedDeps = await this.resolveDependencyPaths(bmadDir, allDependencies);
// Fourth pass: check for transitive dependencies
const transitiveDeps = await this.resolveTransitiveDependencies(bmadDir, resolvedDeps);
// Combine all files
const allFiles = new Set([...primaryFiles.map((f) => f.path), ...resolvedDeps, ...transitiveDeps]);
// Organize by module
const organizedFiles = this.organizeByModule(bmadDir, allFiles);
// Report results (only in verbose mode)
if (options.verbose) {
await this.reportResults(organizedFiles, selectedModules);
}
return {
primaryFiles,
dependencies: resolvedDeps,
transitiveDependencies: transitiveDeps,
allFiles: [...allFiles],
byModule: organizedFiles,
missing: [...this.missingDependencies],
};
}
/**
* Collect primary files from selected modules
*/
async collectPrimaryFiles(bmadDir, modules, options = {}) {
const files = [];
const { moduleManager } = options;
for (const module of modules) {
// Skip external modules - they're installed from cache, not from source
if (moduleManager && (await moduleManager.isExternalModule(module))) {
continue;
}
// Handle both source (src/) and installed (bmad/) directory structures
let moduleDir;
// Check if this is a source directory (has 'src' subdirectory)
const srcDir = path.join(bmadDir, 'src');
if (await fs.pathExists(srcDir)) {
// Source directory structure: src/core-skills or src/bmm-skills
if (module === 'core') {
moduleDir = path.join(srcDir, 'core-skills');
} else if (module === 'bmm') {
moduleDir = path.join(srcDir, 'bmm-skills');
}
}
if (!moduleDir) {
continue;
}
if (!(await fs.pathExists(moduleDir))) {
await prompts.log.warn('Module directory not found: ' + moduleDir);
continue;
}
// Collect agents
const agentsDir = path.join(moduleDir, 'agents');
if (await fs.pathExists(agentsDir)) {
const agentFiles = await glob.glob('*.md', { cwd: agentsDir });
for (const file of agentFiles) {
const agentPath = path.join(agentsDir, file);
// Check for localskip attribute
const content = await fs.readFile(agentPath, 'utf8');
const hasLocalSkip = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
if (hasLocalSkip) {
continue; // Skip agents marked for web-only
}
files.push({
path: agentPath,
type: 'agent',
module,
name: path.basename(file, '.md'),
});
}
}
// Collect tasks
const tasksDir = path.join(moduleDir, 'tasks');
if (await fs.pathExists(tasksDir)) {
const taskFiles = await glob.glob('*.md', { cwd: tasksDir });
for (const file of taskFiles) {
files.push({
path: path.join(tasksDir, file),
type: 'task',
module,
name: path.basename(file, '.md'),
});
}
}
}
return files;
}
/**
* Parse dependencies from file content
*/
async parseDependencies(files) {
const allDeps = new Set();
for (const file of files) {
const content = await fs.readFile(file.path, 'utf8');
// Parse YAML frontmatter for explicit dependencies
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (frontmatterMatch) {
try {
// Pre-process to handle backticks in YAML values
let yamlContent = frontmatterMatch[1];
// Quote values with backticks to make them valid YAML
yamlContent = yamlContent.replaceAll(/: `([^`]+)`/g, ': "$1"');
const frontmatter = yaml.parse(yamlContent);
if (frontmatter.dependencies) {
const deps = Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies : [frontmatter.dependencies];
for (const dep of deps) {
allDeps.add({
from: file.path,
dependency: dep,
type: 'explicit',
});
}
}
// Check for template dependencies
if (frontmatter.template) {
const templates = Array.isArray(frontmatter.template) ? frontmatter.template : [frontmatter.template];
for (const template of templates) {
allDeps.add({
from: file.path,
dependency: template,
type: 'template',
});
}
}
} catch (error) {
await prompts.log.warn('Failed to parse frontmatter in ' + file.name + ': ' + error.message);
}
}
// Parse content for command references (cross-module dependencies)
const commandRefs = this.parseCommandReferences(content);
for (const ref of commandRefs) {
allDeps.add({
from: file.path,
dependency: ref,
type: 'command',
});
}
// Parse for file path references
const fileRefs = this.parseFileReferences(content);
for (const ref of fileRefs) {
// Determine type based on path format
// Paths starting with bmad/ are absolute references to the bmad installation
const depType = ref.startsWith('bmad/') ? 'bmad-path' : 'file';
allDeps.add({
from: file.path,
dependency: ref,
type: depType,
});
}
}
return allDeps;
}
/**
* Parse command references from content
*/
parseCommandReferences(content) {
const refs = new Set();
// Match @task-{name} or @agent-{name} or @{module}-{type}-{name}
const commandPattern = /@(task-|agent-|bmad-)([a-z0-9-]+)/g;
let match;
while ((match = commandPattern.exec(content)) !== null) {
refs.add(match[0]);
}
// Match file paths like bmad/core/agents/analyst
const pathPattern = /bmad\/(core|bmm|cis)\/(agents|tasks)\/([a-z0-9-]+)/g;
while ((match = pathPattern.exec(content)) !== null) {
refs.add(match[0]);
}
return [...refs];
}
/**
* Parse file path references from content
*/
parseFileReferences(content) {
const refs = new Set();
// Match relative paths like ../templates/file.yaml or ./data/file.md
const relativePattern = /['"](\.\.?\/[^'"]+\.(md|yaml|yml|xml|json|txt|csv))['"]/g;
let match;
while ((match = relativePattern.exec(content)) !== null) {
refs.add(match[1]);
}
// Parse exec attributes in command tags
const execPattern = /exec="([^"]+)"/g;
while ((match = execPattern.exec(content)) !== null) {
let execPath = match[1];
if (execPath && execPath !== '*') {
// Remove {project-root} prefix to get the actual path
// Usage is like {project-root}/bmad/core/tasks/foo.md
if (execPath.includes('{project-root}')) {
execPath = execPath.replace('{project-root}', '');
}
refs.add(execPath);
}
}
// Parse tmpl attributes in command tags
const tmplPattern = /tmpl="([^"]+)"/g;
while ((match = tmplPattern.exec(content)) !== null) {
let tmplPath = match[1];
if (tmplPath && tmplPath !== '*') {
// Remove {project-root} prefix to get the actual path
// Usage is like {project-root}/bmad/core/tasks/foo.md
if (tmplPath.includes('{project-root}')) {
tmplPath = tmplPath.replace('{project-root}', '');
}
refs.add(tmplPath);
}
}
return [...refs];
}
/**
* Resolve dependency paths to actual files
*/
async resolveDependencyPaths(bmadDir, dependencies) {
const resolved = new Set();
for (const dep of dependencies) {
const resolvedPaths = await this.resolveSingleDependency(bmadDir, dep);
for (const path of resolvedPaths) {
resolved.add(path);
}
}
return resolved;
}
/**
* Resolve a single dependency to file paths
*/
async resolveSingleDependency(bmadDir, dep) {
const paths = [];
switch (dep.type) {
case 'explicit':
case 'file': {
let depPath = dep.dependency;
// Handle {project-root} prefix if present
if (depPath.includes('{project-root}')) {
// Remove {project-root} and resolve as bmad path
depPath = depPath.replace('{project-root}', '');
if (depPath.startsWith('bmad/')) {
const bmadPath = depPath.replace(/^bmad\//, '');
// Handle glob patterns
if (depPath.includes('*')) {
// Extract the base path and pattern
const pathParts = bmadPath.split('/');
const module = pathParts[0];
const filePattern = pathParts.at(-1);
const middlePath = pathParts.slice(1, -1).join('/');
let basePath;
if (module === 'core') {
basePath = path.join(bmadDir, 'core', middlePath);
} else {
basePath = path.join(bmadDir, 'modules', module, middlePath);
}
if (await fs.pathExists(basePath)) {
const files = await glob.glob(filePattern, { cwd: basePath });
for (const file of files) {
paths.push(path.join(basePath, file));
}
}
} else {
// Direct path
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
}
} else {
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
const modulePath = path.join(bmadDir, 'modules', module, rest);
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
}
}
}
}
} else {
// Regular relative path handling
const sourceDir = path.dirname(dep.from);
// Handle glob patterns
if (depPath.includes('*')) {
const basePath = path.resolve(sourceDir, path.dirname(depPath));
const pattern = path.basename(depPath);
if (await fs.pathExists(basePath)) {
const files = await glob.glob(pattern, { cwd: basePath });
for (const file of files) {
paths.push(path.join(basePath, file));
}
}
} else {
// Direct file reference
const fullPath = path.resolve(sourceDir, depPath);
if (await fs.pathExists(fullPath)) {
paths.push(fullPath);
} else {
this.missingDependencies.add(`${depPath} (referenced by ${path.basename(dep.from)})`);
}
}
}
break;
}
case 'command': {
// Resolve command references to actual files
const commandPath = await this.resolveCommandToPath(bmadDir, dep.dependency);
if (commandPath) {
paths.push(commandPath);
}
break;
}
case 'bmad-path': {
// Resolve bmad/ paths (from {project-root}/bmad/... references)
// These are paths relative to the src directory structure
const bmadPath = dep.dependency.replace(/^bmad\//, '');
// Try to resolve as if it's in src structure
// bmad/core/tasks/foo.md -> src/core-skills/tasks/foo.md
// bmad/bmm/tasks/bar.md -> src/bmm-skills/tasks/bar.md (bmm is directly under src/)
// bmad/cis/agents/bar.md -> src/modules/cis/agents/bar.md
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
} else {
// Not found, but don't report as missing since it might be installed later
}
} else {
// It's a module path like bmm/tasks/foo.md or cis/agents/bar.md
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
let modulePath;
if (module === 'bmm') {
// bmm is directly under src/
modulePath = path.join(bmadDir, module, rest);
} else {
// Other modules are under modules/
modulePath = path.join(bmadDir, 'modules', module, rest);
}
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
} else {
// Not found, but don't report as missing since it might be installed later
}
}
break;
}
case 'template': {
// Resolve template references
let templateDep = dep.dependency;
// Handle {project-root} prefix if present
if (templateDep.includes('{project-root}')) {
// Remove {project-root} and treat as bmad-path
templateDep = templateDep.replace('{project-root}', '');
// Now resolve as a bmad path
if (templateDep.startsWith('bmad/')) {
const bmadPath = templateDep.replace(/^bmad\//, '');
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
}
} else {
// Module path like cis/templates/brainstorm.md
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
const modulePath = path.join(bmadDir, 'modules', module, rest);
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
}
}
}
} else {
// Regular relative template path
const sourceDir = path.dirname(dep.from);
const templatePath = path.resolve(sourceDir, templateDep);
if (await fs.pathExists(templatePath)) {
paths.push(templatePath);
} else {
this.missingDependencies.add(`Template: ${dep.dependency}`);
}
}
break;
}
// No default
}
return paths;
}
/**
* Resolve command reference to file path
*/
async resolveCommandToPath(bmadDir, command) {
// Parse command format: @task-name or @agent-name or bmad/module/type/name
if (command.startsWith('@task-')) {
const taskName = command.slice(6);
// Search all modules for this task
for (const module of ['core', 'bmm', 'cis']) {
const taskPath =
module === 'core'
? path.join(bmadDir, 'core', 'tasks', `${taskName}.md`)
: path.join(bmadDir, 'modules', module, 'tasks', `${taskName}.md`);
if (await fs.pathExists(taskPath)) {
return taskPath;
}
}
} else if (command.startsWith('@agent-')) {
const agentName = command.slice(7);
// Search all modules for this agent
for (const module of ['core', 'bmm', 'cis']) {
const agentPath =
module === 'core'
? path.join(bmadDir, 'core', 'agents', `${agentName}.md`)
: path.join(bmadDir, 'modules', module, 'agents', `${agentName}.md`);
if (await fs.pathExists(agentPath)) {
return agentPath;
}
}
} else if (command.startsWith('bmad/')) {
// Direct path reference
const parts = command.split('/');
if (parts.length >= 4) {
const [, module, type, ...nameParts] = parts;
const name = nameParts.join('/'); // Handle nested paths
// Check if name already has extension
const fileName = name.endsWith('.md') ? name : `${name}.md`;
const filePath =
module === 'core' ? path.join(bmadDir, 'core', type, fileName) : path.join(bmadDir, 'modules', module, type, fileName);
if (await fs.pathExists(filePath)) {
return filePath;
}
}
}
// Don't report as missing if it's a self-reference within the module being installed
if (!command.includes('cis') || command.includes('brain')) {
// Only report missing if it's a true external dependency
// this.missingDependencies.add(`Command: ${command}`);
}
return null;
}
/**
* Resolve transitive dependencies (dependencies of dependencies)
*/
async resolveTransitiveDependencies(bmadDir, directDeps) {
const transitive = new Set();
const processed = new Set();
// Process each direct dependency
for (const depPath of directDeps) {
if (processed.has(depPath)) continue;
processed.add(depPath);
// Only process markdown and YAML files for transitive deps
if ((depPath.endsWith('.md') || depPath.endsWith('.yaml') || depPath.endsWith('.yml')) && (await fs.pathExists(depPath))) {
const content = await fs.readFile(depPath, 'utf8');
const subDeps = await this.parseDependencies([
{
path: depPath,
type: 'dependency',
module: this.getModuleFromPath(bmadDir, depPath),
name: path.basename(depPath),
},
]);
const resolvedSubDeps = await this.resolveDependencyPaths(bmadDir, subDeps);
for (const subDep of resolvedSubDeps) {
if (!directDeps.has(subDep)) {
transitive.add(subDep);
}
}
}
}
return transitive;
}
/**
* Get module name from file path
*/
getModuleFromPath(bmadDir, filePath) {
const relative = path.relative(bmadDir, filePath);
const parts = relative.split(path.sep);
// Handle source directory structure (src/core-skills, src/bmm-skills, or src/modules/xxx)
if (parts[0] === 'src') {
if (parts[1] === 'core-skills') {
return 'core';
} else if (parts[1] === 'bmm-skills') {
return 'bmm';
} else if (parts[1] === 'modules' && parts.length > 2) {
return parts[2];
}
}
// Check if it's in modules directory (installed structure)
if (parts[0] === 'modules' && parts.length > 1) {
return parts[1];
}
// Otherwise return the first part (core, etc.)
// But don't return 'src' as a module name
if (parts[0] === 'src') {
return 'unknown';
}
return parts[0] || 'unknown';
}
/**
* Organize files by module
*/
organizeByModule(bmadDir, files) {
const organized = {};
for (const file of files) {
const module = this.getModuleFromPath(bmadDir, file);
if (!organized[module]) {
organized[module] = {
agents: [],
tasks: [],
tools: [],
templates: [],
data: [],
other: [],
};
}
// Get relative path correctly based on module structure
let moduleBase;
// Check if file is in source directory structure
if (file.includes('/src/core-skills/') || file.includes('/src/bmm-skills/')) {
if (module === 'core') {
moduleBase = path.join(bmadDir, 'src', 'core-skills');
} else if (module === 'bmm') {
moduleBase = path.join(bmadDir, 'src', 'bmm-skills');
}
} else {
moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
}
const relative = path.relative(moduleBase, file);
if (relative.startsWith('agents/') || file.includes('/agents/')) {
organized[module].agents.push(file);
} else if (relative.startsWith('tasks/') || file.includes('/tasks/')) {
organized[module].tasks.push(file);
} else if (relative.startsWith('tools/') || file.includes('/tools/')) {
organized[module].tools.push(file);
} else if (relative.includes('data/')) {
organized[module].data.push(file);
} else {
organized[module].other.push(file);
}
}
return organized;
}
/**
* Report resolution results
*/
async reportResults(organized, selectedModules) {
await prompts.log.success('Dependency resolution complete');
for (const [module, files] of Object.entries(organized)) {
const isSelected = selectedModules.includes(module) || module === 'core';
const totalFiles =
files.agents.length + files.tasks.length + files.tools.length + files.templates.length + files.data.length + files.other.length;
if (totalFiles > 0) {
await prompts.log.info(` ${module.toUpperCase()} module:`);
await prompts.log.message(` Status: ${isSelected ? 'Selected' : 'Dependencies only'}`);
if (files.agents.length > 0) {
await prompts.log.message(` Agents: ${files.agents.length}`);
}
if (files.tasks.length > 0) {
await prompts.log.message(` Tasks: ${files.tasks.length}`);
}
if (files.templates.length > 0) {
await prompts.log.message(` Templates: ${files.templates.length}`);
}
if (files.data.length > 0) {
await prompts.log.message(` Data files: ${files.data.length}`);
}
if (files.other.length > 0) {
await prompts.log.message(` Other files: ${files.other.length}`);
}
}
}
if (this.missingDependencies.size > 0) {
await prompts.log.warn('Missing dependencies:');
for (const missing of this.missingDependencies) {
await prompts.log.warn(` - ${missing}`);
}
}
}
/**
* Create a bundle for web deployment
* @param {Object} resolution - Resolution results from resolve()
* @returns {Object} Bundle data ready for web
*/
async createWebBundle(resolution) {
const bundle = {
metadata: {
created: new Date().toISOString(),
modules: Object.keys(resolution.byModule),
totalFiles: resolution.allFiles.length,
},
agents: {},
tasks: {},
templates: {},
data: {},
};
// Bundle all files by type
for (const filePath of resolution.allFiles) {
if (!(await fs.pathExists(filePath))) continue;
const content = await fs.readFile(filePath, 'utf8');
const relative = path.relative(path.dirname(resolution.primaryFiles[0]?.path || '.'), filePath);
if (filePath.includes('/agents/')) {
bundle.agents[relative] = content;
} else if (filePath.includes('/tasks/')) {
bundle.tasks[relative] = content;
} else if (filePath.includes('template')) {
bundle.templates[relative] = content;
} else {
bundle.data[relative] = content;
}
}
return bundle;
}
}
module.exports = { DependencyResolver };

View File

@ -1,223 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { Manifest } = require('./manifest');
class Detector {
/**
* Detect existing BMAD installation
* @param {string} bmadDir - Path to bmad directory
* @returns {Object} Installation status and details
*/
async detect(bmadDir) {
const result = {
installed: false,
path: bmadDir,
version: null,
hasCore: false,
modules: [],
ides: [],
customModules: [],
manifest: null,
};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return result;
}
// Check for manifest using the Manifest class
const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir);
if (manifestData) {
result.manifest = manifestData;
result.version = manifestData.version;
result.installed = true;
// Copy custom modules if they exist
if (manifestData.customModules) {
result.customModules = manifestData.customModules;
}
}
// Check for core
const corePath = path.join(bmadDir, 'core');
if (await fs.pathExists(corePath)) {
result.hasCore = true;
// Try to get core version from config
const coreConfigPath = path.join(corePath, 'config.yaml');
if (await fs.pathExists(coreConfigPath)) {
try {
const configContent = await fs.readFile(coreConfigPath, 'utf8');
const config = yaml.parse(configContent);
if (!result.version && config.version) {
result.version = config.version;
}
} catch {
// Ignore config read errors
}
}
}
// Check for modules
// If manifest exists, use it as the source of truth for installed modules
// Otherwise fall back to directory scanning (legacy installations)
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
// Use manifest module list - these are officially installed modules
for (const moduleId of manifestData.modules) {
const modulePath = path.join(bmadDir, moduleId);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
const moduleInfo = {
id: moduleId,
path: modulePath,
version: 'unknown',
};
if (await fs.pathExists(moduleConfigPath)) {
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.parse(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || moduleId;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
}
result.modules.push(moduleInfo);
}
} else {
// Fallback: scan directory for modules (legacy installations without manifest)
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config') {
const modulePath = path.join(bmadDir, entry.name);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
// Only treat it as a module if it has a config.yaml
if (await fs.pathExists(moduleConfigPath)) {
const moduleInfo = {
id: entry.name,
path: modulePath,
version: 'unknown',
};
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.parse(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || entry.name;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
result.modules.push(moduleInfo);
}
}
}
}
// Check for IDE configurations from manifest
if (result.manifest && result.manifest.ides) {
// Filter out any undefined/null values
result.ides = result.manifest.ides.filter((ide) => ide && typeof ide === 'string');
}
// Mark as installed if we found core or modules
if (result.hasCore || result.modules.length > 0) {
result.installed = true;
}
return result;
}
/**
* Detect legacy installation (_bmad-method, .bmm, .cis)
* @param {string} projectDir - Project directory to check
* @returns {Object} Legacy installation details
*/
async detectLegacy(projectDir) {
const result = {
hasLegacy: false,
legacyCore: false,
legacyModules: [],
paths: [],
};
// Check for legacy core (_bmad-method)
const legacyCorePath = path.join(projectDir, '_bmad-method');
if (await fs.pathExists(legacyCorePath)) {
result.hasLegacy = true;
result.legacyCore = true;
result.paths.push(legacyCorePath);
}
// Check for legacy modules (directories starting with .)
const entries = await fs.readdir(projectDir, { withFileTypes: true });
for (const entry of entries) {
if (
entry.isDirectory() &&
entry.name.startsWith('.') &&
entry.name !== '_bmad-method' &&
!entry.name.startsWith('.git') &&
!entry.name.startsWith('.vscode') &&
!entry.name.startsWith('.idea')
) {
const modulePath = path.join(projectDir, entry.name);
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
// Check if it's likely a BMAD module
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
result.hasLegacy = true;
result.legacyModules.push({
name: entry.name.slice(1), // Remove leading dot
path: modulePath,
});
result.paths.push(modulePath);
}
}
}
return result;
}
/**
* Check if migration from legacy is needed
* @param {string} projectDir - Project directory
* @returns {Object} Migration requirements
*/
async checkMigrationNeeded(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
const current = await this.detect(bmadDir);
const legacy = await this.detectLegacy(projectDir);
return {
needed: legacy.hasLegacy && !current.installed,
canMigrate: legacy.hasLegacy,
legacy: legacy,
current: current,
};
}
/**
* Detect legacy BMAD v4 .bmad-method folder
* @param {string} projectDir - Project directory to check
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
*/
async detectLegacyV4(projectDir) {
const offenders = [];
// Check for .bmad-method folder
const bmadMethodPath = path.join(projectDir, '.bmad-method');
if (await fs.pathExists(bmadMethodPath)) {
offenders.push(bmadMethodPath);
}
return { hasLegacyV4: offenders.length > 0, offenders };
}
}
module.exports = { Detector };

View File

@ -1,157 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
/**
* Manages IDE configuration persistence
* Saves and loads IDE-specific configurations to/from bmad/_config/ides/
*/
class IdeConfigManager {
constructor() {}
/**
* Get path to IDE config directory
* @param {string} bmadDir - BMAD installation directory
* @returns {string} Path to IDE config directory
*/
getIdeConfigDir(bmadDir) {
return path.join(bmadDir, '_config', 'ides');
}
/**
* Get path to specific IDE config file
* @param {string} bmadDir - BMAD installation directory
* @param {string} ideName - IDE name (e.g., 'claude-code')
* @returns {string} Path to IDE config file
*/
getIdeConfigPath(bmadDir, ideName) {
return path.join(this.getIdeConfigDir(bmadDir), `${ideName}.yaml`);
}
/**
* Save IDE configuration
* @param {string} bmadDir - BMAD installation directory
* @param {string} ideName - IDE name
* @param {Object} configuration - IDE-specific configuration object
*/
async saveIdeConfig(bmadDir, ideName, configuration) {
const configDir = this.getIdeConfigDir(bmadDir);
await fs.ensureDir(configDir);
const configPath = this.getIdeConfigPath(bmadDir, ideName);
const now = new Date().toISOString();
// Check if config already exists to preserve configured_date
let configuredDate = now;
if (await fs.pathExists(configPath)) {
try {
const existing = await this.loadIdeConfig(bmadDir, ideName);
if (existing && existing.configured_date) {
configuredDate = existing.configured_date;
}
} catch {
// Ignore errors reading existing config
}
}
const configData = {
ide: ideName,
configured_date: configuredDate,
last_updated: now,
configuration: configuration || {},
};
// Clean the config to remove any non-serializable values (like functions)
const cleanConfig = structuredClone(configData);
const yamlContent = yaml.stringify(cleanConfig, {
indent: 2,
lineWidth: 0,
sortKeys: false,
});
// Ensure POSIX-compliant final newline
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
await fs.writeFile(configPath, content, 'utf8');
}
/**
* Load IDE configuration
* @param {string} bmadDir - BMAD installation directory
* @param {string} ideName - IDE name
* @returns {Object|null} IDE configuration or null if not found
*/
async loadIdeConfig(bmadDir, ideName) {
const configPath = this.getIdeConfigPath(bmadDir, ideName);
if (!(await fs.pathExists(configPath))) {
return null;
}
try {
const content = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(content);
return config;
} catch (error) {
await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`);
return null;
}
}
/**
* Load all IDE configurations
* @param {string} bmadDir - BMAD installation directory
* @returns {Object} Map of IDE name to configuration
*/
async loadAllIdeConfigs(bmadDir) {
const configDir = this.getIdeConfigDir(bmadDir);
const configs = {};
if (!(await fs.pathExists(configDir))) {
return configs;
}
try {
const files = await fs.readdir(configDir);
for (const file of files) {
if (file.endsWith('.yaml')) {
const ideName = file.replace('.yaml', '');
const config = await this.loadIdeConfig(bmadDir, ideName);
if (config) {
configs[ideName] = config.configuration;
}
}
}
} catch (error) {
await prompts.log.warn(`Failed to load IDE configs: ${error.message}`);
}
return configs;
}
/**
* Check if IDE has saved configuration
* @param {string} bmadDir - BMAD installation directory
* @param {string} ideName - IDE name
* @returns {boolean} True if configuration exists
*/
async hasIdeConfig(bmadDir, ideName) {
const configPath = this.getIdeConfigPath(bmadDir, ideName);
return await fs.pathExists(configPath);
}
/**
* Delete IDE configuration
* @param {string} bmadDir - BMAD installation directory
* @param {string} ideName - IDE name
*/
async deleteIdeConfig(bmadDir, ideName) {
const configPath = this.getIdeConfigPath(bmadDir, ideName);
if (await fs.pathExists(configPath)) {
await fs.remove(configPath);
}
}
}
module.exports = { IdeConfigManager };

File diff suppressed because it is too large Load Diff

View File

@ -1,657 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const prompts = require('../../../lib/prompts');
const { getSourcePath } = require('../../../lib/project-root');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
/**
* Base class for IDE-specific setup
* All IDE handlers should extend this class
*/
class BaseIdeSetup {
constructor(name, displayName = null, preferred = false) {
this.name = name;
this.displayName = displayName || name; // Human-readable name for UI
this.preferred = preferred; // Whether this IDE should be shown in preferred list
this.configDir = null; // Override in subclasses
this.rulesDir = null; // Override in subclasses
this.configFile = null; // Override in subclasses when detection is file-based
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
}
/**
* Set the bmad folder name for placeholder replacement
* @param {string} bmadFolderName - The bmad folder name
*/
setBmadFolderName(bmadFolderName) {
this.bmadFolderName = bmadFolderName;
}
/**
* Main setup method - must be implemented by subclasses
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
throw new Error(`setup() must be implemented by ${this.name} handler`);
}
/**
* Cleanup IDE configuration
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir, options = {}) {
// Default implementation - can be overridden
if (this.configDir) {
const configPath = path.join(projectDir, this.configDir);
if (await fs.pathExists(configPath)) {
const bmadRulesPath = path.join(configPath, BMAD_FOLDER_NAME);
if (await fs.pathExists(bmadRulesPath)) {
await fs.remove(bmadRulesPath);
if (!options.silent) await prompts.log.message(`Removed ${this.name} BMAD configuration`);
}
}
}
}
/**
* Install a custom agent launcher - subclasses should override
* @param {string} projectDir - Project directory
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata
* @returns {Object|null} Info about created command, or null if not supported
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
// Default implementation - subclasses can override
return null;
}
/**
* Detect whether this IDE already has configuration in the project
* Subclasses can override for custom logic
* @param {string} projectDir - Project directory
* @returns {boolean}
*/
async detect(projectDir) {
const pathsToCheck = [];
if (this.configDir) {
pathsToCheck.push(path.join(projectDir, this.configDir));
}
if (this.configFile) {
pathsToCheck.push(path.join(projectDir, this.configFile));
}
if (Array.isArray(this.detectionPaths)) {
for (const candidate of this.detectionPaths) {
if (!candidate) continue;
const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate);
pathsToCheck.push(resolved);
}
}
for (const candidate of pathsToCheck) {
if (await fs.pathExists(candidate)) {
return true;
}
}
return false;
}
/**
* Get list of agents from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @returns {Array} List of agent files
*/
async getAgents(bmadDir) {
const agents = [];
// Get core agents
const coreAgentsPath = path.join(bmadDir, 'core', 'agents');
if (await fs.pathExists(coreAgentsPath)) {
const coreAgents = await this.scanDirectory(coreAgentsPath, '.md');
agents.push(
...coreAgents.map((a) => ({
...a,
module: 'core',
})),
);
}
// Get module agents
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
if (await fs.pathExists(moduleAgentsPath)) {
const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md');
agents.push(
...moduleAgents.map((a) => ({
...a,
module: entry.name,
})),
);
}
}
}
// Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) {
const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true });
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const agentDirPath = path.join(standaloneAgentsDir, agentDir.name);
const agentFiles = await fs.readdir(agentDirPath);
for (const file of agentFiles) {
if (!file.endsWith('.md')) continue;
if (file.includes('.customize.')) continue;
const filePath = path.join(agentDirPath, file);
const content = await fs.readFile(filePath, 'utf8');
if (content.includes('localskip="true"')) continue;
agents.push({
name: file.replace('.md', ''),
path: filePath,
relativePath: path.relative(standaloneAgentsDir, filePath),
filename: file,
module: 'standalone', // Mark as standalone agent
});
}
}
}
return agents;
}
/**
* Get list of tasks from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @param {boolean} standaloneOnly - If true, only return standalone tasks
* @returns {Array} List of task files
*/
async getTasks(bmadDir, standaloneOnly = false) {
const tasks = [];
// Get core tasks (scan for both .md and .xml)
const coreTasksPath = path.join(bmadDir, 'core', 'tasks');
if (await fs.pathExists(coreTasksPath)) {
const coreTasks = await this.scanDirectoryWithStandalone(coreTasksPath, ['.md', '.xml']);
tasks.push(
...coreTasks.map((t) => ({
...t,
module: 'core',
})),
);
}
// Get module tasks
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks');
if (await fs.pathExists(moduleTasksPath)) {
const moduleTasks = await this.scanDirectoryWithStandalone(moduleTasksPath, ['.md', '.xml']);
tasks.push(
...moduleTasks.map((t) => ({
...t,
module: entry.name,
})),
);
}
}
}
// Filter by standalone if requested
if (standaloneOnly) {
return tasks.filter((t) => t.standalone === true);
}
return tasks;
}
/**
* Get list of tools from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @param {boolean} standaloneOnly - If true, only return standalone tools
* @returns {Array} List of tool files
*/
async getTools(bmadDir, standaloneOnly = false) {
const tools = [];
// Get core tools (scan for both .md and .xml)
const coreToolsPath = path.join(bmadDir, 'core', 'tools');
if (await fs.pathExists(coreToolsPath)) {
const coreTools = await this.scanDirectoryWithStandalone(coreToolsPath, ['.md', '.xml']);
tools.push(
...coreTools.map((t) => ({
...t,
module: 'core',
})),
);
}
// Get module tools
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
const moduleToolsPath = path.join(bmadDir, entry.name, 'tools');
if (await fs.pathExists(moduleToolsPath)) {
const moduleTools = await this.scanDirectoryWithStandalone(moduleToolsPath, ['.md', '.xml']);
tools.push(
...moduleTools.map((t) => ({
...t,
module: entry.name,
})),
);
}
}
}
// Filter by standalone if requested
if (standaloneOnly) {
return tools.filter((t) => t.standalone === true);
}
return tools;
}
/**
* Get list of workflows from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @param {boolean} standaloneOnly - If true, only return standalone workflows
* @returns {Array} List of workflow files
*/
async getWorkflows(bmadDir, standaloneOnly = false) {
const workflows = [];
// Get core workflows
const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows');
if (await fs.pathExists(coreWorkflowsPath)) {
const coreWorkflows = await this.findWorkflowFiles(coreWorkflowsPath);
workflows.push(
...coreWorkflows.map((w) => ({
...w,
module: 'core',
})),
);
}
// Get module workflows
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows');
if (await fs.pathExists(moduleWorkflowsPath)) {
const moduleWorkflows = await this.findWorkflowFiles(moduleWorkflowsPath);
workflows.push(
...moduleWorkflows.map((w) => ({
...w,
module: entry.name,
})),
);
}
}
}
// Filter by standalone if requested
if (standaloneOnly) {
return workflows.filter((w) => w.standalone === true);
}
return workflows;
}
/**
* Recursively find workflow.md files
* @param {string} dir - Directory to search
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of workflow file info objects
*/
async findWorkflowFiles(dir, rootDir = null) {
rootDir = rootDir || dir;
const workflows = [];
if (!(await fs.pathExists(dir))) {
return workflows;
}
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively search subdirectories
const subWorkflows = await this.findWorkflowFiles(fullPath, rootDir);
workflows.push(...subWorkflows);
} else if (entry.isFile() && entry.name === 'workflow.md') {
// Read workflow.md frontmatter to get name and standalone property
try {
const content = await fs.readFile(fullPath, 'utf8');
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatterMatch) continue;
const workflowData = yaml.parse(frontmatterMatch[1]);
if (workflowData && workflowData.name) {
// Workflows are standalone by default unless explicitly false
const standalone = workflowData.standalone !== false && workflowData.standalone !== 'false';
workflows.push({
name: workflowData.name,
path: fullPath,
relativePath: path.relative(rootDir, fullPath),
filename: entry.name,
description: workflowData.description || '',
standalone: standalone,
});
}
} catch {
// Skip invalid workflow files
}
}
}
return workflows;
}
/**
* Scan a directory for files with specific extension(s)
* @param {string} dir - Directory to scan
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of file info objects
*/
async scanDirectory(dir, ext, rootDir = null) {
rootDir = rootDir || dir;
const files = [];
if (!(await fs.pathExists(dir))) {
return files;
}
// Normalize ext to array
const extensions = Array.isArray(ext) ? ext : [ext];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subFiles = await this.scanDirectory(fullPath, ext, rootDir);
files.push(...subFiles);
} else if (entry.isFile()) {
// Check if file matches any of the extensions
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
if (matchedExt) {
files.push({
name: path.basename(entry.name, matchedExt),
path: fullPath,
relativePath: path.relative(rootDir, fullPath),
filename: entry.name,
});
}
}
}
return files;
}
/**
* Scan a directory for files with specific extension(s) and check standalone attribute
* @param {string} dir - Directory to scan
* @param {string|Array<string>} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml'])
* @param {string} [rootDir] - Original root directory (used internally for recursion)
* @returns {Array} List of file info objects with standalone property
*/
async scanDirectoryWithStandalone(dir, ext, rootDir = null) {
rootDir = rootDir || dir;
const files = [];
if (!(await fs.pathExists(dir))) {
return files;
}
// Normalize ext to array
const extensions = Array.isArray(ext) ? ext : [ext];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext, rootDir);
files.push(...subFiles);
} else if (entry.isFile()) {
// Check if file matches any of the extensions
const matchedExt = extensions.find((e) => entry.name.endsWith(e));
if (matchedExt) {
// Read file content to check for standalone attribute
// All non-internal files are considered standalone by default
let standalone = true;
try {
const content = await fs.readFile(fullPath, 'utf8');
// Skip internal/engine files (not user-facing)
if (content.includes('internal="true"')) {
continue;
}
// Check for explicit standalone: false
if (entry.name.endsWith('.xml')) {
// For XML files, check for standalone="false" attribute
const tagMatch = content.match(/<(task|tool)[^>]*standalone="false"/);
standalone = !tagMatch;
} else if (entry.name.endsWith('.md')) {
// For MD files, parse YAML frontmatter
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (frontmatterMatch) {
try {
const yaml = require('yaml');
const frontmatter = yaml.parse(frontmatterMatch[1]);
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
} catch {
// If YAML parsing fails, default to standalone
}
}
// No frontmatter means standalone (default)
}
} catch {
// If we can't read the file, default to standalone
standalone = true;
}
files.push({
name: path.basename(entry.name, matchedExt),
path: fullPath,
relativePath: path.relative(rootDir, fullPath),
filename: entry.name,
standalone: standalone,
});
}
}
}
return files;
}
/**
* Create IDE command/rule file from agent or task
* @param {string} content - File content
* @param {Object} metadata - File metadata
* @param {string} projectDir - The actual project directory path
* @returns {string} Processed content
*/
processContent(content, metadata = {}, projectDir = null) {
// Replace placeholders
let processed = content;
// Only replace {project-root} if a specific projectDir is provided
// Otherwise leave the placeholder intact
// Note: Don't add trailing slash - paths in source include leading slash
if (projectDir) {
processed = processed.replaceAll('{project-root}', projectDir);
}
processed = processed.replaceAll('{module}', metadata.module || 'core');
processed = processed.replaceAll('{agent}', metadata.name || '');
processed = processed.replaceAll('{task}', metadata.name || '');
return processed;
}
/**
* Ensure directory exists
* @param {string} dirPath - Directory path
*/
async ensureDir(dirPath) {
await fs.ensureDir(dirPath);
}
/**
* Write file with content (replaces _bmad placeholder)
* @param {string} filePath - File path
* @param {string} content - File content
*/
async writeFile(filePath, content) {
// Replace _bmad placeholder if present
if (typeof content === 'string' && content.includes('_bmad')) {
content = content.replaceAll('_bmad', this.bmadFolderName);
}
// Replace escape sequence _bmad with literal _bmad
if (typeof content === 'string' && content.includes('_bmad')) {
content = content.replaceAll('_bmad', '_bmad');
}
await this.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
/**
* Copy file from source to destination (replaces _bmad placeholder in text files)
* @param {string} source - Source file path
* @param {string} dest - Destination file path
*/
async copyFile(source, dest) {
// List of text file extensions that should have placeholder replacement
const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv'];
const ext = path.extname(source).toLowerCase();
await this.ensureDir(path.dirname(dest));
// Check if this is a text file that might contain placeholders
if (textExtensions.includes(ext)) {
try {
// Read the file content
let content = await fs.readFile(source, 'utf8');
// Replace _bmad placeholder with actual folder name
if (content.includes('_bmad')) {
content = content.replaceAll('_bmad', this.bmadFolderName);
}
// Replace escape sequence _bmad with literal _bmad
if (content.includes('_bmad')) {
content = content.replaceAll('_bmad', '_bmad');
}
// Write to dest with replaced content
await fs.writeFile(dest, content, 'utf8');
} catch {
// If reading as text fails, fall back to regular copy
await fs.copy(source, dest, { overwrite: true });
}
} else {
// Binary file or other file type - just copy directly
await fs.copy(source, dest, { overwrite: true });
}
}
/**
* Check if path exists
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async exists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Alias for exists method
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async pathExists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Read file content
* @param {string} filePath - File path
* @returns {string} File content
*/
async readFile(filePath) {
return await fs.readFile(filePath, 'utf8');
}
/**
* Format name as title
* @param {string} name - Name to format
* @returns {string} Formatted title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Flatten a relative path to a single filename for flat slash command naming
* @deprecated Use toColonPath() or toDashPath() from shared/path-utils.js instead
* Example: 'module/agents/name.md' -> 'bmad-module-agents-name.md'
* Used by IDEs that ignore directory structure for slash commands (e.g., Antigravity, Codex)
* @param {string} relativePath - Relative path to flatten
* @returns {string} Flattened filename with 'bmad-' prefix
*/
flattenFilename(relativePath) {
const sanitized = relativePath.replaceAll(/[/\\]/g, '-');
return `bmad-${sanitized}`;
}
/**
* Create agent configuration file
* @param {string} bmadDir - BMAD installation directory
* @param {Object} agent - Agent information
*/
async createAgentConfig(bmadDir, agent) {
const agentConfigDir = path.join(bmadDir, '_config', 'agents');
await this.ensureDir(agentConfigDir);
// Load agent config template
const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
const templateContent = await this.readFile(templatePath);
const configContent = `# Agent Config: ${agent.name}
${templateContent}`;
const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(configPath, configContent);
}
}
module.exports = { BaseIdeSetup };

View File

@ -1,100 +0,0 @@
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
let _cachedPlatformCodes = null;
/**
* Load the platform codes configuration from YAML
* @returns {Object} Platform codes configuration
*/
async function loadPlatformCodes() {
if (_cachedPlatformCodes) {
return _cachedPlatformCodes;
}
if (!(await fs.pathExists(PLATFORM_CODES_PATH))) {
throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`);
}
const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8');
_cachedPlatformCodes = yaml.parse(content);
return _cachedPlatformCodes;
}
/**
* Get platform information by code
* @param {string} platformCode - Platform code (e.g., 'claude-code', 'cursor')
* @returns {Object|null} Platform info or null if not found
*/
function getPlatformInfo(platformCode) {
if (!_cachedPlatformCodes) {
throw new Error('Platform codes not loaded. Call loadPlatformCodes() first.');
}
return _cachedPlatformCodes.platforms[platformCode] || null;
}
/**
* Get all preferred platforms
* @returns {Promise<Array>} Array of preferred platform codes
*/
async function getPreferredPlatforms() {
const config = await loadPlatformCodes();
return Object.entries(config.platforms)
.filter(([_, info]) => info.preferred)
.map(([code, _]) => code);
}
/**
* Get all platform codes by category
* @param {string} category - Category to filter by (ide, cli, tool, etc.)
* @returns {Promise<Array>} Array of platform codes in the category
*/
async function getPlatformsByCategory(category) {
const config = await loadPlatformCodes();
return Object.entries(config.platforms)
.filter(([_, info]) => info.category === category)
.map(([code, _]) => code);
}
/**
* Get all platforms with installer config
* @returns {Promise<Array>} Array of platform codes that have installer config
*/
async function getConfigDrivenPlatforms() {
const config = await loadPlatformCodes();
return Object.entries(config.platforms)
.filter(([_, info]) => info.installer)
.map(([code, _]) => code);
}
/**
* Get platforms that use custom installers (no installer config)
* @returns {Promise<Array>} Array of platform codes with custom installers
*/
async function getCustomInstallerPlatforms() {
const config = await loadPlatformCodes();
return Object.entries(config.platforms)
.filter(([_, info]) => !info.installer)
.map(([code, _]) => code);
}
/**
* Clear the cached platform codes (useful for testing)
*/
function clearCache() {
_cachedPlatformCodes = null;
}
module.exports = {
loadPlatformCodes,
getPlatformInfo,
getPreferredPlatforms,
getPlatformsByCategory,
getConfigDrivenPlatforms,
getCustomInstallerPlatforms,
clearCache,
};

View File

@ -1,341 +0,0 @@
# BMAD Platform Codes Configuration
# Central configuration for all platform/IDE codes used in the BMAD system
#
# This file defines:
# 1. Platform metadata (name, preferred status, category, description)
# 2. Installer configuration (target directories, templates, artifact types)
#
# Format:
# code: Platform identifier used internally
# name: Display name shown to users
# preferred: Whether this platform is shown as a recommended option on install
# category: Type of platform (ide, cli, tool, service)
# description: Brief description of the platform
# installer: Installation configuration (optional - omit for custom installers)
platforms:
antigravity:
name: "Google Antigravity"
preferred: false
category: ide
description: "Google's AI development environment"
installer:
legacy_targets:
- .agent/workflows
target_dir: .agent/skills
template_type: antigravity
skill_format: true
auggie:
name: "Auggie"
preferred: false
category: cli
description: "AI development tool"
installer:
legacy_targets:
- .augment/commands
target_dir: .augment/skills
template_type: default
skill_format: true
claude-code:
name: "Claude Code"
preferred: true
category: cli
description: "Anthropic's official CLI for Claude"
installer:
legacy_targets:
- .claude/commands
target_dir: .claude/skills
template_type: default
skill_format: true
ancestor_conflict_check: true
cline:
name: "Cline"
preferred: false
category: ide
description: "AI coding assistant"
installer:
legacy_targets:
- .clinerules/workflows
target_dir: .cline/skills
template_type: default
skill_format: true
codex:
name: "Codex"
preferred: false
category: cli
description: "OpenAI Codex integration"
installer:
legacy_targets:
- .codex/prompts
- ~/.codex/prompts
target_dir: .agents/skills
template_type: default
skill_format: true
ancestor_conflict_check: true
artifact_types: [agents, workflows, tasks]
codebuddy:
name: "CodeBuddy"
preferred: false
category: ide
description: "Tencent Cloud Code Assistant - AI-powered coding companion"
installer:
legacy_targets:
- .codebuddy/commands
target_dir: .codebuddy/skills
template_type: default
skill_format: true
crush:
name: "Crush"
preferred: false
category: ide
description: "AI development assistant"
installer:
legacy_targets:
- .crush/commands
target_dir: .crush/skills
template_type: default
skill_format: true
cursor:
name: "Cursor"
preferred: true
category: ide
description: "AI-first code editor"
installer:
legacy_targets:
- .cursor/commands
target_dir: .cursor/skills
template_type: default
skill_format: true
gemini:
name: "Gemini CLI"
preferred: false
category: cli
description: "Google's CLI for Gemini"
installer:
legacy_targets:
- .gemini/commands
target_dir: .gemini/skills
template_type: default
skill_format: true
github-copilot:
name: "GitHub Copilot"
preferred: false
category: ide
description: "GitHub's AI pair programmer"
installer:
legacy_targets:
- .github/agents
- .github/prompts
target_dir: .github/skills
template_type: default
skill_format: true
iflow:
name: "iFlow"
preferred: false
category: ide
description: "AI workflow automation"
installer:
legacy_targets:
- .iflow/commands
target_dir: .iflow/skills
template_type: default
skill_format: true
kilo:
name: "KiloCoder"
preferred: false
category: ide
description: "AI coding platform"
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
installer:
legacy_targets:
- .kilocode/workflows
target_dir: .kilocode/skills
template_type: default
skill_format: true
kiro:
name: "Kiro"
preferred: false
category: ide
description: "Amazon's AI-powered IDE"
installer:
legacy_targets:
- .kiro/steering
target_dir: .kiro/skills
template_type: kiro
skill_format: true
ona:
name: "Ona"
preferred: false
category: ide
description: "Ona AI development environment"
installer:
target_dir: .ona/skills
template_type: default
skill_format: true
opencode:
name: "OpenCode"
preferred: false
category: ide
description: "OpenCode terminal coding assistant"
installer:
legacy_targets:
- .opencode/agents
- .opencode/commands
- .opencode/agent
- .opencode/command
target_dir: .opencode/skills
template_type: opencode
skill_format: true
ancestor_conflict_check: true
pi:
name: "Pi"
preferred: false
category: cli
description: "Provider-agnostic terminal-native AI coding agent"
installer:
target_dir: .pi/skills
template_type: default
skill_format: true
qoder:
name: "Qoder"
preferred: false
category: ide
description: "Qoder AI coding assistant"
installer:
target_dir: .qoder/skills
template_type: default
skill_format: true
qwen:
name: "QwenCoder"
preferred: false
category: ide
description: "Qwen AI coding assistant"
installer:
legacy_targets:
- .qwen/commands
target_dir: .qwen/skills
template_type: default
skill_format: true
roo:
name: "Roo Code"
preferred: false
category: ide
description: "Enhanced Cline fork"
installer:
legacy_targets:
- .roo/commands
target_dir: .roo/skills
template_type: default
skill_format: true
rovo-dev:
name: "Rovo Dev"
preferred: false
category: ide
description: "Atlassian's Rovo development environment"
installer:
legacy_targets:
- .rovodev/workflows
target_dir: .rovodev/skills
template_type: default
skill_format: true
trae:
name: "Trae"
preferred: false
category: ide
description: "AI coding tool"
installer:
legacy_targets:
- .trae/rules
target_dir: .trae/skills
template_type: default
skill_format: true
windsurf:
name: "Windsurf"
preferred: false
category: ide
description: "AI-powered IDE with cascade flows"
installer:
legacy_targets:
- .windsurf/workflows
target_dir: .windsurf/skills
template_type: windsurf
skill_format: true
# ============================================================================
# Installer Config Schema
# ============================================================================
#
# installer:
# target_dir: string # Directory where artifacts are installed
# template_type: string # Default template type to use
# header_template: string (optional) # Override for header/frontmatter template
# body_template: string (optional) # Override for body/content template
# legacy_targets: array (optional) # Old target dirs to clean up on reinstall (migration)
# - string # Relative path, e.g. .opencode/agent
# targets: array (optional) # For multi-target installations
# - target_dir: string
# template_type: string
# artifact_types: [agents, workflows, tasks, tools]
# artifact_types: array (optional) # Filter which artifacts to install (default: all)
# skip_existing: boolean (optional) # Skip files that already exist (default: false)
# skill_format: boolean (optional) # Use directory-per-skill output: <name>/SKILL.md
# # with clean frontmatter (name + description, unquoted)
# ancestor_conflict_check: boolean (optional) # Refuse install when ancestor dir has BMAD files
# # in the same target_dir (for IDEs that inherit
# # skills from parent directories)
# ============================================================================
# Platform Categories
# ============================================================================
categories:
ide:
name: "Integrated Development Environment"
description: "Full-featured code editors with AI assistance"
cli:
name: "Command Line Interface"
description: "Terminal-based tools"
tool:
name: "Development Tool"
description: "Standalone development utilities"
service:
name: "Cloud Service"
description: "Cloud-based development platforms"
extension:
name: "Editor Extension"
description: "Plugins for existing editors"
# ============================================================================
# Naming Conventions and Rules
# ============================================================================
conventions:
code_format: "lowercase-kebab-case"
name_format: "Title Case"
max_code_length: 20
allowed_characters: "a-z0-9-"

View File

@ -1 +0,0 @@
default-workflow-yaml.md

View File

@ -1,136 +0,0 @@
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
/**
* Manages external official modules defined in external-official-modules.yaml
* These are modules hosted in external repositories that can be installed
*
* @class ExternalModuleManager
*/
class ExternalModuleManager {
constructor() {
this.externalModulesConfigPath = path.join(__dirname, '../../../external-official-modules.yaml');
this.cachedModules = null;
}
/**
* Load and parse the external-official-modules.yaml file
* @returns {Object} Parsed YAML content with modules object
*/
async loadExternalModulesConfig() {
if (this.cachedModules) {
return this.cachedModules;
}
try {
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
const config = yaml.parse(content);
this.cachedModules = config;
return config;
} catch (error) {
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
return { modules: {} };
}
}
/**
* Get list of available external modules
* @returns {Array<Object>} Array of module info objects
*/
async listAvailable() {
const config = await this.loadExternalModulesConfig();
const modules = [];
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
modules.push({
key,
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
});
}
return modules;
}
/**
* Get module info by code
* @param {string} code - The module code (e.g., 'cis')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByCode(code) {
const modules = await this.listAvailable();
return modules.find((m) => m.code === code) || null;
}
/**
* Get module info by key
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByKey(key) {
const config = await this.loadExternalModulesConfig();
const moduleConfig = config.modules?.[key];
if (!moduleConfig) {
return null;
}
return {
key,
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
};
}
/**
* Check if a module code exists in external modules
* @param {string} code - The module code to check
* @returns {boolean} True if the module exists
*/
async hasModule(code) {
const module = await this.getModuleByCode(code);
return module !== null;
}
/**
* Get the URL for a module by code
* @param {string} code - The module code
* @returns {string|null} The URL or null if not found
*/
async getModuleUrl(code) {
const module = await this.getModuleByCode(code);
return module ? module.url : null;
}
/**
* Get the module definition path for a module by code
* @param {string} code - The module code
* @returns {string|null} The module definition path or null if not found
*/
async getModuleDefinition(code) {
const module = await this.getModuleByCode(code);
return module ? module.moduleDefinition : null;
}
}
module.exports = { ExternalModuleManager };

View File

@ -1,928 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const prompts = require('../../../lib/prompts');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { ExternalModuleManager } = require('./external-manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
/**
* Manages the installation, updating, and removal of BMAD modules.
* Handles module discovery, dependency resolution, and configuration processing.
*
* @class ModuleManager
* @requires fs-extra
* @requires yaml
* @requires prompts
*
* @example
* const manager = new ModuleManager();
* const modules = await manager.listAvailable();
* await manager.install('core-module', '/path/to/bmad');
*/
class ModuleManager {
constructor(options = {}) {
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.customModulePaths = new Map(); // Initialize custom module paths
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
}
/**
* Set the bmad folder name for placeholder replacement
* @param {string} bmadFolderName - The bmad folder name
*/
setBmadFolderName(bmadFolderName) {
this.bmadFolderName = bmadFolderName;
}
/**
* Set the core configuration for access during module installation
* @param {Object} coreConfig - Core configuration object
*/
setCoreConfig(coreConfig) {
this.coreConfig = coreConfig;
}
/**
* Set custom module paths for priority lookup
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
*/
setCustomModulePaths(customModulePaths) {
this.customModulePaths = customModulePaths;
}
/**
* Copy a file to the target location
* @param {string} sourcePath - Source file path
* @param {string} targetPath - Target file path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/
async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) {
await fs.copy(sourcePath, targetPath, { overwrite });
}
/**
* Copy a directory recursively
* @param {string} sourceDir - Source directory path
* @param {string} targetDir - Target directory path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/
async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) {
await fs.ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
} else {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
}
}
}
/**
* List all available modules (excluding core which is always installed)
* bmm is the only built-in module, directly under src/bmm-skills
* All other modules come from external-official-modules.yaml
* @returns {Object} Object with modules array and customModules array
*/
async listAvailable() {
const modules = [];
const customModules = [];
// Add built-in bmm module (directly under src/bmm-skills)
const bmmPath = getSourcePath('bmm-skills');
if (await fs.pathExists(bmmPath)) {
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
if (bmmInfo) {
modules.push(bmmInfo);
}
}
// Check for cached custom modules in _config/custom/
if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
if (await fs.pathExists(customCacheDir)) {
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
for (const entry of cacheEntries) {
if (entry.isDirectory()) {
const cachePath = path.join(customCacheDir, entry.name);
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
moduleInfo.isCustom = true;
moduleInfo.fromCache = true;
customModules.push(moduleInfo);
}
}
}
}
}
return { modules, customModules };
}
/**
* Get module information from a module path
* @param {string} modulePath - Path to the module directory
* @param {string} defaultName - Default name for the module
* @param {string} sourceDescription - Description of where the module was found
* @returns {Object|null} Module info or null if not a valid module
*/
async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(modulePath, 'module.yaml');
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null;
if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath;
} else if (await fs.pathExists(rootCustomConfigPath)) {
configPath = rootCustomConfigPath;
}
// Skip if this doesn't look like a module
if (!configPath) {
return null;
}
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
const isCustomSource =
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
const moduleInfo = {
id: defaultName,
path: modulePath,
name: defaultName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
description: 'BMAD Module',
version: '5.0.0',
source: sourceDescription,
isCustom: configPath === rootCustomConfigPath || isCustomSource,
};
// Read module config for metadata
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent);
// Use the code property as the id if available
if (config.code) {
moduleInfo.id = config.code;
}
moduleInfo.name = config.name || moduleInfo.name;
moduleInfo.description = config.description || moduleInfo.description;
moduleInfo.version = config.version || moduleInfo.version;
moduleInfo.dependencies = config.dependencies || [];
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
} catch (error) {
await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
}
return moduleInfo;
}
/**
* Find the source path for a module by searching all possible locations
* @param {string} moduleCode - Code of the module to find (from module.yaml)
* @returns {string|null} Path to the module source or null if not found
*/
async findModuleSource(moduleCode, options = {}) {
const projectRoot = getProjectRoot();
// First check custom module paths if they exist
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
return this.customModulePaths.get(moduleCode);
}
// Check for built-in bmm module (directly under src/bmm-skills)
if (moduleCode === 'bmm') {
const bmmPath = getSourcePath('bmm-skills');
if (await fs.pathExists(bmmPath)) {
return bmmPath;
}
}
// Check external official modules
const externalSource = await this.findExternalModuleSource(moduleCode, options);
if (externalSource) {
return externalSource;
}
return null;
}
/**
* Check if a module is an external official module
* @param {string} moduleCode - Code of the module to check
* @returns {boolean} True if the module is external
*/
async isExternalModule(moduleCode) {
return await this.externalModuleManager.hasModule(moduleCode);
}
/**
* Get the cache directory for external modules
* @returns {string} Path to the external modules cache directory
*/
getExternalCacheDir() {
const os = require('node:os');
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
return cacheDir;
}
/**
* Clone an external module repository to cache
* @param {string} moduleCode - Code of the external module
* @returns {string} Path to the cloned repository
*/
async cloneExternalModule(moduleCode, options = {}) {
const { execSync } = require('node:child_process');
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
if (!moduleInfo) {
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
}
const cacheDir = this.getExternalCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
// Create cache directory if it doesn't exist
await fs.ensureDir(cacheDir);
// Helper to create a spinner or a no-op when silent
const createSpinner = async () => {
if (silent) {
return {
start() {},
stop() {},
error() {},
message() {},
cancel() {},
clear() {},
get isSpinning() {
return false;
},
get isCancelled() {
return false;
},
};
}
return await prompts.spinner();
};
// Track if we need to install dependencies
let needsDependencyInstall = false;
let wasNewClone = false;
// Check if already cloned
if (await fs.pathExists(moduleCacheDir)) {
// Try to update if it's a git repo
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
// Fetch and reset to remote - works better with shallow clones than pull
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
// Force dependency install if we got new code
if (currentRef !== newRef) {
needsDependencyInstall = true;
}
} catch {
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
// If update fails, remove and re-clone
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
} else {
wasNewClone = true;
}
// Clone if not exists or was removed
if (wasNewClone) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
} catch (error) {
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
}
}
// Install dependencies if package.json exists
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
if (await fs.pathExists(packageJsonPath)) {
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
// Force install if we updated or cloned new
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
} else {
// Check if package.json is newer than node_modules
let packageJsonNewer = false;
try {
const packageStats = await fs.stat(packageJsonPath);
const nodeModulesStats = await fs.stat(nodeModulesPath);
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
} catch {
// If stat fails, assume we need to install
packageJsonNewer = true;
}
if (packageJsonNewer) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
}
}
}
return moduleCacheDir;
}
/**
* Find the source path for an external module
* @param {string} moduleCode - Code of the external module
* @returns {string|null} Path to the module source or null if not found
*/
async findExternalModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
if (!moduleInfo) {
return null;
}
// Clone the external module repo
const cloneDir = await this.cloneExternalModule(moduleCode, options);
// The module-definition specifies the path to module.yaml relative to repo root
// We need to return the directory containing module.yaml
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
return moduleDir;
}
/**
* Install a module
* @param {string} moduleName - Code of the module to install (from module.yaml)
* @param {string} bmadDir - Target bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Additional installation options
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.logger - Logger instance for output
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!sourcePath) {
// Provide a more user-friendly error message
throw new Error(
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
);
}
// Check if this is a custom module and read its custom.yaml values
let customConfig = null;
const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml');
if (await fs.pathExists(rootCustomConfigPath)) {
try {
const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
customConfig = yaml.parse(customContent);
} catch (error) {
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
}
}
// If this is a custom module, merge its values into the module config
if (customConfig) {
options.moduleConfig = { ...options.moduleConfig, ...customConfig };
if (options.logger) {
await options.logger.log(` Merged custom configuration for ${moduleName}`);
}
}
// Check if already installed
if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath);
}
// Copy module files with filtering
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
// Create directories declared in module.yaml (unless explicitly skipped)
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options);
}
// Capture version info for manifest
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
await manifestObj.addModule(bmadDir, moduleName, {
version: versionInfo.version,
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
});
return {
success: true,
module: moduleName,
path: targetPath,
versionInfo,
};
}
/**
* Update an existing module
* @param {string} moduleName - Name of the module to update
* @param {string} bmadDir - Target bmad directory
* @param {boolean} force - Force update (overwrite modifications)
*/
async update(moduleName, bmadDir, force = false, options = {}) {
const sourcePath = await this.findModuleSource(moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`);
}
// Check if module is installed
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
if (force) {
// Force update - remove and reinstall
await fs.remove(targetPath);
return await this.install(moduleName, bmadDir, null, { installer: options.installer });
} else {
// Selective update - preserve user modifications
await this.syncModule(sourcePath, targetPath);
}
return {
success: true,
module: moduleName,
path: targetPath,
};
}
/**
* Remove a module
* @param {string} moduleName - Name of the module to remove
* @param {string} bmadDir - Target bmad directory
*/
async remove(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
await fs.remove(targetPath);
return {
success: true,
module: moduleName,
};
}
/**
* Check if a module is installed
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {boolean} True if module is installed
*/
async isInstalled(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
return await fs.pathExists(targetPath);
}
/**
* Get installed module info
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {Object|null} Module info or null if not installed
*/
async getInstalledInfo(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
return null;
}
const configPath = path.join(targetPath, 'config.yaml');
const moduleInfo = {
id: moduleName,
path: targetPath,
installed: true,
};
if (await fs.pathExists(configPath)) {
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent);
Object.assign(moduleInfo, config);
} catch (error) {
await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
}
}
return moduleInfo;
}
/**
* Copy module with filtering for localskip agents and conditional content
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} moduleConfig - Module configuration with conditional flags
*/
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
// Get all files in source
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
// Skip sub-modules directory - these are IDE-specific and handled separately
if (file.startsWith('sub-modules/')) {
continue;
}
// Skip sidecar directories - these contain agent-specific assets not needed at install time
const isInSidecarDirectory = path
.dirname(file)
.split('/')
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
if (isInSidecarDirectory) {
continue;
}
// Skip module.yaml at root - it's only needed at install time
if (file === 'module.yaml') {
continue;
}
// Skip module root config.yaml only - generated by config collector with actual values
// Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
// for custom modules that use workflow-specific configuration
if (file === 'config.yaml') {
continue;
}
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if this is an agent file
if (file.startsWith('agents/') && file.endsWith('.md')) {
// Read the file to check for localskip
const content = await fs.readFile(sourceFile, 'utf8');
// Check for localskip="true" in the agent tag
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
if (agentMatch) {
await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
continue; // Skip this agent
}
}
// Copy the file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
// Track the file if callback provided
if (fileTrackingCallback) {
fileTrackingCallback(targetFile);
}
}
}
/**
* Find all .md agent files recursively in a directory
* @param {string} dir - Directory to search
* @returns {Array} List of .md agent file paths
*/
async findAgentMdFiles(dir) {
const agentFiles = [];
async function searchDirectory(searchDir) {
const entries = await fs.readdir(searchDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(searchDir, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
agentFiles.push(fullPath);
} else if (entry.isDirectory()) {
await searchDirectory(fullPath);
}
}
}
await searchDirectory(dir);
return agentFiles;
}
/**
* Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config
* During updates, if a directory path changed, moves the old directory to the new path
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @param {Object} options - Installation options
* @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
* @param {Object} options.coreConfig - Core configuration
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
*/
async createModuleDirectories(moduleName, bmadDir, options = {}) {
const moduleConfig = options.moduleConfig || {};
const existingModuleConfig = options.existingModuleConfig || {};
const projectRoot = path.dirname(bmadDir);
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Special handling for core module - it's in src/core-skills not src/modules
let sourcePath;
if (moduleName === 'core') {
sourcePath = getSourcePath('core-skills');
} else {
sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) {
return emptyResult; // No source found, skip
}
}
// Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) {
return emptyResult; // No module.yaml, skip
}
let moduleYaml;
try {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleYaml = yaml.parse(yamlContent);
} catch {
return emptyResult; // Invalid YAML, skip
}
if (!moduleYaml || !moduleYaml.directories) {
return emptyResult; // No directories declared, skip
}
const directories = moduleYaml.directories;
const wdsFolders = moduleYaml.wds_folders || [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
for (const dirRef of directories) {
// Parse variable reference like "{design_artifacts}"
const varMatch = dirRef.match(/^\{([^}]+)\}$/);
if (!varMatch) {
// Not a variable reference, skip
continue;
}
const configKey = varMatch[1];
const dirValue = moduleConfig[configKey];
if (!dirValue || typeof dirValue !== 'string') {
continue; // No value or not a string, skip
}
// Strip {project-root}/ prefix if present
let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
// Handle remaining {project-root} anywhere in the path
dirPath = dirPath.replaceAll('{project-root}', '');
// Resolve to absolute path
const fullPath = path.join(projectRoot, dirPath);
// Validate path is within project root (prevent directory traversal)
const normalizedPath = path.normalize(fullPath);
const normalizedRoot = path.normalize(projectRoot);
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
const color = await prompts.getColor();
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
continue;
}
// Check if directory path changed from previous config (update/modify scenario)
const oldDirValue = existingModuleConfig[configKey];
let oldFullPath = null;
let oldDirPath = null;
if (oldDirValue && typeof oldDirValue === 'string') {
// F3: Normalize both values before comparing to avoid false negatives
// from trailing slashes, separator differences, or prefix format variations
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
const normalizedNew = path.normalize(dirPath);
if (normalizedOld !== normalizedNew) {
oldDirPath = normalizedOld;
oldFullPath = path.join(projectRoot, oldDirPath);
const normalizedOldAbsolute = path.normalize(oldFullPath);
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
oldFullPath = null; // Old path escapes project root, ignore it
}
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
if (oldFullPath) {
const normalizedNewAbsolute = path.normalize(fullPath);
if (
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
),
);
oldFullPath = null;
}
}
}
}
const dirName = configKey.replaceAll('_', ' ');
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
// Path changed and old dir exists → move old to new location
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
// F2: Wrap in try/catch — fallback to creating new dir on failure
try {
await fs.ensureDir(path.dirname(fullPath));
await fs.move(oldFullPath, fullPath);
movedDirs.push(`${dirName}: ${oldDirPath}${dirPath}`);
} catch (moveError) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`Failed to move ${oldDirPath}${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
),
);
await fs.ensureDir(fullPath);
createdDirs.push(`${dirName}: ${dirPath}`);
}
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
// F5: Both old and new directories exist — warn user about potential orphaned documents
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
),
);
} else if (!(await fs.pathExists(fullPath))) {
// New directory doesn't exist yet → create it
createdDirs.push(`${dirName}: ${dirPath}`);
await fs.ensureDir(fullPath);
}
// Create WDS subfolders if this is the design_artifacts directory
if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
for (const subfolder of wdsFolders) {
const subPath = path.join(fullPath, subfolder);
if (!(await fs.pathExists(subPath))) {
await fs.ensureDir(subPath);
createdWdsFolders.push(subfolder);
}
}
}
}
return { createdDirs, movedDirs, createdWdsFolders };
}
/**
* Private: Process module configuration
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processModuleConfig(modulePath, moduleName) {
const configPath = path.join(modulePath, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
let configContent = await fs.readFile(configPath, 'utf8');
// Replace path placeholders
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
configContent = configContent.replaceAll('{module}', moduleName);
await fs.writeFile(configPath, configContent, 'utf8');
} catch (error) {
await prompts.log.warn(`Failed to process module config: ${error.message}`);
}
}
}
/**
* Private: Sync module files (preserving user modifications)
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
*/
async syncModule(sourcePath, targetPath) {
// Get list of all source files
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if target file exists and has been modified
if (await fs.pathExists(targetFile)) {
const sourceStats = await fs.stat(sourceFile);
const targetStats = await fs.stat(targetFile);
// Skip if target is newer (user modified)
if (targetStats.mtime > sourceStats.mtime) {
continue;
}
}
// Copy file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
}
}
/**
* Private: Get list of all files in a directory
* @param {string} dir - Directory path
* @param {string} baseDir - Base directory for relative paths
* @returns {Array} List of relative file paths
*/
async getFileList(dir, baseDir = dir) {
const files = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await this.getFileList(fullPath, baseDir);
files.push(...subFiles);
} else {
files.push(path.relative(baseDir, fullPath));
}
}
return files;
}
}
module.exports = { ModuleManager };

View File

@ -1,213 +0,0 @@
const fs = require('fs-extra');
const yaml = require('yaml');
const path = require('node:path');
const packageJson = require('../../../package.json');
/**
* Configuration utility class
*/
class Config {
/**
* Load a YAML configuration file
* @param {string} configPath - Path to config file
* @returns {Object} Parsed configuration
*/
async loadYaml(configPath) {
if (!(await fs.pathExists(configPath))) {
throw new Error(`Configuration file not found: ${configPath}`);
}
const content = await fs.readFile(configPath, 'utf8');
return yaml.parse(content);
}
/**
* Save configuration to YAML file
* @param {string} configPath - Path to config file
* @param {Object} config - Configuration object
*/
async saveYaml(configPath, config) {
const yamlContent = yaml.dump(config, {
indent: 2,
lineWidth: 120,
noRefs: true,
});
await fs.ensureDir(path.dirname(configPath));
// Ensure POSIX-compliant final newline
const content = yamlContent.endsWith('\n') ? yamlContent : yamlContent + '\n';
await fs.writeFile(configPath, content, 'utf8');
}
/**
* Process configuration file (replace placeholders)
* @param {string} configPath - Path to config file
* @param {Object} replacements - Replacement values
*/
async processConfig(configPath, replacements = {}) {
let content = await fs.readFile(configPath, 'utf8');
// Standard replacements
const standardReplacements = {
'{project-root}': replacements.root || '',
'{module}': replacements.module || '',
'{version}': replacements.version || packageJson.version,
'{date}': new Date().toISOString().split('T')[0],
};
// Apply all replacements
const allReplacements = { ...standardReplacements, ...replacements };
for (const [placeholder, value] of Object.entries(allReplacements)) {
if (typeof placeholder === 'string' && typeof value === 'string') {
const regex = new RegExp(placeholder.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`), 'g');
content = content.replace(regex, value);
}
}
await fs.writeFile(configPath, content, 'utf8');
}
/**
* Merge configurations
* @param {Object} base - Base configuration
* @param {Object} override - Override configuration
* @returns {Object} Merged configuration
*/
mergeConfigs(base, override) {
return this.deepMerge(base, override);
}
/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
* @returns {Object} Merged object
*/
deepMerge(target, source) {
const output = { ...target };
if (this.isObject(target) && this.isObject(source)) {
for (const key of Object.keys(source)) {
if (this.isObject(source[key])) {
if (key in target) {
output[key] = this.deepMerge(target[key], source[key]);
} else {
output[key] = source[key];
}
} else {
output[key] = source[key];
}
}
}
return output;
}
/**
* Check if value is an object
* @param {*} item - Item to check
* @returns {boolean} True if object
*/
isObject(item) {
return item && typeof item === 'object' && !Array.isArray(item);
}
/**
* Validate configuration against schema
* @param {Object} config - Configuration to validate
* @param {Object} schema - Validation schema
* @returns {Object} Validation result
*/
validateConfig(config, schema) {
const errors = [];
const warnings = [];
// Check required fields
if (schema.required) {
for (const field of schema.required) {
if (!(field in config)) {
errors.push(`Missing required field: ${field}`);
}
}
}
// Check field types
if (schema.properties) {
for (const [field, spec] of Object.entries(schema.properties)) {
if (field in config) {
const value = config[field];
const expectedType = spec.type;
if (expectedType === 'array' && !Array.isArray(value)) {
errors.push(`Field '${field}' should be an array`);
} else if (expectedType === 'object' && !this.isObject(value)) {
errors.push(`Field '${field}' should be an object`);
} else if (expectedType === 'string' && typeof value !== 'string') {
errors.push(`Field '${field}' should be a string`);
} else if (expectedType === 'number' && typeof value !== 'number') {
errors.push(`Field '${field}' should be a number`);
} else if (expectedType === 'boolean' && typeof value !== 'boolean') {
errors.push(`Field '${field}' should be a boolean`);
}
// Check enum values
if (spec.enum && !spec.enum.includes(value)) {
errors.push(`Field '${field}' must be one of: ${spec.enum.join(', ')}`);
}
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Get configuration value with fallback
* @param {Object} config - Configuration object
* @param {string} path - Dot-notation path to value
* @param {*} defaultValue - Default value if not found
* @returns {*} Configuration value
*/
getValue(config, path, defaultValue = null) {
const keys = path.split('.');
let current = config;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return defaultValue;
}
}
return current;
}
/**
* Set configuration value
* @param {Object} config - Configuration object
* @param {string} path - Dot-notation path to value
* @param {*} value - Value to set
*/
setValue(config, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
let current = config;
for (const key of keys) {
if (!(key in current) || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key];
}
current[lastKey] = value;
}
}
module.exports = { Config };

View File

@ -1,116 +0,0 @@
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const { getProjectRoot } = require('./project-root');
/**
* Platform Codes Manager
* Loads and provides access to the centralized platform codes configuration
*/
class PlatformCodes {
constructor() {
this.configPath = path.join(getProjectRoot(), 'tools', 'platform-codes.yaml');
this.loadConfig();
}
/**
* Load the platform codes configuration
*/
loadConfig() {
try {
if (fs.existsSync(this.configPath)) {
const content = fs.readFileSync(this.configPath, 'utf8');
this.config = yaml.parse(content);
} else {
console.warn(`Platform codes config not found at ${this.configPath}`);
this.config = { platforms: {} };
}
} catch (error) {
console.error(`Error loading platform codes: ${error.message}`);
this.config = { platforms: {} };
}
}
/**
* Get all platform codes
* @returns {Object} All platform configurations
*/
getAllPlatforms() {
return this.config.platforms || {};
}
/**
* Get a specific platform configuration
* @param {string} code - Platform code
* @returns {Object|null} Platform configuration or null if not found
*/
getPlatform(code) {
return this.config.platforms[code] || null;
}
/**
* Check if a platform code is valid
* @param {string} code - Platform code to validate
* @returns {boolean} True if valid
*/
isValidPlatform(code) {
return code in this.config.platforms;
}
/**
* Get all preferred platforms
* @returns {Array} Array of preferred platform codes
*/
getPreferredPlatforms() {
return Object.entries(this.config.platforms)
.filter(([, config]) => config.preferred)
.map(([code]) => code);
}
/**
* Get platforms by category
* @param {string} category - Category to filter by
* @returns {Array} Array of platform codes in the category
*/
getPlatformsByCategory(category) {
return Object.entries(this.config.platforms)
.filter(([, config]) => config.category === category)
.map(([code]) => code);
}
/**
* Get platform display name
* @param {string} code - Platform code
* @returns {string} Display name or code if not found
*/
getDisplayName(code) {
const platform = this.getPlatform(code);
return platform ? platform.name : code;
}
/**
* Validate platform code format
* @param {string} code - Platform code to validate
* @returns {boolean} True if format is valid
*/
isValidFormat(code) {
const conventions = this.config.conventions || {};
const pattern = conventions.allowed_characters || 'a-z0-9-';
const maxLength = conventions.max_code_length || 20;
const regex = new RegExp(`^[${pattern}]+$`);
return regex.test(code) && code.length <= maxLength;
}
/**
* Get all platform codes as array
* @returns {Array} Array of platform codes
*/
getCodes() {
return Object.keys(this.config.platforms);
}
config = null;
}
// Export singleton instance
module.exports = new PlatformCodes();

View File

@ -6,7 +6,7 @@ Create a reference documentation page at `docs/reference/modules.md` that lists
## Source of Truth ## Source of Truth
Read `tools/cli/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file. Read `tools/installer/external-official-modules.yaml` — this is the authoritative registry of official external modules. Use the module names, codes, npm package names, and repository URLs from this file.
## Research Step ## Research Step

View File

@ -1,9 +1,11 @@
#!/usr/bin/env node
const { program } = require('commander'); const { program } = require('commander');
const path = require('node:path'); const path = require('node:path');
const fs = require('node:fs'); const fs = require('node:fs');
const { execSync } = require('node:child_process'); const { execSync } = require('node:child_process');
const semver = require('semver'); const semver = require('semver');
const prompts = require('./lib/prompts'); const prompts = require('./prompts');
// The installer flow uses many sequential @clack/prompts, each adding keypress // The installer flow uses many sequential @clack/prompts, each adding keypress
// listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings. // listeners to stdin. Raise the limit to avoid spurious EventEmitter warnings.

View File

@ -8,7 +8,7 @@ const CLIUtils = {
*/ */
getVersion() { getVersion() {
try { try {
const packageJson = require(path.join(__dirname, '..', '..', '..', 'package.json')); const packageJson = require(path.join(__dirname, '..', '..', 'package.json'));
return packageJson.version || 'Unknown'; return packageJson.version || 'Unknown';
} catch { } catch {
return 'Unknown'; return 'Unknown';
@ -16,10 +16,9 @@ const CLIUtils = {
}, },
/** /**
* Display BMAD logo using @clack intro + box * Display BMAD logo and version using @clack intro + box
* @param {boolean} _clearScreen - Deprecated, ignored (no longer clears screen)
*/ */
async displayLogo(_clearScreen = true) { async displayLogo() {
const version = this.getVersion(); const version = this.getVersion();
const color = await prompts.getColor(); const color = await prompts.getColor();

View File

@ -1,7 +1,7 @@
const path = require('node:path'); const path = require('node:path');
const prompts = require('../lib/prompts'); const prompts = require('../prompts');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('../core/installer');
const { UI } = require('../lib/ui'); const { UI } = require('../ui');
const installer = new Installer(); const installer = new Installer();
const ui = new UI(); const ui = new UI();

View File

@ -1,8 +1,8 @@
const path = require('node:path'); const path = require('node:path');
const prompts = require('../lib/prompts'); const prompts = require('../prompts');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('../core/installer');
const { Manifest } = require('../installers/lib/core/manifest'); const { Manifest } = require('../core/manifest');
const { UI } = require('../lib/ui'); const { UI } = require('../ui');
const installer = new Installer(); const installer = new Installer();
const manifest = new Manifest(); const manifest = new Manifest();

View File

@ -1,7 +1,7 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const prompts = require('../lib/prompts'); const prompts = require('../prompts');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('../core/installer');
const installer = new Installer(); const installer = new Installer();
@ -63,8 +63,8 @@ module.exports = {
const existingInstall = await installer.getStatus(projectDir); const existingInstall = await installer.getStatus(projectDir);
const version = existingInstall.version || 'unknown'; const version = existingInstall.version || 'unknown';
const modules = (existingInstall.modules || []).map((m) => m.id || m.name).join(', '); const modules = existingInstall.moduleIds.join(', ');
const ides = (existingInstall.ides || []).join(', '); const ides = existingInstall.ides.join(', ');
const outputFolder = await installer.getOutputFolder(projectDir); const outputFolder = await installer.getOutputFolder(projectDir);

View File

@ -0,0 +1,52 @@
/**
* Clean install configuration built from user input.
* User input comes from either UI answers or headless CLI flags.
*/
class Config {
constructor({ directory, modules, ides, skipPrompts, verbose, actionType, coreConfig, moduleConfigs, quickUpdate }) {
this.directory = directory;
this.modules = Object.freeze([...modules]);
this.ides = Object.freeze([...ides]);
this.skipPrompts = skipPrompts;
this.verbose = verbose;
this.actionType = actionType;
this.coreConfig = coreConfig;
this.moduleConfigs = moduleConfigs;
this._quickUpdate = quickUpdate;
Object.freeze(this);
}
/**
* Build a clean install config from raw user input.
* @param {Object} userInput - UI answers or CLI flags
* @returns {Config}
*/
static build(userInput) {
const modules = [...(userInput.modules || [])];
if (userInput.installCore && !modules.includes('core')) {
modules.unshift('core');
}
return new Config({
directory: userInput.directory,
modules,
ides: userInput.skipIde ? [] : [...(userInput.ides || [])],
skipPrompts: userInput.skipPrompts || false,
verbose: userInput.verbose || false,
actionType: userInput.actionType,
coreConfig: userInput.coreConfig || {},
moduleConfigs: userInput.moduleConfigs || null,
quickUpdate: userInput._quickUpdate || false,
});
}
hasCoreConfig() {
return this.coreConfig && Object.keys(this.coreConfig).length > 0;
}
isQuickUpdate() {
return this._quickUpdate;
}
}
module.exports = { Config };

View File

@ -7,7 +7,7 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('node:path'); const path = require('node:path');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const prompts = require('../../../lib/prompts'); const prompts = require('../prompts');
class CustomModuleCache { class CustomModuleCache {
constructor(bmadDir) { constructor(bmadDir) {

View File

@ -0,0 +1,127 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { Manifest } = require('./manifest');
/**
* Immutable snapshot of an existing BMAD installation.
* Pure query object no filesystem operations after construction.
*/
class ExistingInstall {
#version;
constructor({ installed, version, hasCore, modules, ides, customModules }) {
this.installed = installed;
this.#version = version;
this.hasCore = hasCore;
this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m })));
this.moduleIds = Object.freeze(this.modules.map((m) => m.id));
this.ides = Object.freeze([...ides]);
this.customModules = Object.freeze([...customModules]);
Object.freeze(this);
}
get version() {
if (!this.installed) {
throw new Error('version is not available when nothing is installed');
}
return this.#version;
}
static empty() {
return new ExistingInstall({
installed: false,
version: null,
hasCore: false,
modules: [],
ides: [],
customModules: [],
});
}
/**
* Scan a bmad directory and return an immutable snapshot of what's installed.
* @param {string} bmadDir - Path to bmad directory
* @returns {Promise<ExistingInstall>}
*/
static async detect(bmadDir) {
if (!(await fs.pathExists(bmadDir))) {
return ExistingInstall.empty();
}
let version = null;
let hasCore = false;
const modules = [];
let ides = [];
let customModules = [];
const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir);
if (manifestData) {
version = manifestData.version;
if (manifestData.customModules) {
customModules = manifestData.customModules;
}
if (manifestData.ides) {
ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string');
}
}
const corePath = path.join(bmadDir, 'core');
if (await fs.pathExists(corePath)) {
hasCore = true;
if (!version) {
const coreConfigPath = path.join(corePath, 'config.yaml');
if (await fs.pathExists(coreConfigPath)) {
try {
const configContent = await fs.readFile(coreConfigPath, 'utf8');
const config = yaml.parse(configContent);
if (config.version) {
version = config.version;
}
} catch {
// Ignore config read errors
}
}
}
}
if (manifestData && manifestData.modules && manifestData.modules.length > 0) {
for (const moduleId of manifestData.modules) {
const modulePath = path.join(bmadDir, moduleId);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
const moduleInfo = {
id: moduleId,
path: modulePath,
version: 'unknown',
};
if (await fs.pathExists(moduleConfigPath)) {
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.parse(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || moduleId;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
}
modules.push(moduleInfo);
}
}
const installed = hasCore || modules.length > 0 || !!manifestData;
if (!installed) {
return ExistingInstall.empty();
}
return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules });
}
}
module.exports = { ExistingInstall };

View File

@ -0,0 +1,129 @@
const path = require('node:path');
const fs = require('fs-extra');
const { getProjectRoot } = require('../project-root');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class InstallPaths {
static async create(config) {
const srcDir = getProjectRoot();
await assertReadableDir(srcDir, 'BMAD source root');
const pkgPath = path.join(srcDir, 'package.json');
await assertReadableFile(pkgPath, 'package.json');
const version = require(pkgPath).version;
const projectRoot = path.resolve(config.directory);
await ensureWritableDir(projectRoot, 'project root');
const bmadDir = path.join(projectRoot, BMAD_FOLDER_NAME);
const isUpdate = await fs.pathExists(bmadDir);
const configDir = path.join(bmadDir, '_config');
const agentsDir = path.join(configDir, 'agents');
const customCacheDir = path.join(configDir, 'custom');
const coreDir = path.join(bmadDir, 'core');
for (const [dir, label] of [
[bmadDir, 'bmad directory'],
[configDir, 'config directory'],
[agentsDir, 'agents config directory'],
[customCacheDir, 'custom modules cache'],
[coreDir, 'core module directory'],
]) {
await ensureWritableDir(dir, label);
}
return new InstallPaths({
srcDir,
version,
projectRoot,
bmadDir,
configDir,
agentsDir,
customCacheDir,
coreDir,
isUpdate,
});
}
constructor(props) {
Object.assign(this, props);
Object.freeze(this);
}
manifestFile() {
return path.join(this.configDir, 'manifest.yaml');
}
agentManifest() {
return path.join(this.configDir, 'agent-manifest.csv');
}
filesManifest() {
return path.join(this.configDir, 'files-manifest.csv');
}
helpCatalog() {
return path.join(this.configDir, 'bmad-help.csv');
}
moduleDir(name) {
return path.join(this.bmadDir, name);
}
moduleConfig(name) {
return path.join(this.bmadDir, name, 'config.yaml');
}
}
async function assertReadableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${dirPath}`);
}
if (!stat.isDirectory()) {
throw new Error(`${label} is not a directory: ${dirPath}`);
}
try {
await fs.access(dirPath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${dirPath}`);
}
}
async function assertReadableFile(filePath, label) {
const stat = await fs.stat(filePath).catch(() => null);
if (!stat) {
throw new Error(`${label} does not exist: ${filePath}`);
}
if (!stat.isFile()) {
throw new Error(`${label} is not a file: ${filePath}`);
}
try {
await fs.access(filePath, fs.constants.R_OK);
} catch {
throw new Error(`${label} is not readable: ${filePath}`);
}
}
async function ensureWritableDir(dirPath, label) {
const stat = await fs.stat(dirPath).catch(() => null);
if (stat && !stat.isDirectory()) {
throw new Error(`${label} exists but is not a directory: ${dirPath}`);
}
try {
await fs.ensureDir(dirPath);
} catch (error) {
if (error.code === 'EACCES') {
throw new Error(`${label}: permission denied creating directory: ${dirPath}`);
}
if (error.code === 'ENOSPC') {
throw new Error(`${label}: no space left on device: ${dirPath}`);
}
throw new Error(`${label}: cannot create directory: ${dirPath} (${error.message})`);
}
try {
await fs.access(dirPath, fs.constants.R_OK | fs.constants.W_OK);
} catch {
throw new Error(`${label} is not writable: ${dirPath}`);
}
}
module.exports = { InstallPaths };

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@ const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getSourcePath, getModulePath } = require('../project-root');
const prompts = require('../../../lib/prompts'); const prompts = require('../prompts');
const { const {
loadSkillManifest: loadSkillManifestShared, loadSkillManifest: loadSkillManifestShared,
getCanonicalId: getCanonicalIdShared, getCanonicalId: getCanonicalIdShared,
@ -13,7 +13,7 @@ const {
} = require('../ide/shared/skill-manifest'); } = require('../ide/shared/skill-manifest');
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../package.json');
/** /**
* Generates manifest files for installed skills and agents * Generates manifest files for installed skills and agents

View File

@ -1,8 +1,8 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const { getProjectRoot } = require('../../../lib/project-root'); const { getProjectRoot } = require('../project-root');
const prompts = require('../../../lib/prompts'); const prompts = require('../prompts');
class Manifest { class Manifest {
/** /**

View File

@ -1,7 +1,7 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../../lib/prompts'); const prompts = require('./prompts');
/** /**
* Handler for custom content (custom.yaml) * Handler for custom content (custom.yaml)
* Discovers custom agents and workflows in the project * Discovers custom agents and workflows in the project

View File

@ -2,9 +2,9 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const { BaseIdeSetup } = require('./_base-ide'); const prompts = require('../prompts');
const prompts = require('../../../lib/prompts');
const csv = require('csv-parse/sync'); const csv = require('csv-parse/sync');
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
/** /**
* Config-driven IDE setup handler * Config-driven IDE setup handler
@ -15,31 +15,35 @@ const csv = require('csv-parse/sync');
* *
* Features: * Features:
* - Config-driven from platform-codes.yaml * - Config-driven from platform-codes.yaml
* - Template-based content generation * - Verbatim skill installation from skill-manifest.csv
* - Multi-target installation support (e.g., GitHub Copilot) * - Legacy directory cleanup and IDE-specific marker removal
* - Artifact type filtering (agents, workflows, tasks, tools)
*/ */
class ConfigDrivenIdeSetup extends BaseIdeSetup { class ConfigDrivenIdeSetup {
constructor(platformCode, platformConfig) { constructor(platformCode, platformConfig) {
super(platformCode, platformConfig.name, platformConfig.preferred); this.name = platformCode;
this.displayName = platformConfig.name || platformCode;
this.preferred = platformConfig.preferred || false;
this.platformConfig = platformConfig; this.platformConfig = platformConfig;
this.installerConfig = platformConfig.installer || null; this.installerConfig = platformConfig.installer || null;
this.bmadFolderName = BMAD_FOLDER_NAME;
// Set configDir from target_dir so base-class detect() works // Set configDir from target_dir so detect() works
if (this.installerConfig?.target_dir) { this.configDir = this.installerConfig?.target_dir || null;
this.configDir = this.installerConfig.target_dir;
} }
setBmadFolderName(bmadFolderName) {
this.bmadFolderName = bmadFolderName;
} }
/** /**
* Detect whether this IDE already has configuration in the project. * Detect whether this IDE already has configuration in the project.
* For skill_format platforms, checks for bmad-prefixed entries in target_dir * Checks for bmad-prefixed entries in target_dir.
* (matching old codex.js behavior) instead of just checking directory existence.
* @param {string} projectDir - Project directory * @param {string} projectDir - Project directory
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async detect(projectDir) { async detect(projectDir) {
if (this.installerConfig?.skill_format && this.configDir) { if (!this.configDir) return false;
const dir = path.join(projectDir || process.cwd(), this.configDir); const dir = path.join(projectDir || process.cwd(), this.configDir);
if (await fs.pathExists(dir)) { if (await fs.pathExists(dir)) {
try { try {
@ -51,8 +55,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
} }
return false; return false;
} }
return super.detect(projectDir);
}
/** /**
* Main setup method - called by IdeManager * Main setup method - called by IdeManager
@ -90,12 +92,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return { success: false, reason: 'no-config' }; return { success: false, reason: 'no-config' };
} }
// Handle multi-target installations (e.g., GitHub Copilot)
if (this.installerConfig.targets) {
return this.installToMultipleTargets(projectDir, bmadDir, this.installerConfig.targets, options);
}
// Handle single-target installations
if (this.installerConfig.target_dir) { if (this.installerConfig.target_dir) {
return this.installToTarget(projectDir, bmadDir, this.installerConfig, options); return this.installToTarget(projectDir, bmadDir, this.installerConfig, options);
} }
@ -113,13 +109,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/ */
async installToTarget(projectDir, bmadDir, config, options) { async installToTarget(projectDir, bmadDir, config, options) {
const { target_dir } = config; const { target_dir } = config;
if (!config.skill_format) {
return { success: false, reason: 'missing-skill-format', error: 'Installer config missing skill_format — cannot install skills' };
}
const targetPath = path.join(projectDir, target_dir); const targetPath = path.join(projectDir, target_dir);
await this.ensureDir(targetPath); await fs.ensureDir(targetPath);
this.skillWriteTracker = new Set(); this.skillWriteTracker = new Set();
const results = { skills: 0 }; const results = { skills: 0 };
@ -132,351 +123,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return { success: true, results }; return { success: true, results };
} }
/**
* Install to multiple target directories
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Array} targets - Array of target configurations
* @param {Object} options - Setup options
* @returns {Promise<Object>} Installation result
*/
async installToMultipleTargets(projectDir, bmadDir, targets, options) {
const allResults = { skills: 0 };
for (const target of targets) {
const result = await this.installToTarget(projectDir, bmadDir, target, options);
if (result.success) {
allResults.skills += result.results.skills || 0;
}
}
return { success: true, results: allResults };
}
/**
* Load template based on type and configuration
* @param {string} templateType - Template type (claude, windsurf, etc.)
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
* @param {Object} config - Installation configuration
* @param {string} fallbackTemplateType - Fallback template type if requested template not found
* @returns {Promise<{content: string, extension: string}>} Template content and extension
*/
async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) {
const { header_template, body_template } = config;
// Check for separate header/body templates
if (header_template || body_template) {
const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template);
// Allow config to override extension, default to .md
const ext = config.extension || '.md';
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
return { content, extension: normalizedExt };
}
// Load combined template - try multiple extensions
// If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml')
const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType;
const templateDir = path.join(__dirname, 'templates', 'combined');
const extensions = ['.md', '.toml', '.yaml', '.yml'];
for (const ext of extensions) {
const templatePath = path.join(templateDir, templateBaseName + ext);
if (await fs.pathExists(templatePath)) {
const content = await fs.readFile(templatePath, 'utf8');
return { content, extension: ext };
}
}
// Fall back to default template (if provided)
if (fallbackTemplateType) {
for (const ext of extensions) {
const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`);
if (await fs.pathExists(fallbackPath)) {
const content = await fs.readFile(fallbackPath, 'utf8');
return { content, extension: ext };
}
}
}
// Ultimate fallback - minimal template
return { content: this.getDefaultTemplate(artifactType), extension: '.md' };
}
/**
* Load split templates (header + body)
* @param {string} templateType - Template type
* @param {string} artifactType - Artifact type
* @param {string} headerTpl - Header template name
* @param {string} bodyTpl - Body template name
* @returns {Promise<string>} Combined template content
*/
async loadSplitTemplates(templateType, artifactType, headerTpl, bodyTpl) {
let header = '';
let body = '';
// Load header template
if (headerTpl) {
const headerPath = path.join(__dirname, 'templates', 'split', headerTpl);
if (await fs.pathExists(headerPath)) {
header = await fs.readFile(headerPath, 'utf8');
}
} else {
// Use default header for template type
const defaultHeaderPath = path.join(__dirname, 'templates', 'split', templateType, 'header.md');
if (await fs.pathExists(defaultHeaderPath)) {
header = await fs.readFile(defaultHeaderPath, 'utf8');
}
}
// Load body template
if (bodyTpl) {
const bodyPath = path.join(__dirname, 'templates', 'split', bodyTpl);
if (await fs.pathExists(bodyPath)) {
body = await fs.readFile(bodyPath, 'utf8');
}
} else {
// Use default body for template type
const defaultBodyPath = path.join(__dirname, 'templates', 'split', templateType, 'body.md');
if (await fs.pathExists(defaultBodyPath)) {
body = await fs.readFile(defaultBodyPath, 'utf8');
}
}
// Combine header and body
return `${header}\n${body}`;
}
/**
* Get default minimal template
* @param {string} artifactType - Artifact type
* @returns {string} Default template
*/
getDefaultTemplate(artifactType) {
if (artifactType === 'agent') {
return `---
name: '{{name}}'
description: '{{description}}'
disable-model-invocation: true
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified.
<agent-activation CRITICAL="TRUE">
1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
</agent-activation>
`;
}
return `---
name: '{{name}}'
description: '{{description}}'
---
# {{name}}
LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
`;
}
/**
* Render template with artifact data
* @param {string} template - Template content
* @param {Object} artifact - Artifact data
* @returns {string} Rendered content
*/
renderTemplate(template, artifact) {
// Use the appropriate path property based on artifact type
let pathToUse = artifact.relativePath || '';
switch (artifact.type) {
case 'agent-launcher': {
pathToUse = artifact.agentPath || artifact.relativePath || '';
break;
}
case 'workflow-command': {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
break;
}
case 'task':
case 'tool': {
pathToUse = artifact.path || artifact.relativePath || '';
break;
}
// No default
}
// Replace _bmad placeholder with actual folder name BEFORE inserting paths,
// so that paths containing '_bmad' are not corrupted by the blanket replacement.
let rendered = template.replaceAll('_bmad', this.bmadFolderName);
// Replace {{bmadFolderName}} placeholder if present
rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName);
rendered = rendered
.replaceAll('{{name}}', artifact.name || '')
.replaceAll('{{module}}', artifact.module || 'core')
.replaceAll('{{path}}', pathToUse)
.replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`)
.replaceAll('{{workflow_path}}', pathToUse);
return rendered;
}
/**
* Write artifact as a skill directory with SKILL.md inside.
* Writes artifact as a skill directory with SKILL.md inside.
* @param {string} targetPath - Base skills directory
* @param {Object} artifact - Artifact data
* @param {string} content - Rendered template content
*/
async writeSkillFile(targetPath, artifact, content) {
const { resolveSkillName } = require('./shared/path-utils');
// Get the skill name (prefers canonicalId, falls back to path-derived) and remove .md
const flatName = resolveSkillName(artifact);
const skillName = path.basename(flatName.replace(/\.md$/, ''));
if (!skillName) {
throw new Error(`Cannot derive skill name for artifact: ${artifact.relativePath || JSON.stringify(artifact)}`);
}
// Create skill directory
const skillDir = path.join(targetPath, skillName);
await this.ensureDir(skillDir);
this.skillWriteTracker?.add(skillName);
// Transform content: rewrite frontmatter for skills format
const skillContent = this.transformToSkillFormat(content, skillName);
await this.writeFile(path.join(skillDir, 'SKILL.md'), skillContent);
}
/**
* Transform artifact content to Agent Skills format.
* Rewrites frontmatter to contain only unquoted name and description.
* @param {string} content - Original content with YAML frontmatter
* @param {string} skillName - Skill name (must match directory name)
* @returns {string} Transformed content
*/
transformToSkillFormat(content, skillName) {
// Normalize line endings
content = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
// Parse frontmatter
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!fmMatch) {
// No frontmatter -- wrap with minimal frontmatter
const fm = yaml.stringify({ name: skillName, description: skillName }).trimEnd();
return `---\n${fm}\n---\n\n${content}`;
}
const frontmatter = fmMatch[1];
const body = fmMatch[2];
// Parse frontmatter with yaml library to extract description
let description;
try {
const parsed = yaml.parse(frontmatter);
const rawDesc = parsed?.description;
description = typeof rawDesc === 'string' && rawDesc ? rawDesc : `${skillName} skill`;
} catch {
description = `${skillName} skill`;
}
// Build new frontmatter with only name and description, unquoted
const newFrontmatter = yaml.stringify({ name: skillName, description: String(description) }, { lineWidth: 0 }).trimEnd();
return `---\n${newFrontmatter}\n---\n${body}`;
}
/**
* Install a custom agent launcher.
* For skill_format platforms, produces <skillDir>/SKILL.md.
* For flat platforms, produces a single file in target_dir.
* @param {string} projectDir - Project directory
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata
* @returns {Object|null} Info about created file/skill
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
if (!this.installerConfig?.target_dir) return null;
const { customAgentDashName } = require('./shared/path-utils');
const targetPath = path.join(projectDir, this.installerConfig.target_dir);
await this.ensureDir(targetPath);
// Build artifact to reuse existing template rendering.
// The default-agent template already includes the _bmad/ prefix before {{path}},
// but agentPath is relative to project root (e.g. "_bmad/custom/agents/fred.md").
// Strip the bmadFolderName prefix so the template doesn't produce a double path.
const bmadPrefix = this.bmadFolderName + '/';
const normalizedPath = agentPath.startsWith(bmadPrefix) ? agentPath.slice(bmadPrefix.length) : agentPath;
const artifact = {
type: 'agent-launcher',
name: agentName,
description: metadata?.description || `${agentName} agent`,
agentPath: normalizedPath,
relativePath: normalizedPath,
module: 'custom',
};
const { content: template } = await this.loadTemplate(
this.installerConfig.template_type || 'default',
'agent',
this.installerConfig,
'default-agent',
);
const content = this.renderTemplate(template, artifact);
if (this.installerConfig.skill_format) {
const skillName = customAgentDashName(agentName).replace(/\.md$/, '');
const skillDir = path.join(targetPath, skillName);
await this.ensureDir(skillDir);
const skillContent = this.transformToSkillFormat(content, skillName);
const skillPath = path.join(skillDir, 'SKILL.md');
await this.writeFile(skillPath, skillContent);
return { path: path.relative(projectDir, skillPath), command: `$${skillName}` };
}
// Flat file output
const filename = customAgentDashName(agentName);
const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content);
return { path: path.relative(projectDir, filePath), command: agentName };
}
/**
* Generate filename for artifact
* @param {Object} artifact - Artifact data
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
* @param {string} extension - File extension to use (e.g., '.md', '.toml')
* @returns {string} Generated filename
*/
generateFilename(artifact, artifactType, extension = '.md') {
const { resolveSkillName } = require('./shared/path-utils');
// Reuse central logic to ensure consistent naming conventions
// Prefers canonicalId from manifest when available, falls back to path-derived name
const standardName = resolveSkillName(artifact);
// Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md)
// This handles any extensions that might slip through toDashPath()
const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md');
// If using default markdown, preserve the bmad-agent- prefix for agents
if (extension === '.md') {
return baseName;
}
// For other extensions (e.g., .toml), replace .md extension
// Note: agent prefix is preserved even with non-markdown extensions
return baseName.replace(/\.md$/, extension);
}
/** /**
* Install verbatim native SKILL.md directories from skill-manifest.csv. * Install verbatim native SKILL.md directories from skill-manifest.csv.
* Copies the entire source directory as-is into the IDE skill directory. * Copies the entire source directory as-is into the IDE skill directory.
@ -598,22 +244,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
await this.cleanupRovoDevPrompts(projectDir, options); await this.cleanupRovoDevPrompts(projectDir, options);
} }
// Clean all target directories // Clean target directory
if (this.installerConfig?.targets) { if (this.installerConfig?.target_dir) {
const parentDirs = new Set();
for (const target of this.installerConfig.targets) {
await this.cleanupTarget(projectDir, target.target_dir, options);
// Track parent directories for empty-dir cleanup
const parentDir = path.dirname(target.target_dir);
if (parentDir && parentDir !== '.') {
parentDirs.add(parentDir);
}
}
// After all targets cleaned, remove empty parent directories (recursive up to projectDir)
for (const parentDir of parentDirs) {
await this.removeEmptyParents(projectDir, parentDir);
}
} else if (this.installerConfig?.target_dir) {
await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options);
} }
} }
@ -711,6 +343,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
} }
} }
} }
/** /**
* Strip BMAD-owned content from .github/copilot-instructions.md. * Strip BMAD-owned content from .github/copilot-instructions.md.
* The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers. * The old custom installer injected content between <!-- BMAD:START --> and <!-- BMAD:END --> markers.

View File

@ -1,5 +1,5 @@
const { BMAD_FOLDER_NAME } = require('./shared/path-utils'); const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
const prompts = require('../../../lib/prompts'); const prompts = require('../prompts');
/** /**
* IDE Manager - handles IDE-specific setup * IDE Manager - handles IDE-specific setup
@ -226,23 +226,6 @@ class IdeManager {
return results; return results;
} }
/**
* Get list of supported IDEs
* @returns {Array} List of supported IDE names
*/
getSupportedIdes() {
return [...this.handlers.keys()];
}
/**
* Check if an IDE is supported
* @param {string} ideName - Name of the IDE
* @returns {boolean} True if IDE is supported
*/
isSupported(ideName) {
return this.handlers.has(ideName.toLowerCase());
}
/** /**
* Detect installed IDEs * Detect installed IDEs
* @param {string} projectDir - Project directory * @param {string} projectDir - Project directory
@ -259,41 +242,6 @@ class IdeManager {
return detected; return detected;
} }
/**
* Install custom agent launchers for specified IDEs
* @param {Array} ides - List of IDE names to install for
* @param {string} projectDir - Project directory
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata
* @returns {Object} Results for each IDE
*/
async installCustomAgentLaunchers(ides, projectDir, agentName, agentPath, metadata) {
const results = {};
for (const ideName of ides) {
const handler = this.handlers.get(ideName.toLowerCase());
if (!handler) {
await prompts.log.warn(`IDE '${ideName}' is not yet supported for custom agent installation`);
continue;
}
try {
if (typeof handler.installCustomAgentLauncher === 'function') {
const result = await handler.installCustomAgentLauncher(projectDir, agentName, agentPath, metadata);
if (result) {
results[ideName] = result;
}
}
} catch (error) {
await prompts.log.warn(`Failed to install ${ideName} launcher: ${error.message}`);
}
}
return results;
}
} }
module.exports = { IdeManager }; module.exports = { IdeManager };

View File

@ -0,0 +1,37 @@
const fs = require('fs-extra');
const path = require('node:path');
const yaml = require('yaml');
const PLATFORM_CODES_PATH = path.join(__dirname, 'platform-codes.yaml');
let _cachedPlatformCodes = null;
/**
* Load the platform codes configuration from YAML
* @returns {Object} Platform codes configuration
*/
async function loadPlatformCodes() {
if (_cachedPlatformCodes) {
return _cachedPlatformCodes;
}
if (!(await fs.pathExists(PLATFORM_CODES_PATH))) {
throw new Error(`Platform codes configuration not found at: ${PLATFORM_CODES_PATH}`);
}
const content = await fs.readFile(PLATFORM_CODES_PATH, 'utf8');
_cachedPlatformCodes = yaml.parse(content);
return _cachedPlatformCodes;
}
/**
* Clear the cached platform codes (useful for testing)
*/
function clearCache() {
_cachedPlatformCodes = null;
}
module.exports = {
loadPlatformCodes,
clearCache,
};

View File

@ -0,0 +1,190 @@
# 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
# legacy_targets: (optional) Old target dirs to clean up on reinstall
# ancestor_conflict_check: (optional) Refuse install when ancestor dir has BMAD files
platforms:
antigravity:
name: "Google Antigravity"
preferred: false
installer:
legacy_targets:
- .agent/workflows
target_dir: .agent/skills
auggie:
name: "Auggie"
preferred: false
installer:
legacy_targets:
- .augment/commands
target_dir: .augment/skills
claude-code:
name: "Claude Code"
preferred: true
installer:
legacy_targets:
- .claude/commands
target_dir: .claude/skills
ancestor_conflict_check: true
cline:
name: "Cline"
preferred: false
installer:
legacy_targets:
- .clinerules/workflows
target_dir: .cline/skills
codex:
name: "Codex"
preferred: false
installer:
legacy_targets:
- .codex/prompts
- ~/.codex/prompts
target_dir: .agents/skills
ancestor_conflict_check: true
codebuddy:
name: "CodeBuddy"
preferred: false
installer:
legacy_targets:
- .codebuddy/commands
target_dir: .codebuddy/skills
crush:
name: "Crush"
preferred: false
installer:
legacy_targets:
- .crush/commands
target_dir: .crush/skills
cursor:
name: "Cursor"
preferred: true
installer:
legacy_targets:
- .cursor/commands
target_dir: .cursor/skills
gemini:
name: "Gemini CLI"
preferred: false
installer:
legacy_targets:
- .gemini/commands
target_dir: .gemini/skills
github-copilot:
name: "GitHub Copilot"
preferred: false
installer:
legacy_targets:
- .github/agents
- .github/prompts
target_dir: .github/skills
iflow:
name: "iFlow"
preferred: false
installer:
legacy_targets:
- .iflow/commands
target_dir: .iflow/skills
kilo:
name: "KiloCoder"
preferred: false
suspended: "Kilo Code does not yet support the Agent Skills standard. Support is paused until they implement it. See https://github.com/kilocode/kilo-code/issues for updates."
installer:
legacy_targets:
- .kilocode/workflows
target_dir: .kilocode/skills
kiro:
name: "Kiro"
preferred: false
installer:
legacy_targets:
- .kiro/steering
target_dir: .kiro/skills
ona:
name: "Ona"
preferred: false
installer:
target_dir: .ona/skills
opencode:
name: "OpenCode"
preferred: false
installer:
legacy_targets:
- .opencode/agents
- .opencode/commands
- .opencode/agent
- .opencode/command
target_dir: .opencode/skills
ancestor_conflict_check: true
pi:
name: "Pi"
preferred: false
installer:
target_dir: .pi/skills
qoder:
name: "Qoder"
preferred: false
installer:
target_dir: .qoder/skills
qwen:
name: "QwenCoder"
preferred: false
installer:
legacy_targets:
- .qwen/commands
target_dir: .qwen/skills
roo:
name: "Roo Code"
preferred: false
installer:
legacy_targets:
- .roo/commands
target_dir: .roo/skills
rovo-dev:
name: "Rovo Dev"
preferred: false
installer:
legacy_targets:
- .rovodev/workflows
target_dir: .rovodev/skills
trae:
name: "Trae"
preferred: false
installer:
legacy_targets:
- .trae/rules
target_dir: .trae/skills
windsurf:
name: "Windsurf"
preferred: false
installer:
legacy_targets:
- .windsurf/workflows
target_dir: .windsurf/skills

View File

@ -2,7 +2,7 @@ const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const { glob } = require('glob'); const { glob } = require('glob');
const { getSourcePath } = require('../../../../lib/project-root'); const { getSourcePath } = require('../../project-root');
async function loadModuleInjectionConfig(handler, moduleName) { async function loadModuleInjectionConfig(handler, moduleName) {
const sourceModulesPath = getSourcePath('modules'); const sourceModulesPath = getSourcePath('modules');

View File

@ -1,7 +1,7 @@
const fs = require('fs-extra'); const fs = require('fs-extra');
const path = require('node:path'); const path = require('node:path');
const yaml = require('yaml'); const yaml = require('yaml');
const prompts = require('../../lib/prompts'); const prompts = require('./prompts');
/** /**
* Load and display installer messages from messages.yaml * Load and display installer messages from messages.yaml
@ -18,7 +18,7 @@ class MessageLoader {
return this.messages; return this.messages;
} }
const messagesPath = path.join(__dirname, '..', 'install-messages.yaml'); const messagesPath = path.join(__dirname, 'install-messages.yaml');
try { try {
const content = fs.readFileSync(messagesPath, 'utf8'); const content = fs.readFileSync(messagesPath, 'utf8');

View File

@ -0,0 +1,197 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const { CustomHandler } = require('../custom-handler');
const { Manifest } = require('../core/manifest');
const prompts = require('../prompts');
class CustomModules {
constructor() {
this.paths = new Map();
}
has(moduleCode) {
return this.paths.has(moduleCode);
}
get(moduleCode) {
return this.paths.get(moduleCode);
}
set(moduleId, sourcePath) {
this.paths.set(moduleId, sourcePath);
}
/**
* Install a custom module from its source path.
* @param {string} moduleName - Module identifier
* @param {string} bmadDir - Target bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Install options
* @param {Object} options.moduleConfig - Pre-collected module configuration
* @returns {Object} Install result
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = this.paths.get(moduleName);
if (!sourcePath) {
throw new Error(`No source path for custom module '${moduleName}'`);
}
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`);
}
const targetPath = path.join(bmadDir, moduleName);
// Read custom.yaml and merge into module config
let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {};
const customConfigPath = path.join(sourcePath, 'custom.yaml');
if (await fs.pathExists(customConfigPath)) {
try {
const content = await fs.readFile(customConfigPath, 'utf8');
const customConfig = yaml.parse(content);
if (customConfig) {
moduleConfig = { ...moduleConfig, ...customConfig };
}
} catch (error) {
await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
}
}
// Remove existing installation
if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath);
}
// Copy files with filtering
await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback);
// Add to manifest
const manifest = new Manifest();
const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
await manifest.addModule(bmadDir, moduleName, {
version: versionInfo.version,
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
});
return { success: true, module: moduleName, path: targetPath, moduleConfig };
}
/**
* Copy module files, filtering out install-time-only artifacts.
* @param {string} sourcePath - Source module directory
* @param {string} targetPath - Target module directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
*/
async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) {
const files = await this._getFileList(sourcePath);
for (const file of files) {
if (file.startsWith('sub-modules/')) continue;
const isInSidecar = path
.dirname(file)
.split('/')
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
if (isInSidecar) continue;
if (file === 'module.yaml') continue;
if (file === 'config.yaml') continue;
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Skip web-only agents
if (file.startsWith('agents/') && file.endsWith('.md')) {
const content = await fs.readFile(sourceFile, 'utf8');
if (/<agent[^>]*\slocalskip="true"[^>]*>/.test(content)) {
continue;
}
}
await fs.ensureDir(path.dirname(targetFile));
await fs.copy(sourceFile, targetFile, { overwrite: true });
if (fileTrackingCallback) {
fileTrackingCallback(targetFile);
}
}
}
/**
* Recursively list all files in a directory.
* @param {string} dir - Directory to scan
* @param {string} baseDir - Base directory for relative paths
* @returns {string[]} Relative file paths
*/
async _getFileList(dir, baseDir = dir) {
const files = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await this._getFileList(fullPath, baseDir)));
} else {
files.push(path.relative(baseDir, fullPath));
}
}
return files;
}
/**
* Discover custom module source paths from all available sources.
* @param {Object} config - Installation configuration
* @param {Object} paths - InstallPaths instance
* @returns {Map<string, string>} Map of module ID to source path
*/
async discoverPaths(config, paths) {
this.paths = new Map();
if (config._quickUpdate) {
if (config._customModuleSources) {
for (const [moduleId, customInfo] of config._customModuleSources) {
this.paths.set(moduleId, customInfo.sourcePath);
}
}
return this.paths;
}
// From UI: selectedFiles
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot);
if (customInfo && customInfo.id) {
this.paths.set(customInfo.id, customInfo.path);
}
}
}
// From UI: sources
if (config.customContent && config.customContent.sources) {
for (const source of config.customContent.sources) {
this.paths.set(source.id, source.path);
}
}
// From UI: cachedModules
if (config.customContent && config.customContent.cachedModules) {
const selectedCachedIds = config.customContent.selectedCachedModules || [];
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
for (const cachedModule of config.customContent.cachedModules) {
if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) {
this.paths.set(cachedModule.id, cachedModule.cachePath);
}
}
}
return this.paths;
}
}
module.exports = { CustomModules };

View File

@ -0,0 +1,323 @@
const fs = require('fs-extra');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const yaml = require('yaml');
const prompts = require('../prompts');
/**
* Manages external official modules defined in external-official-modules.yaml
* These are modules hosted in external repositories that can be installed
*
* @class ExternalModuleManager
*/
class ExternalModuleManager {
constructor() {
this.externalModulesConfigPath = path.join(__dirname, '../external-official-modules.yaml');
this.cachedModules = null;
}
/**
* Load and parse the external-official-modules.yaml file
* @returns {Object} Parsed YAML content with modules object
*/
async loadExternalModulesConfig() {
if (this.cachedModules) {
return this.cachedModules;
}
try {
const content = await fs.readFile(this.externalModulesConfigPath, 'utf8');
const config = yaml.parse(content);
this.cachedModules = config;
return config;
} catch (error) {
await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
return { modules: {} };
}
}
/**
* Get list of available external modules
* @returns {Array<Object>} Array of module info objects
*/
async listAvailable() {
const config = await this.loadExternalModulesConfig();
const modules = [];
for (const [key, moduleConfig] of Object.entries(config.modules || {})) {
modules.push({
key,
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
});
}
return modules;
}
/**
* Get module info by code
* @param {string} code - The module code (e.g., 'cis')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByCode(code) {
const modules = await this.listAvailable();
return modules.find((m) => m.code === code) || null;
}
/**
* Get module info by key
* @param {string} key - The module key (e.g., 'bmad-creative-intelligence-suite')
* @returns {Object|null} Module info or null if not found
*/
async getModuleByKey(key) {
const config = await this.loadExternalModulesConfig();
const moduleConfig = config.modules?.[key];
if (!moduleConfig) {
return null;
}
return {
key,
url: moduleConfig.url,
moduleDefinition: moduleConfig['module-definition'],
code: moduleConfig.code,
name: moduleConfig.name,
header: moduleConfig.header,
subheader: moduleConfig.subheader,
description: moduleConfig.description || '',
defaultSelected: moduleConfig.defaultSelected === true,
type: moduleConfig.type || 'community', // bmad-org or community
npmPackage: moduleConfig.npmPackage || null, // Include npm package name
isExternal: true,
};
}
/**
* Check if a module code exists in external modules
* @param {string} code - The module code to check
* @returns {boolean} True if the module exists
*/
async hasModule(code) {
const module = await this.getModuleByCode(code);
return module !== null;
}
/**
* Get the URL for a module by code
* @param {string} code - The module code
* @returns {string|null} The URL or null if not found
*/
async getModuleUrl(code) {
const module = await this.getModuleByCode(code);
return module ? module.url : null;
}
/**
* Get the module definition path for a module by code
* @param {string} code - The module code
* @returns {string|null} The module definition path or null if not found
*/
async getModuleDefinition(code) {
const module = await this.getModuleByCode(code);
return module ? module.moduleDefinition : null;
}
/**
* Get the cache directory for external modules
* @returns {string} Path to the external modules cache directory
*/
getExternalCacheDir() {
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
return cacheDir;
}
/**
* Clone an external module repository to cache
* @param {string} moduleCode - Code of the external module
* @param {Object} options - Clone options
* @param {boolean} options.silent - Suppress spinner output
* @returns {string} Path to the cloned repository
*/
async cloneExternalModule(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) {
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
}
const cacheDir = this.getExternalCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
// Create cache directory if it doesn't exist
await fs.ensureDir(cacheDir);
// Helper to create a spinner or a no-op when silent
const createSpinner = async () => {
if (silent) {
return {
start() {},
stop() {},
error() {},
message() {},
cancel() {},
clear() {},
get isSpinning() {
return false;
},
get isCancelled() {
return false;
},
};
}
return await prompts.spinner();
};
// Track if we need to install dependencies
let needsDependencyInstall = false;
let wasNewClone = false;
// Check if already cloned
if (await fs.pathExists(moduleCacheDir)) {
// Try to update if it's a git repo
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
// Fetch and reset to remote - works better with shallow clones than pull
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
// Force dependency install if we got new code
if (currentRef !== newRef) {
needsDependencyInstall = true;
}
} catch {
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
// If update fails, remove and re-clone
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
} else {
wasNewClone = true;
}
// Clone if not exists or was removed
if (wasNewClone) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
try {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
} catch (error) {
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
}
}
// Install dependencies if package.json exists
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
if (await fs.pathExists(packageJsonPath)) {
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
// Force install if we updated or cloned new
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
} else {
// Check if package.json is newer than node_modules
let packageJsonNewer = false;
try {
const packageStats = await fs.stat(packageJsonPath);
const nodeModulesStats = await fs.stat(nodeModulesPath);
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
} catch {
// If stat fails, assume we need to install
packageJsonNewer = true;
}
if (packageJsonNewer) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
}
}
}
return moduleCacheDir;
}
/**
* Find the source path for an external module
* @param {string} moduleCode - Code of the external module
* @param {Object} options - Options passed to cloneExternalModule
* @returns {string|null} Path to the module source or null if not found
*/
async findExternalModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) {
return null;
}
// Clone the external module repo
const cloneDir = await this.cloneExternalModule(moduleCode, options);
// The module-definition specifies the path to module.yaml relative to repo root
// We need to return the directory containing module.yaml
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
return moduleDir;
}
}
module.exports = { ExternalModuleManager };

View File

@ -1,30 +1,700 @@
const path = require('node:path'); const path = require('node:path');
const fs = require('fs-extra'); const fs = require('fs-extra');
const yaml = require('yaml'); const yaml = require('yaml');
const { getProjectRoot, getModulePath } = require('../../../lib/project-root'); const prompts = require('../prompts');
const { CLIUtils } = require('../../../lib/cli-utils'); const { getProjectRoot, getSourcePath, getModulePath } = require('../project-root');
const prompts = require('../../../lib/prompts'); const { CLIUtils } = require('../cli-utils');
const { ExternalModuleManager } = require('./external-manager');
class ConfigCollector { class OfficialModules {
constructor() { constructor(options = {}) {
this.externalModuleManager = new ExternalModuleManager();
// Config collection state (merged from ConfigCollector)
this.collectedConfig = {}; this.collectedConfig = {};
this.existingConfig = null; this._existingConfig = null;
this.currentProjectDir = null; this.currentProjectDir = null;
this._moduleManagerInstance = null;
} }
/** /**
* Get or create a cached ModuleManager instance (lazy initialization) * Module configurations collected during install.
* @returns {Object} ModuleManager instance
*/ */
_getModuleManager() { get moduleConfigs() {
if (!this._moduleManagerInstance) { return this.collectedConfig;
const { ModuleManager } = require('../modules/manager');
this._moduleManagerInstance = new ModuleManager();
} }
return this._moduleManagerInstance;
/**
* Existing module configurations read from a previous installation.
*/
get existingConfig() {
return this._existingConfig;
} }
/**
* Build a configured OfficialModules instance from install config.
* @param {Object} config - Clean install config (from Config.build)
* @param {Object} paths - InstallPaths instance
* @returns {OfficialModules}
*/
static async build(config, paths) {
const instance = new OfficialModules();
// Pre-collected by UI or quickUpdate — store and load existing for path-change detection
if (config.moduleConfigs) {
instance.collectedConfig = config.moduleConfigs;
await instance.loadExistingConfig(paths.projectRoot);
return instance;
}
// Headless collection (--yes flag from CLI without UI, tests)
if (config.hasCoreConfig()) {
instance.collectedConfig.core = config.coreConfig;
instance.allAnswers = {};
for (const [key, value] of Object.entries(config.coreConfig)) {
instance.allAnswers[`core_${key}`] = value;
}
}
const toCollect = config.hasCoreConfig() ? config.modules.filter((m) => m !== 'core') : [...config.modules];
await instance.collectAllConfigurations(toCollect, paths.projectRoot, {
skipPrompts: config.skipPrompts,
});
return instance;
}
/**
* Copy a file to the target location
* @param {string} sourcePath - Source file path
* @param {string} targetPath - Target file path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/
async copyFile(sourcePath, targetPath, overwrite = true) {
await fs.copy(sourcePath, targetPath, { overwrite });
}
/**
* Copy a directory recursively
* @param {string} sourceDir - Source directory path
* @param {string} targetDir - Target directory path
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
*/
async copyDirectory(sourceDir, targetDir, overwrite = true) {
await fs.ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await this.copyDirectory(sourcePath, targetPath, overwrite);
} else {
await this.copyFile(sourcePath, targetPath, overwrite);
}
}
}
/**
* List all available built-in modules (core and bmm).
* All other modules come from external-official-modules.yaml
* @returns {Object} Object with modules array and customModules array
*/
async listAvailable() {
const modules = [];
const customModules = [];
// Add built-in core module (directly under src/core-skills)
const corePath = getSourcePath('core-skills');
if (await fs.pathExists(corePath)) {
const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills');
if (coreInfo) {
modules.push(coreInfo);
}
}
// Add built-in bmm module (directly under src/bmm-skills)
const bmmPath = getSourcePath('bmm-skills');
if (await fs.pathExists(bmmPath)) {
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
if (bmmInfo) {
modules.push(bmmInfo);
}
}
return { modules, customModules };
}
/**
* Get module information from a module path
* @param {string} modulePath - Path to the module directory
* @param {string} defaultName - Default name for the module
* @param {string} sourceDescription - Description of where the module was found
* @returns {Object|null} Module info or null if not a valid module
*/
async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(modulePath, 'module.yaml');
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null;
if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath;
} else if (await fs.pathExists(rootCustomConfigPath)) {
configPath = rootCustomConfigPath;
}
// Skip if this doesn't look like a module
if (!configPath) {
return null;
}
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
const isCustomSource =
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
const moduleInfo = {
id: defaultName,
path: modulePath,
name: defaultName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
description: 'BMAD Module',
version: '5.0.0',
source: sourceDescription,
isCustom: configPath === rootCustomConfigPath || isCustomSource,
};
// Read module config for metadata
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent);
// Use the code property as the id if available
if (config.code) {
moduleInfo.id = config.code;
}
moduleInfo.name = config.name || moduleInfo.name;
moduleInfo.description = config.description || moduleInfo.description;
moduleInfo.version = config.version || moduleInfo.version;
moduleInfo.dependencies = config.dependencies || [];
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
} catch (error) {
await prompts.log.warn(`Failed to read config for ${defaultName}: ${error.message}`);
}
return moduleInfo;
}
/**
* Find the source path for a module by searching all possible locations
* @param {string} moduleCode - Code of the module to find (from module.yaml)
* @returns {string|null} Path to the module source or null if not found
*/
async findModuleSource(moduleCode, options = {}) {
const projectRoot = getProjectRoot();
// Check for core module (directly under src/core-skills)
if (moduleCode === 'core') {
const corePath = getSourcePath('core-skills');
if (await fs.pathExists(corePath)) {
return corePath;
}
}
// Check for built-in bmm module (directly under src/bmm-skills)
if (moduleCode === 'bmm') {
const bmmPath = getSourcePath('bmm-skills');
if (await fs.pathExists(bmmPath)) {
return bmmPath;
}
}
// Check external official modules
const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
if (externalSource) {
return externalSource;
}
return null;
}
/**
* Install a module
* @param {string} moduleName - Code of the module to install (from module.yaml)
* @param {string} bmadDir - Target bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Additional installation options
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.logger - Logger instance for output
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
const targetPath = path.join(bmadDir, moduleName);
if (!sourcePath) {
throw new Error(
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
);
}
if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath);
}
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
if (!options.skipModuleInstaller) {
await this.createModuleDirectories(moduleName, bmadDir, options);
}
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
await manifestObj.addModule(bmadDir, moduleName, {
version: versionInfo.version,
source: versionInfo.source,
npmPackage: versionInfo.npmPackage,
repoUrl: versionInfo.repoUrl,
});
return { success: true, module: moduleName, path: targetPath, versionInfo };
}
/**
* Update an existing module
* @param {string} moduleName - Name of the module to update
* @param {string} bmadDir - Target bmad directory
*/
async update(moduleName, bmadDir) {
const sourcePath = await this.findModuleSource(moduleName);
const targetPath = path.join(bmadDir, moduleName);
if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`);
}
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
await this.syncModule(sourcePath, targetPath);
return {
success: true,
module: moduleName,
path: targetPath,
};
}
/**
* Remove a module
* @param {string} moduleName - Name of the module to remove
* @param {string} bmadDir - Target bmad directory
*/
async remove(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
await fs.remove(targetPath);
return {
success: true,
module: moduleName,
};
}
/**
* Check if a module is installed
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {boolean} True if module is installed
*/
async isInstalled(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
return await fs.pathExists(targetPath);
}
/**
* Get installed module info
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {Object|null} Module info or null if not installed
*/
async getInstalledInfo(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
return null;
}
const configPath = path.join(targetPath, 'config.yaml');
const moduleInfo = {
id: moduleName,
path: targetPath,
installed: true,
};
if (await fs.pathExists(configPath)) {
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent);
Object.assign(moduleInfo, config);
} catch (error) {
await prompts.log.warn(`Failed to read installed module config: ${error.message}`);
}
}
return moduleInfo;
}
/**
* Copy module with filtering for localskip agents and conditional content
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} moduleConfig - Module configuration with conditional flags
*/
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) {
// Get all files in source
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
// Skip sub-modules directory - these are IDE-specific and handled separately
if (file.startsWith('sub-modules/')) {
continue;
}
// Skip sidecar directories - these contain agent-specific assets not needed at install time
const isInSidecarDirectory = path
.dirname(file)
.split('/')
.some((dir) => dir.toLowerCase().endsWith('-sidecar'));
if (isInSidecarDirectory) {
continue;
}
// Skip module.yaml at root - it's only needed at install time
if (file === 'module.yaml') {
continue;
}
// Skip module root config.yaml only - generated by config collector with actual values
// Workflow-level config.yaml (e.g. workflows/orchestrate-story/config.yaml) must be copied
// for custom modules that use workflow-specific configuration
if (file === 'config.yaml') {
continue;
}
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if this is an agent file
if (file.startsWith('agents/') && file.endsWith('.md')) {
// Read the file to check for localskip
const content = await fs.readFile(sourceFile, 'utf8');
// Check for localskip="true" in the agent tag
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
if (agentMatch) {
await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
continue; // Skip this agent
}
}
// Copy the file with placeholder replacement
await this.copyFile(sourceFile, targetFile);
// Track the file if callback provided
if (fileTrackingCallback) {
fileTrackingCallback(targetFile);
}
}
}
/**
* Find all .md agent files recursively in a directory
* @param {string} dir - Directory to search
* @returns {Array} List of .md agent file paths
*/
async findAgentMdFiles(dir) {
const agentFiles = [];
async function searchDirectory(searchDir) {
const entries = await fs.readdir(searchDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(searchDir, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
agentFiles.push(fullPath);
} else if (entry.isDirectory()) {
await searchDirectory(fullPath);
}
}
}
await searchDirectory(dir);
return agentFiles;
}
/**
* Create directories declared in module.yaml's `directories` key
* This replaces the security-risky module installer pattern with declarative config
* During updates, if a directory path changed, moves the old directory to the new path
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @param {Object} options - Installation options
* @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates)
* @param {Object} options.coreConfig - Core configuration
* @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info
*/
async createModuleDirectories(moduleName, bmadDir, options = {}) {
const moduleConfig = options.moduleConfig || {};
const existingModuleConfig = options.existingModuleConfig || {};
const projectRoot = path.dirname(bmadDir);
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
// Special handling for core module - it's in src/core-skills not src/modules
let sourcePath;
if (moduleName === 'core') {
sourcePath = getSourcePath('core-skills');
} else {
sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) {
return emptyResult; // No source found, skip
}
}
// Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
if (!(await fs.pathExists(moduleYamlPath))) {
return emptyResult; // No module.yaml, skip
}
let moduleYaml;
try {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
moduleYaml = yaml.parse(yamlContent);
} catch {
return emptyResult; // Invalid YAML, skip
}
if (!moduleYaml || !moduleYaml.directories) {
return emptyResult; // No directories declared, skip
}
const directories = moduleYaml.directories;
const wdsFolders = moduleYaml.wds_folders || [];
const createdDirs = [];
const movedDirs = [];
const createdWdsFolders = [];
for (const dirRef of directories) {
// Parse variable reference like "{design_artifacts}"
const varMatch = dirRef.match(/^\{([^}]+)\}$/);
if (!varMatch) {
// Not a variable reference, skip
continue;
}
const configKey = varMatch[1];
const dirValue = moduleConfig[configKey];
if (!dirValue || typeof dirValue !== 'string') {
continue; // No value or not a string, skip
}
// Strip {project-root}/ prefix if present
let dirPath = dirValue.replace(/^\{project-root\}\/?/, '');
// Handle remaining {project-root} anywhere in the path
dirPath = dirPath.replaceAll('{project-root}', '');
// Resolve to absolute path
const fullPath = path.join(projectRoot, dirPath);
// Validate path is within project root (prevent directory traversal)
const normalizedPath = path.normalize(fullPath);
const normalizedRoot = path.normalize(projectRoot);
if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) {
const color = await prompts.getColor();
await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`));
continue;
}
// Check if directory path changed from previous config (update/modify scenario)
const oldDirValue = existingModuleConfig[configKey];
let oldFullPath = null;
let oldDirPath = null;
if (oldDirValue && typeof oldDirValue === 'string') {
// F3: Normalize both values before comparing to avoid false negatives
// from trailing slashes, separator differences, or prefix format variations
let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, '');
normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', ''));
const normalizedNew = path.normalize(dirPath);
if (normalizedOld !== normalizedNew) {
oldDirPath = normalizedOld;
oldFullPath = path.join(projectRoot, oldDirPath);
const normalizedOldAbsolute = path.normalize(oldFullPath);
if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) {
oldFullPath = null; // Old path escapes project root, ignore it
}
// F13: Prevent parent/child move (e.g. docs/planning → docs/planning/v2)
if (oldFullPath) {
const normalizedNewAbsolute = path.normalize(fullPath);
if (
normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) ||
normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep)
) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`,
),
);
oldFullPath = null;
}
}
}
}
const dirName = configKey.replaceAll('_', ' ');
if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) {
// Path changed and old dir exists → move old to new location
// F1: Use fs.move() instead of fs.rename() for cross-device/volume support
// F2: Wrap in try/catch — fallback to creating new dir on failure
try {
await fs.ensureDir(path.dirname(fullPath));
await fs.move(oldFullPath, fullPath);
movedDirs.push(`${dirName}: ${oldDirPath}${dirPath}`);
} catch (moveError) {
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`Failed to move ${oldDirPath}${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`,
),
);
await fs.ensureDir(fullPath);
createdDirs.push(`${dirName}: ${dirPath}`);
}
} else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) {
// F5: Both old and new directories exist — warn user about potential orphaned documents
const color = await prompts.getColor();
await prompts.log.warn(
color.yellow(
`${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents — please review and merge manually.`,
),
);
} else if (!(await fs.pathExists(fullPath))) {
// New directory doesn't exist yet → create it
createdDirs.push(`${dirName}: ${dirPath}`);
await fs.ensureDir(fullPath);
}
// Create WDS subfolders if this is the design_artifacts directory
if (configKey === 'design_artifacts' && wdsFolders.length > 0) {
for (const subfolder of wdsFolders) {
const subPath = path.join(fullPath, subfolder);
if (!(await fs.pathExists(subPath))) {
await fs.ensureDir(subPath);
createdWdsFolders.push(subfolder);
}
}
}
}
return { createdDirs, movedDirs, createdWdsFolders };
}
/**
* Private: Process module configuration
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processModuleConfig(modulePath, moduleName) {
const configPath = path.join(modulePath, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
let configContent = await fs.readFile(configPath, 'utf8');
// Replace path placeholders
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
configContent = configContent.replaceAll('{module}', moduleName);
await fs.writeFile(configPath, configContent, 'utf8');
} catch (error) {
await prompts.log.warn(`Failed to process module config: ${error.message}`);
}
}
}
/**
* Private: Sync module files (preserving user modifications)
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
*/
async syncModule(sourcePath, targetPath) {
// Get list of all source files
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if target file exists and has been modified
if (await fs.pathExists(targetFile)) {
const sourceStats = await fs.stat(sourceFile);
const targetStats = await fs.stat(targetFile);
// Skip if target is newer (user modified)
if (targetStats.mtime > sourceStats.mtime) {
continue;
}
}
// Copy file with placeholder replacement
await this.copyFile(sourceFile, targetFile);
}
}
/**
* Private: Get list of all files in a directory
* @param {string} dir - Directory path
* @param {string} baseDir - Base directory for relative paths
* @returns {Array} List of relative file paths
*/
async getFileList(dir, baseDir = dir) {
const files = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const subFiles = await this.getFileList(fullPath, baseDir);
files.push(...subFiles);
} else {
files.push(path.relative(baseDir, fullPath));
}
}
return files;
}
// ─── Config collection methods (merged from ConfigCollector) ───
/** /**
* Find the bmad installation directory in a project * Find the bmad installation directory in a project
* V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml * V6+ installations can use ANY folder name but ALWAYS have _config/manifest.yaml
@ -95,7 +765,7 @@ class ConfigCollector {
* @param {string} projectDir - Target project directory * @param {string} projectDir - Target project directory
*/ */
async loadExistingConfig(projectDir) { async loadExistingConfig(projectDir) {
this.existingConfig = {}; this._existingConfig = {};
// Check if project directory exists first // Check if project directory exists first
if (!(await fs.pathExists(projectDir))) { if (!(await fs.pathExists(projectDir))) {
@ -129,7 +799,7 @@ class ConfigCollector {
const content = await fs.readFile(moduleConfigPath, 'utf8'); const content = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.parse(content); const moduleConfig = yaml.parse(content);
if (moduleConfig) { if (moduleConfig) {
this.existingConfig[entry.name] = moduleConfig; this._existingConfig[entry.name] = moduleConfig;
foundAny = true; foundAny = true;
} }
} catch { } catch {
@ -153,7 +823,7 @@ class ConfigCollector {
const results = []; const results = [];
for (const moduleName of modules) { for (const moduleName of modules) {
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search // Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
let moduleConfigPath = null; let moduleConfigPath = null;
const customPath = this.customModulePaths?.get(moduleName); const customPath = this.customModulePaths?.get(moduleName);
if (customPath) { if (customPath) {
@ -163,7 +833,7 @@ class ConfigCollector {
if (await fs.pathExists(standardPath)) { if (await fs.pathExists(standardPath)) {
moduleConfigPath = standardPath; moduleConfigPath = standardPath;
} else { } else {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
} }
@ -349,7 +1019,7 @@ class ConfigCollector {
this.currentProjectDir = projectDir; this.currentProjectDir = projectDir;
// Load existing config if not already loaded // Load existing config if not already loaded
if (!this.existingConfig) { if (!this._existingConfig) {
await this.loadExistingConfig(projectDir); await this.loadExistingConfig(projectDir);
} }
@ -364,7 +1034,7 @@ class ConfigCollector {
// If not found in src/modules, we need to find it by searching the project // If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -378,7 +1048,7 @@ class ConfigCollector {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else { } else {
// Check if this is a custom module with custom.yaml // Check if this is a custom module with custom.yaml
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
@ -391,11 +1061,11 @@ class ConfigCollector {
} }
// No config schema for this module - use existing values // No config schema for this module - use existing values
if (this.existingConfig && this.existingConfig[moduleName]) { if (this._existingConfig && this._existingConfig[moduleName]) {
if (!this.collectedConfig[moduleName]) { if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {}; this.collectedConfig[moduleName] = {};
} }
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
} }
return false; return false;
} }
@ -409,7 +1079,7 @@ class ConfigCollector {
// Compare schema with existing config to find new/missing fields // Compare schema with existing config to find new/missing fields
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : []; const existingKeys = this._existingConfig && this._existingConfig[moduleName] ? Object.keys(this._existingConfig[moduleName]) : [];
// Check if this module has no configuration keys at all (like CIS) // Check if this module has no configuration keys at all (like CIS)
// Filter out metadata fields and only count actual config objects // Filter out metadata fields and only count actual config objects
@ -440,11 +1110,11 @@ class ConfigCollector {
// If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts // If in silent mode and no new keys (neither interactive nor static), use existing config and skip prompts
if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) { if (silentMode && newKeys.length === 0 && newStaticKeys.length === 0) {
if (this.existingConfig && this.existingConfig[moduleName]) { if (this._existingConfig && this._existingConfig[moduleName]) {
if (!this.collectedConfig[moduleName]) { if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {}; this.collectedConfig[moduleName] = {};
} }
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
// Special handling for user_name: ensure it has a value // Special handling for user_name: ensure it has a value
if ( if (
@ -455,7 +1125,7 @@ class ConfigCollector {
} }
// Also populate allAnswers for cross-referencing // Also populate allAnswers for cross-referencing
for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
// Ensure user_name is properly set in allAnswers too // Ensure user_name is properly set in allAnswers too
let finalValue = value; let finalValue = value;
if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) { if (moduleName === 'core' && key === 'user_name' && (!value || value === '[USER_NAME]')) {
@ -519,8 +1189,8 @@ class ConfigCollector {
// Process all answers (both static and prompted) // Process all answers (both static and prompted)
// First, copy existing config to preserve values that aren't being updated // First, copy existing config to preserve values that aren't being updated
if (this.existingConfig && this.existingConfig[moduleName]) { if (this._existingConfig && this._existingConfig[moduleName]) {
this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; this.collectedConfig[moduleName] = { ...this._existingConfig[moduleName] };
} else { } else {
this.collectedConfig[moduleName] = {}; this.collectedConfig[moduleName] = {};
} }
@ -545,11 +1215,11 @@ class ConfigCollector {
} }
// Copy over existing values for fields that weren't prompted // Copy over existing values for fields that weren't prompted
if (this.existingConfig && this.existingConfig[moduleName]) { if (this._existingConfig && this._existingConfig[moduleName]) {
if (!this.collectedConfig[moduleName]) { if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {}; this.collectedConfig[moduleName] = {};
} }
for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { for (const [key, value] of Object.entries(this._existingConfig[moduleName])) {
if (!this.collectedConfig[moduleName][key]) { if (!this.collectedConfig[moduleName][key]) {
this.collectedConfig[moduleName][key] = value; this.collectedConfig[moduleName][key] = value;
this.allAnswers[`${moduleName}_${key}`] = value; this.allAnswers[`${moduleName}_${key}`] = value;
@ -652,7 +1322,7 @@ class ConfigCollector {
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) { async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
this.currentProjectDir = projectDir; this.currentProjectDir = projectDir;
// Load existing config if needed and not already loaded // Load existing config if needed and not already loaded
if (!skipLoadExisting && !this.existingConfig) { if (!skipLoadExisting && !this._existingConfig) {
await this.loadExistingConfig(projectDir); await this.loadExistingConfig(projectDir);
} }
@ -674,7 +1344,7 @@ class ConfigCollector {
// If not found in src/modules or custom paths, search the project // If not found in src/modules or custom paths, search the project
if (!(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) { if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
@ -994,8 +1664,8 @@ class ConfigCollector {
} }
// Prefer the current module's persisted value when re-prompting an existing install // Prefer the current module's persisted value when re-prompting an existing install
if (!configValue && currentModule && this.existingConfig?.[currentModule]?.[configKey] !== undefined) { if (!configValue && currentModule && this._existingConfig?.[currentModule]?.[configKey] !== undefined) {
configValue = this.existingConfig[currentModule][configKey]; configValue = this._existingConfig[currentModule][configKey];
} }
// Check in already collected config // Check in already collected config
@ -1009,10 +1679,10 @@ class ConfigCollector {
} }
// Fall back to other existing module config values // Fall back to other existing module config values
if (!configValue && this.existingConfig) { if (!configValue && this._existingConfig) {
for (const mod of Object.keys(this.existingConfig)) { for (const mod of Object.keys(this._existingConfig)) {
if (mod !== '_meta' && this.existingConfig[mod] && this.existingConfig[mod][configKey]) { if (mod !== '_meta' && this._existingConfig[mod] && this._existingConfig[mod][configKey]) {
configValue = this.existingConfig[mod][configKey]; configValue = this._existingConfig[mod][configKey];
break; break;
} }
} }
@ -1083,8 +1753,8 @@ class ConfigCollector {
// Check for existing value // Check for existing value
let existingValue = null; let existingValue = null;
if (this.existingConfig && this.existingConfig[moduleName]) { if (this._existingConfig && this._existingConfig[moduleName]) {
existingValue = this.existingConfig[moduleName][key]; existingValue = this._existingConfig[moduleName][key];
existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig); existingValue = this.normalizeExistingValueForPrompt(existingValue, moduleName, item, moduleConfig);
} }
@ -1369,4 +2039,4 @@ class ConfigCollector {
} }
} }
module.exports = { ConfigCollector }; module.exports = { OfficialModules };

View File

@ -2,8 +2,8 @@ const path = require('node:path');
const os = require('node:os'); const os = require('node:os');
const fs = require('fs-extra'); const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { CustomHandler } = require('../installers/lib/custom/handler'); const { CustomHandler } = require('./custom-handler');
const { ExternalModuleManager } = require('../installers/lib/modules/external-manager'); const { ExternalModuleManager } = require('./modules/external-manager');
const prompts = require('./prompts'); const prompts = require('./prompts');
// Separator class for visual grouping in select/multiselect prompts // Separator class for visual grouping in select/multiselect prompts
@ -32,7 +32,7 @@ class UI {
await CLIUtils.displayLogo(); await CLIUtils.displayLogo();
// Display version-specific start message from install-messages.yaml // Display version-specific start message from install-messages.yaml
const { MessageLoader } = require('../installers/lib/message-loader'); const { MessageLoader } = require('./message-loader');
const messageLoader = new MessageLoader(); const messageLoader = new MessageLoader();
await messageLoader.displayStartMessage(); await messageLoader.displayStartMessage();
@ -51,125 +51,11 @@ class UI {
confirmedDirectory = await this.getConfirmedDirectory(); confirmedDirectory = await this.getConfirmedDirectory();
} }
// Preflight: Check for legacy BMAD v4 footprints immediately after getting directory const { Installer } = require('./core/installer');
const { Detector } = require('../installers/lib/core/detector');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer(); const installer = new Installer();
const legacyV4 = await detector.detectLegacyV4(confirmedDirectory); const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
if (legacyV4.hasLegacyV4) {
await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4);
}
// Check for legacy folders and prompt for rename before showing any menus // Check if there's an existing BMAD installation
let hasLegacyCfg = false;
let hasLegacyBmadFolder = false;
let bmadDir = null;
let legacyBmadPath = null;
// First check for legacy .bmad folder (instead of _bmad)
// Only check if directory exists
if (await fs.pathExists(confirmedDirectory)) {
const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && (entry.name === '.bmad' || entry.name === 'bmad')) {
hasLegacyBmadFolder = true;
legacyBmadPath = path.join(confirmedDirectory, entry.name);
bmadDir = legacyBmadPath;
// Check if it has _cfg folder
const cfgPath = path.join(legacyBmadPath, '_cfg');
if (await fs.pathExists(cfgPath)) {
hasLegacyCfg = true;
}
break;
}
}
}
// If no .bmad or bmad found, check for current installations _bmad
if (!hasLegacyBmadFolder) {
const bmadResult = await installer.findBmadDir(confirmedDirectory);
bmadDir = bmadResult.bmadDir;
hasLegacyCfg = bmadResult.hasLegacyCfg;
}
// Handle legacy .bmad or _cfg folder - these are very old (v4 or alpha)
// Show version warning instead of offering conversion
if (hasLegacyBmadFolder || hasLegacyCfg) {
await prompts.log.warn('LEGACY INSTALLATION DETECTED');
await prompts.note(
'Found a ".bmad"/"bmad" folder, or a legacy "_cfg" folder under the bmad folder -\n' +
'this is from an old BMAD version that is out of date for automatic upgrade,\n' +
'manual intervention required.\n\n' +
'You have a legacy version installed (v4 or alpha).\n' +
'Legacy installations may have compatibility issues.\n\n' +
'For the best experience, we strongly recommend:\n' +
' 1. Delete your current BMAD installation folder (.bmad or bmad)\n' +
' 2. Run a fresh installation\n\n' +
'If you do not want to start fresh, you can attempt to proceed beyond this\n' +
'point IF you have ensured the bmad folder is named _bmad, and under it there\n' +
'is a _config folder. If you have a folder under your bmad folder named _cfg,\n' +
'you would need to rename it _config, and then restart the installer.\n\n' +
'Benefits of a fresh install:\n' +
' \u2022 Cleaner configuration without legacy artifacts\n' +
' \u2022 All new features properly configured\n' +
' \u2022 Fewer potential conflicts\n\n' +
'If you have already produced output from an earlier alpha version, you can\n' +
'still retain those artifacts. After installation, ensure you configured during\n' +
'install the proper file locations for artifacts depending on the module you\n' +
'are using, or move the files to the proper locations.',
'Legacy Installation Detected',
);
const proceed = await prompts.select({
message: 'How would you like to proceed?',
choices: [
{
name: 'Cancel and do a fresh install (recommended)',
value: 'cancel',
},
{
name: 'Proceed anyway (will attempt update, potentially may fail or have unstable behavior)',
value: 'proceed',
},
],
default: 'cancel',
});
if (proceed === 'cancel') {
await prompts.note('1. Delete the existing bmad folder in your project\n' + "2. Run 'bmad install' again", 'To do a fresh install');
process.exit(0);
return;
}
const s = await prompts.spinner();
s.start('Updating folder structure...');
try {
// Handle .bmad folder
if (hasLegacyBmadFolder) {
const newBmadPath = path.join(confirmedDirectory, '_bmad');
await fs.move(legacyBmadPath, newBmadPath);
bmadDir = newBmadPath;
s.stop(`Renamed "${path.basename(legacyBmadPath)}" to "_bmad"`);
}
// Handle _cfg folder (either from .bmad or standalone)
const cfgPath = path.join(bmadDir, '_cfg');
if (await fs.pathExists(cfgPath)) {
s.start('Renaming configuration folder...');
const newCfgPath = path.join(bmadDir, '_config');
await fs.move(cfgPath, newCfgPath);
s.stop('Renamed "_cfg" to "_config"');
}
} catch (error) {
s.stop('Failed to update folder structure');
await prompts.log.error(`Error: ${error.message}`);
process.exit(1);
}
}
// Check if there's an existing BMAD installation (after any folder renames)
const hasExistingInstall = await fs.pathExists(bmadDir); const hasExistingInstall = await fs.pathExists(bmadDir);
let customContentConfig = { hasCustomContent: false }; let customContentConfig = { hasCustomContent: false };
@ -184,19 +70,10 @@ class UI {
if (hasExistingInstall) { if (hasExistingInstall) {
// Get version information // Get version information
const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory);
const packageJsonPath = path.join(__dirname, '../../../package.json'); const packageJsonPath = path.join(__dirname, '../../package.json');
const currentVersion = require(packageJsonPath).version; const currentVersion = require(packageJsonPath).version;
const installedVersion = existingInstall.version || 'unknown'; const installedVersion = existingInstall.version || 'unknown';
// Check if version is pre beta
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
// If user chose to cancel, exit the installer
if (!shouldProceed) {
process.exit(0);
return;
}
// Build menu choices dynamically // Build menu choices dynamically
const choices = []; const choices = [];
@ -402,7 +279,7 @@ class UI {
customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules); customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
} else { } else {
// Preserve existing custom modules if user doesn't want to modify them // Preserve existing custom modules if user doesn't want to modify them
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('./core/installer');
const installer = new Installer(); const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(confirmedDirectory); const { bmadDir } = await installer.findBmadDir(confirmedDirectory);
@ -423,22 +300,24 @@ class UI {
selectedModules.push(...customModuleResult.selectedCustomModules); selectedModules.push(...customModuleResult.selectedCustomModules);
} }
// Filter out core - it's always installed via installCore flag // Ensure core is in the modules list
selectedModules = selectedModules.filter((m) => m !== 'core'); if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
}
// Get tool selection // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options); const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
return { return {
actionType: 'update', actionType: 'update',
directory: confirmedDirectory, directory: confirmedDirectory,
installCore: true,
modules: selectedModules, modules: selectedModules,
ides: toolSelection.ides, ides: toolSelection.ides,
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
customContent: customModuleResult.customContentConfig, customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}; };
@ -543,18 +422,21 @@ class UI {
selectedModules.push(...customContentConfig.selectedModuleIds); selectedModules.push(...customContentConfig.selectedModuleIds);
} }
selectedModules = selectedModules.filter((m) => m !== 'core'); // Ensure core is in the modules list
if (!selectedModules.includes('core')) {
selectedModules.unshift('core');
}
let toolSelection = await this.promptToolSelection(confirmedDirectory, options); let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options); const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options);
return { return {
actionType: 'install', actionType: 'install',
directory: confirmedDirectory, directory: confirmedDirectory,
installCore: true,
modules: selectedModules, modules: selectedModules,
ides: toolSelection.ides, ides: toolSelection.ides,
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: moduleConfigs.core || {},
moduleConfigs: moduleConfigs,
customContent: customContentConfig, customContent: customContentConfig,
skipPrompts: options.yes || false, skipPrompts: options.yes || false,
}; };
@ -570,18 +452,15 @@ class UI {
* @returns {Object} Tool configuration * @returns {Object} Tool configuration
*/ */
async promptToolSelection(projectDir, options = {}) { async promptToolSelection(projectDir, options = {}) {
// Check for existing configured IDEs - use findBmadDir to detect custom folder names const { ExistingInstall } = require('./core/existing-install');
const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('./core/installer');
const { Installer } = require('../installers/lib/core/installer');
const detector = new Detector();
const installer = new Installer(); const installer = new Installer();
const bmadResult = await installer.findBmadDir(projectDir || process.cwd()); const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd());
const bmadDir = bmadResult.bmadDir; const existingInstall = await ExistingInstall.detect(bmadDir);
const existingInstall = await detector.detect(bmadDir); const configuredIdes = existingInstall.ides;
const configuredIdes = existingInstall.ides || [];
// Get IDE manager to fetch available IDEs dynamically // Get IDE manager to fetch available IDEs dynamically
const { IdeManager } = require('../installers/lib/ide/manager'); const { IdeManager } = require('./ide/manager');
const ideManager = new IdeManager(); const ideManager = new IdeManager();
await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs
@ -811,29 +690,29 @@ class UI {
* @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir * @returns {Object} Object with existingInstall, installedModuleIds, and bmadDir
*/ */
async getExistingInstallation(directory) { async getExistingInstallation(directory) {
const { Detector } = require('../installers/lib/core/detector'); const { ExistingInstall } = require('./core/existing-install');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('./core/installer');
const detector = new Detector();
const installer = new Installer(); const installer = new Installer();
const bmadDirResult = await installer.findBmadDir(directory); const { bmadDir } = await installer.findBmadDir(directory);
const bmadDir = bmadDirResult.bmadDir; const existingInstall = await ExistingInstall.detect(bmadDir);
const existingInstall = await detector.detect(bmadDir); const installedModuleIds = new Set(existingInstall.moduleIds);
const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
return { existingInstall, installedModuleIds, bmadDir }; return { existingInstall, installedModuleIds, bmadDir };
} }
/** /**
* Collect core configuration * Collect all module configurations (core + selected modules).
* All interactive prompting happens here in the UI layer.
* @param {string} directory - Installation directory * @param {string} directory - Installation directory
* @param {string[]} modules - Modules to configure (including 'core')
* @param {Object} options - Command-line options * @param {Object} options - Command-line options
* @returns {Object} Core configuration * @returns {Object} Collected module configurations keyed by module name
*/ */
async collectCoreConfig(directory, options = {}) { async collectModuleConfigs(directory, modules, options = {}) {
const { ConfigCollector } = require('../installers/lib/core/config-collector'); const { OfficialModules } = require('./modules/official-modules');
const configCollector = new ConfigCollector(); const configCollector = new OfficialModules();
// If options are provided, set them directly // Seed core config from CLI options if provided
if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) {
const coreConfig = {}; const coreConfig = {};
if (options.userName) { if (options.userName) {
@ -855,8 +734,6 @@ class UI {
// Load existing config to merge with provided options // Load existing config to merge with provided options
await configCollector.loadExistingConfig(directory); await configCollector.loadExistingConfig(directory);
// Merge provided options with existing config (or defaults)
const existingConfig = configCollector.collectedConfig.core || {}; const existingConfig = configCollector.collectedConfig.core || {};
configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig }; configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig };
@ -872,7 +749,6 @@ class UI {
await configCollector.loadExistingConfig(directory); await configCollector.loadExistingConfig(directory);
const existingConfig = configCollector.collectedConfig.core || {}; const existingConfig = configCollector.collectedConfig.core || {};
// If no existing config, use defaults
if (Object.keys(existingConfig).length === 0) { if (Object.keys(existingConfig).length === 0) {
let safeUsername; let safeUsername;
try { try {
@ -889,16 +765,14 @@ class UI {
}; };
await prompts.log.info('Using default configuration (--yes flag)'); await prompts.log.info('Using default configuration (--yes flag)');
} }
} else {
// Load existing configs first if they exist
await configCollector.loadExistingConfig(directory);
// Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
await configCollector.collectModuleConfig('core', directory, false, true);
} }
const coreConfig = configCollector.collectedConfig.core; // Collect all module configs — core is skipped if already seeded above
// Ensure we always have a core config object, even if empty await configCollector.collectAllConfigurations(modules, directory, {
return coreConfig || {}; skipPrompts: options.yes || false,
});
return configCollector.collectedConfig;
} }
/** /**
@ -935,9 +809,9 @@ class UI {
} }
// Add official modules // Add official modules
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('./modules/official-modules');
const moduleManager = new ModuleManager(); const officialModules = new OfficialModules();
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable(); const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
// First, add all items to appropriate sections // First, add all items to appropriate sections
const allCustomModules = []; const allCustomModules = [];
@ -992,9 +866,9 @@ class UI {
* @returns {Array} Selected module codes (excluding core) * @returns {Array} Selected module codes (excluding core)
*/ */
async selectAllModules(installedModuleIds = new Set()) { async selectAllModules(installedModuleIds = new Set()) {
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('./modules/official-modules');
const moduleManager = new ModuleManager(); const officialModulesSource = new OfficialModules();
const { modules: localModules } = await moduleManager.listAvailable(); const { modules: localModules } = await officialModulesSource.listAvailable();
// Get external modules // Get external modules
const externalManager = new ExternalModuleManager(); const externalManager = new ExternalModuleManager();
@ -1069,7 +943,7 @@ class UI {
maxItems: allOptions.length, maxItems: allOptions.length,
}); });
const result = selected ? selected.filter((m) => m !== 'core') : []; const result = selected ? [...selected] : [];
// Display selected modules as bulleted list // Display selected modules as bulleted list
if (result.length > 0) { if (result.length > 0) {
@ -1089,9 +963,9 @@ class UI {
* @returns {Array} Default module codes * @returns {Array} Default module codes
*/ */
async getDefaultModules(installedModuleIds = new Set()) { async getDefaultModules(installedModuleIds = new Set()) {
const { ModuleManager } = require('../installers/lib/modules/manager'); const { OfficialModules } = require('./modules/official-modules');
const moduleManager = new ModuleManager(); const officialModules = new OfficialModules();
const { modules: localModules } = await moduleManager.listAvailable(); const { modules: localModules } = await officialModules.listAvailable();
const defaultModules = []; const defaultModules = [];
@ -1149,7 +1023,7 @@ class UI {
const files = await fs.readdir(directory); const files = await fs.readdir(directory);
if (files.length > 0) { if (files.length > 0) {
// Check for any bmad installation (any folder with _config/manifest.yaml) // Check for any bmad installation (any folder with _config/manifest.yaml)
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('./core/installer');
const installer = new Installer(); const installer = new Installer();
const bmadResult = await installer.findBmadDir(directory); const bmadResult = await installer.findBmadDir(directory);
const hasBmadInstall = const hasBmadInstall =
@ -1385,50 +1259,18 @@ class UI {
return path.resolve(expanded); return path.resolve(expanded);
} }
/**
* Load existing configurations to use as defaults
* @param {string} directory - Installation directory
* @returns {Object} Existing configurations
*/
async loadExistingConfigurations(directory) {
const configs = {
hasCustomContent: false,
coreConfig: {},
ideConfig: { ides: [], skipIde: false },
};
try {
// Load core config
configs.coreConfig = await this.collectCoreConfig(directory);
// Load IDE configuration
const configuredIdes = await this.getConfiguredIdes(directory);
if (configuredIdes.length > 0) {
configs.ideConfig.ides = configuredIdes;
configs.ideConfig.skipIde = false;
}
return configs;
} catch {
// If loading fails, return empty configs
await prompts.log.warn('Could not load existing configurations');
return configs;
}
}
/** /**
* Get configured IDEs from existing installation * Get configured IDEs from existing installation
* @param {string} directory - Installation directory * @param {string} directory - Installation directory
* @returns {Array} List of configured IDEs * @returns {Array} List of configured IDEs
*/ */
async getConfiguredIdes(directory) { async getConfiguredIdes(directory) {
const { Detector } = require('../installers/lib/core/detector'); const { ExistingInstall } = require('./core/existing-install');
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('./core/installer');
const detector = new Detector();
const installer = new Installer(); const installer = new Installer();
const bmadResult = await installer.findBmadDir(directory); const { bmadDir } = await installer.findBmadDir(directory);
const existingInstall = await detector.detect(bmadResult.bmadDir); const existingInstall = await ExistingInstall.detect(bmadDir);
return existingInstall.ides || []; return existingInstall.ides;
} }
/** /**
@ -1573,7 +1415,7 @@ class UI {
const { existingInstall } = await this.getExistingInstallation(directory); const { existingInstall } = await this.getExistingInstallation(directory);
// Check if there are any custom modules in cache // Check if there are any custom modules in cache
const { Installer } = require('../installers/lib/core/installer'); const { Installer } = require('./core/installer');
const installer = new Installer(); const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory); const { bmadDir } = await installer.findBmadDir(directory);
@ -1707,82 +1549,6 @@ class UI {
return result; return result;
} }
/**
* Check if installed version is a legacy version that needs fresh install
* @param {string} installedVersion - The installed version
* @returns {boolean} True if legacy (v4 or any alpha)
*/
isLegacyVersion(installedVersion) {
if (!installedVersion || installedVersion === 'unknown') {
return true; // Treat unknown as legacy for safety
}
// Check if version string contains -alpha or -Alpha (any v6 alpha)
return /-alpha\./i.test(installedVersion);
}
/**
* Show warning for legacy version (v4 or alpha) and ask if user wants to proceed
* @param {string} installedVersion - The installed version
* @param {string} currentVersion - The current version
* @param {string} bmadFolderName - Name of the BMAD folder
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
*/
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed
}
let warningContent;
if (installedVersion === 'unknown') {
warningContent = 'Unable to detect your installed BMAD version.\n' + 'This appears to be a legacy or unsupported installation.';
} else {
warningContent =
`You are updating from ${installedVersion} to ${currentVersion}.\n` + 'You have a legacy version installed (v4 or alpha).';
}
warningContent +=
'\n\nFor the best experience, we recommend:\n' +
' 1. Delete your current BMAD installation folder\n' +
` (the "${bmadFolderName}/" folder in your project)\n` +
' 2. Run a fresh installation\n\n' +
'Benefits of a fresh install:\n' +
' \u2022 Cleaner configuration without legacy artifacts\n' +
' \u2022 All new features properly configured\n' +
' \u2022 Fewer potential conflicts';
await prompts.log.warn('VERSION WARNING');
await prompts.note(warningContent, 'Version Warning');
if (options.yes) {
await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update');
return true;
}
const proceed = await prompts.select({
message: 'How would you like to proceed?',
choices: [
{
name: 'Proceed with update anyway (may have issues)',
value: 'proceed',
},
{
name: 'Cancel (recommended - do a fresh install instead)',
value: 'cancel',
},
],
default: 'cancel',
});
if (proceed === 'cancel') {
await prompts.note(
`1. Delete the "${bmadFolderName}/" folder in your project\n` + "2. Run 'bmad install' again",
'To do a fresh install',
);
}
return proceed === 'proceed';
}
/** /**
* Display module versions with update availability * Display module versions with update availability
* @param {Array} modules - Array of module info objects with version info * @param {Array} modules - Array of module info objects with version info

View File

@ -0,0 +1,5 @@
# JavaScript Conventions
## Function ordering
Define functions top-to-bottom in call order: callers above callees. If `install()` calls `_initPaths()`, then `install` appears first and `_initPaths` appears after it.

View File

@ -1,13 +0,0 @@
/**
* Escape XML special characters in a string
* @param {string} text - The text to escape
* @returns {string} The escaped text
*/
function escapeXml(text) {
if (!text) return '';
return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
module.exports = {
escapeXml,
};