Merge branch 'main' into fix/add-project-context-to-workflows

This commit is contained in:
Alex Verkhovsky 2026-02-09 22:14:56 -07:00 committed by GitHub
commit 802ba385f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1751 additions and 808 deletions

View File

@ -1,5 +1,58 @@
# Changelog # Changelog
## [6.0.0-Beta.8]
**Release: February 8, 2026**
### 🌟 Key Highlights
1. **Non-Interactive Installation** — Full CI/CD support with 10 new CLI flags for automated deployments
2. **Complete @clack/prompts Migration** — Unified CLI experience with consolidated installer output
3. **CSV File Reference Validation** — Extended Layer 1 validator to catch broken workflow references in CSV files
4. **Kiro IDE Support** — Standardized config-driven installation, replacing custom installer
### 🎁 Features
* **Non-Interactive Installation** — Added `--directory`, `--modules`, `--tools`, `--custom-content`, `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder`, and `-y/--yes` flags for CI/CD automation (#1520)
* **CSV File Reference Validation** — Extended validator to scan `.csv` files for broken workflow references, checking 501 references across 212 files (#1573)
* **Kiro IDE Support** — Replaced broken custom installer with config-driven templates using `#[[file:...]]` syntax and `inclusion: manual` frontmatter (#1589)
* **OpenCode Template Consolidation** — Combined split templates with `mode: primary` frontmatter for Tab-switching support, fixing agent discovery (#1556)
* **Modules Reference Page** — Added official external modules reference documentation (#1540)
### 🐛 Bug Fixes
* **Installer Streamlining** — Removed "None - Skip module installation" option, eliminated ~100 lines of dead code, and added ESM/.cjs support for module installers (#1590)
* **CodeRabbit Workflow** — Changed `pull_request` to `pull_request_target` to fix 403 errors and enable reviews on fork PRs (#1583)
* **Party Mode Return Protocol** — Added RETURN PROTOCOL to prevent lost-in-the-middle failures after Party Mode completes (#1569)
* **Spacebar Toggle** — Fixed SPACE key not working in autocomplete multiselect prompts for tool/IDE selection (#1557)
* **OpenCode Agent Routing** — Fixed agents installing to wrong directory by adding `targets` array for routing `.opencode/agent/` vs `.opencode/command/` (#1549)
* **Technical Research Workflow** — Fixed step-05 routing to step-06 and corrected `stepsCompleted` values (#1547)
* **Forbidden Variable Removal** — Removed `workflow_path` variable from 16 workflow step files (#1546)
* **Kilo Installer** — Fixed YAML formatting issues by trimming activation header and converting to yaml.parse/stringify (#1537)
* **bmad-help** — Now reads project-specific docs and respects `communication_language` setting (#1535)
* **Cache Errors** — Removed `--prefer-offline` npm flag to prevent stale cache errors during installation (#1531)
### ♻️ Refactoring
* **Complete @clack/prompts Migration** — Migrated 24 files from legacy libraries (ora, chalk, boxen, figlet, etc.), replaced ~100 console.log+chalk calls, consolidated installer output to single spinner, and removed 5 dependencies (#1586)
* **Downloads Page Removal** — Removed downloads page, bundle generation, and archiver dependency in favor of GitHub's native archives (#1577)
* **Workflow Verb Standardization** — Replaced "invoke/run" with "load and follow/load" in review workflow prompts (#1570)
* **Documentation Language** — Renamed "brownfield" to "established projects" and flattened directory structure for accessibility (#1539)
### 📚 Documentation
* **Comprehensive Site Review** — Fixed broken directory tree diagram, corrected grammar/capitalization, added SEO descriptions, and reordered how-to guides (#1578)
* **SEO Metadata** — Added description front matter to 9 documentation pages for search engine optimization (#1566)
* **PR Template** — Added pull request template for consistent PR descriptions (#1554)
* **Manual Release Cleanup** — Removed broken manual-release workflow and related scripts (#1576)
### 🔧 Maintenance
* **Dual-Mode AI Code Review** — Configured Augment Code (audit mode) and CodeRabbit (adversarial mode) for improved code quality (#1511)
* **Package-Lock Sync** — Cleaned up 471 lines of orphaned dependencies after archiver removal (#1580)
---
## [6.0.0-Beta.7] ## [6.0.0-Beta.7]
**Release: February 4, 2026** **Release: February 4, 2026**

View File

@ -114,17 +114,6 @@ export default [
}, },
}, },
// Module installer scripts use CommonJS for compatibility
{
files: ['**/_module-installer/**/*.js'],
rules: {
// Allow CommonJS patterns for installer scripts
'unicorn/prefer-module': 'off',
'n/no-missing-require': 'off',
'n/no-unpublished-require': 'off',
},
},
// ESLint config file should not be checked for publish-related Node rules // ESLint config file should not be checked for publish-related Node rules
{ {
files: ['eslint.config.mjs'], files: ['eslint.config.mjs'],

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "bmad-method", "name": "bmad-method",
"version": "6.0.0-Beta.7", "version": "6.0.0-Beta.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bmad-method", "name": "bmad-method",
"version": "6.0.0-Beta.7", "version": "6.0.0-Beta.8",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@clack/core": "^1.0.0", "@clack/core": "^1.0.0",

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "bmad-method", "name": "bmad-method",
"version": "6.0.0-Beta.7", "version": "6.0.0-Beta.8",
"description": "Breakthrough Method of Agile AI-driven Development", "description": "Breakthrough Method of Agile AI-driven Development",
"keywords": [ "keywords": [
"agile", "agile",

View File

@ -1,48 +0,0 @@
const fs = require('fs-extra');
const path = require('node:path');
const chalk = require('chalk');
// Directories to create from config
const DIRECTORIES = ['output_folder', 'planning_artifacts', 'implementation_artifacts'];
/**
* BMM Module Installer
* Creates output directories configured in module config
*
* @param {Object} options - Installation options
* @param {string} options.projectRoot - The root directory of the target project
* @param {Object} options.config - Module configuration from module.yaml
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.logger - Logger instance for output
* @returns {Promise<boolean>} - Success status
*/
async function install(options) {
const { projectRoot, config, logger } = options;
try {
logger.log(chalk.blue('🚀 Installing BMM Module...'));
// Create configured directories
for (const configKey of DIRECTORIES) {
const configValue = config[configKey];
if (!configValue) continue;
const dirPath = configValue.replace('{project-root}/', '');
const fullPath = path.join(projectRoot, dirPath);
if (!(await fs.pathExists(fullPath))) {
const dirName = configKey.replace('_', ' ');
logger.log(chalk.yellow(`Creating ${dirName} directory: ${dirPath}`));
await fs.ensureDir(fullPath);
}
}
logger.log(chalk.green('✓ BMM Module installation complete'));
return true;
} catch (error) {
logger.error(chalk.red(`Error installing BMM module: ${error.message}`));
return false;
}
}
module.exports = { install };

View File

@ -5,6 +5,7 @@ agent:
title: Business Analyst title: Business Analyst
icon: 📊 icon: 📊
module: bmm module: bmm
capabilities: "market research, competitive analysis, requirements elicitation, domain expertise"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: Architect title: Architect
icon: 🏗️ icon: 🏗️
module: bmm module: bmm
capabilities: "distributed systems, cloud infrastructure, API design, scalable patterns"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: Developer Agent title: Developer Agent
icon: 💻 icon: 💻
module: bmm module: bmm
capabilities: "story execution, test-driven development, code implementation"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -5,6 +5,7 @@ agent:
title: Product Manager title: Product Manager
icon: 📋 icon: 📋
module: bmm module: bmm
capabilities: "PRD creation, requirements discovery, stakeholder alignment, user interviews"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -5,6 +5,7 @@ agent:
title: QA Engineer title: QA Engineer
icon: 🧪 icon: 🧪
module: bmm module: bmm
capabilities: "test automation, API testing, E2E testing, coverage analysis"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: Quick Flow Solo Dev title: Quick Flow Solo Dev
icon: 🚀 icon: 🚀
module: bmm module: bmm
capabilities: "rapid spec creation, lean implementation, minimum ceremony"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: Scrum Master title: Scrum Master
icon: 🏃 icon: 🏃
module: bmm module: bmm
capabilities: "sprint planning, story preparation, agile ceremonies, backlog management"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: Technical Writer title: Technical Writer
icon: 📚 icon: 📚
module: bmm module: bmm
capabilities: "documentation, Mermaid diagrams, standards compliance, concept explanation"
hasSidecar: true hasSidecar: true
persona: persona:

View File

@ -7,6 +7,7 @@ agent:
title: UX Designer title: UX Designer
icon: 🎨 icon: 🎨
module: bmm module: bmm
capabilities: "user research, interaction design, UI patterns, experience strategy"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -42,3 +42,9 @@ project_knowledge: # Artifacts from research, document-project output, other lon
prompt: "Where should long-term project knowledge be stored? (docs, research, references)" prompt: "Where should long-term project knowledge be stored? (docs, research, references)"
default: "docs" default: "docs"
result: "{project-root}/{value}" result: "{project-root}/{value}"
# Directories to create during installation (declarative, no code execution)
directories:
- "{planning_artifacts}"
- "{implementation_artifacts}"
- "{project_knowledge}"

View File

@ -1,60 +0,0 @@
const chalk = require('chalk');
/**
* Core Module Installer
* Standard module installer function that executes after IDE installations
*
* @param {Object} options - Installation options
* @param {string} options.projectRoot - The root directory of the target project
* @param {Object} options.config - Module configuration from module.yaml
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.logger - Logger instance for output
* @returns {Promise<boolean>} - Success status
*/
async function install(options) {
const { projectRoot, config, installedIDEs, logger } = options;
try {
logger.log(chalk.blue('🏗️ Installing Core Module...'));
// Core agent configs are created by the main installer's createAgentConfigs method
// No need to create them here - they'll be handled along with all other agents
// Handle IDE-specific configurations if needed
if (installedIDEs && installedIDEs.length > 0) {
logger.log(chalk.cyan(`Configuring Core for IDEs: ${installedIDEs.join(', ')}`));
// Add any IDE-specific Core configurations here
for (const ide of installedIDEs) {
await configureForIDE(ide, projectRoot, config, logger);
}
}
logger.log(chalk.green('✓ Core Module installation complete'));
return true;
} catch (error) {
logger.error(chalk.red(`Error installing Core module: ${error.message}`));
return false;
}
}
/**
* Configure Core module for specific IDE
* @private
*/
async function configureForIDE(ide) {
// Add IDE-specific configurations here
switch (ide) {
case 'claude-code': {
// Claude Code specific Core configurations
break;
}
// Add more IDEs as needed
default: {
// No specific configuration needed
break;
}
}
}
module.exports = { install };

View File

@ -7,6 +7,7 @@ agent:
name: "BMad Master" name: "BMad Master"
title: "BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator" title: "BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator"
icon: "🧙" icon: "🧙"
capabilities: "runtime resource management, workflow orchestration, task execution, knowledge custodian"
hasSidecar: false hasSidecar: false
persona: persona:

View File

@ -39,7 +39,6 @@ module.exports = {
if (config.actionType === 'cancel') { if (config.actionType === 'cancel') {
await prompts.log.warn('Installation cancelled.'); await prompts.log.warn('Installation cancelled.');
process.exit(0); process.exit(0);
return;
} }
// Handle quick update separately // Handle quick update separately
@ -47,23 +46,14 @@ module.exports = {
const result = await installer.quickUpdate(config); const result = await installer.quickUpdate(config);
await prompts.log.success('Quick update complete!'); await prompts.log.success('Quick update complete!');
await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`); await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`);
// Display version-specific end message
const { MessageLoader } = require('../installers/lib/message-loader');
const messageLoader = new MessageLoader();
await messageLoader.displayEndMessage();
process.exit(0); process.exit(0);
return;
} }
// Handle compile agents separately // Handle compile agents separately
if (config.actionType === 'compile-agents') { if (config.actionType === 'compile-agents') {
const result = await installer.compileAgents(config); const result = await installer.compileAgents(config);
await prompts.log.success('Agent recompilation complete!');
await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`); await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`);
process.exit(0); process.exit(0);
return;
} }
// Regular install/update flow // Regular install/update flow
@ -72,16 +62,10 @@ module.exports = {
// Check if installation was cancelled // Check if installation was cancelled
if (result && result.cancelled) { if (result && result.cancelled) {
process.exit(0); process.exit(0);
return;
} }
// Check if installation succeeded // Check if installation succeeded
if (result && result.success) { if (result && result.success) {
// Display version-specific end message from install-messages.yaml
const { MessageLoader } = require('../installers/lib/message-loader');
const messageLoader = new MessageLoader();
await messageLoader.displayEndMessage();
process.exit(0); process.exit(0);
} }
} catch (error) { } catch (error) {

View File

@ -42,13 +42,12 @@ modules:
type: bmad-org type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise npmPackage: bmad-method-test-architecture-enterprise
# TODO: Enable once fixes applied: # whiteport-design-system:
# url: https://github.com/bmad-code-org/bmad-method-wds-expansion
# whiteport-design-system: # module-definition: src/module.yaml
# url: https://github.com/bmad-code-org/bmad-method-wds-expansion # code: wds
# module-definition: src/module.yaml # name: "Whiteport UX Design System"
# code: WDS # description: "UX design framework with Figma integration"
# name: "Whiteport UX Design System" # defaultSelected: false
# description: "UX design framework with Figma integration" # type: community
# defaultSelected: false # npmPackage: bmad-method-wds-expansion
# type: community

View File

@ -10,32 +10,14 @@ startMessage: |
We've officially graduated from Alpha! This milestone represents: We've officially graduated from Alpha! This milestone represents:
- 50+ workflows covering the full development lifecycle - 50+ workflows covering the full development lifecycle
- Stability - we will still be adding and evolving and optimizing, - Stability - we will still be adding and evolving and optimizing,
but anticipate no massive breaking changes but anticipate no massive breaking changes
- Groundwork in place for customization and community modules - Groundwork in place for customization and community modules
📚 New Docs Site: http://docs.bmad-method.org/
- High quality tutorials, guided walkthrough, and articles coming soon!
- Everything is free. No paywalls. No gated content.
- Knowledge should be shared, not sold.
💡 Love BMad? Please star us on GitHub & subscribe on YouTube!
- GitHub: https://github.com/bmad-code-org/BMAD-METHOD/
- YouTube: https://www.youtube.com/@BMadCode
Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Display at the END of installation (after all setup completes)
endMessage: |
════════════════════════════════════════════════════════════════════════════════
✨ BMAD V6 BETA IS INSTALLED! Thank you for being part of this journey!
🌟 BMad is 100% free and open source. 🌟 BMad is 100% free and open source.
- No gated Discord. No paywalls. - No gated Discord. No paywalls. No gated content.
- We believe in empowering everyone, not just those who can pay. - We believe in empowering everyone, not just those who can pay.
- Knowledge should be shared, not sold.
🙏 SUPPORT BMAD DEVELOPMENT: 🙏 SUPPORT BMAD DEVELOPMENT:
- During the Beta, please give us feedback and raise issues on GitHub! - During the Beta, please give us feedback and raise issues on GitHub!
@ -47,13 +29,14 @@ endMessage: |
- Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method - Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method
- For speaking inquiries or interviews, reach out to BMad on Discord! - For speaking inquiries or interviews, reach out to BMad on Discord!
📚 RESOURCES: ⭐ HELP US GROW:
- Docs: http://docs.bmad-method.org/ (bookmark it!)
- Changelog: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
⭐⭐⭐ HELP US GROW:
- Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/
- Subscribe on YouTube: https://www.youtube.com/@BMadCode - Subscribe on YouTube: https://www.youtube.com/@BMadCode
- Every star & sub helps us reach more developers! - Every star & sub helps us reach more developers!
════════════════════════════════════════════════════════════════════════════════ Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# No end message - install summary and next steps are rendered by the installer
endMessage: ""

View File

@ -10,6 +10,19 @@ class 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)
* @returns {Object} ModuleManager instance
*/
_getModuleManager() {
if (!this._moduleManagerInstance) {
const { ModuleManager } = require('../modules/manager');
this._moduleManagerInstance = new ModuleManager();
}
return this._moduleManagerInstance;
} }
/** /**
@ -129,6 +142,70 @@ class ConfigCollector {
return foundAny; return foundAny;
} }
/**
* Pre-scan module schemas to gather metadata for the configuration gateway prompt.
* Returns info about which modules have configurable options.
* @param {Array} modules - List of non-core module names
* @returns {Promise<Array>} Array of {moduleName, displayName, questionCount, hasFieldsWithoutDefaults}
*/
async scanModuleSchemas(modules) {
const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']);
const results = [];
for (const moduleName of modules) {
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
let moduleConfigPath = null;
const customPath = this.customModulePaths?.get(moduleName);
if (customPath) {
moduleConfigPath = path.join(customPath, 'module.yaml');
} else {
const standardPath = path.join(getModulePath(moduleName), 'module.yaml');
if (await fs.pathExists(standardPath)) {
moduleConfigPath = standardPath;
} else {
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
if (moduleSourcePath) {
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
}
}
}
if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) {
continue;
}
try {
const content = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.parse(content);
if (!moduleConfig) continue;
const displayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`;
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
const questionKeys = configKeys.filter((key) => {
if (metadataFields.has(key)) return false;
const item = moduleConfig[key];
return item && typeof item === 'object' && item.prompt;
});
const hasFieldsWithoutDefaults = questionKeys.some((key) => {
const item = moduleConfig[key];
return item.default === undefined || item.default === null || item.default === '';
});
results.push({
moduleName,
displayName,
questionCount: questionKeys.length,
hasFieldsWithoutDefaults,
});
} catch (error) {
await prompts.log.warn(`Could not read schema for module "${moduleName}": ${error.message}`);
}
}
return results;
}
/** /**
* Collect configuration for all modules * Collect configuration for all modules
* @param {Array} modules - List of modules to configure (including 'core') * @param {Array} modules - List of modules to configure (including 'core')
@ -141,6 +218,7 @@ class ConfigCollector {
// Store custom module paths for use in collectModuleConfig // Store custom module paths for use in collectModuleConfig
this.customModulePaths = options.customModulePaths || new Map(); this.customModulePaths = options.customModulePaths || new Map();
this.skipPrompts = options.skipPrompts || false; this.skipPrompts = options.skipPrompts || false;
this.modulesToCustomize = undefined;
await this.loadExistingConfig(projectDir); await this.loadExistingConfig(projectDir);
// Check if core was already collected (e.g., in early collection phase) // Check if core was already collected (e.g., in early collection phase)
@ -154,10 +232,95 @@ class ConfigCollector {
this.allAnswers = {}; this.allAnswers = {};
} }
for (const moduleName of allModules) { // Split processing: core first, then gateway, then remaining modules
const coreModules = allModules.filter((m) => m === 'core');
const nonCoreModules = allModules.filter((m) => m !== 'core');
// Collect core config first (always fully prompted)
for (const moduleName of coreModules) {
await this.collectModuleConfig(moduleName, projectDir); await this.collectModuleConfig(moduleName, projectDir);
} }
// Show batch configuration gateway for non-core modules
// Scan all non-core module schemas for display names and config metadata
let scannedModules = [];
if (!this.skipPrompts && nonCoreModules.length > 0) {
scannedModules = await this.scanModuleSchemas(nonCoreModules);
const customizableModules = scannedModules.filter((m) => m.questionCount > 0);
if (customizableModules.length > 0) {
const configMode = await prompts.select({
message: 'Module configuration',
choices: [
{ name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' },
{ name: 'Customize', value: 'customize', hint: 'choose modules to configure' },
],
default: 'express',
});
if (configMode === 'customize') {
const choices = customizableModules.map((m) => ({
name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`,
value: m.moduleName,
hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined,
checked: m.hasFieldsWithoutDefaults,
}));
const selected = await prompts.multiselect({
message: 'Select modules to customize:',
choices,
required: false,
});
this.modulesToCustomize = new Set(selected);
} else {
// Express mode: no modules to customize
this.modulesToCustomize = new Set();
}
} else {
// All non-core modules have zero config - no gateway needed
this.modulesToCustomize = new Set();
}
}
// Collect remaining non-core modules
if (this.modulesToCustomize === undefined) {
// No gateway was shown (skipPrompts, no non-core modules, or direct call) - process all normally
for (const moduleName of nonCoreModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
} else {
// Split into default modules (tasks progress) and customized modules (interactive)
const defaultModules = nonCoreModules.filter((m) => !this.modulesToCustomize.has(m));
const customizeModules = nonCoreModules.filter((m) => this.modulesToCustomize.has(m));
// Run default modules with a single spinner
if (defaultModules.length > 0) {
// Build display name map from all scanned modules for pre-call spinner messages
const displayNameMap = new Map();
for (const m of scannedModules) {
displayNameMap.set(m.moduleName, m.displayName);
}
const configSpinner = await prompts.spinner();
configSpinner.start('Configuring modules...');
for (const moduleName of defaultModules) {
const displayName = displayNameMap.get(moduleName) || moduleName.toUpperCase();
configSpinner.message(`Configuring ${displayName}...`);
try {
this._silentConfig = true;
await this.collectModuleConfig(moduleName, projectDir);
} finally {
this._silentConfig = false;
}
}
configSpinner.stop('Module configuration complete');
}
// Run customized modules individually (may show interactive prompts)
for (const moduleName of customizeModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
}
// Add metadata // Add metadata
this.collectedConfig._meta = { this.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version, version: require(path.join(getProjectRoot(), 'package.json')).version,
@ -188,20 +351,15 @@ class ConfigCollector {
this.allAnswers = {}; this.allAnswers = {};
} }
// Load module's install config schema // Load module's config schema from module.yaml
// First, try the standard src/modules location // First, try the standard src/modules location
let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml');
let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
// 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(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
// Use the module manager to find the module source const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml');
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
} }
} }
@ -211,19 +369,14 @@ class ConfigCollector {
if (await fs.pathExists(moduleConfigPath)) { if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else { } else {
// Check if this is a custom module with custom.yaml // Check if this is a custom module with custom.yaml
const { ModuleManager } = require('../modules/manager'); const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
const moduleInstallerCustomPath = path.join(moduleSourcePath, '_module-installer', 'custom.yaml');
if ((await fs.pathExists(rootCustomConfigPath)) || (await fs.pathExists(moduleInstallerCustomPath))) { if (await fs.pathExists(rootCustomConfigPath)) {
isCustomModule = true; isCustomModule = true;
// For custom modules, we don't have an install-config schema, so just use existing values // For custom modules, we don't have an install-config schema, so just use existing values
// The custom.yaml values will be loaded and merged during installation // The custom.yaml values will be loaded and merged during installation
@ -500,28 +653,21 @@ class ConfigCollector {
} }
// Load module's config // Load module's config
// First, check if we have a custom module path for this module // First, check if we have a custom module path for this module
let installerConfigPath = null;
let moduleConfigPath = null; let moduleConfigPath = null;
if (this.customModulePaths && this.customModulePaths.has(moduleName)) { if (this.customModulePaths && this.customModulePaths.has(moduleName)) {
const customPath = this.customModulePaths.get(moduleName); const customPath = this.customModulePaths.get(moduleName);
installerConfigPath = path.join(customPath, '_module-installer', 'module.yaml');
moduleConfigPath = path.join(customPath, 'module.yaml'); moduleConfigPath = path.join(customPath, 'module.yaml');
} else { } else {
// Try the standard src/modules location // Try the standard src/modules location
installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml');
moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml');
} }
// 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(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { if (!(await fs.pathExists(moduleConfigPath))) {
// Use the module manager to find the module source const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) { if (moduleSourcePath) {
installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml');
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
} }
} }
@ -529,8 +675,6 @@ class ConfigCollector {
let configPath = null; let configPath = null;
if (await fs.pathExists(moduleConfigPath)) { if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else { } else {
// No config for this module // No config for this module
return; return;
@ -590,12 +734,12 @@ class ConfigCollector {
} }
} }
} else { } else {
await prompts.log.step(moduleDisplayName); if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`);
let customize = true; let useDefaults = true;
if (moduleName === 'core') { if (moduleName === 'core') {
// Core module: no confirm prompt, continues directly useDefaults = false; // Core: always show all questions
} else { } else if (this.modulesToCustomize === undefined) {
// Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing) // Fallback: original per-module confirm (backward compat for direct calls)
const customizeAnswer = await prompts.prompt([ const customizeAnswer = await prompts.prompt([
{ {
type: 'confirm', type: 'confirm',
@ -604,10 +748,13 @@ class ConfigCollector {
default: true, default: true,
}, },
]); ]);
customize = customizeAnswer.customize; useDefaults = customizeAnswer.customize;
} else {
// Batch mode: use defaults unless module was selected for customization
useDefaults = !this.modulesToCustomize.has(moduleName);
} }
if (customize && moduleName !== 'core') { if (useDefaults && moduleName !== 'core') {
// Accept defaults - only ask questions that have NO default value // Accept defaults - only ask questions that have NO default value
const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === ''); const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === '');
@ -737,16 +884,18 @@ class ConfigCollector {
const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key)); const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key));
const hasNoConfig = actualConfigKeys.length === 0; const hasNoConfig = actualConfigKeys.length === 0;
if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) { if (!this._silentConfig) {
await prompts.log.step(moduleDisplayName); if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) {
if (moduleConfig.subheader) { await prompts.log.step(moduleDisplayName);
await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); if (moduleConfig.subheader) {
await prompts.log.message(` \u2713 ${moduleConfig.subheader}`);
} else {
await prompts.log.message(` \u2713 No custom configuration required`);
}
} else { } else {
await prompts.log.message(` \u2713 No custom configuration required`); // Module has config but just no questions to ask
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
} }
} else {
// Module has config but just no questions to ask
await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`);
} }
} }

View File

@ -7,6 +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');
class CustomModuleCache { class CustomModuleCache {
constructor(bmadDir) { constructor(bmadDir) {
@ -195,7 +196,7 @@ class CustomModuleCache {
// Verify cache integrity // Verify cache integrity
const currentCacheHash = await this.calculateHash(cacheDir); const currentCacheHash = await this.calculateHash(cacheDir);
if (currentCacheHash !== cached.cacheHash) { if (currentCacheHash !== cached.cacheHash) {
console.warn(`Warning: Cache integrity check failed for ${moduleId}`); await prompts.log.warn(`Cache integrity check failed for ${moduleId}`);
} }
return { return {

View File

@ -1,6 +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');
/** /**
* Manages IDE configuration persistence * Manages IDE configuration persistence
@ -93,7 +94,7 @@ class IdeConfigManager {
const config = yaml.parse(content); const config = yaml.parse(content);
return config; return config;
} catch (error) { } catch (error) {
console.warn(`Warning: Failed to load IDE config for ${ideName}:`, error.message); await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`);
return null; return null;
} }
} }
@ -123,7 +124,7 @@ class IdeConfigManager {
} }
} }
} catch (error) { } catch (error) {
console.warn('Warning: Failed to load IDE configs:', error.message); await prompts.log.warn(`Failed to load IDE configs: ${error.message}`);
} }
return configs; return configs;

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ 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('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
// Load package.json for version info // Load package.json for version info
const packageJson = require('../../../../../package.json'); const packageJson = require('../../../../../package.json');
@ -241,7 +242,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } catch (error) {
console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`); await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`);
} }
} }
} }
@ -321,6 +322,7 @@ class ManifestGenerator {
const nameMatch = content.match(/name="([^"]+)"/); const nameMatch = content.match(/name="([^"]+)"/);
const titleMatch = content.match(/title="([^"]+)"/); const titleMatch = content.match(/title="([^"]+)"/);
const iconMatch = content.match(/icon="([^"]+)"/); const iconMatch = content.match(/icon="([^"]+)"/);
const capabilitiesMatch = content.match(/capabilities="([^"]+)"/);
// Extract persona fields // Extract persona fields
const roleMatch = content.match(/<role>([^<]+)<\/role>/); const roleMatch = content.match(/<role>([^<]+)<\/role>/);
@ -342,6 +344,7 @@ class ManifestGenerator {
displayName: nameMatch ? nameMatch[1] : agentName, displayName: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : '', title: titleMatch ? titleMatch[1] : '',
icon: iconMatch ? iconMatch[1] : '', icon: iconMatch ? iconMatch[1] : '',
capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '',
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '', role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '', identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '', communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
@ -691,7 +694,7 @@ class ManifestGenerator {
return preservedRows; return preservedRows;
} catch (error) { } catch (error) {
console.warn(`Warning: Failed to read existing CSV ${csvPath}:`, error.message); await prompts.log.warn(`Failed to read existing CSV ${csvPath}: ${error.message}`);
return []; return [];
} }
} }
@ -784,7 +787,7 @@ class ManifestGenerator {
} }
// Create CSV header with persona fields // Create CSV header with persona fields
let csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; let csvContent = 'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path\n';
// Combine existing and new agents, preferring new data for duplicates // Combine existing and new agents, preferring new data for duplicates
const allAgents = new Map(); const allAgents = new Map();
@ -802,6 +805,7 @@ class ManifestGenerator {
displayName: agent.displayName, displayName: agent.displayName,
title: agent.title, title: agent.title,
icon: agent.icon, icon: agent.icon,
capabilities: agent.capabilities,
role: agent.role, role: agent.role,
identity: agent.identity, identity: agent.identity,
communicationStyle: agent.communicationStyle, communicationStyle: agent.communicationStyle,
@ -818,6 +822,7 @@ class ManifestGenerator {
escapeCsv(record.displayName), escapeCsv(record.displayName),
escapeCsv(record.title), escapeCsv(record.title),
escapeCsv(record.icon), escapeCsv(record.icon),
escapeCsv(record.capabilities),
escapeCsv(record.role), escapeCsv(record.role),
escapeCsv(record.identity), escapeCsv(record.identity),
escapeCsv(record.communicationStyle), escapeCsv(record.communicationStyle),
@ -1068,7 +1073,7 @@ class ManifestGenerator {
} }
} }
} catch (error) { } catch (error) {
console.warn(`Warning: Could not scan for installed modules: ${error.message}`); await prompts.log.warn(`Could not scan for installed modules: ${error.message}`);
} }
return modules; return modules;

View File

@ -2,6 +2,7 @@ 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('../../../lib/project-root');
const prompts = require('../../../lib/prompts');
class Manifest { class Manifest {
/** /**
@ -100,7 +101,7 @@ class Manifest {
ides: manifestData.ides || [], ides: manifestData.ides || [],
}; };
} catch (error) { } catch (error) {
console.error('Failed to read YAML manifest:', error.message); await prompts.log.error(`Failed to read YAML manifest: ${error.message}`);
} }
} }
@ -230,7 +231,7 @@ class Manifest {
const content = await fs.readFile(yamlPath, 'utf8'); const content = await fs.readFile(yamlPath, 'utf8');
return yaml.parse(content); return yaml.parse(content);
} catch (error) { } catch (error) {
console.error('Failed to read YAML manifest:', error.message); await prompts.log.error(`Failed to read YAML manifest: ${error.message}`);
} }
} }
@ -472,7 +473,7 @@ class Manifest {
} }
} }
} catch (error) { } catch (error) {
console.warn(`Warning: Could not parse ${filePath}:`, error.message); await prompts.log.warn(`Could not parse ${filePath}: ${error.message}`);
} }
} }
// Handle other file types (CSV, JSON, YAML, etc.) // Handle other file types (CSV, JSON, YAML, etc.)
@ -774,7 +775,7 @@ class Manifest {
configs[moduleName] = yaml.parse(content); configs[moduleName] = yaml.parse(content);
} }
} catch (error) { } catch (error) {
console.warn(`Could not load config for module ${moduleName}:`, error.message); await prompts.log.warn(`Could not load config for module ${moduleName}: ${error.message}`);
} }
} }
@ -876,7 +877,7 @@ class Manifest {
const pkg = require(packageJsonPath); const pkg = require(packageJsonPath);
version = pkg.version; version = pkg.version;
} catch (error) { } catch (error) {
console.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`);
} }
} }
} }
@ -904,7 +905,7 @@ class Manifest {
repoUrl: moduleConfig.repoUrl || null, repoUrl: moduleConfig.repoUrl || null,
}; };
} catch (error) { } catch (error) {
console.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`);
} }
} }

View File

@ -55,7 +55,7 @@ class CustomHandler {
// Found a custom.yaml file // Found a custom.yaml file
customPaths.push(fullPath); customPaths.push(fullPath);
} else if ( } else if (
entry.name === 'module.yaml' && // Check if this is a custom module (either in _module-installer or in root directory) entry.name === 'module.yaml' && // Check if this is a custom module (in root directory)
// Skip if it's in src/modules (those are standard modules) // Skip if it's in src/modules (those are standard modules)
!fullPath.includes(path.join('src', 'modules')) !fullPath.includes(path.join('src', 'modules'))
) { ) {

View File

@ -23,6 +23,11 @@ class CodexSetup extends BaseIdeSetup {
* @returns {Object} Collected configuration * @returns {Object} Collected configuration
*/ */
async collectConfiguration(options = {}) { async collectConfiguration(options = {}) {
// Non-interactive mode: use default (global)
if (options.skipPrompts) {
return { installLocation: 'global' };
}
let confirmed = false; let confirmed = false;
let installLocation = 'global'; let installLocation = 'global';

View File

@ -0,0 +1,655 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
const yaml = require('yaml');
/**
* GitHub Copilot setup handler
* Creates agents in .github/agents/, prompts in .github/prompts/,
* copilot-instructions.md, and configures VS Code settings
*/
class GitHubCopilotSetup extends BaseIdeSetup {
constructor() {
super('github-copilot', 'GitHub Copilot', false);
// Don't set configDir to '.github' — nearly every GitHub repo has that directory,
// which would cause the base detect() to false-positive. Use detectionPaths instead.
this.configDir = null;
this.githubDir = '.github';
this.agentsDir = 'agents';
this.promptsDir = 'prompts';
this.detectionPaths = ['.github/copilot-instructions.md', '.github/agents'];
}
/**
* Setup GitHub Copilot configuration
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Create .github/agents and .github/prompts directories
const githubDir = path.join(projectDir, this.githubDir);
const agentsDir = path.join(githubDir, this.agentsDir);
const promptsDir = path.join(githubDir, this.promptsDir);
await this.ensureDir(agentsDir);
await this.ensureDir(promptsDir);
// Preserve any customised tool permissions from existing files before cleanup
this.existingToolPermissions = await this.collectExistingToolPermissions(projectDir);
// Clean up any existing BMAD files before reinstalling
await this.cleanup(projectDir);
// Load agent manifest for enriched descriptions
const agentManifest = await this.loadAgentManifest(bmadDir);
// Generate agent launchers
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
// Create agent .agent.md files
let agentCount = 0;
for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name);
// Compute fileName first so we can look up any existing tool permissions
const dashName = toDashPath(artifact.relativePath);
const fileName = dashName.replace(/\.md$/, '.agent.md');
const toolsStr = this.getToolsForFile(fileName);
const agentContent = this.createAgentContent(artifact, agentMeta, toolsStr);
const targetPath = path.join(agentsDir, fileName);
await this.writeFile(targetPath, agentContent);
agentCount++;
console.log(chalk.green(` ✓ Created agent: ${fileName}`));
}
// Generate prompt files from bmad-help.csv
const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest);
// Generate copilot-instructions.md
await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest);
console.log(chalk.green(`\n${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents created in .github/agents/`));
console.log(chalk.dim(` - ${promptCount} prompts created in .github/prompts/`));
console.log(chalk.dim(` - copilot-instructions.md generated`));
console.log(chalk.dim(` - Destination: .github/`));
return {
success: true,
results: {
agents: agentCount,
workflows: promptCount,
tasks: 0,
tools: 0,
},
};
}
/**
* Load agent manifest CSV into a Map keyed by agent name
* @param {string} bmadDir - BMAD installation directory
* @returns {Map} Agent metadata keyed by name
*/
async loadAgentManifest(bmadDir) {
const manifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv');
const agents = new Map();
if (!(await fs.pathExists(manifestPath))) {
return agents;
}
try {
const csvContent = await fs.readFile(manifestPath, 'utf8');
const records = csv.parse(csvContent, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
agents.set(record.name, record);
}
} catch {
// Gracefully degrade if manifest is unreadable/malformed
}
return agents;
}
/**
* Load bmad-help.csv to drive prompt generation
* @param {string} bmadDir - BMAD installation directory
* @returns {Array|null} Parsed CSV rows
*/
async loadBmadHelp(bmadDir) {
const helpPath = path.join(bmadDir, '_config', 'bmad-help.csv');
if (!(await fs.pathExists(helpPath))) {
return null;
}
try {
const csvContent = await fs.readFile(helpPath, 'utf8');
return csv.parse(csvContent, {
columns: true,
skip_empty_lines: true,
});
} catch {
// Gracefully degrade if help CSV is unreadable/malformed
return null;
}
}
/**
* Create agent .agent.md content with enriched description
* @param {Object} artifact - Agent artifact from AgentCommandGenerator
* @param {Object|undefined} manifestEntry - Agent manifest entry with metadata
* @returns {string} Agent file content
*/
createAgentContent(artifact, manifestEntry, toolsStr) {
// Build enriched description from manifest metadata
let description;
if (manifestEntry) {
const persona = manifestEntry.displayName || artifact.name;
const title = manifestEntry.title || this.formatTitle(artifact.name);
const capabilities = manifestEntry.capabilities || 'agent capabilities';
description = `${persona}${title}: ${capabilities}`;
} else {
description = `Activates the ${this.formatTitle(artifact.name)} agent persona.`;
}
// Build the agent file path for the activation block
const agentPath = artifact.agentPath || artifact.relativePath;
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
return `---
description: '${description.replaceAll("'", "''")}'
tools: ${toolsStr}
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 ${agentFilePath}
2. READ its entire contents - this contains the complete agent persona, menu, and instructions
3. FOLLOW every step in the <activation> section precisely
4. DISPLAY the welcome/greeting as instructed
5. PRESENT the numbered menu
6. WAIT for user input before proceeding
</agent-activation>
`;
}
/**
* Generate .prompt.md files for workflows, tasks, tech-writer commands, and agent activators
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Array} agentArtifacts - Agent artifacts for activator generation
* @param {Map} agentManifest - Agent manifest data
* @returns {number} Count of prompts generated
*/
async generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest) {
const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir);
let promptCount = 0;
// Load bmad-help.csv to drive workflow/task prompt generation
const helpEntries = await this.loadBmadHelp(bmadDir);
if (helpEntries) {
for (const entry of helpEntries) {
const command = entry.command;
if (!command) continue; // Skip entries without a command (tech-writer commands have no command column)
const workflowFile = entry['workflow-file'];
if (!workflowFile) continue; // Skip entries with no workflow file path
const promptFileName = `${command}.prompt.md`;
const toolsStr = this.getToolsForFile(promptFileName);
const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr);
const promptPath = path.join(promptsDir, promptFileName);
await this.writeFile(promptPath, promptContent);
promptCount++;
}
// Generate tech-writer command prompts (entries with no command column)
for (const entry of helpEntries) {
if (entry.command) continue; // Already handled above
const techWriterPrompt = this.createTechWriterPromptContent(entry);
if (techWriterPrompt) {
const promptFileName = `${techWriterPrompt.fileName}.prompt.md`;
const promptPath = path.join(promptsDir, promptFileName);
await this.writeFile(promptPath, techWriterPrompt.content);
promptCount++;
}
}
}
// Generate agent activator prompts (Pattern D)
for (const artifact of agentArtifacts) {
const agentMeta = agentManifest.get(artifact.name);
const fileName = `bmad-${artifact.name}.prompt.md`;
const toolsStr = this.getToolsForFile(fileName);
const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr);
const promptPath = path.join(promptsDir, fileName);
await this.writeFile(promptPath, promptContent);
promptCount++;
}
return promptCount;
}
/**
* Create prompt content for a workflow/task entry from bmad-help.csv
* Determines the pattern (A, B, or A for .xml tasks) based on file extension
* @param {Object} entry - bmad-help.csv row
* @param {string} workflowFile - Workflow file path
* @returns {string} Prompt file content
*/
createWorkflowPromptContent(entry, workflowFile, toolsStr) {
const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name));
// bmm/config.yaml is safe to hardcode here: these prompts are only generated when
// bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed.
const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`;
let body;
if (workflowFile.endsWith('.yaml')) {
// Pattern B: YAML-based workflows — use workflow engine
body = `${configLine}
2. Load the workflow engine at {project-root}/${this.bmadFolderName}/core/tasks/workflow.xml
3. Load and execute the workflow configuration at {project-root}/${workflowFile} using the engine from step 2`;
} else if (workflowFile.endsWith('.xml')) {
// Pattern A variant: XML tasks — load and execute directly
body = `${configLine}
2. Load and execute the task at {project-root}/${workflowFile}`;
} else {
// Pattern A: MD workflows — load and follow directly
body = `${configLine}
2. Load and follow the workflow at {project-root}/${workflowFile}`;
}
return `---
description: '${description}'
agent: 'agent'
tools: ${toolsStr}
---
${body}
`;
}
/**
* Create a short 2-5 word description for a prompt from the entry name
* @param {string} name - Entry name from bmad-help.csv
* @returns {string} Short description
*/
createPromptDescription(name) {
const descriptionMap = {
'Brainstorm Project': 'Brainstorm ideas',
'Market Research': 'Market research',
'Domain Research': 'Domain research',
'Technical Research': 'Technical research',
'Create Brief': 'Create product brief',
'Create PRD': 'Create PRD',
'Validate PRD': 'Validate PRD',
'Edit PRD': 'Edit PRD',
'Create UX': 'Create UX design',
'Create Architecture': 'Create architecture',
'Create Epics and Stories': 'Create epics and stories',
'Check Implementation Readiness': 'Check implementation readiness',
'Sprint Planning': 'Sprint planning',
'Sprint Status': 'Sprint status',
'Create Story': 'Create story',
'Validate Story': 'Validate story',
'Dev Story': 'Dev story',
'QA Automation Test': 'QA automation',
'Code Review': 'Code review',
Retrospective: 'Retrospective',
'Document Project': 'Document project',
'Generate Project Context': 'Generate project context',
'Quick Spec': 'Quick spec',
'Quick Dev': 'Quick dev',
'Correct Course': 'Correct course',
Brainstorming: 'Brainstorm ideas',
'Party Mode': 'Party mode',
'bmad-help': 'BMAD help',
'Index Docs': 'Index documents',
'Shard Document': 'Shard document',
'Editorial Review - Prose': 'Editorial review prose',
'Editorial Review - Structure': 'Editorial review structure',
'Adversarial Review (General)': 'Adversarial review',
};
return descriptionMap[name] || name;
}
/**
* Create prompt content for tech-writer agent-only commands (Pattern C)
* @param {Object} entry - bmad-help.csv row
* @returns {Object|null} { fileName, content } or null if not a tech-writer command
*/
createTechWriterPromptContent(entry) {
if (entry['agent-name'] !== 'tech-writer') return null;
const techWriterCommands = {
'Write Document': { code: 'WD', file: 'bmad-bmm-write-document', description: 'Write document' },
'Update Standards': { code: 'US', file: 'bmad-bmm-update-standards', description: 'Update standards' },
'Mermaid Generate': { code: 'MG', file: 'bmad-bmm-mermaid-generate', description: 'Mermaid generate' },
'Validate Document': { code: 'VD', file: 'bmad-bmm-validate-document', description: 'Validate document' },
'Explain Concept': { code: 'EC', file: 'bmad-bmm-explain-concept', description: 'Explain concept' },
};
const cmd = techWriterCommands[entry.name];
if (!cmd) return null;
const safeDescription = this.escapeYamlSingleQuote(cmd.description);
const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`);
const content = `---
description: '${safeDescription}'
agent: 'agent'
tools: ${toolsStr}
---
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables
2. Load the full agent file from {project-root}/${this.bmadFolderName}/bmm/agents/tech-writer/tech-writer.md and activate the Paige (Technical Writer) persona
3. Execute the ${entry.name} menu command (${cmd.code})
`;
return { fileName: cmd.file, content };
}
/**
* Create agent activator prompt content (Pattern D)
* @param {Object} artifact - Agent artifact
* @param {Object|undefined} manifestEntry - Agent manifest entry
* @returns {string} Prompt file content
*/
createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) {
let description;
if (manifestEntry) {
description = manifestEntry.title || this.formatTitle(artifact.name);
} else {
description = this.formatTitle(artifact.name);
}
const safeDescription = this.escapeYamlSingleQuote(description);
const agentPath = artifact.agentPath || artifact.relativePath;
const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`;
// bmm/config.yaml is safe to hardcode: agent activators are only generated from
// bmm agent artifacts, so bmm is guaranteed to be installed.
return `---
description: '${safeDescription}'
agent: 'agent'
tools: ${toolsStr}
---
1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables
2. Load the full agent file from ${agentFilePath}
3. Follow ALL activation instructions in the agent file
4. Display the welcome/greeting as instructed
5. Present the numbered menu
6. Wait for user input before proceeding
`;
}
/**
* Generate copilot-instructions.md from module config
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Map} agentManifest - Agent manifest data
*/
async generateCopilotInstructions(projectDir, bmadDir, agentManifest) {
const configVars = await this.loadModuleConfig(bmadDir);
// Build the agents table from the manifest
let agentsTable = '| Agent | Persona | Title | Capabilities |\n|---|---|---|---|\n';
const agentOrder = [
'bmad-master',
'analyst',
'architect',
'dev',
'pm',
'qa',
'quick-flow-solo-dev',
'sm',
'tech-writer',
'ux-designer',
];
for (const agentName of agentOrder) {
const meta = agentManifest.get(agentName);
if (meta) {
const capabilities = meta.capabilities || 'agent capabilities';
const cleanTitle = (meta.title || '').replaceAll('""', '"');
agentsTable += `| ${agentName} | ${meta.displayName} | ${cleanTitle} | ${capabilities} |\n`;
}
}
const bmad = this.bmadFolderName;
const bmadSection = `# BMAD Method — Project Instructions
## Project Configuration
- **Project**: ${configVars.project_name || '{{project_name}}'}
- **User**: ${configVars.user_name || '{{user_name}}'}
- **Communication Language**: ${configVars.communication_language || '{{communication_language}}'}
- **Document Output Language**: ${configVars.document_output_language || '{{document_output_language}}'}
- **User Skill Level**: ${configVars.user_skill_level || '{{user_skill_level}}'}
- **Output Folder**: ${configVars.output_folder || '{{output_folder}}'}
- **Planning Artifacts**: ${configVars.planning_artifacts || '{{planning_artifacts}}'}
- **Implementation Artifacts**: ${configVars.implementation_artifacts || '{{implementation_artifacts}}'}
- **Project Knowledge**: ${configVars.project_knowledge || '{{project_knowledge}}'}
## BMAD Runtime Structure
- **Agent definitions**: \`${bmad}/bmm/agents/\` (BMM module) and \`${bmad}/core/agents/\` (core)
- **Workflow definitions**: \`${bmad}/bmm/workflows/\` (organized by phase)
- **Core tasks**: \`${bmad}/core/tasks/\` (help, editorial review, indexing, sharding, adversarial review)
- **Core workflows**: \`${bmad}/core/workflows/\` (brainstorming, party-mode, advanced-elicitation)
- **Workflow engine**: \`${bmad}/core/tasks/workflow.xml\` (executes YAML-based workflows)
- **Module configuration**: \`${bmad}/bmm/config.yaml\`
- **Core configuration**: \`${bmad}/core/config.yaml\`
- **Agent manifest**: \`${bmad}/_config/agent-manifest.csv\`
- **Workflow manifest**: \`${bmad}/_config/workflow-manifest.csv\`
- **Help manifest**: \`${bmad}/_config/bmad-help.csv\`
- **Agent memory**: \`${bmad}/_memory/\`
## Key Conventions
- Always load \`${bmad}/bmm/config.yaml\` before any agent activation or workflow execution
- Store all config fields as session variables: \`{user_name}\`, \`{communication_language}\`, \`{output_folder}\`, \`{planning_artifacts}\`, \`{implementation_artifacts}\`, \`{project_knowledge}\`
- MD-based workflows execute directly load and follow the \`.md\` file
- YAML-based workflows require the workflow engine load \`workflow.xml\` first, then pass the \`.yaml\` config
- Follow step-based workflow execution: load steps JIT, never multiple at once
- Save outputs after EACH step when using the workflow engine
- The \`{project-root}\` variable resolves to the workspace root at runtime
## Available Agents
${agentsTable}
## Slash Commands
Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent activators. Agents are also available in the agents dropdown.`;
const instructionsPath = path.join(projectDir, this.githubDir, 'copilot-instructions.md');
const markerStart = '<!-- BMAD:START -->';
const markerEnd = '<!-- BMAD:END -->';
const markedContent = `${markerStart}\n${bmadSection}\n${markerEnd}`;
if (await fs.pathExists(instructionsPath)) {
const existing = await fs.readFile(instructionsPath, 'utf8');
const startIdx = existing.indexOf(markerStart);
const endIdx = existing.indexOf(markerEnd);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Replace only the BMAD section between markers
const before = existing.slice(0, startIdx);
const after = existing.slice(endIdx + markerEnd.length);
const merged = `${before}${markedContent}${after}`;
await this.writeFile(instructionsPath, merged);
console.log(chalk.green(' ✓ Updated BMAD section in copilot-instructions.md'));
} else {
// Existing file without markers — back it up before overwriting
const backupPath = `${instructionsPath}.bak`;
await fs.copy(instructionsPath, backupPath);
console.log(chalk.yellow(` ⚠ Backed up existing copilot-instructions.md → copilot-instructions.md.bak`));
await this.writeFile(instructionsPath, `${markedContent}\n`);
console.log(chalk.green(' ✓ Generated copilot-instructions.md (with BMAD markers)'));
}
} else {
// No existing file — create fresh with markers
await this.writeFile(instructionsPath, `${markedContent}\n`);
console.log(chalk.green(' ✓ Generated copilot-instructions.md'));
}
}
/**
* Load module config.yaml for template variables
* @param {string} bmadDir - BMAD installation directory
* @returns {Object} Config variables
*/
async loadModuleConfig(bmadDir) {
const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml');
const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml');
for (const configPath of [bmmConfigPath, coreConfigPath]) {
if (await fs.pathExists(configPath)) {
try {
const content = await fs.readFile(configPath, 'utf8');
return yaml.parse(content) || {};
} catch {
// Fall through to next config
}
}
}
return {};
}
/**
* Escape a string for use inside YAML single-quoted values.
* In YAML, the only escape inside single quotes is '' for a literal '.
* @param {string} value - Raw string
* @returns {string} Escaped string safe for YAML single-quoted context
*/
escapeYamlSingleQuote(value) {
return (value || '').replaceAll("'", "''");
}
/**
* Scan existing agent and prompt files for customised tool permissions before cleanup.
* Returns a Map<filename, toolsArray> so permissions can be preserved across reinstalls.
* @param {string} projectDir - Project directory
* @returns {Map} Existing tool permissions keyed by filename
*/
async collectExistingToolPermissions(projectDir) {
const permissions = new Map();
const dirs = [
[path.join(projectDir, this.githubDir, this.agentsDir), /^bmad.*\.agent\.md$/],
[path.join(projectDir, this.githubDir, this.promptsDir), /^bmad-.*\.prompt\.md$/],
];
for (const [dir, pattern] of dirs) {
if (!(await fs.pathExists(dir))) continue;
const files = await fs.readdir(dir);
for (const file of files) {
if (!pattern.test(file)) continue;
try {
const content = await fs.readFile(path.join(dir, file), 'utf8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) continue;
const frontmatter = yaml.parse(fmMatch[1]);
if (frontmatter && Array.isArray(frontmatter.tools)) {
permissions.set(file, frontmatter.tools);
}
} catch {
// Skip unreadable files
}
}
}
return permissions;
}
/**
* Get the tools array string for a file, preserving any existing customisation.
* Falls back to the default tools if no prior customisation exists.
* @param {string} fileName - Target filename (e.g. 'bmad-agent-bmm-pm.agent.md')
* @returns {string} YAML inline array string
*/
getToolsForFile(fileName) {
const defaultTools = ['read', 'edit', 'search', 'execute'];
const tools = (this.existingToolPermissions && this.existingToolPermissions.get(fileName)) || defaultTools;
return '[' + tools.map((t) => `'${t}'`).join(', ') + ']';
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup GitHub Copilot configuration - surgically remove only BMAD files
*/
async cleanup(projectDir) {
// Clean up agents directory
const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir);
if (await fs.pathExists(agentsDir)) {
const files = await fs.readdir(agentsDir);
let removed = 0;
for (const file of files) {
if (file.startsWith('bmad') && (file.endsWith('.agent.md') || file.endsWith('.md'))) {
await fs.remove(path.join(agentsDir, file));
removed++;
}
}
if (removed > 0) {
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD agents`));
}
}
// Clean up prompts directory
const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir);
if (await fs.pathExists(promptsDir)) {
const files = await fs.readdir(promptsDir);
let removed = 0;
for (const file of files) {
if (file.startsWith('bmad-') && file.endsWith('.prompt.md')) {
await fs.remove(path.join(promptsDir, file));
removed++;
}
}
if (removed > 0) {
console.log(chalk.dim(` Cleaned up ${removed} existing BMAD prompts`));
}
}
// Note: copilot-instructions.md is NOT cleaned up here.
// generateCopilotInstructions() handles marker-based replacement in a single
// read-modify-write pass, which correctly preserves user content outside the markers.
// Stripping markers here would cause generation to treat the file as legacy (no markers)
// and overwrite user content.
}
}
module.exports = { GitHubCopilotSetup };

View File

@ -8,7 +8,7 @@ const prompts = require('../../../lib/prompts');
* Dynamically discovers and loads IDE handlers * Dynamically discovers and loads IDE handlers
* *
* Loading strategy: * Loading strategy:
* 1. Custom installer files (codex.js, kilo.js) - for platforms with unique installation logic * 1. Custom installer files (codex.js, github-copilot.js, kilo.js) - for platforms with unique installation logic
* 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns * 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns
*/ */
class IdeManager { class IdeManager {
@ -44,7 +44,7 @@ class IdeManager {
/** /**
* Dynamically load all IDE handlers * Dynamically load all IDE handlers
* 1. Load custom installer files first (codex.js, kilo.js) * 1. Load custom installer files first (codex.js, github-copilot.js, kilo.js)
* 2. Load config-driven handlers from platform-codes.yaml * 2. Load config-driven handlers from platform-codes.yaml
*/ */
async loadHandlers() { async loadHandlers() {
@ -61,7 +61,7 @@ class IdeManager {
*/ */
async loadCustomInstallerFiles() { async loadCustomInstallerFiles() {
const ideDir = __dirname; const ideDir = __dirname;
const customFiles = ['codex.js', 'kilo.js']; const customFiles = ['codex.js', 'github-copilot.js', 'kilo.js'];
for (const file of customFiles) { for (const file of customFiles) {
const filePath = path.join(ideDir, file); const filePath = path.join(ideDir, file);

View File

@ -89,11 +89,7 @@ platforms:
preferred: false preferred: false
category: ide category: ide
description: "GitHub's AI pair programmer" description: "GitHub's AI pair programmer"
installer: # No installer config - uses custom github-copilot.js
targets:
- target_dir: .github/agents
template_type: copilot_agents
artifact_types: [agents]
iflow: iflow:
name: "iFlow" name: "iFlow"

View File

@ -1,6 +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');
/** /**
* Manages external official modules defined in external-official-modules.yaml * Manages external official modules defined in external-official-modules.yaml
@ -29,7 +30,7 @@ class ExternalModuleManager {
this.cachedModules = config; this.cachedModules = config;
return config; return config;
} catch (error) { } catch (error) {
console.warn(`Failed to load external modules config: ${error.message}`); await prompts.log.warn(`Failed to load external modules config: ${error.message}`);
return { modules: {} }; return { modules: {} };
} }
} }

View File

@ -236,17 +236,11 @@ class ModuleManager {
async getModuleInfo(modulePath, defaultName, sourceDescription) { async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (module.yaml OR custom.yaml) // Check for module structure (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(modulePath, 'module.yaml'); const moduleConfigPath = path.join(modulePath, 'module.yaml');
const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml');
const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml');
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null; let configPath = null;
if (await fs.pathExists(moduleConfigPath)) { if (await fs.pathExists(moduleConfigPath)) {
configPath = moduleConfigPath; configPath = moduleConfigPath;
} else if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(customConfigPath)) {
configPath = customConfigPath;
} else if (await fs.pathExists(rootCustomConfigPath)) { } else if (await fs.pathExists(rootCustomConfigPath)) {
configPath = rootCustomConfigPath; configPath = rootCustomConfigPath;
} }
@ -268,7 +262,7 @@ class ModuleManager {
description: 'BMAD Module', description: 'BMAD Module',
version: '5.0.0', version: '5.0.0',
source: sourceDescription, source: sourceDescription,
isCustom: configPath === customConfigPath || configPath === rootCustomConfigPath || isCustomSource, isCustom: configPath === rootCustomConfigPath || isCustomSource,
}; };
// Read module config for metadata // Read module config for metadata
@ -458,7 +452,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) { } catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`); if (!silent) await prompts.log.warn(` ${error.message}`);
} }
} else { } else {
// Check if package.json is newer than node_modules // Check if package.json is newer than node_modules
@ -484,7 +478,7 @@ class ModuleManager {
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
} catch (error) { } catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
if (!silent) await prompts.log.warn(` Warning: ${error.message}`); if (!silent) await prompts.log.warn(` ${error.message}`);
} }
} }
} }
@ -541,21 +535,13 @@ class ModuleManager {
// Check if this is a custom module and read its custom.yaml values // Check if this is a custom module and read its custom.yaml values
let customConfig = null; let customConfig = null;
const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml');
const moduleInstallerCustomPath = path.join(sourcePath, '_module-installer', 'custom.yaml');
if (await fs.pathExists(rootCustomConfigPath)) { if (await fs.pathExists(rootCustomConfigPath)) {
try { try {
const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); const customContent = await fs.readFile(rootCustomConfigPath, 'utf8');
customConfig = yaml.parse(customContent); customConfig = yaml.parse(customContent);
} catch (error) { } catch (error) {
await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`); await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`);
}
} else if (await fs.pathExists(moduleInstallerCustomPath)) {
try {
const customContent = await fs.readFile(moduleInstallerCustomPath, 'utf8');
customConfig = yaml.parse(customContent);
} catch (error) {
await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`);
} }
} }
@ -563,7 +549,7 @@ class ModuleManager {
if (customConfig) { if (customConfig) {
options.moduleConfig = { ...options.moduleConfig, ...customConfig }; options.moduleConfig = { ...options.moduleConfig, ...customConfig };
if (options.logger) { if (options.logger) {
options.logger.log(` Merged custom configuration for ${moduleName}`); await options.logger.log(` Merged custom configuration for ${moduleName}`);
} }
} }
@ -585,9 +571,9 @@ class ModuleManager {
// Process agent files to inject activation block // Process agent files to inject activation block
await this.processAgentFiles(targetPath, moduleName); await this.processAgentFiles(targetPath, moduleName);
// Call module-specific installer if it exists (unless explicitly skipped) // Create directories declared in module.yaml (unless explicitly skipped)
if (!options.skipModuleInstaller) { if (!options.skipModuleInstaller) {
await this.runModuleInstaller(moduleName, bmadDir, options); await this.createModuleDirectories(moduleName, bmadDir, options);
} }
// Capture version info for manifest // Capture version info for manifest
@ -743,8 +729,8 @@ class ModuleManager {
continue; continue;
} }
// Skip _module-installer directory - it's only needed at install time // Skip module.yaml at root - it's only needed at install time
if (file.startsWith('_module-installer/') || file === 'module.yaml') { if (file === 'module.yaml') {
continue; continue;
} }
@ -871,7 +857,7 @@ class ModuleManager {
await fs.writeFile(targetFile, strippedYaml, 'utf8'); await fs.writeFile(targetFile, strippedYaml, 'utf8');
} catch { } catch {
// If anything fails, just copy the file as-is // If anything fails, just copy the file as-is
await prompts.log.warn(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`); await prompts.log.warn(` Could not process ${path.basename(sourceFile)}, copying as-is`);
await fs.copy(sourceFile, targetFile, { overwrite: true }); await fs.copy(sourceFile, targetFile, { overwrite: true });
} }
} }
@ -1026,7 +1012,7 @@ class ModuleManager {
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`); await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
} }
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') { } else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
await prompts.log.warn(` Warning: Agent marked as having sidecar but ${sidecarDirName} directory not found`); await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`);
} }
} }
@ -1259,62 +1245,175 @@ class ModuleManager {
} }
/** /**
* Run module-specific installer if it exists * 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} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory * @param {string} bmadDir - Target bmad directory
* @param {Object} options - Installation options * @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 runModuleInstaller(moduleName, bmadDir, options = {}) { 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 not src/modules // Special handling for core module - it's in src/core not src/modules
let sourcePath; let sourcePath;
if (moduleName === 'core') { if (moduleName === 'core') {
sourcePath = getSourcePath('core'); sourcePath = getSourcePath('core');
} else { } else {
sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); sourcePath = await this.findModuleSource(moduleName, { silent: true });
if (!sourcePath) { if (!sourcePath) {
// No source found, skip module installer return emptyResult; // No source found, skip
return;
} }
} }
const installerPath = path.join(sourcePath, '_module-installer', 'installer.js'); // Read module.yaml to find the `directories` key
const moduleYamlPath = path.join(sourcePath, 'module.yaml');
// Check if module has a custom installer if (!(await fs.pathExists(moduleYamlPath))) {
if (!(await fs.pathExists(installerPath))) { return emptyResult; // No module.yaml, skip
return; // No custom installer
} }
let moduleYaml;
try { try {
// Load the module installer const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleInstaller = require(installerPath); moduleYaml = yaml.parse(yamlContent);
} catch {
return emptyResult; // Invalid YAML, skip
}
if (typeof moduleInstaller.install === 'function') { if (!moduleYaml || !moduleYaml.directories) {
// Get project root (parent of bmad directory) return emptyResult; // No directories declared, skip
const projectRoot = path.dirname(bmadDir); }
// Prepare logger (use console if not provided) const directories = moduleYaml.directories;
const logger = options.logger || { const wdsFolders = moduleYaml.wds_folders || [];
log: console.log, const createdDirs = [];
error: console.error, const movedDirs = [];
warn: console.warn, const createdWdsFolders = [];
};
// Call the module installer for (const dirRef of directories) {
const result = await moduleInstaller.install({ // Parse variable reference like "{design_artifacts}"
projectRoot, const varMatch = dirRef.match(/^\{([^}]+)\}$/);
config: options.moduleConfig || {}, if (!varMatch) {
coreConfig: options.coreConfig || {}, // Not a variable reference, skip
installedIDEs: options.installedIDEs || [], continue;
logger, }
});
if (!result) { const configKey = varMatch[1];
await prompts.log.warn(`Module installer for ${moduleName} returned false`); 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);
}
} }
} }
} catch (error) {
await prompts.log.error(`Error running module installer for ${moduleName}: ${error.message}`);
} }
return { createdDirs, movedDirs, createdWdsFolders };
} }
/** /**
@ -1383,10 +1482,6 @@ class ModuleManager {
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
// Skip _module-installer directories
if (entry.name === '_module-installer') {
continue;
}
const subFiles = await this.getFileList(fullPath, baseDir); const subFiles = await this.getFileList(fullPath, baseDir);
files.push(...subFiles); files.push(...subFiles);
} else { } else {

View File

@ -279,6 +279,9 @@ async function compileToXml(agentYaml, agentName = '', targetPath = '') {
`title="${meta.title || ''}"`, `title="${meta.title || ''}"`,
`icon="${meta.icon || '🤖'}"`, `icon="${meta.icon || '🤖'}"`,
]; ];
if (meta.capabilities) {
agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`);
}
xml += `<agent ${agentAttrs.join(' ')}>\n`; xml += `<agent ${agentAttrs.join(' ')}>\n`;

View File

@ -189,7 +189,7 @@ class UI {
const installedVersion = existingInstall.version || 'unknown'; const installedVersion = existingInstall.version || 'unknown';
// Check if version is pre beta // Check if version is pre beta
const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir)); const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options);
// If user chose to cancel, exit the installer // If user chose to cancel, exit the installer
if (!shouldProceed) { if (!shouldProceed) {
@ -227,6 +227,14 @@ class UI {
} }
actionType = options.action; actionType = options.action;
await prompts.log.info(`Using action from command-line: ${actionType}`); await prompts.log.info(`Using action from command-line: ${actionType}`);
} else if (options.yes) {
// Default to quick-update if available, otherwise first available choice
if (choices.length === 0) {
throw new Error('No valid actions available for this installation');
}
const hasQuickUpdate = choices.some((c) => c.value === 'quick-update');
actionType = hasQuickUpdate ? 'quick-update' : choices[0].value;
await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`);
} else { } else {
actionType = await prompts.select({ actionType = await prompts.select({
message: 'How would you like to proceed?', message: 'How would you like to proceed?',
@ -242,6 +250,7 @@ class UI {
actionType: 'quick-update', actionType: 'quick-update',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -252,6 +261,7 @@ class UI {
actionType: 'compile-agents', actionType: 'compile-agents',
directory: confirmedDirectory, directory: confirmedDirectory,
customContent: { hasCustomContent: false }, customContent: { hasCustomContent: false },
skipPrompts: options.yes || false,
}; };
} }
@ -272,9 +282,13 @@ class UI {
.map((m) => m.trim()) .map((m) => m.trim())
.filter(Boolean); .filter(Boolean);
await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`);
} else if (options.yes) {
selectedModules = await this.getDefaultModules(installedModuleIds);
await prompts.log.info(
`Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`,
);
} else { } else {
selectedModules = await this.selectAllModules(installedModuleIds); selectedModules = await this.selectAllModules(installedModuleIds);
selectedModules = selectedModules.filter((m) => m !== 'core');
} }
// After module selection, ask about custom modules // After module selection, ask about custom modules
@ -331,6 +345,22 @@ class UI {
}, },
}; };
} }
} else if (options.yes) {
// Non-interactive mode: preserve existing custom modules (matches default: false)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
customModuleResult.selectedCustomModules.push(entry.name);
}
}
await prompts.log.info(
`Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`,
);
} else {
await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found');
}
} else { } else {
const changeCustomModules = await prompts.confirm({ const changeCustomModules = await prompts.confirm({
message: 'Modify custom modules, agents, or workflows?', message: 'Modify custom modules, agents, or workflows?',
@ -362,6 +392,9 @@ class UI {
selectedModules.push(...customModuleResult.selectedCustomModules); selectedModules.push(...customModuleResult.selectedCustomModules);
} }
// Filter out core - it's always installed via installCore flag
selectedModules = selectedModules.filter((m) => m !== 'core');
// Get tool selection // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, options); const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
@ -376,6 +409,7 @@ class UI {
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: customModuleResult.customContentConfig, customContent: customModuleResult.customContentConfig,
skipPrompts: options.yes || false,
}; };
} }
} }
@ -527,6 +561,27 @@ class UI {
if (configuredIdes.length > 0) { if (configuredIdes.length > 0) {
const allTools = [...preferredIdes, ...otherIdes]; const allTools = [...preferredIdes, ...otherIdes];
// Non-interactive: handle --tools and --yes flags before interactive prompt
if (options.tools) {
if (options.tools.toLowerCase() === 'none') {
await prompts.log.info('Skipping tool configuration (--tools none)');
return { ides: [], skipIde: true };
}
const selectedIdes = options.tools
.split(',')
.map((t) => t.trim())
.filter(Boolean);
await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`);
await this.displaySelectedTools(selectedIdes, preferredIdes, allTools);
return { ides: selectedIdes, skipIde: false };
}
if (options.yes) {
await prompts.log.info(`Non-interactive mode (--yes): keeping configured tools: ${configuredIdes.join(', ')}`);
await this.displaySelectedTools(configuredIdes, preferredIdes, allTools);
return { ides: configuredIdes, skipIde: false };
}
// Sort: configured tools first, then preferred, then others // Sort: configured tools first, then preferred, then others
const sortedTools = [ const sortedTools = [
...allTools.filter((ide) => configuredIdes.includes(ide.value)), ...allTools.filter((ide) => configuredIdes.includes(ide.value)),
@ -689,18 +744,6 @@ class UI {
}); });
} }
/**
* Display installation summary
* @param {Object} result - Installation result
*/
async showInstallSummary(result) {
let summary = `Installed to: ${result.path}`;
if (result.modules && result.modules.length > 0) {
summary += `\nModules: ${result.modules.join(', ')}`;
}
await prompts.note(summary, 'BMAD is ready to use!');
}
/** /**
* Get confirmed directory from user * Get confirmed directory from user
* @returns {string} Confirmed directory path * @returns {string} Confirmed directory path
@ -899,107 +942,10 @@ class UI {
} }
/** /**
* Prompt for module selection * Select all modules (official + community) using grouped multiselect.
* @param {Array} moduleChoices - Available module choices * Core is shown as locked but filtered from the result since it's always installed separately.
* @returns {Array} Selected module IDs
*/
async selectModules(moduleChoices, defaultSelections = null) {
// If defaultSelections is provided, use it to override checked state
// Otherwise preserve the checked state from moduleChoices (set by getModuleChoices)
const choicesWithDefaults = moduleChoices.map((choice) => ({
...choice,
...(defaultSelections === null ? {} : { checked: defaultSelections.includes(choice.value) }),
}));
// Add a "None" option at the end for users who changed their mind
const choicesWithSkipOption = [
...choicesWithDefaults,
{
value: '__NONE__',
label: '\u26A0 None / I changed my mind - skip module installation',
checked: false,
},
];
const selected = await prompts.multiselect({
message: 'Select modules to install (use arrow keys, space to toggle):',
choices: choicesWithSkipOption,
required: true,
});
// If user selected both "__NONE__" and other items, honor the "None" choice
if (selected && selected.includes('__NONE__') && selected.length > 1) {
await prompts.log.warn('"None / I changed my mind" was selected, so no modules will be installed.');
return [];
}
// Filter out the special '__NONE__' value
return selected ? selected.filter((m) => m !== '__NONE__') : [];
}
/**
* Get external module choices for selection
* @returns {Array} External module choices for prompt
*/
async getExternalModuleChoices() {
const externalManager = new ExternalModuleManager();
const modules = await externalManager.listAvailable();
return modules.map((mod) => ({
name: mod.name,
value: mod.code, // Use the code (e.g., 'cis') as the value
checked: mod.defaultSelected || false,
hint: mod.description || undefined, // Show description as hint
module: mod, // Store full module info for later use
}));
}
/**
* Prompt for external module selection
* @param {Array} externalModuleChoices - Available external module choices
* @param {Array} defaultSelections - Module codes to pre-select
* @returns {Array} Selected external module codes
*/
async selectExternalModules(externalModuleChoices, defaultSelections = []) {
// Build a message showing available modules
const message = 'Select official BMad modules to install (use arrow keys, space to toggle):';
// Mark choices as checked based on defaultSelections
const choicesWithDefaults = externalModuleChoices.map((choice) => ({
...choice,
checked: defaultSelections.includes(choice.value),
}));
// Add a "None" option at the end for users who changed their mind
const choicesWithSkipOption = [
...choicesWithDefaults,
{
name: '⚠ None / I changed my mind - skip external module installation',
value: '__NONE__',
checked: false,
},
];
const selected = await prompts.multiselect({
message,
choices: choicesWithSkipOption,
required: true,
});
// If user selected both "__NONE__" and other items, honor the "None" choice
if (selected && selected.includes('__NONE__') && selected.length > 1) {
await prompts.log.warn('"None / I changed my mind" was selected, so no external modules will be installed.');
return [];
}
// Filter out the special '__NONE__' value
return selected ? selected.filter((m) => m !== '__NONE__') : [];
}
/**
* Select all modules (core + official + community) using grouped multiselect
* @param {Set} installedModuleIds - Currently installed module IDs * @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Selected module codes * @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 { ModuleManager } = require('../installers/lib/modules/manager');
@ -1068,11 +1014,7 @@ class UI {
} }
} }
} }
allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })), { allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })));
// "None" option at the end
label: '\u26A0 None - Skip module installation',
value: '__NONE__',
});
const selected = await prompts.autocompleteMultiselect({ const selected = await prompts.autocompleteMultiselect({
message: 'Select modules to install:', message: 'Select modules to install:',
@ -1083,14 +1025,7 @@ class UI {
maxItems: allOptions.length, maxItems: allOptions.length,
}); });
// If user selected both "__NONE__" and other items, honor the "None" choice const result = selected ? selected.filter((m) => m !== 'core') : [];
if (selected && selected.includes('__NONE__') && selected.length > 1) {
await prompts.log.warn('"None" was selected, so no modules will be installed.');
return [];
}
// Filter out the special '__NONE__' value
const result = selected ? selected.filter((m) => m !== '__NONE__') : [];
// Display selected modules as bulleted list // Display selected modules as bulleted list
if (result.length > 0) { if (result.length > 0) {
@ -1748,7 +1683,7 @@ class UI {
* @param {string} bmadFolderName - Name of the BMAD folder * @param {string} bmadFolderName - Name of the BMAD folder
* @returns {Promise<boolean>} True if user wants to proceed, false if they cancel * @returns {Promise<boolean>} True if user wants to proceed, false if they cancel
*/ */
async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName) { async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) {
if (!this.isLegacyVersion(installedVersion)) { if (!this.isLegacyVersion(installedVersion)) {
return true; // Not legacy, proceed return true; // Not legacy, proceed
} }
@ -1774,6 +1709,11 @@ class UI {
await prompts.log.warn('VERSION WARNING'); await prompts.log.warn('VERSION WARNING');
await prompts.note(warningContent, '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({ const proceed = await prompts.select({
message: 'How would you like to proceed?', message: 'How would you like to proceed?',
choices: [ choices: [

View File

@ -228,6 +228,7 @@ function buildMetadataSchema(expectedModule) {
title: createNonEmptyString('agent.metadata.title'), title: createNonEmptyString('agent.metadata.title'),
icon: createNonEmptyString('agent.metadata.icon'), icon: createNonEmptyString('agent.metadata.icon'),
module: createNonEmptyString('agent.metadata.module').optional(), module: createNonEmptyString('agent.metadata.module').optional(),
capabilities: createNonEmptyString('agent.metadata.capabilities').optional(),
hasSidecar: z.boolean(), hasSidecar: z.boolean(),
}; };

View File

@ -42,7 +42,7 @@ const STRICT = process.argv.includes('--strict');
const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']); const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']);
// Skip directories // Skip directories
const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']); const SKIP_DIRS = new Set(['node_modules', '.git']);
// Pattern: {project-root}/_bmad/ references // Pattern: {project-root}/_bmad/ references
const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g; const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g;