Compare commits

...

8 Commits

Author SHA1 Message Date
gabadi bda13f4320
Merge cb6006686c into c91db0db4b 2026-03-28 02:05:21 +00:00
Brian c91db0db4b
fix: revert bmb module-definition path to src/module.yaml (#2146)
bmad-builder reverted its skills/ directory back to src/ for installer
compatibility (bmad-code-org/bmad-builder#40). Update the external
modules manifest to match.
2026-03-27 08:46:18 -06:00
Alex Verkhovsky 513f440a23
refactor(installer): restructure installer with clean separation of concerns (#2129)
* 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

* fix(installer): guard ExistingInstall.version and surface module.yaml errors

Guard ExistingInstall.version access with .installed check in
uninstall.js, ui.js, and installer.js to prevent throwing on
empty/partial _bmad dirs. Surface invalid module.yaml parse errors
as warnings instead of silently returning empty results.
2026-03-27 06:50:07 -06:00
Alex Verkhovsky cb6006686c
Merge branch 'main' into fix/quick-dev-consumer-propagation 2026-03-25 20:56:14 -06:00
2-gabadi 92b7c12bf2
fix: remove redundant step-03 precondition
The step-03 consumer tracing instruction duplicated step-02 without
adding validation. Keep only the step-02 planning instruction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:41:06 -03:00
2-gabadi df8b54c946
fix: handle zero-consumers case and add recovery path
- step-02: add `consumer — none found` annotation for empty grep results
- step-03: replace dead-end HALT with actionable recovery (perform
  tracing now) so the agent isn't stuck if step-02 was skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:37:45 -03:00
2-gabadi cb60c06a29
fix: reference exact annotation prefix in step-03 HALT gate
Use backtick-quoted `consumer —` prefix to match the step-02
convention, making the precondition check unambiguous and grepable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:30:19 -03:00
2-gabadi 17e87f8164
fix: add consumer propagation check to bmad-quick-dev workflow
When introducing new type variants, error codes, or DB values, the
workflow now requires enumerating all consumers in the Code Map during
planning (step-02) and HALTs implementation (step-03) if consumer
tracing was missed.

Fixes #2122

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 03:25:39 -03:00
86 changed files with 3708 additions and 7719 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

@ -73,7 +73,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

@ -12,7 +12,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
## INSTRUCTIONS ## INSTRUCTIONS
1. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._ 1. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._ If the approach introduces new type variants, error codes, or DB values: grep all consumers of the parent type/column and annotate each in the Code Map as `consumer — update required` or `consumer — excluded (reason)`. If no consumers exist, note `consumer — none found`.
2. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, and write the result to `{wipFile}`. 2. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, and write the result to `{wipFile}`.
3. Self-review against READY FOR DEVELOPMENT standard. 3. Self-review against READY FOR DEVELOPMENT standard.
4. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human. 4. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human.

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();
@ -62,9 +62,9 @@ module.exports = {
} }
const existingInstall = await installer.getStatus(projectDir); const existingInstall = await installer.getStatus(projectDir);
const version = existingInstall.version || 'unknown'; const version = existingInstall.installed ? 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

@ -4,7 +4,7 @@
modules: modules:
bmad-builder: bmad-builder:
url: https://github.com/bmad-code-org/bmad-builder url: https://github.com/bmad-code-org/bmad-builder
module-definition: skills/module.yaml module-definition: src/module.yaml
code: bmb code: bmb
name: "BMad Builder" name: "BMad Builder"
description: "Agent and Builder" description: "Agent and Builder"

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,43 +15,45 @@ 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);
if (await fs.pathExists(dir)) { const dir = path.join(projectDir || process.cwd(), this.configDir);
try { if (await fs.pathExists(dir)) {
const entries = await fs.readdir(dir); try {
return entries.some((e) => typeof e === 'string' && e.startsWith('bmad')); const entries = await fs.readdir(dir);
} catch { return entries.some((e) => typeof e === 'string' && e.startsWith('bmad'));
return false; } catch {
} return false;
} }
return false;
} }
return super.detect(projectDir); return false;
} }
/** /**
@ -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,701 @@
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 (error) {
await prompts.log.warn(`Invalid module.yaml for ${moduleName}: ${error.message}`);
return emptyResult;
}
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 +766,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 +800,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 +824,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 +834,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 +1020,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 +1035,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 +1049,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 +1062,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 +1080,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 +1111,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 +1126,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 +1190,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 +1216,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 +1323,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 +1345,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 +1665,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 +1680,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 +1754,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 +2040,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,18 +70,9 @@ 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.installed ? existingInstall.version || 'unknown' : '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,
};