feat: v6.0.0-alpha.0 - the future is now

This commit is contained in:
Brian Madison
2025-09-28 23:17:07 -05:00
parent 52f6889089
commit 0a6a3f3015
747 changed files with 52759 additions and 235199 deletions

42
tools/cli/bmad-cli.js Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
const { program } = require('commander');
const path = require('node:path');
const fs = require('node:fs');
// Load package.json from root for version info
const packageJson = require('../../package.json');
// Load all command modules
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter((file) => file.endsWith('.js'));
const commands = {};
for (const file of commandFiles) {
const command = require(path.join(commandsPath, file));
commands[command.command] = command;
}
// Set up main program
program.version(packageJson.version).description('BMAD Core CLI - Universal AI agent framework');
// Register all commands
for (const [name, cmd] of Object.entries(commands)) {
const command = program.command(name).description(cmd.description);
// Add options
for (const option of cmd.options || []) {
command.option(...option);
}
// Set action
command.action(cmd.action);
}
// Parse arguments
program.parse(process.argv);
// Show help if no command provided
if (process.argv.slice(2).length === 0) {
program.outputHelp();
}

157
tools/cli/bundlers/bundle-web.js Executable file
View File

@@ -0,0 +1,157 @@
const { WebBundler } = require('./web-bundler');
const chalk = require('chalk');
const { program } = require('commander');
const path = require('node:path');
const fs = require('fs-extra');
program.name('bundle-web').description('Generate web bundles for BMAD agents and teams').version('1.0.0');
program
.command('all')
.description('Bundle all modules')
.option('-o, --output <path>', 'Output directory', 'web-bundles')
.action(async (options) => {
try {
const bundler = new WebBundler(null, options.output);
await bundler.bundleAll();
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
program
.command('rebundle')
.description('Clean and rebundle all modules')
.option('-o, --output <path>', 'Output directory', 'web-bundles')
.action(async (options) => {
try {
// Clean output directory first
const outputDir = path.isAbsolute(options.output) ? options.output : path.join(process.cwd(), options.output);
if (await fs.pathExists(outputDir)) {
console.log(chalk.cyan(`🧹 Cleaning ${options.output}...`));
await fs.emptyDir(outputDir);
}
// Bundle all
const bundler = new WebBundler(null, options.output);
await bundler.bundleAll();
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
program
.command('module <name>')
.description('Bundle a specific module')
.option('-o, --output <path>', 'Output directory', 'web-bundles')
.action(async (moduleName, options) => {
try {
const bundler = new WebBundler(null, options.output);
await bundler.loadWebActivation();
const result = await bundler.bundleModule(moduleName);
if (result.agents.length === 0 && result.teams.length === 0) {
console.log(chalk.yellow(`No agents or teams found in module: ${moduleName}`));
} else {
console.log(chalk.green(`\n✨ Successfully bundled ${result.agents.length} agents and ${result.teams.length} teams`));
}
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
program
.command('agent <module> <agent>')
.description('Bundle a specific agent')
.option('-o, --output <path>', 'Output directory', 'web-bundles')
.action(async (moduleName, agentFile, options) => {
try {
const bundler = new WebBundler(null, options.output);
await bundler.loadWebActivation();
// Ensure .md extension
if (!agentFile.endsWith('.md')) {
agentFile += '.md';
}
// Pre-discover module for complete manifests
await bundler.preDiscoverModule(moduleName);
await bundler.bundleAgent(moduleName, agentFile, false);
console.log(chalk.green(`\n✨ Successfully bundled agent: ${agentFile}`));
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
program
.command('list')
.description('List available modules and agents')
.action(async () => {
try {
const bundler = new WebBundler();
const modules = await bundler.discoverModules();
console.log(chalk.cyan.bold('\n📦 Available Modules:\n'));
for (const module of modules) {
console.log(chalk.bold(` ${module}/`));
const modulePath = path.join(bundler.modulesPath, module);
const agents = await bundler.discoverAgents(modulePath);
const teams = await bundler.discoverTeams(modulePath);
if (agents.length > 0) {
console.log(chalk.gray(' agents/'));
for (const agent of agents) {
console.log(chalk.gray(` - ${agent}`));
}
}
if (teams.length > 0) {
console.log(chalk.gray(' teams/'));
for (const team of teams) {
console.log(chalk.gray(` - ${team}`));
}
}
}
console.log('');
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
program
.command('clean')
.description('Remove all web bundles')
.action(async () => {
try {
const fs = require('fs-extra');
const outputDir = path.join(process.cwd(), 'web-bundles');
if (await fs.pathExists(outputDir)) {
await fs.remove(outputDir);
console.log(chalk.green('✓ Web bundles directory cleaned'));
} else {
console.log(chalk.yellow('Web bundles directory does not exist'));
}
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
});
// Parse command line arguments
program.parse(process.argv);
// Show help if no command provided
if (process.argv.slice(2).length === 0) {
program.outputHelp();
}

View File

@@ -0,0 +1,28 @@
const { WebBundler } = require('./web-bundler');
const chalk = require('chalk');
const path = require('node:path');
async function testAnalystBundle() {
console.log(chalk.cyan.bold('\n🧪 Testing Analyst Agent Bundle\n'));
try {
const bundler = new WebBundler();
// Load web activation first
await bundler.loadWebActivation();
// Bundle just the analyst agent from bmm module
// Only bundle the analyst for testing
const agentPath = path.join(bundler.modulesPath, 'bmm', 'agents', 'analyst.md');
await bundler.bundleAgent('bmm', 'analyst.md');
console.log(chalk.green.bold('\n✅ Test completed successfully!\n'));
} catch (error) {
console.error(chalk.red('\n❌ Test failed:'), error.message);
console.error(error.stack);
process.exit(1);
}
}
// Run test
testAnalystBundle();

View File

@@ -0,0 +1,118 @@
const { WebBundler } = require('./web-bundler');
const chalk = require('chalk');
const fs = require('fs-extra');
const path = require('node:path');
async function testWebBundler() {
console.log(chalk.cyan.bold('\n🧪 Testing Web Bundler\n'));
const bundler = new WebBundler();
let passedTests = 0;
let failedTests = 0;
// Test 1: Load web activation
try {
await bundler.loadWebActivation();
console.log(chalk.green('✓ Web activation loaded successfully'));
passedTests++;
} catch (error) {
console.error(chalk.red('✗ Failed to load web activation:'), error.message);
failedTests++;
}
// Test 2: Discover modules
try {
const modules = await bundler.discoverModules();
console.log(chalk.green(`✓ Discovered ${modules.length} modules:`, modules.join(', ')));
passedTests++;
} catch (error) {
console.error(chalk.red('✗ Failed to discover modules:'), error.message);
failedTests++;
}
// Test 3: Bundle analyst agent
try {
const result = await bundler.bundleAgent('bmm', 'analyst.md');
// Check if bundle was created
const bundlePath = path.join(bundler.outputDir, 'bmm', 'agents', 'analyst.xml');
if (await fs.pathExists(bundlePath)) {
const content = await fs.readFile(bundlePath, 'utf8');
// Validate bundle structure
const hasAgent = content.includes('<agent');
const hasActivation = content.includes('<activation');
const hasPersona = content.includes('<persona>');
const activationBeforePersona = content.indexOf('<activation') < content.indexOf('<persona>');
const hasManifests =
content.includes('<agent-party id="bmad/_cfg/agent-party.xml">') && content.includes('<manifest id="bmad/web-manifest.xml">');
const hasDependencies = content.includes('<dependencies>');
console.log(chalk.green('✓ Analyst bundle created successfully'));
console.log(chalk.gray(` - Has agent tag: ${hasAgent ? '✓' : '✗'}`));
console.log(chalk.gray(` - Has activation: ${hasActivation ? '✓' : '✗'}`));
console.log(chalk.gray(` - Has persona: ${hasPersona ? '✓' : '✗'}`));
console.log(chalk.gray(` - Activation before persona: ${activationBeforePersona ? '✓' : '✗'}`));
console.log(chalk.gray(` - Has manifests: ${hasManifests ? '✓' : '✗'}`));
console.log(chalk.gray(` - Has dependencies: ${hasDependencies ? '✓' : '✗'}`));
if (hasAgent && hasActivation && hasPersona && activationBeforePersona && hasManifests && hasDependencies) {
passedTests++;
} else {
console.error(chalk.red('✗ Bundle structure validation failed'));
failedTests++;
}
} else {
console.error(chalk.red('✗ Bundle file not created'));
failedTests++;
}
} catch (error) {
console.error(chalk.red('✗ Failed to bundle analyst agent:'), error.message);
failedTests++;
}
// Test 4: Bundle a different agent (architect which exists)
try {
const result = await bundler.bundleAgent('bmm', 'architect.md');
const bundlePath = path.join(bundler.outputDir, 'bmm', 'agents', 'architect.xml');
if (await fs.pathExists(bundlePath)) {
console.log(chalk.green('✓ Architect bundle created successfully'));
passedTests++;
} else {
console.error(chalk.red('✗ Architect bundle file not created'));
failedTests++;
}
} catch (error) {
console.error(chalk.red('✗ Failed to bundle architect agent:'), error.message);
failedTests++;
}
// Test 5: Bundle all agents in a module
try {
const results = await bundler.bundleModule('bmm');
console.log(chalk.green(`✓ Bundled ${results.agents.length} agents from bmm module`));
passedTests++;
} catch (error) {
console.error(chalk.red('✗ Failed to bundle bmm module:'), error.message);
failedTests++;
}
// Summary
console.log(chalk.bold('\n📊 Test Results:'));
console.log(chalk.green(` Passed: ${passedTests}`));
console.log(chalk.red(` Failed: ${failedTests}`));
if (failedTests === 0) {
console.log(chalk.green.bold('\n✅ All tests passed!\n'));
} else {
console.log(chalk.red.bold(`\n${failedTests} test(s) failed\n`));
process.exit(1);
}
}
// Run tests
testWebBundler().catch((error) => {
console.error(chalk.red('Fatal error:'), error);
process.exit(1);
});

View File

@@ -0,0 +1,880 @@
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { DependencyResolver } = require('../installers/lib/core/dependency-resolver');
const { XmlHandler } = require('../lib/xml-handler');
const { AgentPartyGenerator } = require('../lib/agent-party-generator');
const xml2js = require('xml2js');
const { getProjectRoot, getSourcePath, getModulePath } = require('../lib/project-root');
class WebBundler {
constructor(sourceDir = null, outputDir = 'web-bundles') {
this.sourceDir = sourceDir || getSourcePath();
this.outputDir = path.isAbsolute(outputDir) ? outputDir : path.join(getProjectRoot(), outputDir);
this.modulesPath = getSourcePath('modules');
this.utilityPath = getSourcePath('utility');
this.dependencyResolver = new DependencyResolver();
this.xmlHandler = new XmlHandler();
// Cache for resolved dependencies to avoid duplicates
this.dependencyCache = new Map();
// Discovered agents and teams for manifest generation
this.discoveredAgents = [];
this.discoveredTeams = [];
// Temporary directory for generated manifests
this.tempDir = path.join(process.cwd(), '.bundler-temp');
this.tempManifestDir = path.join(this.tempDir, 'bmad', '_cfg');
// Bundle statistics
this.stats = {
totalAgents: 0,
bundledAgents: 0,
skippedAgents: 0,
failedAgents: 0,
invalidXml: 0,
warnings: [],
};
}
/**
* Main entry point to bundle all modules
*/
async bundleAll() {
console.log(chalk.cyan.bold('═══════════════════════════════════════════════'));
console.log(chalk.cyan.bold(' 🚀 Web Bundle Generation'));
console.log(chalk.cyan.bold('═══════════════════════════════════════════════\n'));
try {
// Pre-discover all modules to generate complete manifests
const modules = await this.discoverModules();
for (const module of modules) {
await this.preDiscoverModule(module);
}
// Create temporary manifest files
await this.createTempManifests();
// Process all modules
for (const module of modules) {
await this.bundleModule(module);
}
// Display summary
this.displaySummary();
} finally {
// Clean up temp files
await this.cleanupTempFiles();
}
}
/**
* Bundle a specific module
*/
async bundleModule(moduleName) {
const modulePath = path.join(this.modulesPath, moduleName);
if (!(await fs.pathExists(modulePath))) {
console.log(chalk.yellow(`Module ${moduleName} not found`));
return { module: moduleName, agents: [], teams: [] };
}
console.log(chalk.bold(`\n📦 Bundling module: ${moduleName}`));
const results = {
module: moduleName,
agents: [],
teams: [],
};
// Pre-discover all agents and teams for manifest generation
await this.preDiscoverModule(moduleName);
// Ensure temp manifests exist (might not exist if called directly)
if (!(await fs.pathExists(this.tempManifestDir))) {
await this.createTempManifests();
}
// Process agents
const agents = await this.discoverAgents(modulePath);
for (const agent of agents) {
try {
await this.bundleAgent(moduleName, agent, false); // false = don't track again
results.agents.push(agent);
} catch (error) {
console.error(` Failed to bundle agent ${agent}:`, error.message);
}
}
// Process teams (Phase 4 - to be implemented)
// const teams = await this.discoverTeams(modulePath);
// for (const team of teams) {
// try {
// await this.bundleTeam(moduleName, team);
// results.teams.push(team);
// } catch (error) {
// console.error(` Failed to bundle team ${team}:`, error.message);
// }
// }
return results;
}
/**
* Bundle a single agent
*/
async bundleAgent(moduleName, agentFile, shouldTrack = true) {
const agentName = path.basename(agentFile, '.md');
this.stats.totalAgents++;
console.log(chalk.dim(` → Processing: ${agentName}`));
const agentPath = path.join(this.modulesPath, moduleName, 'agents', agentFile);
// Check if agent file exists
if (!(await fs.pathExists(agentPath))) {
this.stats.failedAgents++;
console.log(chalk.red(` ✗ Agent file not found`));
throw new Error(`Agent file not found: ${agentPath}`);
}
// Read agent file
const content = await fs.readFile(agentPath, 'utf8');
// Extract agent XML from markdown
let agentXml = this.extractAgentXml(content);
if (!agentXml) {
this.stats.failedAgents++;
console.log(chalk.red(` ✗ No agent XML found in ${agentFile}`));
return;
}
// Check if agent has bundle="false" attribute
if (this.shouldSkipBundling(agentXml)) {
this.stats.skippedAgents++;
console.log(chalk.gray(` ⊘ Skipped (bundle="false")`));
return;
}
// Process {project-root} references in agent XML
agentXml = this.processProjectRootReferences(agentXml);
// Track for manifest generation BEFORE generating manifests (if not pre-discovered)
if (shouldTrack) {
const agentDetails = AgentPartyGenerator.extractAgentDetails(content, moduleName, agentName);
if (agentDetails) {
this.discoveredAgents.push(agentDetails);
}
}
// Resolve dependencies with warning tracking
const dependencyWarnings = [];
const dependencies = await this.resolveAgentDependencies(agentXml, moduleName, dependencyWarnings);
if (dependencyWarnings.length > 0) {
this.stats.warnings.push({ agent: agentName, warnings: dependencyWarnings });
}
// Build the bundle (no manifests for individual agents)
const bundle = this.buildAgentBundle(agentXml, dependencies);
// Validate XML
const isValid = await this.validateXml(bundle);
if (!isValid) {
this.stats.invalidXml++;
console.log(chalk.red(` ⚠ Invalid XML generated!`));
}
// Write bundle to output
const outputPath = path.join(this.outputDir, moduleName, 'agents', `${agentName}.xml`);
await fs.ensureDir(path.dirname(outputPath));
await fs.writeFile(outputPath, bundle, 'utf8');
this.stats.bundledAgents++;
const statusIcon = isValid ? chalk.green('✓') : chalk.yellow('⚠');
console.log(` ${statusIcon} Bundled: ${agentName}.xml${isValid ? '' : chalk.yellow(' (invalid XML)')}`);
}
/**
* Pre-discover all agents and teams in a module for manifest generation
*/
async preDiscoverModule(moduleName) {
const modulePath = path.join(this.modulesPath, moduleName);
// Clear any previously discovered agents for this module
this.discoveredAgents = this.discoveredAgents.filter((a) => a.module !== moduleName);
// Discover agents
const agentsPath = path.join(modulePath, 'agents');
if (await fs.pathExists(agentsPath)) {
const files = await fs.readdir(agentsPath);
for (const file of files) {
if (file.endsWith('.md')) {
const agentPath = path.join(agentsPath, file);
const content = await fs.readFile(agentPath, 'utf8');
const agentXml = this.extractAgentXml(content);
if (agentXml) {
// Skip agents with bundle="false"
if (this.shouldSkipBundling(agentXml)) {
continue;
}
const agentName = path.basename(file, '.md');
// Use the shared generator to extract agent details (pass full content)
const agentDetails = AgentPartyGenerator.extractAgentDetails(content, moduleName, agentName);
if (agentDetails) {
this.discoveredAgents.push(agentDetails);
}
}
}
}
}
// TODO: Discover teams when implemented
}
/**
* Extract agent XML from markdown content
*/
extractAgentXml(content) {
// Try 4 backticks first (can contain 3 backtick blocks inside)
let match = content.match(/````xml\s*([\s\S]*?)````/);
if (!match) {
// Fall back to 3 backticks if no 4-backtick block found
match = content.match(/```xml\s*([\s\S]*?)```/);
}
if (match) {
const xmlContent = match[1];
const agentMatch = xmlContent.match(/<agent[^>]*>[\s\S]*?<\/agent>/);
return agentMatch ? agentMatch[0] : null;
}
// Fall back to direct extraction
match = content.match(/<agent[^>]*>[\s\S]*?<\/agent>/);
return match ? match[0] : null;
}
/**
* Resolve all dependencies for an agent
*/
async resolveAgentDependencies(agentXml, moduleName, warnings = []) {
const dependencies = new Map();
const processed = new Set();
// Extract file references from agent XML
const fileRefs = this.extractFileReferences(agentXml);
// Process each file reference
for (const ref of fileRefs) {
await this.processFileDependency(ref, dependencies, processed, moduleName, warnings);
}
return dependencies;
}
/**
* Extract file references from agent XML
*/
extractFileReferences(xml) {
const refs = new Set();
// Match various file reference patterns
const patterns = [
/exec="([^"]+)"/g, // Command exec paths
/tmpl="([^"]+)"/g, // Template paths
/data="([^"]+)"/g, // Data file paths
/file="([^"]+)"/g, // Generic file refs
/src="([^"]+)"/g, // Source paths
/system-prompts="([^"]+)"/g,
/tools="([^"]+)"/g,
/workflows="([^"]+)"/g,
/knowledge="([^"]+)"/g,
/{project-root}\/([^"'\s<>]+)/g,
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(xml)) !== null) {
let filePath = match[1];
// Remove {project-root} prefix if present
filePath = filePath.replace(/^{project-root}\//, '');
if (filePath) {
refs.add(filePath);
}
}
}
return [...refs];
}
/**
* Process a file dependency recursively
*/
async processFileDependency(filePath, dependencies, processed, moduleName, warnings = []) {
// Skip if already processed
if (processed.has(filePath)) {
return;
}
processed.add(filePath);
// Handle wildcard patterns
if (filePath.includes('*')) {
await this.processWildcardDependency(filePath, dependencies, processed, moduleName, warnings);
return;
}
// Resolve actual file path
const actualPath = this.resolveFilePath(filePath, moduleName);
if (!actualPath || !(await fs.pathExists(actualPath))) {
warnings.push(filePath);
return;
}
// Read file content
let content = await fs.readFile(actualPath, 'utf8');
// Process {project-root} references
content = this.processProjectRootReferences(content);
// Extract dependencies from frontmatter if present
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
// Look for dependencies in frontmatter
const depMatch = frontmatter.match(/dependencies:\s*\[(.*?)\]/);
if (depMatch) {
const deps = depMatch[1].match(/['"]([^'"]+)['"]/g);
if (deps) {
for (const dep of deps) {
const depPath = dep.replaceAll(/['"]/g, '').replace(/^{project-root}\//, '');
if (depPath && !processed.has(depPath)) {
await this.processFileDependency(depPath, dependencies, processed, moduleName, warnings);
}
}
}
}
// Look for template references
const templateMatch = frontmatter.match(/template:\s*\[(.*?)\]/);
if (templateMatch) {
const templates = templateMatch[1].match(/['"]([^'"]+)['"]/g);
if (templates) {
for (const template of templates) {
const templatePath = template.replaceAll(/['"]/g, '').replace(/^{project-root}\//, '');
if (templatePath && !processed.has(templatePath)) {
await this.processFileDependency(templatePath, dependencies, processed, moduleName, warnings);
}
}
}
}
}
// Extract XML from markdown if applicable
const ext = path.extname(actualPath).toLowerCase();
let processedContent = content;
switch (ext) {
case '.md': {
// Try to extract XML from markdown - handle both 3 and 4 backtick blocks
// First try 4 backticks (which can contain 3 backtick blocks inside)
let xmlMatches = [...content.matchAll(/````xml\s*([\s\S]*?)````/g)];
// If no 4-backtick blocks, try 3 backticks
if (xmlMatches.length === 0) {
xmlMatches = [...content.matchAll(/```xml\s*([\s\S]*?)```/g)];
}
const xmlBlocks = [];
for (const match of xmlMatches) {
if (match[1]) {
xmlBlocks.push(match[1].trim());
}
}
if (xmlBlocks.length > 0) {
// For XML content, just include it directly (it's already valid XML)
processedContent = xmlBlocks.join('\n\n');
} else {
// No XML blocks found, skip non-XML markdown files
return;
}
break;
}
case '.csv': {
// CSV files need special handling - convert to XML file-index
const lines = content.split('\n').filter((line) => line.trim());
if (lines.length === 0) return;
const headers = lines[0].split(',').map((h) => h.trim());
const rows = lines.slice(1);
const indexParts = [`<file-index id="${filePath}">`];
indexParts.push(' <items>');
// Track files referenced in CSV for additional bundling
const referencedFiles = new Set();
for (const row of rows) {
const values = row.split(',').map((v) => v.trim());
if (values.every((v) => !v)) continue;
indexParts.push(' <item>');
for (const [i, header] of headers.entries()) {
const value = values[i] || '';
const tagName = header.toLowerCase().replaceAll(/[^a-z0-9]/g, '_');
indexParts.push(` <${tagName}>${value}</${tagName}>`);
// Track referenced files
if (header.toLowerCase().includes('file') && value.endsWith('.md')) {
// Build path relative to CSV location
const csvDir = path.dirname(actualPath);
const refPath = path.join(csvDir, value);
if (fs.existsSync(refPath)) {
const refId = filePath.replace('index.csv', value);
referencedFiles.add(refId);
}
}
}
indexParts.push(' </item>');
}
indexParts.push(' </items>', '</file-index>');
// Store the XML version
dependencies.set(filePath, indexParts.join('\n'));
// Process referenced files from CSV
for (const refId of referencedFiles) {
if (!processed.has(refId)) {
await this.processFileDependency(refId, dependencies, processed, moduleName, warnings);
}
}
return;
}
case '.xml': {
// XML files can be included directly
processedContent = content;
break;
}
default: {
// For other non-XML file types, skip them
return;
}
}
// Store the processed content
dependencies.set(filePath, processedContent);
// Recursively scan for more dependencies
const nestedRefs = this.extractFileReferences(processedContent);
for (const ref of nestedRefs) {
await this.processFileDependency(ref, dependencies, processed, moduleName, warnings);
}
}
/**
* Process wildcard dependency patterns
*/
async processWildcardDependency(pattern, dependencies, processed, moduleName, warnings = []) {
// Remove {project-root} prefix
pattern = pattern.replace(/^{project-root}\//, '');
// Get directory and file pattern
const lastSlash = pattern.lastIndexOf('/');
const dirPath = pattern.slice(0, Math.max(0, lastSlash));
const filePattern = pattern.slice(Math.max(0, lastSlash + 1));
// Resolve directory path without checking file existence
let dir;
if (dirPath.startsWith('bmad/')) {
// Remove bmad/ prefix
const actualPath = dirPath.replace(/^bmad\//, '');
// Try different path mappings for directories
const possibleDirs = [
// Try as module path: bmad/cis/... -> src/modules/cis/...
path.join(this.sourceDir, 'modules', actualPath),
// Try as direct path: bmad/core/... -> src/core/...
path.join(this.sourceDir, actualPath),
];
for (const testDir of possibleDirs) {
if (fs.existsSync(testDir)) {
dir = testDir;
break;
}
}
}
if (!dir) {
warnings.push(`${pattern} (could not resolve directory)`);
return;
}
if (!(await fs.pathExists(dir))) {
warnings.push(pattern);
return;
}
// Read directory and match files
const files = await fs.readdir(dir);
let matchedFiles = [];
if (filePattern === '*.*') {
matchedFiles = files;
} else if (filePattern.startsWith('*.')) {
const ext = filePattern.slice(1);
matchedFiles = files.filter((f) => f.endsWith(ext));
} else {
// Simple glob matching
const regex = new RegExp('^' + filePattern.replace('*', '.*') + '$');
matchedFiles = files.filter((f) => regex.test(f));
}
// Process each matched file
for (const file of matchedFiles) {
const fullPath = dirPath + '/' + file;
if (!processed.has(fullPath)) {
await this.processFileDependency(fullPath, dependencies, processed, moduleName, warnings);
}
}
}
/**
* Resolve file path relative to project
*/
resolveFilePath(filePath, moduleName) {
// Remove {project-root} prefix
filePath = filePath.replace(/^{project-root}\//, '');
// Check temp directory first for _cfg files
if (filePath.startsWith('bmad/_cfg/')) {
const filename = filePath.split('/').pop();
const tempPath = path.join(this.tempManifestDir, filename);
if (fs.existsSync(tempPath)) {
return tempPath;
}
}
// Handle different path patterns for bmad files
// bmad/cis/tasks/brain-session.md -> src/modules/cis/tasks/brain-session.md
// bmad/core/tasks/create-doc.md -> src/core/tasks/create-doc.md
// bmad/bmm/templates/brief.md -> src/modules/bmm/templates/brief.md
let actualPath = filePath;
if (filePath.startsWith('bmad/')) {
// Remove bmad/ prefix
actualPath = filePath.replace(/^bmad\//, '');
// Check if it's a module-specific file (cis, bmm, etc) or core file
const parts = actualPath.split('/');
const firstPart = parts[0];
// Try different path mappings
const possiblePaths = [
// Try in temp directory first
path.join(this.tempDir, filePath),
// Try as module path: bmad/cis/... -> src/modules/cis/...
path.join(this.sourceDir, 'modules', actualPath),
// Try as direct path: bmad/core/... -> src/core/...
path.join(this.sourceDir, actualPath),
// Try without any prefix in src
path.join(this.sourceDir, parts.slice(1).join('/')),
// Try in project root
path.join(this.sourceDir, '..', actualPath),
// Try original with bmad
path.join(this.sourceDir, '..', filePath),
];
for (const testPath of possiblePaths) {
if (fs.existsSync(testPath)) {
return testPath;
}
}
}
// Try standard paths for non-bmad files
const basePaths = [
this.sourceDir, // src directory
path.join(this.modulesPath, moduleName), // Current module
path.join(this.sourceDir, '..'), // Project root
];
for (const basePath of basePaths) {
const fullPath = path.join(basePath, actualPath);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
}
/**
* Process and remove {project-root} references
*/
processProjectRootReferences(content) {
// Remove {project-root}/ prefix (with slash)
content = content.replaceAll('{project-root}/', '');
// Also remove {project-root} without slash
content = content.replaceAll('{project-root}', '');
return content;
}
/**
* Escape special XML characters in text content
*/
escapeXmlText(text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
/**
* Escape XML content while preserving XML tags
*/
escapeXmlContent(content) {
const tagPattern = /<([^>]+)>/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = tagPattern.exec(content)) !== null) {
if (match.index > lastIndex) {
parts.push(this.escapeXmlText(content.slice(lastIndex, match.index)));
}
parts.push('<' + match[1] + '>');
lastIndex = match.index + match[0].length;
}
if (lastIndex < content.length) {
parts.push(this.escapeXmlText(content.slice(lastIndex)));
}
return parts.join('');
}
/**
* Build the final agent bundle XML
*/
buildAgentBundle(agentXml, dependencies) {
const parts = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<agent-bundle>',
' <!-- Agent Definition -->',
' ' + agentXml.replaceAll('\n', '\n '),
];
// Add dependencies without wrapper tags
if (dependencies && dependencies.size > 0) {
parts.push('\n <!-- Dependencies -->');
for (const [id, content] of dependencies) {
// Escape XML content while preserving tags
const escapedContent = this.escapeXmlContent(content);
// Indent properly
const indentedContent = escapedContent
.split('\n')
.map((line) => ' ' + line)
.join('\n');
parts.push(indentedContent);
}
}
parts.push('</agent-bundle>');
return parts.join('\n');
}
/**
* Discover all modules
*/
async discoverModules() {
const modules = [];
if (!(await fs.pathExists(this.modulesPath))) {
console.log(chalk.yellow('No modules directory found'));
return modules;
}
const entries = await fs.readdir(this.modulesPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
modules.push(entry.name);
}
}
return modules;
}
/**
* Discover agents in a module
*/
async discoverAgents(modulePath) {
const agents = [];
const agentsPath = path.join(modulePath, 'agents');
if (!(await fs.pathExists(agentsPath))) {
return agents;
}
const files = await fs.readdir(agentsPath);
for (const file of files) {
if (file.endsWith('.md')) {
agents.push(file);
}
}
return agents;
}
/**
* Discover all teams in a module
*/
async discoverTeams(modulePath) {
const teams = [];
const teamsPath = path.join(modulePath, 'teams');
if (!(await fs.pathExists(teamsPath))) {
return teams;
}
const files = await fs.readdir(teamsPath);
for (const file of files) {
if (file.endsWith('.md')) {
teams.push(file);
}
}
return teams;
}
/**
* Extract agent name from XML
*/
getAgentName(xml) {
const match = xml.match(/<agent[^>]*name="([^"]+)"/);
return match ? match[1] : 'Unknown';
}
/**
* Extract agent description from XML
*/
getAgentDescription(xml) {
const match = xml.match(/<description>([^<]+)<\/description>/);
return match ? match[1] : '';
}
/**
* Check if agent should be skipped for bundling
*/
shouldSkipBundling(xml) {
// Check for bundle="false" attribute in the agent tag
const match = xml.match(/<agent[^>]*bundle="false"[^>]*>/);
return match !== null;
}
/**
* Create temporary manifest files
*/
async createTempManifests() {
// Ensure temp directory exists
await fs.ensureDir(this.tempManifestDir);
// Generate agent-party.xml using shared generator
const agentPartyPath = path.join(this.tempManifestDir, 'agent-party.xml');
await AgentPartyGenerator.writeAgentParty(agentPartyPath, this.discoveredAgents, { forWeb: true });
console.log(chalk.dim(' ✓ Created temporary manifest files'));
}
/**
* Clean up temporary files
*/
async cleanupTempFiles() {
if (await fs.pathExists(this.tempDir)) {
await fs.remove(this.tempDir);
console.log(chalk.dim('\n✓ Cleaned up temporary files'));
}
}
/**
* Validate XML content
*/
async validateXml(xmlContent) {
try {
await xml2js.parseStringPromise(xmlContent, {
strict: true,
explicitArray: false,
});
return true;
} catch {
return false;
}
}
/**
* Display summary statistics
*/
displaySummary() {
console.log(chalk.cyan.bold('\n═══════════════════════════════════════════════'));
console.log(chalk.cyan.bold(' SUMMARY'));
console.log(chalk.cyan.bold('═══════════════════════════════════════════════\n'));
console.log(chalk.bold('Bundle Statistics:'));
console.log(` Total agents found: ${this.stats.totalAgents}`);
console.log(` Successfully bundled: ${chalk.green(this.stats.bundledAgents)}`);
console.log(` Skipped (bundle=false): ${chalk.gray(this.stats.skippedAgents)}`);
if (this.stats.failedAgents > 0) {
console.log(` Failed to bundle: ${chalk.red(this.stats.failedAgents)}`);
}
if (this.stats.invalidXml > 0) {
console.log(` Invalid XML bundles: ${chalk.yellow(this.stats.invalidXml)}`);
}
// Display warnings summary
if (this.stats.warnings.length > 0) {
console.log(chalk.yellow('\n⚠ Missing Dependencies by Agent:'));
// Group and display warnings by agent
for (const agentWarning of this.stats.warnings) {
if (agentWarning.warnings.length > 0) {
console.log(chalk.bold(`\n ${agentWarning.agent}:`));
// Display unique warnings for this agent
const uniqueWarnings = [...new Set(agentWarning.warnings)];
for (const warning of uniqueWarnings) {
console.log(chalk.dim(`${warning}`));
}
}
}
}
// Final status
if (this.stats.invalidXml > 0) {
console.log(chalk.yellow('\n⚠ Some bundles have invalid XML. Please review the output.'));
} else if (this.stats.failedAgents > 0) {
console.log(chalk.yellow('\n⚠ Some agents failed to bundle. Please review the errors.'));
} else {
console.log(chalk.green('\n✨ All bundles generated successfully!'));
}
console.log(chalk.cyan.bold('\n═══════════════════════════════════════════════\n'));
}
}
module.exports = { WebBundler };

View File

@@ -0,0 +1,41 @@
const chalk = require('chalk');
const path = require('node:path');
const { Installer } = require('../installers/lib/core/installer');
const { UI } = require('../lib/ui');
const installer = new Installer();
const ui = new UI();
module.exports = {
command: 'install',
description: 'Install BMAD Core agents and tools',
options: [],
action: async () => {
try {
const config = await ui.promptInstall();
const result = await installer.install(config);
console.log(chalk.green('\n✨ Installation complete!'));
console.log(
chalk.cyan('BMAD Core and Selected Modules have been installed to:'),
chalk.bold(result.path || path.resolve(config.directory, 'bmad')),
);
console.log(chalk.yellow('\nThank you for helping test the early release version of the new BMad Core and BMad Method!'));
console.log(chalk.cyan('Check docs/alpha-release-notes.md in this repository for important information.'));
process.exit(0);
} catch (error) {
// Check if error has a complete formatted message
if (error.fullMessage) {
console.error(error.fullMessage);
if (error.stack) {
console.error('\n' + chalk.dim(error.stack));
}
} else {
// Generic error handling for all other errors
console.error(chalk.red('Installation failed:'), error.message);
console.error(chalk.dim(error.stack));
}
process.exit(1);
}
},
};

View File

@@ -0,0 +1,28 @@
const chalk = require('chalk');
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
module.exports = {
command: 'list',
description: 'List available modules',
options: [],
action: async () => {
try {
const modules = await installer.getAvailableModules();
console.log(chalk.cyan('\n📦 Available BMAD Modules:\n'));
for (const module of modules) {
console.log(chalk.bold(` ${module.id}`));
console.log(chalk.dim(` ${module.description}`));
console.log(chalk.dim(` Version: ${module.version}`));
console.log();
}
process.exit(0);
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
},
};

View File

@@ -0,0 +1,47 @@
const chalk = require('chalk');
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
module.exports = {
command: 'status',
description: 'Show installation status',
options: [['-d, --directory <path>', 'Installation directory', '.']],
action: async (options) => {
try {
const status = await installer.getStatus(options.directory);
if (!status.installed) {
console.log(chalk.yellow('\n⚠ No BMAD installation found in:'), options.directory);
console.log(chalk.dim('Run "bmad install" to set up BMAD Method'));
process.exit(0);
}
console.log(chalk.cyan('\n📊 BMAD Installation Status\n'));
console.log(chalk.bold('Location:'), status.path);
console.log(chalk.bold('Version:'), status.version);
console.log(chalk.bold('Core:'), status.hasCore ? chalk.green('✓ Installed') : chalk.red('✗ Not installed'));
if (status.modules.length > 0) {
console.log(chalk.bold('\nModules:'));
for (const mod of status.modules) {
console.log(` ${chalk.green('✓')} ${mod.id} (v${mod.version})`);
}
} else {
console.log(chalk.bold('\nModules:'), chalk.dim('None installed'));
}
if (status.ides.length > 0) {
console.log(chalk.bold('\nConfigured IDEs:'));
for (const ide of status.ides) {
console.log(` ${chalk.green('✓')} ${ide}`);
}
}
process.exit(0);
} catch (error) {
console.error(chalk.red('Error:'), error.message);
process.exit(1);
}
},
};

View File

@@ -0,0 +1,44 @@
const chalk = require('chalk');
const path = require('node:path');
const { Installer } = require('../installers/lib/core/installer');
const { UI } = require('../lib/ui');
const installer = new Installer();
const ui = new UI();
module.exports = {
command: 'uninstall',
description: 'Remove BMAD installation',
options: [
['-d, --directory <path>', 'Installation directory', '.'],
['--force', 'Skip confirmation prompt'],
],
action: async (options) => {
try {
const bmadPath = path.join(options.directory, 'bmad');
if (!options.force) {
const { confirm } = await ui.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Are you sure you want to remove BMAD from ${bmadPath}?`,
default: false,
},
]);
if (!confirm) {
console.log('Uninstall cancelled.');
process.exit(0);
}
}
await installer.uninstall(options.directory);
console.log(chalk.green('\n✨ BMAD Method has been uninstalled.'));
process.exit(0);
} catch (error) {
console.error(chalk.red('Uninstall failed:'), error.message);
process.exit(1);
}
},
};

View File

@@ -0,0 +1,28 @@
const chalk = require('chalk');
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
module.exports = {
command: 'update',
description: 'Update existing BMAD installation',
options: [
['-d, --directory <path>', 'Installation directory', '.'],
['--force', 'Force update, overwriting modified files'],
['--dry-run', 'Show what would be updated without making changes'],
],
action: async (options) => {
try {
await installer.update({
directory: options.directory,
force: options.force,
dryRun: options.dryRun,
});
console.log(chalk.green('\n✨ Update complete!'));
process.exit(0);
} catch (error) {
console.error(chalk.red('Update failed:'), error.message);
process.exit(1);
}
},
};

View File

@@ -0,0 +1,383 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const chalk = require('chalk');
const inquirer = require('inquirer');
const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils');
class ConfigCollector {
constructor() {
this.collectedConfig = {};
this.existingConfig = null;
}
/**
* Load existing config if it exists from module config files
* @param {string} projectDir - Target project directory
*/
async loadExistingConfig(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
this.existingConfig = {};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return false;
}
// Try to load existing module configs
const modules = ['core', 'bmm', 'cis'];
let foundAny = false;
for (const moduleName of modules) {
const moduleConfigPath = path.join(bmadDir, moduleName, 'config.yaml');
if (await fs.pathExists(moduleConfigPath)) {
try {
const content = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.load(content);
if (moduleConfig) {
this.existingConfig[moduleName] = moduleConfig;
foundAny = true;
}
} catch {
// Ignore parse errors for individual modules
}
}
}
if (foundAny) {
console.log(chalk.cyan('\n📋 Found existing BMAD module configurations'));
}
return foundAny;
}
/**
* Collect configuration for all modules
* @param {Array} modules - List of modules to configure (including 'core')
* @param {string} projectDir - Target project directory
*/
async collectAllConfigurations(modules, projectDir) {
await this.loadExistingConfig(projectDir);
// Check if core was already collected (e.g., in early collection phase)
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
// If core wasn't already collected, include it
const allModules = coreAlreadyCollected ? modules.filter((m) => m !== 'core') : ['core', ...modules.filter((m) => m !== 'core')];
// Store all answers across modules for cross-referencing
if (!this.allAnswers) {
this.allAnswers = {};
}
for (const moduleName of allModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
// Add metadata
this.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
installDate: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
return this.collectedConfig;
}
/**
* Collect configuration for a single module
* @param {string} moduleName - Module name
* @param {string} projectDir - Target project directory
* @param {boolean} skipLoadExisting - Skip loading existing config (for early core collection)
* @param {boolean} skipCompletion - Skip showing completion message (for early core collection)
*/
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
// Load existing config if needed and not already loaded
if (!skipLoadExisting && !this.existingConfig) {
await this.loadExistingConfig(projectDir);
}
// Initialize allAnswers if not already initialized
if (!this.allAnswers) {
this.allAnswers = {};
}
// Load module's config.yaml (check new location first, then fallback)
const installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-menu-config.yaml');
const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(legacyConfigPath)) {
configPath = legacyConfigPath;
} else {
// No config for this module
return;
}
const configContent = await fs.readFile(configPath, 'utf8');
const moduleConfig = yaml.load(configContent);
if (!moduleConfig) {
return;
}
// Display module prompts using better formatting
if (moduleConfig.prompt) {
const prompts = Array.isArray(moduleConfig.prompt) ? moduleConfig.prompt : [moduleConfig.prompt];
CLIUtils.displayPromptSection(prompts);
}
// Process each config item
const questions = [];
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
for (const key of configKeys) {
const item = moduleConfig[key];
// Skip if not a config object
if (!item || typeof item !== 'object' || !item.prompt) {
continue;
}
const question = await this.buildQuestion(moduleName, key, item);
if (question) {
questions.push(question);
}
}
if (questions.length > 0) {
console.log(); // Line break before questions
const answers = await inquirer.prompt(questions);
// Store answers for cross-referencing
Object.assign(this.allAnswers, answers);
// Process answers and build result values
for (const key of Object.keys(answers)) {
const originalKey = key.replace(`${moduleName}_`, '');
const item = moduleConfig[originalKey];
const value = answers[key];
// Build the result using the template
let result;
// For arrays (multi-select), handle differently
if (Array.isArray(value)) {
// If there's a result template and it's a string, don't use it for arrays
// Just use the array value directly
result = value;
} else if (item.result) {
result = item.result;
// Replace placeholders only for strings
if (typeof result === 'string' && value !== undefined) {
// Replace {value} with the actual value
if (typeof value === 'string') {
result = result.replace('{value}', value);
} else if (typeof value === 'boolean' || typeof value === 'number') {
// For boolean and number values, if result is just "{value}", use the raw value
if (result === '{value}') {
result = value;
} else {
// Otherwise replace in the string
result = result.replace('{value}', value);
}
} else {
// For non-string values, use directly
result = value;
}
// Only do further replacements if result is still a string
if (typeof result === 'string') {
// Replace references to other config values
result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => {
// Check if it's a special placeholder
if (configKey === 'project-root') {
return '{project-root}';
}
// Skip if it's the 'value' placeholder we already handled
if (configKey === 'value') {
return match;
}
// Look for the config value across all modules
// First check if it's in the current module's answers
let configValue = answers[`${moduleName}_${configKey}`];
// Then check all answers (for cross-module references like outputFolder)
if (!configValue) {
// Try with various module prefixes
for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) {
if (answerKey.endsWith(`_${configKey}`)) {
configValue = answerValue;
break;
}
}
}
// Check in already collected config
if (!configValue) {
for (const mod of Object.keys(this.collectedConfig)) {
if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) {
configValue = this.collectedConfig[mod][configKey];
// Extract just the value part if it's a result template
if (typeof configValue === 'string' && configValue.includes('{project-root}/')) {
configValue = configValue.replace('{project-root}/', '');
}
break;
}
}
}
return configValue || match;
});
}
}
} else {
// No result template, use value directly
result = value;
}
// Store only the result value (no prompts, defaults, examples, etc.)
if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {};
}
this.collectedConfig[moduleName][originalKey] = result;
}
// Display module completion message after collecting all answers (unless skipped)
if (!skipCompletion) {
CLIUtils.displayModuleComplete(moduleName);
}
}
}
/**
* Build an inquirer question from a config item
* @param {string} moduleName - Module name
* @param {string} key - Config key
* @param {Object} item - Config item definition
*/
async buildQuestion(moduleName, key, item) {
const questionName = `${moduleName}_${key}`;
// Check for existing value
let existingValue = null;
if (this.existingConfig && this.existingConfig[moduleName]) {
existingValue = this.existingConfig[moduleName][key];
// Clean up existing value - remove {project-root}/ prefix if present
// This prevents duplication when the result template adds it back
if (typeof existingValue === 'string' && existingValue.startsWith('{project-root}/')) {
existingValue = existingValue.replace('{project-root}/', '');
}
}
// Determine question type and default value
let questionType = 'input';
let defaultValue = item.default;
let choices = null;
// Handle different question types
if (item['single-select']) {
questionType = 'list';
choices = item['single-select'];
if (existingValue && choices.includes(existingValue)) {
defaultValue = existingValue;
}
} else if (item['multi-select']) {
questionType = 'checkbox';
choices = item['multi-select'].map((choice) => ({
name: choice,
value: choice,
checked: existingValue
? existingValue.includes(choice)
: item.default && Array.isArray(item.default)
? item.default.includes(choice)
: false,
}));
} else if (typeof defaultValue === 'boolean') {
questionType = 'confirm';
}
// Build the prompt message
let message = '';
// Handle array prompts for multi-line messages
if (Array.isArray(item.prompt)) {
message = item.prompt.join('\n');
} else {
message = item.prompt;
}
// Add current value indicator for existing configs
if (existingValue !== null && existingValue !== undefined) {
if (typeof existingValue === 'boolean') {
message += chalk.dim(` (current: ${existingValue ? 'true' : 'false'})`);
defaultValue = existingValue;
} else if (Array.isArray(existingValue)) {
message += chalk.dim(` (current: ${existingValue.join(', ')})`);
} else if (questionType !== 'list') {
// Show the cleaned value (without {project-root}/) for display
message += chalk.dim(` (current: ${existingValue})`);
defaultValue = existingValue;
}
} else if (item.example && questionType === 'input') {
// Show example for input fields
const exampleText = typeof item.example === 'string' ? item.example.replace('{project-root}/', '') : JSON.stringify(item.example);
message += chalk.dim(` (e.g., ${exampleText})`);
}
const question = {
type: questionType,
name: questionName,
message: message,
default: defaultValue,
};
// Add choices for select types
if (choices) {
question.choices = choices;
}
// Add validation for input fields
if (questionType === 'input') {
question.validate = (input) => {
if (!input && item.required) {
return 'This field is required';
}
return true;
};
}
return question;
}
/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
*/
deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
result[key] = this.deepMerge(result[key], source[key]);
} else {
result[key] = source[key];
}
} else {
result[key] = source[key];
}
}
return result;
}
}
module.exports = { ConfigCollector };

View File

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

View File

@@ -0,0 +1,208 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const { Manifest } = require('./manifest');
class Detector {
/**
* Detect existing BMAD installation
* @param {string} bmadDir - Path to bmad directory
* @returns {Object} Installation status and details
*/
async detect(bmadDir) {
const result = {
installed: false,
path: bmadDir,
version: null,
hasCore: false,
modules: [],
ides: [],
manifest: null,
};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return result;
}
// Check for manifest using the Manifest class
const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir);
if (manifestData) {
result.manifest = manifestData;
result.version = manifestData.version;
result.installed = true;
}
// Check for core
const corePath = path.join(bmadDir, 'core');
if (await fs.pathExists(corePath)) {
result.hasCore = true;
// Try to get core version from config
const coreConfigPath = path.join(corePath, 'config.yaml');
if (await fs.pathExists(coreConfigPath)) {
try {
const configContent = await fs.readFile(coreConfigPath, 'utf8');
const config = yaml.load(configContent);
if (!result.version && config.version) {
result.version = config.version;
}
} catch {
// Ignore config read errors
}
}
}
// Check for modules
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
const modulePath = path.join(bmadDir, entry.name);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
const moduleInfo = {
id: entry.name,
path: modulePath,
version: 'unknown',
};
if (await fs.pathExists(moduleConfigPath)) {
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.load(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || entry.name;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
}
result.modules.push(moduleInfo);
}
}
// Check for IDE configurations from manifest
if (result.manifest && result.manifest.ides) {
result.ides = result.manifest.ides;
}
// Mark as installed if we found core or modules
if (result.hasCore || result.modules.length > 0) {
result.installed = true;
}
return result;
}
/**
* Detect legacy installation (.bmad-method, .bmm, .cis)
* @param {string} projectDir - Project directory to check
* @returns {Object} Legacy installation details
*/
async detectLegacy(projectDir) {
const result = {
hasLegacy: false,
legacyCore: false,
legacyModules: [],
paths: [],
};
// Check for legacy core (.bmad-method)
const legacyCorePath = path.join(projectDir, '.bmad-method');
if (await fs.pathExists(legacyCorePath)) {
result.hasLegacy = true;
result.legacyCore = true;
result.paths.push(legacyCorePath);
}
// Check for legacy modules (directories starting with .)
const entries = await fs.readdir(projectDir, { withFileTypes: true });
for (const entry of entries) {
if (
entry.isDirectory() &&
entry.name.startsWith('.') &&
entry.name !== '.bmad-method' &&
!entry.name.startsWith('.git') &&
!entry.name.startsWith('.vscode') &&
!entry.name.startsWith('.idea')
) {
const modulePath = path.join(projectDir, entry.name);
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
// Check if it's likely a BMAD module
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
result.hasLegacy = true;
result.legacyModules.push({
name: entry.name.slice(1), // Remove leading dot
path: modulePath,
});
result.paths.push(modulePath);
}
}
}
return result;
}
/**
* Check if migration from legacy is needed
* @param {string} projectDir - Project directory
* @returns {Object} Migration requirements
*/
async checkMigrationNeeded(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
const current = await this.detect(bmadDir);
const legacy = await this.detectLegacy(projectDir);
return {
needed: legacy.hasLegacy && !current.installed,
canMigrate: legacy.hasLegacy,
legacy: legacy,
current: current,
};
}
/**
* Detect legacy BMAD v4 footprints (case-sensitive path checks)
* @param {string} projectDir - Project directory to check
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
*/
async detectLegacyV4(projectDir) {
// Helper: check existence of a nested path with case-sensitive segment matching
const existsCaseSensitive = async (baseDir, segments) => {
let dir = baseDir;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return false;
}
const hit = entries.find((e) => e.name === seg);
if (!hit) return false;
// Parents must be directories; the last segment may be a file or directory
if (i < segments.length - 1 && !hit.isDirectory()) return false;
dir = path.join(dir, hit.name);
}
return true;
};
const offenders = [];
if (await existsCaseSensitive(projectDir, ['.bmad-core'])) {
offenders.push(path.join(projectDir, '.bmad-core'));
}
if (await existsCaseSensitive(projectDir, ['.claude', 'commands', 'BMad'])) {
offenders.push(path.join(projectDir, '.claude', 'commands', 'BMad'));
}
if (await existsCaseSensitive(projectDir, ['.crush', 'commands', 'BMad'])) {
offenders.push(path.join(projectDir, '.crush', 'commands', 'BMad'));
}
return { hasLegacyV4: offenders.length > 0, offenders };
}
}
module.exports = { Detector };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
/**
* Generates manifest files for installed workflows, agents, and tasks
*/
class ManifestGenerator {
constructor() {
this.workflows = [];
this.agents = [];
this.tasks = [];
this.modules = [];
this.files = [];
}
/**
* Generate all manifests for the installation
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Selected modules for installation
*/
async generateManifests(bmadDir, selectedModules) {
// Create _cfg directory if it doesn't exist
const cfgDir = path.join(bmadDir, '_cfg');
await fs.ensureDir(cfgDir);
// Store modules list
this.modules = ['core', ...selectedModules];
// Collect workflow data
await this.collectWorkflows(selectedModules);
// Collect agent data
await this.collectAgents(selectedModules);
// Collect task data
await this.collectTasks(selectedModules);
// Write manifest files
await this.writeMainManifest(cfgDir);
await this.writeWorkflowManifest(cfgDir);
await this.writeAgentManifest(cfgDir);
await this.writeTaskManifest(cfgDir);
await this.writeFilesManifest(cfgDir);
return {
workflows: this.workflows.length,
agents: this.agents.length,
tasks: this.tasks.length,
files: this.files.length,
};
}
/**
* Collect all workflows from core and selected modules
*/
async collectWorkflows(selectedModules) {
this.workflows = [];
// Get core workflows
const corePath = getModulePath('core');
const coreWorkflows = await this.getWorkflowsFromPath(corePath, 'core');
this.workflows.push(...coreWorkflows);
// Get module workflows
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const moduleWorkflows = await this.getWorkflowsFromPath(modulePath, moduleName);
this.workflows.push(...moduleWorkflows);
}
}
/**
* Recursively find and parse workflow.yaml files
*/
async getWorkflowsFromPath(basePath, moduleName) {
const workflows = [];
const workflowsPath = path.join(basePath, 'workflows');
if (!(await fs.pathExists(workflowsPath))) {
return workflows;
}
// Recursively find workflow.yaml files
const findWorkflows = async (dir, relativePath = '') => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await findWorkflows(fullPath, newRelativePath);
} else if (entry.name === 'workflow.yaml') {
// Parse workflow file
try {
const content = await fs.readFile(fullPath, 'utf8');
const workflow = yaml.load(content);
// Skip template workflows (those with placeholder values)
if (workflow.name && workflow.name.includes('{') && workflow.name.includes('}')) {
continue;
}
if (workflow.name && workflow.description) {
// Build relative path for installation
const installPath =
moduleName === 'core'
? `bmad/core/workflows/${relativePath}/workflow.yaml`
: `bmad/${moduleName}/workflows/${relativePath}/workflow.yaml`;
workflows.push({
name: workflow.name,
description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'workflow',
name: workflow.name,
module: moduleName,
path: installPath,
});
}
} catch (error) {
console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`);
}
}
}
};
await findWorkflows(workflowsPath);
return workflows;
}
/**
* Collect all agents from core and selected modules
*/
async collectAgents(selectedModules) {
this.agents = [];
// Get core agents
const corePath = getModulePath('core');
const coreAgentsPath = path.join(corePath, 'agents');
if (await fs.pathExists(coreAgentsPath)) {
const coreAgents = await this.getAgentsFromDir(coreAgentsPath, 'core');
this.agents.push(...coreAgents);
}
// Get module agents
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const agentsPath = path.join(modulePath, 'agents');
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
this.agents.push(...moduleAgents);
}
}
}
/**
* Get agents from a directory
*/
async getAgentsFromDir(dirPath, moduleName) {
const agents = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip web-only agents
if (content.includes('localskip="true"')) {
continue;
}
// Extract agent metadata from content if possible
const nameMatch = content.match(/name="([^"]+)"/);
const descMatch = content.match(/<objective>([^<]+)<\/objective>/);
// Build relative path for installation
const installPath = moduleName === 'core' ? `bmad/core/agents/${file}` : `bmad/${moduleName}/agents/${file}`;
const agentName = file.replace('.md', '');
agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
description: descMatch ? descMatch[1].trim().replaceAll('"', '""') : '',
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'agent',
name: agentName,
module: moduleName,
path: installPath,
});
}
}
return agents;
}
/**
* Collect all tasks from core and selected modules
*/
async collectTasks(selectedModules) {
this.tasks = [];
// Get core tasks
const corePath = getModulePath('core');
const coreTasksPath = path.join(corePath, 'tasks');
if (await fs.pathExists(coreTasksPath)) {
const coreTasks = await this.getTasksFromDir(coreTasksPath, 'core');
this.tasks.push(...coreTasks);
}
// Get module tasks
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const tasksPath = path.join(modulePath, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
this.tasks.push(...moduleTasks);
}
}
}
/**
* Get tasks from a directory
*/
async getTasksFromDir(dirPath, moduleName) {
const tasks = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Extract task metadata from content if possible
const nameMatch = content.match(/name="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
// Build relative path for installation
const installPath = moduleName === 'core' ? `bmad/core/tasks/${file}` : `bmad/${moduleName}/tasks/${file}`;
const taskName = file.replace('.md', '');
tasks.push({
name: taskName,
displayName: nameMatch ? nameMatch[1] : taskName,
description: objMatch ? objMatch[1].trim().replaceAll('"', '""') : '',
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'task',
name: taskName,
module: moduleName,
path: installPath,
});
}
}
return tasks;
}
/**
* Write main manifest as YAML with installation info only
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
const manifest = {
installation: {
version: '6.0.0-alpha.0',
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
},
modules: this.modules.map((name) => ({
name,
version: '',
shortTitle: '',
})),
ides: ['claude-code'],
};
const yamlStr = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(manifestPath, yamlStr);
}
/**
* Write workflow manifest CSV
*/
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
// Create CSV header
let csv = 'name,description,module,path\n';
// Add rows
for (const workflow of this.workflows) {
csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write agent manifest CSV
*/
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
// Create CSV header
let csv = 'name,displayName,description,module,path\n';
// Add rows
for (const agent of this.agents) {
csv += `"${agent.name}","${agent.displayName}","${agent.description}","${agent.module}","${agent.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write task manifest CSV
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
// Create CSV header
let csv = 'name,displayName,description,module,path\n';
// Add rows
for (const task of this.tasks) {
csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write files manifest CSV
*/
async writeFilesManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'files-manifest.csv');
// Create CSV header
let csv = 'type,name,module,path\n';
// Sort files by type, then module, then name
this.files.sort((a, b) => {
if (a.type !== b.type) return a.type.localeCompare(b.type);
if (a.module !== b.module) return a.module.localeCompare(b.module);
return a.name.localeCompare(b.name);
});
// Add rows
for (const file of this.files) {
csv += `"${file.type}","${file.name}","${file.module}","${file.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
}
module.exports = { ManifestGenerator };

View File

@@ -0,0 +1,484 @@
const path = require('node:path');
const fs = require('fs-extra');
class Manifest {
/**
* Create a new manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} data - Manifest data
* @param {Array} installedFiles - List of installed files to track
*/
async create(bmadDir, data, installedFiles = []) {
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
// Ensure _cfg directory exists
await fs.ensureDir(path.dirname(manifestPath));
// Load module configs to get module metadata
// If core is installed, add it to modules list
const allModules = [...(data.modules || [])];
if (data.core) {
allModules.unshift('core'); // Add core at the beginning
}
const moduleConfigs = await this.loadModuleConfigs(allModules);
// Parse installed files to extract metadata - pass bmadDir for relative paths
const fileMetadata = await this.parseInstalledFiles(installedFiles, bmadDir);
// Don't store installation path in manifest
// Generate CSV content
const csvContent = this.generateManifestCsv({ ...data, modules: allModules }, fileMetadata, moduleConfigs);
await fs.writeFile(manifestPath, csvContent, 'utf8');
return { success: true, path: manifestPath, filesTracked: fileMetadata.length };
}
/**
* Read existing manifest
* @param {string} bmadDir - Path to bmad directory
* @returns {Object|null} Manifest data or null if not found
*/
async read(bmadDir) {
const csvPath = path.join(bmadDir, '_cfg', 'manifest.csv');
if (await fs.pathExists(csvPath)) {
try {
const content = await fs.readFile(csvPath, 'utf8');
return this.parseManifestCsv(content);
} catch (error) {
console.error('Failed to read CSV manifest:', error.message);
}
}
return null;
}
/**
* Update existing manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} updates - Fields to update
* @param {Array} installedFiles - Updated list of installed files
*/
async update(bmadDir, updates, installedFiles = null) {
const manifest = (await this.read(bmadDir)) || {};
// Merge updates
Object.assign(manifest, updates);
manifest.lastUpdated = new Date().toISOString();
// If new file list provided, reparse metadata
let fileMetadata = manifest.files || [];
if (installedFiles) {
fileMetadata = await this.parseInstalledFiles(installedFiles);
}
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
await fs.ensureDir(path.dirname(manifestPath));
const csvContent = this.generateManifestCsv({ ...manifest, ...updates }, fileMetadata);
await fs.writeFile(manifestPath, csvContent, 'utf8');
return { ...manifest, ...updates, files: fileMetadata };
}
/**
* Add a module to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to add
*/
async addModule(bmadDir, moduleName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.modules) {
manifest.modules = [];
}
if (!manifest.modules.includes(moduleName)) {
manifest.modules.push(moduleName);
await this.update(bmadDir, { modules: manifest.modules });
}
}
/**
* Remove a module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to remove
*/
async removeModule(bmadDir, moduleName) {
const manifest = await this.read(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.indexOf(moduleName);
if (index !== -1) {
manifest.modules.splice(index, 1);
await this.update(bmadDir, { modules: manifest.modules });
}
}
/**
* Add an IDE configuration to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} ideName - IDE name to add
*/
async addIde(bmadDir, ideName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.ides) {
manifest.ides = [];
}
if (!manifest.ides.includes(ideName)) {
manifest.ides.push(ideName);
await this.update(bmadDir, { ides: manifest.ides });
}
}
/**
* Parse installed files to extract metadata
* @param {Array} installedFiles - List of installed file paths
* @param {string} bmadDir - Path to bmad directory for relative paths
* @returns {Array} Array of file metadata objects
*/
async parseInstalledFiles(installedFiles, bmadDir) {
const fileMetadata = [];
for (const filePath of installedFiles) {
const fileExt = path.extname(filePath).toLowerCase();
// Make path relative to parent of bmad directory, starting with 'bmad/'
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
// Handle markdown files - extract XML metadata
if (fileExt === '.md') {
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
if (metadata) {
fileMetadata.push(metadata);
}
}
} catch (error) {
console.warn(`Warning: Could not parse ${filePath}:`, error.message);
}
}
// Handle other file types (CSV, JSON, etc.)
else {
fileMetadata.push({
file: relativePath,
type: fileExt.slice(1), // Remove the dot
name: path.basename(filePath, fileExt),
title: null,
});
}
}
return fileMetadata;
}
/**
* Extract XML node attributes from MD file content
* @param {string} content - File content
* @param {string} filePath - File path for context
* @param {string} relativePath - Relative path starting with 'bmad/'
* @returns {Object|null} Extracted metadata or null
*/
extractXmlNodeAttributes(content, filePath, relativePath) {
// Look for XML blocks in code fences
const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
if (!xmlBlockMatch) {
return null;
}
const xmlContent = xmlBlockMatch[1];
// Extract root XML node (agent, task, template, etc.)
const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
if (!rootNodeMatch) {
return null;
}
const nodeType = rootNodeMatch[1];
const attributes = rootNodeMatch[2];
// Extract name and title attributes (id not needed since we have path)
const nameMatch = attributes.match(/name="([^"]*)"/);
const titleMatch = attributes.match(/title="([^"]*)"/);
return {
file: relativePath,
type: nodeType,
name: nameMatch ? nameMatch[1] : null,
title: titleMatch ? titleMatch[1] : null,
};
}
/**
* Generate CSV manifest content
* @param {Object} data - Manifest data
* @param {Array} fileMetadata - File metadata array
* @param {Object} moduleConfigs - Module configuration data
* @returns {string} CSV content
*/
generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
const timestamp = new Date().toISOString();
let csv = [];
// Header section
csv.push(
'# BMAD Manifest',
`# Generated: ${timestamp}`,
'',
'## Installation Info',
'Property,Value',
`Version,${data.version}`,
`InstallDate,${data.installDate || timestamp}`,
`LastUpdated,${data.lastUpdated || timestamp}`,
);
if (data.language) {
csv.push(`Language,${data.language}`);
}
csv.push('');
// Modules section
if (data.modules && data.modules.length > 0) {
csv.push('## Modules', 'Name,Version,ShortTitle');
for (const moduleName of data.modules) {
const config = moduleConfigs[moduleName] || {};
csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
}
csv.push('');
}
// IDEs section
if (data.ides && data.ides.length > 0) {
csv.push('## IDEs', 'IDE');
for (const ide of data.ides) {
csv.push(this.escapeCsv(ide));
}
csv.push('');
}
// Files section
if (fileMetadata.length > 0) {
csv.push('## Files', 'Type,Path,Name,Title');
for (const file of fileMetadata) {
csv.push([file.type || '', file.file || '', file.name || '', file.title || ''].map((v) => this.escapeCsv(v)).join(','));
}
}
return csv.join('\n');
}
/**
* Parse CSV manifest content back to object
* @param {string} csvContent - CSV content to parse
* @returns {Object} Parsed manifest data
*/
parseManifestCsv(csvContent) {
const result = {
modules: [],
ides: [],
files: [],
};
const lines = csvContent.split('\n');
let section = '';
for (const line_ of lines) {
const line = line_.trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) {
// Check for section headers
if (line.startsWith('## ')) {
section = line.slice(3).toLowerCase();
}
continue;
}
// Parse based on current section
switch (section) {
case 'installation info': {
// Skip header row
if (line === 'Property,Value') continue;
const [property, ...valueParts] = line.split(',');
const value = this.unescapeCsv(valueParts.join(','));
switch (property) {
// Path no longer stored in manifest
case 'Version': {
result.version = value;
break;
}
case 'InstallDate': {
result.installDate = value;
break;
}
case 'LastUpdated': {
result.lastUpdated = value;
break;
}
case 'Language': {
result.language = value;
break;
}
}
break;
}
case 'modules': {
// Skip header row
if (line === 'Name,Version,ShortTitle') continue;
const parts = this.parseCsvLine(line);
if (parts[0]) {
result.modules.push(parts[0]);
}
break;
}
case 'ides': {
// Skip header row
if (line === 'IDE') continue;
result.ides.push(this.unescapeCsv(line));
break;
}
case 'files': {
// Skip header row
if (line === 'Type,Path,Name,Title') continue;
const parts = this.parseCsvLine(line);
if (parts.length >= 2) {
result.files.push({
type: parts[0] || '',
file: parts[1] || '',
name: parts[2] || null,
title: parts[3] || null,
});
}
break;
}
// No default
}
}
return result;
}
/**
* Parse a CSV line handling quotes and commas
* @param {string} line - CSV line to parse
* @returns {Array} Array of values
*/
parseCsvLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
current += '"';
i++;
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
result.push(this.unescapeCsv(current));
current = '';
} else {
current += char;
}
}
// Add the last field
result.push(this.unescapeCsv(current));
return result;
}
/**
* Escape CSV special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeCsv(text) {
if (!text) return '';
const str = String(text);
// If contains comma, newline, or quote, wrap in quotes and escape quotes
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return '"' + str.replaceAll('"', '""') + '"';
}
return str;
}
/**
* Unescape CSV field
* @param {string} text - Text to unescape
* @returns {string} Unescaped text
*/
unescapeCsv(text) {
if (!text) return '';
// Remove surrounding quotes if present
if (text.startsWith('"') && text.endsWith('"')) {
text = text.slice(1, -1);
// Unescape doubled quotes
text = text.replaceAll('""', '"');
}
return text;
}
/**
* Load module configuration files
* @param {Array} modules - List of module names
* @returns {Object} Module configurations indexed by name
*/
async loadModuleConfigs(modules) {
const configs = {};
for (const moduleName of modules) {
// Handle core module differently - it's in src/core not src/modules/core
const configPath =
moduleName === 'core'
? path.join(process.cwd(), 'src', 'core', 'config.yaml')
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
try {
if (await fs.pathExists(configPath)) {
const yaml = require('js-yaml');
const content = await fs.readFile(configPath, 'utf8');
configs[moduleName] = yaml.load(content);
}
} catch (error) {
console.warn(`Could not load config for module ${moduleName}:`, error.message);
}
}
return configs;
}
}
module.exports = { Manifest };

View File

@@ -0,0 +1,281 @@
const path = require('node:path');
const fs = require('fs-extra');
const chalk = require('chalk');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getSourcePath } = require('../../../lib/project-root');
/**
* Base class for IDE-specific setup
* All IDE handlers should extend this class
*/
class BaseIdeSetup {
constructor(name, displayName = null, preferred = false) {
this.name = name;
this.displayName = displayName || name; // Human-readable name for UI
this.preferred = preferred; // Whether this IDE should be shown in preferred list
this.configDir = null; // Override in subclasses
this.rulesDir = null; // Override in subclasses
this.xmlHandler = new XmlHandler();
}
/**
* Main setup method - must be implemented by subclasses
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
throw new Error(`setup() must be implemented by ${this.name} handler`);
}
/**
* Cleanup IDE configuration
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
// Default implementation - can be overridden
if (this.configDir) {
const configPath = path.join(projectDir, this.configDir);
if (await fs.pathExists(configPath)) {
const bmadRulesPath = path.join(configPath, 'bmad');
if (await fs.pathExists(bmadRulesPath)) {
await fs.remove(bmadRulesPath);
console.log(chalk.dim(`Removed ${this.name} BMAD configuration`));
}
}
}
}
/**
* Get list of agents from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @returns {Array} List of agent files
*/
async getAgents(bmadDir) {
const agents = [];
// Get core agents
const coreAgentsPath = path.join(bmadDir, 'core', 'agents');
if (await fs.pathExists(coreAgentsPath)) {
const coreAgents = await this.scanDirectory(coreAgentsPath, '.md');
agents.push(
...coreAgents.map((a) => ({
...a,
module: 'core',
})),
);
}
// Get module agents
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
if (await fs.pathExists(moduleAgentsPath)) {
const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md');
agents.push(
...moduleAgents.map((a) => ({
...a,
module: entry.name,
})),
);
}
}
}
return agents;
}
/**
* Get list of tasks from BMAD installation
* @param {string} bmadDir - BMAD installation directory
* @returns {Array} List of task files
*/
async getTasks(bmadDir) {
const tasks = [];
// Get core tasks
const coreTasksPath = path.join(bmadDir, 'core', 'tasks');
if (await fs.pathExists(coreTasksPath)) {
const coreTasks = await this.scanDirectory(coreTasksPath, '.md');
tasks.push(
...coreTasks.map((t) => ({
...t,
module: 'core',
})),
);
}
// Get module tasks
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks');
if (await fs.pathExists(moduleTasksPath)) {
const moduleTasks = await this.scanDirectory(moduleTasksPath, '.md');
tasks.push(
...moduleTasks.map((t) => ({
...t,
module: entry.name,
})),
);
}
}
}
return tasks;
}
/**
* Scan a directory for files with specific extension
* @param {string} dir - Directory to scan
* @param {string} ext - File extension to match
* @returns {Array} List of file info objects
*/
async scanDirectory(dir, ext) {
const files = [];
if (!(await fs.pathExists(dir))) {
return files;
}
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subFiles = await this.scanDirectory(fullPath, ext);
files.push(...subFiles);
} else if (entry.isFile() && entry.name.endsWith(ext)) {
files.push({
name: path.basename(entry.name, ext),
path: fullPath,
relativePath: path.relative(dir, fullPath),
filename: entry.name,
});
}
}
return files;
}
/**
* Create IDE command/rule file from agent or task
* @param {string} content - File content
* @param {Object} metadata - File metadata
* @param {string} projectDir - The actual project directory path
* @returns {string} Processed content
*/
processContent(content, metadata = {}, projectDir = null) {
// Replace placeholders
let processed = content;
// Inject activation block for agent files FIRST (before replacements)
if (metadata.name && content.includes('<agent')) {
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
}
// Use the actual project directory path if provided, otherwise default to 'bmad/'
const projectRoot = projectDir ? projectDir + '/' : 'bmad/';
// Common replacements (including in the activation block)
processed = processed.replaceAll('{project-root}', projectRoot);
processed = processed.replaceAll('{module}', metadata.module || 'core');
processed = processed.replaceAll('{agent}', metadata.name || '');
processed = processed.replaceAll('{task}', metadata.name || '');
return processed;
}
/**
* Ensure directory exists
* @param {string} dirPath - Directory path
*/
async ensureDir(dirPath) {
await fs.ensureDir(dirPath);
}
/**
* Write file with content
* @param {string} filePath - File path
* @param {string} content - File content
*/
async writeFile(filePath, content) {
await this.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
/**
* Copy file from source to destination
* @param {string} source - Source file path
* @param {string} dest - Destination file path
*/
async copyFile(source, dest) {
await this.ensureDir(path.dirname(dest));
await fs.copy(source, dest, { overwrite: true });
}
/**
* Check if path exists
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async exists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Alias for exists method
* @param {string} pathToCheck - Path to check
* @returns {boolean} True if path exists
*/
async pathExists(pathToCheck) {
return await fs.pathExists(pathToCheck);
}
/**
* Read file content
* @param {string} filePath - File path
* @returns {string} File content
*/
async readFile(filePath) {
return await fs.readFile(filePath, 'utf8');
}
/**
* Format name as title
* @param {string} name - Name to format
* @returns {string} Formatted title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Create agent configuration file
* @param {string} bmadDir - BMAD installation directory
* @param {Object} agent - Agent information
*/
async createAgentConfig(bmadDir, agent) {
const agentConfigDir = path.join(bmadDir, '_cfg', 'agents');
await this.ensureDir(agentConfigDir);
// Load agent config template
const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
const templateContent = await this.readFile(templatePath);
const configContent = `# Agent Config: ${agent.name}
${templateContent}`;
const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(configPath, configContent);
}
}
module.exports = { BaseIdeSetup };

View File

@@ -0,0 +1,271 @@
const path = require('node:path');
const os = require('node:os');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const inquirer = require('inquirer');
/**
* Auggie CLI setup handler
* Allows flexible installation of agents to multiple locations
*/
class AuggieSetup extends BaseIdeSetup {
constructor() {
super('auggie', 'Auggie CLI');
this.defaultLocations = [
{ name: 'Project Directory (.auggie/commands)', value: '.auggie/commands', checked: true },
{ name: 'User Home (~/.auggie/commands)', value: path.join(os.homedir(), '.auggie', 'commands') },
{ name: 'Custom Location', value: 'custom' },
];
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const response = await inquirer.prompt([
{
type: 'checkbox',
name: 'locations',
message: 'Select Auggie CLI installation locations:',
choices: this.defaultLocations,
validate: (answers) => {
if (answers.length === 0) {
return 'Please select at least one location';
}
return true;
},
},
]);
const locations = [];
for (const loc of response.locations) {
if (loc === 'custom') {
const custom = await inquirer.prompt([
{
type: 'input',
name: 'path',
message: 'Enter custom path for Auggie commands:',
validate: (input) => {
if (!input.trim()) {
return 'Path cannot be empty';
}
return true;
},
},
]);
locations.push(custom.path);
} else {
locations.push(loc);
}
}
return { auggieLocations: locations };
}
/**
* Setup Auggie CLI 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}...`));
// Use pre-collected configuration if available
const config = options.preCollectedConfig || {};
const locations = await this.getInstallLocations(projectDir, { ...options, auggieLocations: config.auggieLocations });
if (locations.length === 0) {
console.log(chalk.yellow('No locations selected. Skipping Auggie CLI setup.'));
return { success: false, reason: 'no-locations' };
}
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
let totalInstalled = 0;
// Install to each selected location
for (const location of locations) {
console.log(chalk.dim(`\n Installing to: ${location}`));
const agentsDir = path.join(location, 'agents');
const tasksDir = path.join(location, 'tasks');
await this.ensureDir(agentsDir);
await this.ensureDir(tasksDir);
// Install agents
for (const agent of agents) {
const content = await this.readFile(agent.path);
const commandContent = this.createAgentCommand(agent, content);
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(targetPath, commandContent);
totalInstalled++;
}
// Install tasks
for (const task of tasks) {
const content = await this.readFile(task.path);
const commandContent = this.createTaskCommand(task, content);
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
await this.writeFile(targetPath, commandContent);
totalInstalled++;
}
console.log(chalk.green(` ✓ Installed ${agents.length} agents and ${tasks.length} tasks`));
}
console.log(chalk.green(`\n${this.name} configured:`));
console.log(chalk.dim(` - ${totalInstalled} total commands installed`));
console.log(chalk.dim(` - ${locations.length} location(s) configured`));
return {
success: true,
commands: totalInstalled,
locations: locations.length,
};
}
/**
* Get installation locations from user
*/
async getInstallLocations(projectDir, options) {
if (options.auggieLocations) {
// Process the pre-collected locations to resolve relative paths
const processedLocations = [];
for (const loc of options.auggieLocations) {
if (loc === '.auggie/commands') {
// Relative to project directory
processedLocations.push(path.join(projectDir, loc));
} else {
processedLocations.push(loc);
}
}
return processedLocations;
}
const response = await inquirer.prompt([
{
type: 'checkbox',
name: 'locations',
message: 'Select Auggie CLI installation locations:',
choices: this.defaultLocations,
validate: (answers) => {
if (answers.length === 0) {
return 'Please select at least one location';
}
return true;
},
},
]);
const locations = [];
for (const loc of response.locations) {
if (loc === 'custom') {
const custom = await inquirer.prompt([
{
type: 'input',
name: 'path',
message: 'Enter custom path for Auggie commands:',
validate: (input) => {
if (!input.trim()) {
return 'Path cannot be empty';
}
return true;
},
},
]);
locations.push(custom.path);
} else if (loc.startsWith('.auggie')) {
// Relative to project directory
locations.push(path.join(projectDir, loc));
} else {
locations.push(loc);
}
}
return locations;
}
/**
* Create agent command content
*/
createAgentCommand(agent, content) {
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
return `# ${title} Agent
## Activation
Type \`@${agent.name}\` to activate this agent.
${content}
## Module
BMAD ${agent.module.toUpperCase()} module
`;
}
/**
* Create task command content
*/
createTaskCommand(task, content) {
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
return `# ${taskName} Task
## Activation
Type \`@task-${task.name}\` to execute this task.
${content}
## Module
BMAD ${task.module.toUpperCase()} module
`;
}
/**
* Cleanup Auggie configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
// Check common locations
const locations = [path.join(os.homedir(), '.auggie', 'commands'), path.join(projectDir, '.auggie', 'commands')];
for (const location of locations) {
const agentsDir = path.join(location, 'agents');
const tasksDir = path.join(location, 'tasks');
if (await fs.pathExists(agentsDir)) {
// Remove only BMAD files (those with module prefix)
const files = await fs.readdir(agentsDir);
for (const file of files) {
if (file.includes('-') && file.endsWith('.md')) {
await fs.remove(path.join(agentsDir, file));
}
}
}
if (await fs.pathExists(tasksDir)) {
const files = await fs.readdir(tasksDir);
for (const file of files) {
if (file.includes('-') && file.endsWith('.md')) {
await fs.remove(path.join(tasksDir, file));
}
}
}
}
console.log(chalk.dim('Cleaned up Auggie CLI configurations'));
}
}
module.exports = { AuggieSetup };

View File

@@ -0,0 +1,625 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { WorkflowCommandGenerator } = require('./workflow-command-generator');
/**
* Claude Code IDE setup handler
*/
class ClaudeCodeSetup extends BaseIdeSetup {
constructor() {
super('claude-code', 'Claude Code', true); // preferred IDE
this.configDir = '.claude';
this.commandsDir = 'commands';
this.agentsDir = 'agents';
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const config = {
subagentChoices: null,
installLocation: null,
};
const sourceModulesPath = getSourcePath('modules');
const modules = options.selectedModules || [];
for (const moduleName of modules) {
// Check for Claude Code sub-module injection config in SOURCE directory
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
if (await this.exists(injectionConfigPath)) {
const fs = require('fs-extra');
const yaml = require('js-yaml');
try {
// Load injection configuration
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
const injectionConfig = yaml.load(configContent);
// Ask about subagents if they exist and we haven't asked yet
if (injectionConfig.subagents && !config.subagentChoices) {
config.subagentChoices = await this.promptSubagentInstallation(injectionConfig.subagents);
if (config.subagentChoices.install !== 'none') {
// Ask for installation location
const inquirer = require('inquirer');
const locationAnswer = await inquirer.prompt([
{
type: 'list',
name: 'location',
message: 'Where would you like to install Claude Code subagents?',
choices: [
{ name: 'Project level (.claude/agents/)', value: 'project' },
{ name: 'User level (~/.claude/agents/)', value: 'user' },
],
default: 'project',
},
]);
config.installLocation = locationAnswer.location;
}
}
} catch (error) {
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
}
}
}
return config;
}
/**
* Setup Claude Code IDE configuration
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
// Store project directory for use in processContent
this.projectDir = projectDir;
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Create .claude/commands directory structure
const claudeDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(claudeDir, this.commandsDir);
const bmadCommandsDir = path.join(commandsDir, 'bmad');
await this.ensureDir(bmadCommandsDir);
// Get agents and tasks from SOURCE, not installed location
// This ensures we process files with {project-root} placeholders intact
const sourceDir = getSourcePath('modules');
const agents = await this.getAgentsFromSource(sourceDir, options.selectedModules || []);
const tasks = await this.getTasksFromSource(sourceDir, options.selectedModules || []);
// Create directories for each module
const modules = new Set();
for (const item of [...agents, ...tasks]) modules.add(item.module);
for (const module of modules) {
await this.ensureDir(path.join(bmadCommandsDir, module));
await this.ensureDir(path.join(bmadCommandsDir, module, 'agents'));
await this.ensureDir(path.join(bmadCommandsDir, module, 'tasks'));
}
// Process and copy agents
let agentCount = 0;
for (const agent of agents) {
const content = await this.readAndProcess(agent.path, {
module: agent.module,
name: agent.name,
});
const targetPath = path.join(bmadCommandsDir, agent.module, 'agents', `${agent.name}.md`);
await this.writeFile(targetPath, content);
agentCount++;
}
// Process and copy tasks
let taskCount = 0;
for (const task of tasks) {
const content = await this.readAndProcess(task.path, {
module: task.module,
name: task.name,
});
const targetPath = path.join(bmadCommandsDir, task.module, 'tasks', `${task.name}.md`);
await this.writeFile(targetPath, content);
taskCount++;
}
// Process Claude Code specific injections for installed modules
// Use pre-collected configuration if available
if (options.preCollectedConfig) {
await this.processModuleInjectionsWithConfig(projectDir, bmadDir, options, options.preCollectedConfig);
} else {
await this.processModuleInjections(projectDir, bmadDir, options);
}
// Skip CLAUDE.md creation - let user manage their own CLAUDE.md file
// await this.createClaudeConfig(projectDir, modules);
// Generate workflow commands from manifest (if it exists)
const workflowGen = new WorkflowCommandGenerator();
const workflowResult = await workflowGen.generateWorkflowCommands(projectDir, bmadDir);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents installed`));
console.log(chalk.dim(` - ${taskCount} tasks installed`));
if (workflowResult.generated > 0) {
console.log(chalk.dim(` - ${workflowResult.generated} workflow commands generated`));
}
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, bmadCommandsDir)}`));
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
// Method removed - CLAUDE.md file management left to user
/**
* Read and process file content
*/
async readAndProcess(filePath, metadata) {
const fs = require('fs-extra');
const content = await fs.readFile(filePath, 'utf8');
return this.processContent(content, metadata);
}
/**
* Override processContent to use the actual project directory path
*/
processContent(content, metadata = {}) {
// Use the base class method with the actual project directory
return super.processContent(content, metadata, this.projectDir);
}
/**
* Get agents from source modules (not installed location)
*/
async getAgentsFromSource(sourceDir, selectedModules) {
const fs = require('fs-extra');
const agents = [];
// Add core agents
const corePath = getModulePath('core');
if (await fs.pathExists(path.join(corePath, 'agents'))) {
const coreAgents = await this.getAgentsFromDir(path.join(corePath, 'agents'), 'core');
agents.push(...coreAgents);
}
// Add module agents
for (const moduleName of selectedModules) {
const modulePath = path.join(sourceDir, moduleName);
const agentsPath = path.join(modulePath, 'agents');
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
agents.push(...moduleAgents);
}
}
return agents;
}
/**
* Get tasks from source modules (not installed location)
*/
async getTasksFromSource(sourceDir, selectedModules) {
const fs = require('fs-extra');
const tasks = [];
// Add core tasks
const corePath = getModulePath('core');
if (await fs.pathExists(path.join(corePath, 'tasks'))) {
const coreTasks = await this.getTasksFromDir(path.join(corePath, 'tasks'), 'core');
tasks.push(...coreTasks);
}
// Add module tasks
for (const moduleName of selectedModules) {
const modulePath = path.join(sourceDir, moduleName);
const tasksPath = path.join(modulePath, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
tasks.push(...moduleTasks);
}
}
return tasks;
}
/**
* Get agents from a specific directory
*/
async getAgentsFromDir(dirPath, moduleName) {
const fs = require('fs-extra');
const agents = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip web-only agents
if (content.includes('localskip="true"')) {
continue;
}
agents.push({
path: filePath,
name: file.replace('.md', ''),
module: moduleName,
});
}
}
return agents;
}
/**
* Get tasks from a specific directory
*/
async getTasksFromDir(dirPath, moduleName) {
const fs = require('fs-extra');
const tasks = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
tasks.push({
path: path.join(dirPath, file),
name: file.replace('.md', ''),
module: moduleName,
});
}
}
return tasks;
}
/**
* Process module injections with pre-collected configuration
*/
async processModuleInjectionsWithConfig(projectDir, bmadDir, options, preCollectedConfig) {
const fs = require('fs-extra');
const yaml = require('js-yaml');
// Get list of installed modules
const modules = options.selectedModules || [];
const { subagentChoices, installLocation } = preCollectedConfig;
// Get the actual source directory (not the installation directory)
const sourceModulesPath = getSourcePath('modules');
for (const moduleName of modules) {
// Check for Claude Code sub-module injection config in SOURCE directory
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
if (await this.exists(injectionConfigPath)) {
try {
// Load injection configuration
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
const config = yaml.load(configContent);
// Process content injections based on user choices
if (config.injections && subagentChoices && subagentChoices.install !== 'none') {
for (const injection of config.injections) {
// Check if this injection is related to a selected subagent
if (this.shouldInject(injection, subagentChoices)) {
await this.injectContent(projectDir, injection, subagentChoices);
}
}
}
// Copy selected subagents
if (config.subagents && subagentChoices && subagentChoices.install !== 'none') {
await this.copySelectedSubagents(
projectDir,
path.dirname(injectionConfigPath),
config.subagents,
subagentChoices,
installLocation,
);
}
} catch (error) {
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
}
}
}
}
/**
* Process Claude Code specific injections for installed modules
* Looks for injections.yaml in each module's claude-code sub-module
*/
async processModuleInjections(projectDir, bmadDir, options) {
const fs = require('fs-extra');
const yaml = require('js-yaml');
const inquirer = require('inquirer');
// Get list of installed modules
const modules = options.selectedModules || [];
let subagentChoices = null;
let installLocation = null;
// Get the actual source directory (not the installation directory)
const sourceModulesPath = getSourcePath('modules');
for (const moduleName of modules) {
// Check for Claude Code sub-module injection config in SOURCE directory
const injectionConfigPath = path.join(sourceModulesPath, moduleName, 'sub-modules', 'claude-code', 'injections.yaml');
if (await this.exists(injectionConfigPath)) {
console.log(chalk.cyan(`\nConfiguring ${moduleName} Claude Code features...`));
try {
// Load injection configuration
const configContent = await fs.readFile(injectionConfigPath, 'utf8');
const config = yaml.load(configContent);
// Ask about subagents if they exist and we haven't asked yet
if (config.subagents && !subagentChoices) {
subagentChoices = await this.promptSubagentInstallation(config.subagents);
if (subagentChoices.install !== 'none') {
// Ask for installation location
const locationAnswer = await inquirer.prompt([
{
type: 'list',
name: 'location',
message: 'Where would you like to install Claude Code subagents?',
choices: [
{ name: 'Project level (.claude/agents/)', value: 'project' },
{ name: 'User level (~/.claude/agents/)', value: 'user' },
],
default: 'project',
},
]);
installLocation = locationAnswer.location;
}
}
// Process content injections based on user choices
if (config.injections && subagentChoices && subagentChoices.install !== 'none') {
for (const injection of config.injections) {
// Check if this injection is related to a selected subagent
if (this.shouldInject(injection, subagentChoices)) {
await this.injectContent(projectDir, injection, subagentChoices);
}
}
}
// Copy selected subagents
if (config.subagents && subagentChoices && subagentChoices.install !== 'none') {
await this.copySelectedSubagents(
projectDir,
path.dirname(injectionConfigPath),
config.subagents,
subagentChoices,
installLocation,
);
}
} catch (error) {
console.log(chalk.yellow(` Warning: Failed to process ${moduleName} features: ${error.message}`));
}
}
}
}
/**
* Prompt user for subagent installation preferences
*/
async promptSubagentInstallation(subagentConfig) {
const inquirer = require('inquirer');
// First ask if they want to install subagents
const { install } = await inquirer.prompt([
{
type: 'list',
name: 'install',
message: 'Would you like to install Claude Code subagents for enhanced functionality?',
choices: [
{ name: 'Yes, install all subagents', value: 'all' },
{ name: 'Yes, let me choose specific subagents', value: 'selective' },
{ name: 'No, skip subagent installation', value: 'none' },
],
default: 'all',
},
]);
if (install === 'selective') {
// Show list of available subagents with descriptions
const subagentInfo = {
'market-researcher.md': 'Market research and competitive analysis',
'requirements-analyst.md': 'Requirements extraction and validation',
'technical-evaluator.md': 'Technology stack evaluation',
'epic-optimizer.md': 'Epic and story breakdown optimization',
'document-reviewer.md': 'Document quality review',
};
const { selected } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selected',
message: 'Select subagents to install:',
choices: subagentConfig.files.map((file) => ({
name: `${file.replace('.md', '')} - ${subagentInfo[file] || 'Specialized assistant'}`,
value: file,
checked: true,
})),
},
]);
return { install: 'selective', selected };
}
return { install };
}
/**
* Check if an injection should be applied based on user choices
*/
shouldInject(injection, subagentChoices) {
// If user chose no subagents, no injections
if (subagentChoices.install === 'none') {
return false;
}
// If user chose all subagents, all injections apply
if (subagentChoices.install === 'all') {
return true;
}
// For selective installation, check the 'requires' field
if (subagentChoices.install === 'selective') {
// If injection requires 'any' subagent and user selected at least one
if (injection.requires === 'any' && subagentChoices.selected.length > 0) {
return true;
}
// Check if the required subagent was selected
if (injection.requires) {
const requiredAgent = injection.requires + '.md';
return subagentChoices.selected.includes(requiredAgent);
}
// Fallback: check if injection mentions a selected agent
const selectedAgentNames = subagentChoices.selected.map((f) => f.replace('.md', ''));
for (const agentName of selectedAgentNames) {
if (injection.point && injection.point.includes(agentName)) {
return true;
}
}
}
return false;
}
/**
* Inject content at specified point in file
*/
async injectContent(projectDir, injection, subagentChoices = null) {
const fs = require('fs-extra');
const targetPath = path.join(projectDir, injection.file);
if (await this.exists(targetPath)) {
let content = await fs.readFile(targetPath, 'utf8');
const marker = `<!-- IDE-INJECT-POINT: ${injection.point} -->`;
if (content.includes(marker)) {
let injectionContent = injection.content;
// Filter content if selective subagents chosen
if (subagentChoices && subagentChoices.install === 'selective' && injection.point === 'pm-agent-instructions') {
injectionContent = this.filterAgentInstructions(injection.content, subagentChoices.selected);
}
content = content.replace(marker, injectionContent);
await fs.writeFile(targetPath, content);
console.log(chalk.dim(` Injected: ${injection.point}${injection.file}`));
}
}
}
/**
* Filter agent instructions to only include selected subagents
*/
filterAgentInstructions(content, selectedFiles) {
const selectedAgents = selectedFiles.map((f) => f.replace('.md', ''));
const lines = content.split('\n');
const filteredLines = [];
let includeNextLine = true;
for (const line of lines) {
// Always include structural lines
if (line.includes('<llm') || line.includes('</llm>')) {
filteredLines.push(line);
includeNextLine = true;
}
// Check if line mentions a subagent
else if (line.includes('subagent')) {
let shouldInclude = false;
for (const agent of selectedAgents) {
if (line.includes(agent)) {
shouldInclude = true;
break;
}
}
if (shouldInclude) {
filteredLines.push(line);
}
}
// Include general instructions
else if (line.includes('When creating PRDs') || line.includes('ACTIVELY delegate')) {
filteredLines.push(line);
}
}
// Only return content if we have actual instructions
if (filteredLines.length > 2) {
// More than just llm tags
return filteredLines.join('\n');
}
return ''; // Return empty if no relevant content
}
/**
* Copy selected subagents to appropriate Claude agents directory
*/
async copySelectedSubagents(projectDir, moduleClaudeDir, subagentConfig, choices, location) {
const fs = require('fs-extra');
const sourceDir = path.join(moduleClaudeDir, subagentConfig.source);
// Determine target directory based on user choice
let targetDir;
if (location === 'user') {
targetDir = path.join(require('node:os').homedir(), '.claude', 'agents');
console.log(chalk.dim(` Installing subagents globally to: ~/.claude/agents/`));
} else {
targetDir = path.join(projectDir, '.claude', 'agents');
console.log(chalk.dim(` Installing subagents to project: .claude/agents/`));
}
// Ensure target directory exists
await this.ensureDir(targetDir);
// Determine which files to copy
let filesToCopy = [];
if (choices.install === 'all') {
filesToCopy = subagentConfig.files;
} else if (choices.install === 'selective') {
filesToCopy = choices.selected;
}
// Copy selected subagent files
for (const file of filesToCopy) {
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
if (await this.exists(sourcePath)) {
await fs.copyFile(sourcePath, targetPath);
console.log(chalk.green(` ✓ Installed: ${file.replace('.md', '')}`));
}
}
if (filesToCopy.length > 0) {
console.log(chalk.dim(` Total subagents installed: ${filesToCopy.length}`));
}
}
}
module.exports = { ClaudeCodeSetup };

View File

@@ -0,0 +1,301 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const inquirer = require('inquirer');
/**
* Cline IDE setup handler
* Creates rules in .clinerules directory with ordering support
*/
class ClineSetup extends BaseIdeSetup {
constructor() {
super('cline', 'Cline');
this.configDir = '.clinerules';
this.defaultOrder = {
core: 10,
bmm: 20,
cis: 30,
other: 99,
};
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const response = await inquirer.prompt([
{
type: 'list',
name: 'ordering',
message: 'How should BMAD rules be ordered in Cline?',
choices: [
{ name: 'By module (core first, then modules)', value: 'module' },
{ name: 'By importance (dev agents first)', value: 'importance' },
{ name: 'Alphabetical (simple A-Z ordering)', value: 'alphabetical' },
{ name: "Custom (I'll reorder manually)", value: 'custom' },
],
default: 'module',
},
]);
return { ordering: response.ordering };
}
/**
* Setup Cline IDE 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 .clinerules directory
const clineRulesDir = path.join(projectDir, this.configDir);
await this.ensureDir(clineRulesDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Use pre-collected configuration if available
const config = options.preCollectedConfig || {};
const orderingStrategy = config.ordering || options.ordering || 'module';
// Process agents as rules with ordering
let ruleCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const order = this.getOrder(agent, orderingStrategy);
const processedContent = this.createAgentRule(agent, content, projectDir);
// Use numeric prefix for ordering
const prefix = order.toString().padStart(2, '0');
const targetPath = path.join(clineRulesDir, `${prefix}-${agent.module}-${agent.name}.md`);
await this.writeFile(targetPath, processedContent);
ruleCount++;
}
// Process tasks with ordering
for (const task of tasks) {
const content = await this.readFile(task.path);
const order = this.getTaskOrder(task, orderingStrategy);
const processedContent = this.createTaskRule(task, content);
// Tasks get higher order numbers to appear after agents
const prefix = (order + 50).toString().padStart(2, '0');
const targetPath = path.join(clineRulesDir, `${prefix}-task-${task.module}-${task.name}.md`);
await this.writeFile(targetPath, processedContent);
ruleCount++;
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${ruleCount} rules created in ${path.relative(projectDir, clineRulesDir)}`));
console.log(chalk.dim(` - Ordering: ${orderingStrategy}`));
// Important message about toggle system
console.log(chalk.yellow('\n ⚠️ IMPORTANT: Cline Toggle System'));
console.log(chalk.cyan(' Rules are OFF by default to avoid context pollution'));
console.log(chalk.dim(' To use BMAD agents:'));
console.log(chalk.dim(' 1. Click rules icon below chat input'));
console.log(chalk.dim(' 2. Toggle ON the specific agent you need'));
console.log(chalk.dim(' 3. Type @{agent-name} to activate'));
console.log(chalk.dim(' 4. Toggle OFF when done to free context'));
console.log(chalk.dim('\n 💡 Best practice: Only enable 1-2 agents at a time'));
return {
success: true,
rules: ruleCount,
ordering: orderingStrategy,
};
}
/**
* Ask user about rule ordering strategy
*/
async askOrderingStrategy() {
const response = await inquirer.prompt([
{
type: 'list',
name: 'ordering',
message: 'How should BMAD rules be ordered in Cline?',
choices: [
{ name: 'By module (core first, then modules)', value: 'module' },
{ name: 'By importance (dev agents first)', value: 'importance' },
{ name: 'Alphabetical (simple A-Z ordering)', value: 'alphabetical' },
{ name: "Custom (I'll reorder manually)", value: 'custom' },
],
default: 'module',
},
]);
return response.ordering;
}
/**
* Get order number for an agent based on strategy
*/
getOrder(agent, strategy) {
switch (strategy) {
case 'module': {
return this.defaultOrder[agent.module] || this.defaultOrder.other;
}
case 'importance': {
// Prioritize certain agent types
if (agent.name.includes('dev') || agent.name.includes('code')) return 10;
if (agent.name.includes('architect') || agent.name.includes('design')) return 15;
if (agent.name.includes('test') || agent.name.includes('qa')) return 20;
if (agent.name.includes('doc') || agent.name.includes('write')) return 25;
if (agent.name.includes('review')) return 30;
return 40;
}
case 'alphabetical': {
// Use a fixed number, files will sort alphabetically by name
return 50;
}
default: {
// 'custom' or any other value - user will reorder manually
return 99;
}
}
}
/**
* Get order number for a task
*/
getTaskOrder(task, strategy) {
// Tasks always come after agents
return this.getOrder(task, strategy) + 50;
}
/**
* Create rule content for an agent
*/
createAgentRule(agent, content, projectDir) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
// Extract YAML content
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlContent = yamlMatch ? yamlMatch[1] : content;
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
let ruleContent = `# ${title} Agent
This rule defines the ${title} persona and project standards.
## Role Definition
When the user types \`@${agent.name}\`, adopt this persona and follow these guidelines:
\`\`\`yaml
${yamlContent}
\`\`\`
## Project Standards
- Always maintain consistency with project documentation in BMAD directories
- Follow the agent's specific guidelines and constraints
- Update relevant project files when making changes
- Reference the complete agent definition in [${relativePath}](${relativePath})
## Usage
Type \`@${agent.name}\` to activate this ${title} persona.
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.
`;
return ruleContent;
}
/**
* Create rule content for a task
*/
createTaskRule(task, content) {
// Extract task name
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
let ruleContent = `# ${taskName} Task
This rule defines the ${taskName} task workflow.
## Task Workflow
When this task is referenced, execute the following steps:
${content}
## Project Integration
- This task follows BMAD Method standards
- Ensure all outputs align with project conventions
- Update relevant documentation after task completion
## Usage
Reference with \`@task-${task.name}\` to access this workflow.
## Module
Part of the BMAD ${task.module.toUpperCase()} module.
`;
return ruleContent;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Cline configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const clineRulesDir = path.join(projectDir, this.configDir);
if (await fs.pathExists(clineRulesDir)) {
// Remove all numbered BMAD rules
const files = await fs.readdir(clineRulesDir);
let removed = 0;
for (const file of files) {
// Check if it matches our naming pattern (XX-module-name.md)
if (/^\d{2}-.*\.md$/.test(file)) {
const filePath = path.join(clineRulesDir, file);
const content = await fs.readFile(filePath, 'utf8');
// Verify it's a BMAD rule
if (content.includes('BMAD') && content.includes('Module')) {
await fs.remove(filePath);
removed++;
}
}
}
console.log(chalk.dim(`Removed ${removed} BMAD rules from Cline`));
}
}
}
module.exports = { ClineSetup };

View File

@@ -0,0 +1,267 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const inquirer = require('inquirer');
/**
* Codex setup handler (supports both CLI and Web)
* Creates comprehensive AGENTS.md file in project root
*/
class CodexSetup extends BaseIdeSetup {
constructor() {
super('codex', 'Codex', true); // preferred IDE
this.agentsFile = 'AGENTS.md';
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const response = await inquirer.prompt([
{
type: 'list',
name: 'mode',
message: 'Select Codex deployment mode:',
choices: [
{ name: 'CLI (Command-line interface)', value: 'cli' },
{ name: 'Web (Browser-based interface)', value: 'web' },
],
default: 'cli',
},
]);
return { codexMode: response.mode };
}
/**
* Setup Codex 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}...`));
// Use pre-collected configuration if available
const config = options.preCollectedConfig || {};
const mode = config.codexMode || options.codexMode || 'cli';
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Create AGENTS.md content
const content = this.createAgentsDocument(agents, tasks, mode);
// Write AGENTS.md file
const agentsPath = path.join(projectDir, this.agentsFile);
await this.writeFile(agentsPath, content);
// Handle mode-specific setup
if (mode === 'web') {
await this.setupWebMode(projectDir);
} else {
await this.setupCliMode(projectDir);
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - Mode: ${mode === 'web' ? 'Web' : 'CLI'}`));
console.log(chalk.dim(` - ${agents.length} agents documented`));
console.log(chalk.dim(` - ${tasks.length} tasks documented`));
console.log(chalk.dim(` - Agents file: ${this.agentsFile}`));
return {
success: true,
mode,
agents: agents.length,
tasks: tasks.length,
};
}
/**
* Select Codex mode (CLI or Web)
*/
async selectMode() {
const response = await inquirer.prompt([
{
type: 'list',
name: 'mode',
message: 'Select Codex deployment mode:',
choices: [
{ name: 'CLI (Command-line interface)', value: 'cli' },
{ name: 'Web (Browser-based interface)', value: 'web' },
],
default: 'cli',
},
]);
return response.mode;
}
/**
* Create comprehensive agents document
*/
createAgentsDocument(agents, tasks, mode) {
let content = `# BMAD Method - Agent Directory
This document contains all available BMAD agents and tasks for use with Codex ${mode === 'web' ? 'Web' : 'CLI'}.
## Quick Start
${
mode === 'web'
? `Access agents through the web interface:
1. Navigate to the Agents section
2. Select an agent to activate
3. The agent persona will be active for your session`
: `Activate agents in CLI:
1. Reference agents using \`@{agent-name}\`
2. Execute tasks using \`@task-{task-name}\`
3. Agents remain active for the conversation`
}
---
## Available Agents
`;
// Group agents by module
const agentsByModule = {};
for (const agent of agents) {
if (!agentsByModule[agent.module]) {
agentsByModule[agent.module] = [];
}
agentsByModule[agent.module].push(agent);
}
// Document each module's agents
for (const [module, moduleAgents] of Object.entries(agentsByModule)) {
content += `### ${module.toUpperCase()} Module\n\n`;
for (const agent of moduleAgents) {
const agentContent = this.readFileSync(agent.path);
const titleMatch = agentContent.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = agentContent.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
const whenToUseMatch = agentContent.match(/whenToUse="([^"]+)"/);
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
content += `#### ${icon} ${title} (\`@${agent.name}\`)\n\n`;
content += `**When to use:** ${whenToUse}\n\n`;
content += `**Activation:** Type \`@${agent.name}\` to activate this agent.\n\n`;
}
}
content += `---
## Available Tasks
`;
// Group tasks by module
const tasksByModule = {};
for (const task of tasks) {
if (!tasksByModule[task.module]) {
tasksByModule[task.module] = [];
}
tasksByModule[task.module].push(task);
}
// Document each module's tasks
for (const [module, moduleTasks] of Object.entries(tasksByModule)) {
content += `### ${module.toUpperCase()} Module Tasks\n\n`;
for (const task of moduleTasks) {
const taskContent = this.readFileSync(task.path);
const nameMatch = taskContent.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
content += `- **${taskName}** (\`@task-${task.name}\`)\n`;
}
content += '\n';
}
content += `---
## Usage Guidelines
1. **One agent at a time**: Activate a single agent for focused assistance
2. **Task execution**: Tasks are one-time workflows, not persistent personas
3. **Module organization**: Agents and tasks are grouped by their source module
4. **Context preservation**: ${mode === 'web' ? 'Sessions maintain agent context' : 'Conversations maintain agent context'}
---
*Generated by BMAD Method installer for Codex ${mode === 'web' ? 'Web' : 'CLI'}*
`;
return content;
}
/**
* Read file synchronously (for document generation)
*/
readFileSync(filePath) {
const fs = require('node:fs');
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return '';
}
}
/**
* Setup for CLI mode
*/
async setupCliMode(projectDir) {
// CLI mode - ensure .gitignore includes AGENTS.md if needed
const fs = require('fs-extra');
const gitignorePath = path.join(projectDir, '.gitignore');
if (await fs.pathExists(gitignorePath)) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
if (!gitignoreContent.includes('AGENTS.md')) {
// User can decide whether to track this file
console.log(chalk.dim(' Note: Consider adding AGENTS.md to .gitignore if desired'));
}
}
}
/**
* Setup for Web mode
*/
async setupWebMode(projectDir) {
// Web mode - add to .gitignore to avoid committing
const fs = require('fs-extra');
const gitignorePath = path.join(projectDir, '.gitignore');
if (await fs.pathExists(gitignorePath)) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
if (!gitignoreContent.includes('AGENTS.md')) {
await fs.appendFile(gitignorePath, '\n# Codex Web agents file\nAGENTS.md\n');
console.log(chalk.dim(' Added AGENTS.md to .gitignore for web deployment'));
}
}
}
/**
* Cleanup Codex configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const agentsPath = path.join(projectDir, this.agentsFile);
if (await fs.pathExists(agentsPath)) {
await fs.remove(agentsPath);
console.log(chalk.dim('Removed AGENTS.md file'));
}
}
}
module.exports = { CodexSetup };

View File

@@ -0,0 +1,204 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Crush IDE setup handler
* Creates commands in .crush/commands/ directory structure
*/
class CrushSetup extends BaseIdeSetup {
constructor() {
super('crush', 'Crush');
this.configDir = '.crush';
this.commandsDir = 'commands';
}
/**
* Setup Crush IDE 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 .crush/commands/bmad directory structure
const crushDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(crushDir, this.commandsDir, 'bmad');
const agentsDir = path.join(commandsDir, 'agents');
const tasksDir = path.join(commandsDir, 'tasks');
await this.ensureDir(agentsDir);
await this.ensureDir(tasksDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Setup agents as commands
let agentCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const commandContent = this.createAgentCommand(agent, content, projectDir);
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(targetPath, commandContent);
agentCount++;
}
// Setup tasks as commands
let taskCount = 0;
for (const task of tasks) {
const content = await this.readFile(task.path);
const commandContent = this.createTaskCommand(task, content);
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
await this.writeFile(targetPath, commandContent);
taskCount++;
}
// Create module-specific subdirectories for better organization
await this.organizeByModule(commandsDir, agents, tasks, bmadDir);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agent commands created`));
console.log(chalk.dim(` - ${taskCount} task commands created`));
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
console.log(chalk.dim('\n Commands can be accessed via Crush command palette'));
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
/**
* Organize commands by module
*/
async organizeByModule(commandsDir, agents, tasks, bmadDir) {
// Get unique modules
const modules = new Set();
for (const agent of agents) modules.add(agent.module);
for (const task of tasks) modules.add(task.module);
// Create module directories
for (const module of modules) {
const moduleDir = path.join(commandsDir, module);
const moduleAgentsDir = path.join(moduleDir, 'agents');
const moduleTasksDir = path.join(moduleDir, 'tasks');
await this.ensureDir(moduleAgentsDir);
await this.ensureDir(moduleTasksDir);
// Copy module-specific agents
const moduleAgents = agents.filter((a) => a.module === module);
for (const agent of moduleAgents) {
const content = await this.readFile(agent.path);
const commandContent = this.createAgentCommand(agent, content, bmadDir);
const targetPath = path.join(moduleAgentsDir, `${agent.name}.md`);
await this.writeFile(targetPath, commandContent);
}
// Copy module-specific tasks
const moduleTasks = tasks.filter((t) => t.module === module);
for (const task of moduleTasks) {
const content = await this.readFile(task.path);
const commandContent = this.createTaskCommand(task, content);
const targetPath = path.join(moduleTasksDir, `${task.name}.md`);
await this.writeFile(targetPath, commandContent);
}
}
}
/**
* Create agent command content
*/
createAgentCommand(agent, content, projectDir) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
let commandContent = `# /${agent.name} Command
When this command is used, adopt the following agent persona:
## ${icon} ${title} Agent
${content}
## Command Usage
This command activates the ${title} agent from the BMAD ${agent.module.toUpperCase()} module.
## File Reference
Complete agent definition: [${relativePath}](${relativePath})
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.
`;
return commandContent;
}
/**
* Create task command content
*/
createTaskCommand(task, content) {
// Extract task name
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
let commandContent = `# /task-${task.name} Command
When this command is used, execute the following task:
## ${taskName} Task
${content}
## Command Usage
This command executes the ${taskName} task from the BMAD ${task.module.toUpperCase()} module.
## Module
Part of the BMAD ${task.module.toUpperCase()} module.
`;
return commandContent;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Crush configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
if (await fs.pathExists(bmadCommandsDir)) {
await fs.remove(bmadCommandsDir);
console.log(chalk.dim(`Removed BMAD commands from Crush`));
}
}
}
module.exports = { CrushSetup };

View File

@@ -0,0 +1,224 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Cursor IDE setup handler
*/
class CursorSetup extends BaseIdeSetup {
constructor() {
super('cursor', 'Cursor', true); // preferred IDE
this.configDir = '.cursor';
this.rulesDir = 'rules';
}
/**
* Setup Cursor IDE 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 .cursor/rules directory structure
const cursorDir = path.join(projectDir, this.configDir);
const rulesDir = path.join(cursorDir, this.rulesDir);
const bmadRulesDir = path.join(rulesDir, 'bmad');
await this.ensureDir(bmadRulesDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Create directories for each module
const modules = new Set();
for (const item of [...agents, ...tasks]) modules.add(item.module);
for (const module of modules) {
await this.ensureDir(path.join(bmadRulesDir, module));
await this.ensureDir(path.join(bmadRulesDir, module, 'agents'));
await this.ensureDir(path.join(bmadRulesDir, module, 'tasks'));
}
// Process and copy agents
let agentCount = 0;
for (const agent of agents) {
const content = await this.readAndProcess(agent.path, {
module: agent.module,
name: agent.name,
});
const targetPath = path.join(bmadRulesDir, agent.module, 'agents', `${agent.name}.mdc`);
await this.writeFile(targetPath, content);
agentCount++;
}
// Process and copy tasks
let taskCount = 0;
for (const task of tasks) {
const content = await this.readAndProcess(task.path, {
module: task.module,
name: task.name,
});
const targetPath = path.join(bmadRulesDir, task.module, 'tasks', `${task.name}.mdc`);
await this.writeFile(targetPath, content);
taskCount++;
}
// Create BMAD index file (but NOT .cursorrules - user manages that)
await this.createBMADIndex(bmadRulesDir, agents, tasks, modules);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents installed`));
console.log(chalk.dim(` - ${taskCount} tasks installed`));
console.log(chalk.dim(` - Rules directory: ${path.relative(projectDir, bmadRulesDir)}`));
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
/**
* Create BMAD index file for easy navigation
*/
async createBMADIndex(bmadRulesDir, agents, tasks, modules) {
const indexPath = path.join(bmadRulesDir, 'index.mdc');
let content = `---
description: BMAD Method - Master Index
globs:
alwaysApply: true
---
# BMAD Method - Cursor Rules Index
This is the master index for all BMAD agents and tasks available in your project.
## Installation Complete!
BMAD rules have been installed to: \`.cursor/rules/bmad/\`
**Note:** BMAD does not modify your \`.cursorrules\` file. You manage that separately.
## How to Use
- Reference specific agents: @bmad/{module}/agents/{agent-name}
- Reference specific tasks: @bmad/{module}/tasks/{task-name}
- Reference entire modules: @bmad/{module}
- Reference this index: @bmad/index
## Available Modules
`;
for (const module of modules) {
content += `### ${module.toUpperCase()}\n\n`;
// List agents for this module
const moduleAgents = agents.filter((a) => a.module === module);
if (moduleAgents.length > 0) {
content += `**Agents:**\n`;
for (const agent of moduleAgents) {
content += `- @bmad/${module}/agents/${agent.name} - ${agent.name}\n`;
}
content += '\n';
}
// List tasks for this module
const moduleTasks = tasks.filter((t) => t.module === module);
if (moduleTasks.length > 0) {
content += `**Tasks:**\n`;
for (const task of moduleTasks) {
content += `- @bmad/${module}/tasks/${task.name} - ${task.name}\n`;
}
content += '\n';
}
}
content += `
## Quick Reference
- All BMAD rules are Manual type - reference them explicitly when needed
- Agents provide persona-based assistance with specific expertise
- Tasks are reusable workflows for common operations
- Each agent includes an activation block for proper initialization
## Configuration
BMAD rules are configured as Manual rules (alwaysApply: false) to give you control
over when they're included in your context. Reference them explicitly when you need
specific agent expertise or task workflows.
`;
await this.writeFile(indexPath, content);
}
/**
* Read and process file content
*/
async readAndProcess(filePath, metadata) {
const fs = require('fs-extra');
const content = await fs.readFile(filePath, 'utf8');
return this.processContent(content, metadata);
}
/**
* Override processContent to add MDC metadata header for Cursor
* @param {string} content - File content
* @param {Object} metadata - File metadata
* @returns {string} Processed content with MDC header
*/
processContent(content, metadata = {}) {
// First apply base processing (includes activation injection for agents)
let processed = super.processContent(content, metadata);
// Determine the type and description based on content
const isAgent = content.includes('<agent');
const isTask = content.includes('<task');
let description = '';
let globs = '';
if (isAgent) {
// Extract agent title if available
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : metadata.name;
description = `BMAD ${metadata.module.toUpperCase()} Agent: ${title}`;
// Manual rules for agents don't need globs
globs = '';
} else if (isTask) {
// Extract task name if available
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : metadata.name;
description = `BMAD ${metadata.module.toUpperCase()} Task: ${taskName}`;
// Tasks might be auto-attached to certain file types
globs = '';
} else {
description = `BMAD ${metadata.module.toUpperCase()}: ${metadata.name}`;
globs = '';
}
// Create MDC metadata header
const mdcHeader = `---
description: ${description}
globs: ${globs}
alwaysApply: false
---
`;
// Add the MDC header to the processed content
return mdcHeader + processed;
}
}
module.exports = { CursorSetup };

View File

@@ -0,0 +1,160 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Gemini CLI setup handler
* Creates TOML files in .gemini/commands/ structure
*/
class GeminiSetup extends BaseIdeSetup {
constructor() {
super('gemini', 'Gemini CLI', true); // preferred IDE
this.configDir = '.gemini';
this.commandsDir = 'commands';
}
/**
* Setup Gemini CLI 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 .gemini/commands/agents and .gemini/commands/tasks directories
const geminiDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(geminiDir, this.commandsDir);
const agentsDir = path.join(commandsDir, 'agents');
const tasksDir = path.join(commandsDir, 'tasks');
await this.ensureDir(agentsDir);
await this.ensureDir(tasksDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Install agents as TOML files
let agentCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const tomlContent = this.createAgentToml(agent, content, bmadDir);
const tomlPath = path.join(agentsDir, `${agent.name}.toml`);
await this.writeFile(tomlPath, tomlContent);
agentCount++;
console.log(chalk.green(` ✓ Added agent: /bmad:agents:${agent.name}`));
}
// Install tasks as TOML files
let taskCount = 0;
for (const task of tasks) {
const content = await this.readFile(task.path);
const tomlContent = this.createTaskToml(task, content, bmadDir);
const tomlPath = path.join(tasksDir, `${task.name}.toml`);
await this.writeFile(tomlPath, tomlContent);
taskCount++;
console.log(chalk.green(` ✓ Added task: /bmad:tasks:${task.name}`));
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents configured`));
console.log(chalk.dim(` - ${taskCount} tasks configured`));
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
console.log(chalk.dim(` - Agent activation: /bmad:agents:{agent-name}`));
console.log(chalk.dim(` - Task activation: /bmad:tasks:{task-name}`));
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
/**
* Create agent TOML content
*/
createAgentToml(agent, content, bmadDir) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
// Get relative path from project root to agent file
const relativePath = path.relative(process.cwd(), agent.path).replaceAll('\\', '/');
// Create TOML content
const tomlContent = `description = "Activates the ${title} agent from the BMad Method."
prompt = """
CRITICAL: You are now the BMad '${title}' agent. Adopt its persona and capabilities as defined in the following configuration.
Read and internalize the full agent definition, following all instructions and maintaining this persona until explicitly told to switch or exit.
@${relativePath}
"""
`;
return tomlContent;
}
/**
* Create task TOML content
*/
createTaskToml(task, content, bmadDir) {
// Extract task name from XML if available
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
// Get relative path from project root to task file
const relativePath = path.relative(process.cwd(), task.path).replaceAll('\\', '/');
// Create TOML content
const tomlContent = `description = "Executes the ${taskName} task from the BMad Method."
prompt = """
Execute the following BMad Method task workflow:
@${relativePath}
Follow all instructions and complete the task as defined.
"""
`;
return tomlContent;
}
/**
* Cleanup Gemini configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const commandsDir = path.join(projectDir, this.configDir, this.commandsDir);
const agentsDir = path.join(commandsDir, 'agents');
const tasksDir = path.join(commandsDir, 'tasks');
// Remove BMAD TOML files
if (await fs.pathExists(agentsDir)) {
const files = await fs.readdir(agentsDir);
for (const file of files) {
if (file.endsWith('.toml')) {
await fs.remove(path.join(agentsDir, file));
}
}
}
if (await fs.pathExists(tasksDir)) {
const files = await fs.readdir(tasksDir);
for (const file of files) {
if (file.endsWith('.toml')) {
await fs.remove(path.join(tasksDir, file));
}
}
}
console.log(chalk.dim(`Removed BMAD configuration from Gemini CLI`));
}
}
module.exports = { GeminiSetup };

View File

@@ -0,0 +1,289 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const inquirer = require('inquirer');
/**
* GitHub Copilot setup handler
* Creates chat modes in .github/chatmodes/ and configures VS Code settings
*/
class GitHubCopilotSetup extends BaseIdeSetup {
constructor() {
super('github-copilot', 'GitHub Copilot', true); // preferred IDE
this.configDir = '.github';
this.chatmodesDir = 'chatmodes';
this.vscodeDir = '.vscode';
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const config = {};
console.log('\n' + chalk.blue(' 🔧 VS Code Settings Configuration'));
console.log(chalk.dim(' GitHub Copilot works best with specific settings\n'));
const response = await inquirer.prompt([
{
type: 'list',
name: 'configChoice',
message: 'How would you like to configure VS Code settings?',
choices: [
{ name: 'Use recommended defaults (fastest)', value: 'defaults' },
{ name: 'Configure each setting manually', value: 'manual' },
{ name: 'Skip settings configuration', value: 'skip' },
],
default: 'defaults',
},
]);
config.vsCodeConfig = response.configChoice;
if (response.configChoice === 'manual') {
config.manualSettings = await inquirer.prompt([
{
type: 'input',
name: 'maxRequests',
message: 'Maximum requests per session (1-50)?',
default: '15',
validate: (input) => {
const num = parseInt(input);
return (num >= 1 && num <= 50) || 'Enter 1-50';
},
},
{
type: 'confirm',
name: 'runTasks',
message: 'Allow running workspace tasks?',
default: true,
},
{
type: 'confirm',
name: 'mcpDiscovery',
message: 'Enable MCP server discovery?',
default: true,
},
{
type: 'confirm',
name: 'autoFix',
message: 'Enable automatic error fixing?',
default: true,
},
{
type: 'confirm',
name: 'autoApprove',
message: 'Auto-approve tools (less secure)?',
default: false,
},
]);
}
return config;
}
/**
* 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}...`));
// Configure VS Code settings using pre-collected config if available
const config = options.preCollectedConfig || {};
await this.configureVsCodeSettings(projectDir, { ...options, ...config });
// Create .github/chatmodes directory
const githubDir = path.join(projectDir, this.configDir);
const chatmodesDir = path.join(githubDir, this.chatmodesDir);
await this.ensureDir(chatmodesDir);
// Get agents
const agents = await this.getAgents(bmadDir);
// Create chat mode files
let modeCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const chatmodeContent = this.createChatmodeContent(agent, content);
const targetPath = path.join(chatmodesDir, `${agent.module}-${agent.name}.chatmode.md`);
await this.writeFile(targetPath, chatmodeContent);
modeCount++;
console.log(chalk.green(` ✓ Created chat mode: ${agent.module}-${agent.name}`));
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${modeCount} chat modes created`));
console.log(chalk.dim(` - Chat modes directory: ${path.relative(projectDir, chatmodesDir)}`));
console.log(chalk.dim(` - VS Code settings configured`));
console.log(chalk.dim('\n Chat modes available in VS Code Chat view'));
return {
success: true,
chatmodes: modeCount,
settings: true,
};
}
/**
* Configure VS Code settings for GitHub Copilot
*/
async configureVsCodeSettings(projectDir, options) {
const fs = require('fs-extra');
const vscodeDir = path.join(projectDir, this.vscodeDir);
const settingsPath = path.join(vscodeDir, 'settings.json');
await this.ensureDir(vscodeDir);
// Read existing settings
let existingSettings = {};
if (await fs.pathExists(settingsPath)) {
try {
const content = await fs.readFile(settingsPath, 'utf8');
existingSettings = JSON.parse(content);
console.log(chalk.yellow(' Found existing .vscode/settings.json'));
} catch {
console.warn(chalk.yellow(' Could not parse settings.json, creating new'));
}
}
// Use pre-collected configuration or skip if not available
let configChoice = options.vsCodeConfig;
if (!configChoice) {
// If no pre-collected config, skip configuration
console.log(chalk.yellow(' ⚠ No configuration collected, skipping VS Code settings'));
return;
}
if (configChoice === 'skip') {
console.log(chalk.yellow(' ⚠ Skipping VS Code settings'));
return;
}
let bmadSettings = {};
if (configChoice === 'defaults') {
bmadSettings = {
'chat.agent.enabled': true,
'chat.agent.maxRequests': 15,
'github.copilot.chat.agent.runTasks': true,
'chat.mcp.discovery.enabled': true,
'github.copilot.chat.agent.autoFix': true,
'chat.tools.autoApprove': false,
};
console.log(chalk.green(' ✓ Using recommended defaults'));
} else {
// Manual configuration - use pre-collected settings
const manual = options.manualSettings || {};
bmadSettings = {
'chat.agent.enabled': true,
'chat.agent.maxRequests': parseInt(manual.maxRequests || 15),
'github.copilot.chat.agent.runTasks': manual.runTasks === undefined ? true : manual.runTasks,
'chat.mcp.discovery.enabled': manual.mcpDiscovery === undefined ? true : manual.mcpDiscovery,
'github.copilot.chat.agent.autoFix': manual.autoFix === undefined ? true : manual.autoFix,
'chat.tools.autoApprove': manual.autoApprove || false,
};
}
// Merge settings (existing take precedence)
const mergedSettings = { ...bmadSettings, ...existingSettings };
// Write settings
await fs.writeFile(settingsPath, JSON.stringify(mergedSettings, null, 2));
console.log(chalk.green(' ✓ VS Code settings configured'));
}
/**
* Create chat mode content
*/
createChatmodeContent(agent, content) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
const description = whenToUseMatch ? whenToUseMatch[1] : `Activates the ${title} agent persona.`;
// Available GitHub Copilot tools
const tools = [
'changes',
'codebase',
'fetch',
'findTestFiles',
'githubRepo',
'problems',
'usages',
'editFiles',
'runCommands',
'runTasks',
'runTests',
'search',
'searchResults',
'terminalLastCommand',
'terminalSelection',
'testFailure',
];
let chatmodeContent = `---
description: "${description.replaceAll('"', String.raw`\"`)}"
tools: ${JSON.stringify(tools)}
---
# ${title} Agent
${content}
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.
`;
return chatmodeContent;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup GitHub Copilot configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const chatmodesDir = path.join(projectDir, this.configDir, this.chatmodesDir);
if (await fs.pathExists(chatmodesDir)) {
// Remove BMAD chat modes
const files = await fs.readdir(chatmodesDir);
let removed = 0;
for (const file of files) {
if (file.endsWith('.chatmode.md')) {
const filePath = path.join(chatmodesDir, file);
const content = await fs.readFile(filePath, 'utf8');
if (content.includes('BMAD') && content.includes('Module')) {
await fs.remove(filePath);
removed++;
}
}
}
console.log(chalk.dim(`Removed ${removed} BMAD chat modes from GitHub Copilot`));
}
}
}
module.exports = { GitHubCopilotSetup };

View File

@@ -0,0 +1,142 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* iFlow CLI setup handler
* Creates commands in .iflow/commands/ directory structure
*/
class IFlowSetup extends BaseIdeSetup {
constructor() {
super('iflow', 'iFlow CLI');
this.configDir = '.iflow';
this.commandsDir = 'commands';
}
/**
* Setup iFlow CLI 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 .iflow/commands/bmad directory structure
const iflowDir = path.join(projectDir, this.configDir);
const commandsDir = path.join(iflowDir, this.commandsDir, 'bmad');
const agentsDir = path.join(commandsDir, 'agents');
const tasksDir = path.join(commandsDir, 'tasks');
await this.ensureDir(agentsDir);
await this.ensureDir(tasksDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Setup agents as commands
let agentCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const commandContent = this.createAgentCommand(agent, content);
const targetPath = path.join(agentsDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(targetPath, commandContent);
agentCount++;
}
// Setup tasks as commands
let taskCount = 0;
for (const task of tasks) {
const content = await this.readFile(task.path);
const commandContent = this.createTaskCommand(task, content);
const targetPath = path.join(tasksDir, `${task.module}-${task.name}.md`);
await this.writeFile(targetPath, commandContent);
taskCount++;
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agent commands created`));
console.log(chalk.dim(` - ${taskCount} task commands created`));
console.log(chalk.dim(` - Commands directory: ${path.relative(projectDir, commandsDir)}`));
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
/**
* Create agent command content
*/
createAgentCommand(agent, content) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
let commandContent = `# /${agent.name} Command
When this command is used, adopt the following agent persona:
## ${title} Agent
${content}
## Usage
This command activates the ${title} agent from the BMAD ${agent.module.toUpperCase()} module.
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.
`;
return commandContent;
}
/**
* Create task command content
*/
createTaskCommand(task, content) {
// Extract task name
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
let commandContent = `# /task-${task.name} Command
When this command is used, execute the following task:
## ${taskName} Task
${content}
## Usage
This command executes the ${taskName} task from the BMAD ${task.module.toUpperCase()} module.
## Module
Part of the BMAD ${task.module.toUpperCase()} module.
`;
return commandContent;
}
/**
* Cleanup iFlow configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const bmadCommandsDir = path.join(projectDir, this.configDir, this.commandsDir, 'bmad');
if (await fs.pathExists(bmadCommandsDir)) {
await fs.remove(bmadCommandsDir);
console.log(chalk.dim(`Removed BMAD commands from iFlow CLI`));
}
}
}
module.exports = { IFlowSetup };

View File

@@ -0,0 +1,171 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* KiloCode IDE setup handler
* Creates custom modes in .kilocodemodes file (similar to Roo)
*/
class KiloSetup extends BaseIdeSetup {
constructor() {
super('kilo', 'Kilo Code');
this.configFile = '.kilocodemodes';
}
/**
* Setup KiloCode IDE 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}...`));
// Check for existing .kilocodemodes file
const kiloModesPath = path.join(projectDir, this.configFile);
let existingModes = [];
let existingContent = '';
if (await this.pathExists(kiloModesPath)) {
existingContent = await this.readFile(kiloModesPath);
// Parse existing modes
const modeMatches = existingContent.matchAll(/- slug: ([\w-]+)/g);
for (const match of modeMatches) {
existingModes.push(match[1]);
}
console.log(chalk.yellow(`Found existing .kilocodemodes file with ${existingModes.length} modes`));
}
// Get agents
const agents = await this.getAgents(bmadDir);
// Create modes content
let newModesContent = '';
let addedCount = 0;
let skippedCount = 0;
for (const agent of agents) {
const slug = `bmad-${agent.module}-${agent.name}`;
// Skip if already exists
if (existingModes.includes(slug)) {
console.log(chalk.dim(` Skipping ${slug} - already exists`));
skippedCount++;
continue;
}
const content = await this.readFile(agent.path);
const modeEntry = this.createModeEntry(agent, content, projectDir);
newModesContent += modeEntry;
addedCount++;
}
// Build final content
let finalContent = '';
if (existingContent) {
finalContent = existingContent.trim() + '\n' + newModesContent;
} else {
finalContent = 'customModes:\n' + newModesContent;
}
// Write .kilocodemodes file
await this.writeFile(kiloModesPath, finalContent);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${addedCount} modes added`));
if (skippedCount > 0) {
console.log(chalk.dim(` - ${skippedCount} modes skipped (already exist)`));
}
console.log(chalk.dim(` - Configuration file: ${this.configFile}`));
console.log(chalk.dim('\n Modes will be available when you open this project in KiloCode'));
return {
success: true,
modes: addedCount,
skipped: skippedCount,
};
}
/**
* Create a mode entry for an agent
*/
createModeEntry(agent, content, projectDir) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
const roleDefinitionMatch = content.match(/roleDefinition="([^"]+)"/);
const roleDefinition = roleDefinitionMatch
? roleDefinitionMatch[1]
: `You are a ${title} specializing in ${title.toLowerCase()} tasks.`;
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
// Build mode entry (KiloCode uses same schema as Roo)
const slug = `bmad-${agent.module}-${agent.name}`;
let modeEntry = ` - slug: ${slug}\n`;
modeEntry += ` name: '${icon} ${title}'\n`;
modeEntry += ` roleDefinition: ${roleDefinition}\n`;
modeEntry += ` whenToUse: ${whenToUse}\n`;
modeEntry += ` customInstructions: CRITICAL Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
modeEntry += ` groups:\n`;
modeEntry += ` - read\n`;
modeEntry += ` - edit\n`;
return modeEntry;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup KiloCode configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const kiloModesPath = path.join(projectDir, this.configFile);
if (await fs.pathExists(kiloModesPath)) {
const content = await fs.readFile(kiloModesPath, 'utf8');
// Remove BMAD modes only
const lines = content.split('\n');
const filteredLines = [];
let skipMode = false;
let removedCount = 0;
for (const line of lines) {
if (/^\s*- slug: bmad-/.test(line)) {
skipMode = true;
removedCount++;
} else if (skipMode && /^\s*- slug: /.test(line)) {
skipMode = false;
}
if (!skipMode) {
filteredLines.push(line);
}
}
await fs.writeFile(kiloModesPath, filteredLines.join('\n'));
console.log(chalk.dim(`Removed ${removedCount} BMAD modes from .kilocodemodes`));
}
}
}
module.exports = { KiloSetup };

View File

@@ -0,0 +1,203 @@
const fs = require('fs-extra');
const path = require('node:path');
const chalk = require('chalk');
/**
* IDE Manager - handles IDE-specific setup
* Dynamically discovers and loads IDE handlers
*/
class IdeManager {
constructor() {
this.handlers = new Map();
this.loadHandlers();
}
/**
* Dynamically load all IDE handlers from directory
*/
loadHandlers() {
const ideDir = __dirname;
try {
// Get all JS files in the IDE directory
const files = fs.readdirSync(ideDir).filter((file) => {
// Skip base class, manager, utility files (starting with _), and helper modules
return file.endsWith('.js') && !file.startsWith('_') && file !== 'manager.js' && file !== 'workflow-command-generator.js';
});
// Sort alphabetically for consistent ordering
files.sort();
for (const file of files) {
const moduleName = path.basename(file, '.js');
try {
const modulePath = path.join(ideDir, file);
const HandlerModule = require(modulePath);
// Get the first exported class (handles various export styles)
const HandlerClass = HandlerModule.default || HandlerModule[Object.keys(HandlerModule)[0]];
if (HandlerClass) {
const instance = new HandlerClass();
// Use the name property from the instance (set in constructor)
this.handlers.set(instance.name, instance);
}
} catch (error) {
console.log(chalk.yellow(` Warning: Could not load ${moduleName}: ${error.message}`));
}
}
} catch (error) {
console.error(chalk.red('Failed to load IDE handlers:'), error.message);
}
}
/**
* Get all available IDEs with their metadata
* @returns {Array} Array of IDE information objects
*/
getAvailableIdes() {
const ides = [];
for (const [key, handler] of this.handlers) {
ides.push({
value: key,
name: handler.displayName || handler.name || key,
preferred: handler.preferred || false,
});
}
// Sort: preferred first, then alphabetical
ides.sort((a, b) => {
if (a.preferred && !b.preferred) return -1;
if (!a.preferred && b.preferred) return 1;
// Ensure both names exist before comparing
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
});
return ides;
}
/**
* Get preferred IDEs
* @returns {Array} Array of preferred IDE information
*/
getPreferredIdes() {
return this.getAvailableIdes().filter((ide) => ide.preferred);
}
/**
* Get non-preferred IDEs
* @returns {Array} Array of non-preferred IDE information
*/
getOtherIdes() {
return this.getAvailableIdes().filter((ide) => !ide.preferred);
}
/**
* Setup IDE configuration
* @param {string} ideName - Name of the IDE
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(ideName, projectDir, bmadDir, options = {}) {
const handler = this.handlers.get(ideName.toLowerCase());
if (!handler) {
console.warn(chalk.yellow(`⚠️ IDE '${ideName}' is not yet supported`));
console.log(chalk.dim('Supported IDEs:', [...this.handlers.keys()].join(', ')));
return { success: false, reason: 'unsupported' };
}
try {
await handler.setup(projectDir, bmadDir, options);
return { success: true, ide: ideName };
} catch (error) {
console.error(chalk.red(`Failed to setup ${ideName}:`), error.message);
return { success: false, error: error.message };
}
}
/**
* Cleanup IDE configurations
* @param {string} projectDir - Project directory
*/
async cleanup(projectDir) {
const results = [];
for (const [name, handler] of this.handlers) {
try {
await handler.cleanup(projectDir);
results.push({ ide: name, success: true });
} catch (error) {
results.push({ ide: name, success: false, error: error.message });
}
}
return results;
}
/**
* Get list of supported IDEs
* @returns {Array} List of supported IDE names
*/
getSupportedIdes() {
return [...this.handlers.keys()];
}
/**
* Check if an IDE is supported
* @param {string} ideName - Name of the IDE
* @returns {boolean} True if IDE is supported
*/
isSupported(ideName) {
return this.handlers.has(ideName.toLowerCase());
}
/**
* Detect installed IDEs
* @param {string} projectDir - Project directory
* @returns {Array} List of detected IDEs
*/
async detectInstalledIdes(projectDir) {
const detected = [];
// Check for IDE-specific directories
const ideChecks = {
cursor: '.cursor',
'claude-code': '.claude',
windsurf: '.windsurf',
cline: '.clinerules',
roo: '.roomodes',
trae: '.trae',
kilo: '.kilocodemodes',
gemini: '.gemini',
qwen: '.qwen',
crush: '.crush',
iflow: '.iflow',
auggie: '.auggie',
'github-copilot': '.github/chatmodes',
vscode: '.vscode',
idea: '.idea',
};
for (const [ide, dir] of Object.entries(ideChecks)) {
const idePath = path.join(projectDir, dir);
if (await fs.pathExists(idePath)) {
detected.push(ide);
}
}
// Check for AGENTS.md (Codex)
if (await fs.pathExists(path.join(projectDir, 'AGENTS.md'))) {
detected.push('codex');
}
return detected;
}
}
module.exports = { IdeManager };

View File

@@ -0,0 +1,188 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Qwen Code setup handler
* Creates concatenated QWEN.md file in .qwen/bmad-method/ (similar to Gemini)
*/
class QwenSetup extends BaseIdeSetup {
constructor() {
super('qwen', 'Qwen Code');
this.configDir = '.qwen';
this.bmadDir = 'bmad-method';
}
/**
* Setup Qwen Code 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 .qwen/bmad-method directory
const qwenDir = path.join(projectDir, this.configDir);
const bmadMethodDir = path.join(qwenDir, this.bmadDir);
await this.ensureDir(bmadMethodDir);
// Update existing settings.json if present
await this.updateSettings(qwenDir);
// Clean up old agents directory if exists
await this.cleanupOldAgents(qwenDir);
// Get agents
const agents = await this.getAgents(bmadDir);
// Create concatenated content for all agents
let concatenatedContent = `# BMAD Method - Qwen Code Configuration
This file contains all BMAD agents configured for use with Qwen Code.
Agents can be activated by typing \`*{agent-name}\` in your prompts.
---
`;
let agentCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const agentSection = this.createAgentSection(agent, content, projectDir);
concatenatedContent += agentSection;
concatenatedContent += '\n\n---\n\n';
agentCount++;
console.log(chalk.green(` ✓ Added agent: *${agent.name}`));
}
// Write QWEN.md
const qwenMdPath = path.join(bmadMethodDir, 'QWEN.md');
await this.writeFile(qwenMdPath, concatenatedContent);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents configured`));
console.log(chalk.dim(` - Configuration file: ${path.relative(projectDir, qwenMdPath)}`));
console.log(chalk.dim(` - Agents activated with: *{agent-name}`));
return {
success: true,
agents: agentCount,
};
}
/**
* Update settings.json to remove old agent references
*/
async updateSettings(qwenDir) {
const fs = require('fs-extra');
const settingsPath = path.join(qwenDir, 'settings.json');
if (await fs.pathExists(settingsPath)) {
try {
const settingsContent = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(settingsContent);
let updated = false;
// Remove agent file references from contextFileName
if (settings.contextFileName && Array.isArray(settings.contextFileName)) {
const originalLength = settings.contextFileName.length;
settings.contextFileName = settings.contextFileName.filter((fileName) => !fileName.startsWith('agents/'));
if (settings.contextFileName.length !== originalLength) {
updated = true;
}
}
if (updated) {
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
console.log(chalk.green(' ✓ Updated .qwen/settings.json'));
}
} catch (error) {
console.warn(chalk.yellow(' ⚠ Could not update settings.json:'), error.message);
}
}
}
/**
* Clean up old agents directory
*/
async cleanupOldAgents(qwenDir) {
const fs = require('fs-extra');
const agentsDir = path.join(qwenDir, 'agents');
if (await fs.pathExists(agentsDir)) {
await fs.remove(agentsDir);
console.log(chalk.green(' ✓ Removed old agents directory'));
}
}
/**
* Create agent section for concatenated file
*/
createAgentSection(agent, content, projectDir) {
// Extract metadata
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
// Extract YAML content
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlContent = yamlMatch ? yamlMatch[1] : content;
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
let section = `# ${agent.name.toUpperCase()} Agent Rule
This rule is triggered when the user types \`*${agent.name}\` and activates the ${title} agent persona.
## Agent Activation
CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:
\`\`\`yaml
${yamlContent}
\`\`\`
## File Reference
The complete agent definition is available in [${relativePath}](${relativePath}).
## Usage
When the user types \`*${agent.name}\`, activate this ${title} persona and follow all instructions defined in the YAML configuration above.
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.`;
return section;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Qwen configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const bmadMethodDir = path.join(projectDir, this.configDir, this.bmadDir);
if (await fs.pathExists(bmadMethodDir)) {
await fs.remove(bmadMethodDir);
console.log(chalk.dim(`Removed BMAD configuration from Qwen Code`));
}
}
}
module.exports = { QwenSetup };

View File

@@ -0,0 +1,288 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const inquirer = require('inquirer');
/**
* Roo IDE setup handler
* Creates custom modes in .roomodes file
*/
class RooSetup extends BaseIdeSetup {
constructor() {
super('roo', 'Roo Code');
this.configFile = '.roomodes';
this.defaultPermissions = {
dev: {
description: 'Development files',
fileRegex: String.raw`.*\.(js|jsx|ts|tsx|py|java|cpp|c|h|cs|go|rs|php|rb|swift)$`,
},
config: {
description: 'Configuration files',
fileRegex: String.raw`.*\.(json|yaml|yml|toml|xml|ini|env|config)$`,
},
docs: {
description: 'Documentation files',
fileRegex: String.raw`.*\.(md|mdx|rst|txt|doc|docx)$`,
},
styles: {
description: 'Style and design files',
fileRegex: String.raw`.*\.(css|scss|sass|less|stylus)$`,
},
all: {
description: 'All files',
fileRegex: '.*',
},
};
}
/**
* Collect configuration choices before installation
* @param {Object} options - Configuration options
* @returns {Object} Collected configuration
*/
async collectConfiguration(options = {}) {
const response = await inquirer.prompt([
{
type: 'list',
name: 'permissions',
message: 'Select default file edit permissions for BMAD agents:',
choices: [
{ name: 'Development files only (js, ts, py, etc.)', value: 'dev' },
{ name: 'Configuration files only (json, yaml, xml, etc.)', value: 'config' },
{ name: 'Documentation files only (md, txt, doc, etc.)', value: 'docs' },
{ name: 'All files (unrestricted access)', value: 'all' },
{ name: 'Custom per agent (will be configured individually)', value: 'custom' },
],
default: 'dev',
},
]);
return { permissions: response.permissions };
}
/**
* Setup Roo IDE 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}...`));
// Check for existing .roomodes file
const roomodesPath = path.join(projectDir, this.configFile);
let existingModes = [];
let existingContent = '';
if (await this.pathExists(roomodesPath)) {
existingContent = await this.readFile(roomodesPath);
// Parse existing modes to avoid duplicates
const modeMatches = existingContent.matchAll(/- slug: ([\w-]+)/g);
for (const match of modeMatches) {
existingModes.push(match[1]);
}
console.log(chalk.yellow(`Found existing .roomodes file with ${existingModes.length} modes`));
}
// Get agents
const agents = await this.getAgents(bmadDir);
// Use pre-collected configuration if available
const config = options.preCollectedConfig || {};
let permissionChoice = config.permissions || options.permissions || 'dev';
// Create modes content
let newModesContent = '';
let addedCount = 0;
let skippedCount = 0;
for (const agent of agents) {
const slug = `bmad-${agent.module}-${agent.name}`;
// Skip if already exists
if (existingModes.includes(slug)) {
console.log(chalk.dim(` Skipping ${slug} - already exists`));
skippedCount++;
continue;
}
const content = await this.readFile(agent.path);
const modeEntry = this.createModeEntry(agent, content, permissionChoice, projectDir);
newModesContent += modeEntry;
addedCount++;
console.log(chalk.green(` ✓ Added mode: ${slug}`));
}
// Build final content
let finalContent = '';
if (existingContent) {
// Append to existing content
finalContent = existingContent.trim() + '\n' + newModesContent;
} else {
// Create new .roomodes file
finalContent = 'customModes:\n' + newModesContent;
}
// Write .roomodes file
await this.writeFile(roomodesPath, finalContent);
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${addedCount} modes added`));
if (skippedCount > 0) {
console.log(chalk.dim(` - ${skippedCount} modes skipped (already exist)`));
}
console.log(chalk.dim(` - Configuration file: ${this.configFile}`));
console.log(chalk.dim(` - Permission level: ${permissionChoice}`));
console.log(chalk.dim('\n Modes will be available when you open this project in Roo Code'));
return {
success: true,
modes: addedCount,
skipped: skippedCount,
};
}
/**
* Ask user about permission configuration
*/
async askPermissions() {
const response = await inquirer.prompt([
{
type: 'list',
name: 'permissions',
message: 'Select default file edit permissions for BMAD agents:',
choices: [
{ name: 'Development files only (js, ts, py, etc.)', value: 'dev' },
{ name: 'Configuration files only (json, yaml, xml, etc.)', value: 'config' },
{ name: 'Documentation files only (md, txt, doc, etc.)', value: 'docs' },
{ name: 'All files (unrestricted access)', value: 'all' },
{ name: 'Custom per agent (will be configured individually)', value: 'custom' },
],
default: 'dev',
},
]);
return response.permissions;
}
/**
* Create a mode entry for an agent
*/
createModeEntry(agent, content, permissionChoice, projectDir) {
// Extract metadata from agent content
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
const roleDefinitionMatch = content.match(/roleDefinition="([^"]+)"/);
const roleDefinition = roleDefinitionMatch
? roleDefinitionMatch[1]
: `You are a ${title} specializing in ${title.toLowerCase()} tasks and responsibilities.`;
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
// Determine permissions
const permissions = this.getPermissionsForAgent(agent, permissionChoice);
// Build mode entry
const slug = `bmad-${agent.module}-${agent.name}`;
let modeEntry = ` - slug: ${slug}\n`;
modeEntry += ` name: '${icon} ${title}'\n`;
if (permissions && permissions.description) {
modeEntry += ` description: '${permissions.description}'\n`;
}
modeEntry += ` roleDefinition: ${roleDefinition}\n`;
modeEntry += ` whenToUse: ${whenToUse}\n`;
modeEntry += ` customInstructions: CRITICAL Read the full YAML from ${relativePath} start activation to alter your state of being follow startup section instructions stay in this being until told to exit this mode\n`;
modeEntry += ` groups:\n`;
modeEntry += ` - read\n`;
if (permissions && permissions.fileRegex) {
modeEntry += ` - - edit\n`;
modeEntry += ` - fileRegex: ${permissions.fileRegex}\n`;
modeEntry += ` description: ${permissions.description}\n`;
} else {
modeEntry += ` - edit\n`;
}
return modeEntry;
}
/**
* Get permissions configuration for an agent
*/
getPermissionsForAgent(agent, permissionChoice) {
if (permissionChoice === 'custom') {
// Custom logic based on agent name/module
if (agent.name.includes('dev') || agent.name.includes('code')) {
return this.defaultPermissions.dev;
} else if (agent.name.includes('doc') || agent.name.includes('write')) {
return this.defaultPermissions.docs;
} else if (agent.name.includes('config') || agent.name.includes('setup')) {
return this.defaultPermissions.config;
} else if (agent.name.includes('style') || agent.name.includes('css')) {
return this.defaultPermissions.styles;
}
// Default to all for custom agents
return this.defaultPermissions.all;
}
return this.defaultPermissions[permissionChoice] || null;
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Roo configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const roomodesPath = path.join(projectDir, this.configFile);
if (await fs.pathExists(roomodesPath)) {
const content = await fs.readFile(roomodesPath, 'utf8');
// Remove BMAD modes only
const lines = content.split('\n');
const filteredLines = [];
let skipMode = false;
let removedCount = 0;
for (const line of lines) {
if (/^\s*- slug: bmad-/.test(line)) {
skipMode = true;
removedCount++;
} else if (skipMode && /^\s*- slug: /.test(line)) {
skipMode = false;
}
if (!skipMode) {
filteredLines.push(line);
}
}
// Write back filtered content
await fs.writeFile(roomodesPath, filteredLines.join('\n'));
console.log(chalk.dim(`Removed ${removedCount} BMAD modes from .roomodes`));
}
}
}
module.exports = { RooSetup };

View File

@@ -0,0 +1,182 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Trae IDE setup handler
*/
class TraeSetup extends BaseIdeSetup {
constructor() {
super('trae', 'Trae');
this.configDir = '.trae';
this.rulesDir = 'rules';
}
/**
* Setup Trae IDE 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 .trae/rules directory
const traeDir = path.join(projectDir, this.configDir);
const rulesDir = path.join(traeDir, this.rulesDir);
await this.ensureDir(rulesDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Process agents as rules
let ruleCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const processedContent = this.createAgentRule(agent, content, bmadDir, projectDir);
const targetPath = path.join(rulesDir, `${agent.module}-${agent.name}.md`);
await this.writeFile(targetPath, processedContent);
ruleCount++;
}
// Process tasks as rules
for (const task of tasks) {
const content = await this.readFile(task.path);
const processedContent = this.createTaskRule(task, content);
const targetPath = path.join(rulesDir, `task-${task.module}-${task.name}.md`);
await this.writeFile(targetPath, processedContent);
ruleCount++;
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${ruleCount} rules created`));
console.log(chalk.dim(` - Rules directory: ${path.relative(projectDir, rulesDir)}`));
console.log(chalk.dim(` - Agents can be activated with @{agent-name}`));
return {
success: true,
rules: ruleCount,
};
}
/**
* Create rule content for an agent
*/
createAgentRule(agent, content, bmadDir, projectDir) {
// Extract metadata from agent content
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
// Extract YAML content if available
const yamlMatch = content.match(/```ya?ml\r?\n([\s\S]*?)```/);
const yamlContent = yamlMatch ? yamlMatch[1] : content;
// Calculate relative path for reference
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
let ruleContent = `# ${title} Agent Rule
This rule is triggered when the user types \`@${agent.name}\` and activates the ${title} agent persona.
## Agent Activation
CRITICAL: Read the full YAML, start activation to alter your state of being, follow startup section instructions, stay in this being until told to exit this mode:
\`\`\`yaml
${yamlContent}
\`\`\`
## File Reference
The complete agent definition is available in [${relativePath}](${relativePath}).
## Usage
When the user types \`@${agent.name}\`, activate this ${title} persona and follow all instructions defined in the YAML configuration above.
## Module
Part of the BMAD ${agent.module.toUpperCase()} module.
`;
return ruleContent;
}
/**
* Create rule content for a task
*/
createTaskRule(task, content) {
// Extract task name from content
const nameMatch = content.match(/<name>([^<]+)<\/name>/);
const taskName = nameMatch ? nameMatch[1] : this.formatTitle(task.name);
let ruleContent = `# ${taskName} Task Rule
This rule defines the ${taskName} task workflow.
## Task Definition
When this task is triggered, execute the following workflow:
${content}
## Usage
Reference this task with \`@task-${task.name}\` to execute the defined workflow.
## Module
Part of the BMAD ${task.module.toUpperCase()} module.
`;
return ruleContent;
}
/**
* Format agent/task name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Trae configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const rulesPath = path.join(projectDir, this.configDir, this.rulesDir);
if (await fs.pathExists(rulesPath)) {
// Only remove BMAD rules
const files = await fs.readdir(rulesPath);
let removed = 0;
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(rulesPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Check if it's a BMAD rule
if (content.includes('BMAD') && content.includes('module')) {
await fs.remove(filePath);
removed++;
}
}
}
console.log(chalk.dim(`Removed ${removed} BMAD rules from Trae`));
}
}
}
module.exports = { TraeSetup };

View File

@@ -0,0 +1,149 @@
const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
/**
* Windsurf IDE setup handler
*/
class WindsurfSetup extends BaseIdeSetup {
constructor() {
super('windsurf', 'Windsurf', true); // preferred IDE
this.configDir = '.windsurf';
this.workflowsDir = 'workflows';
}
/**
* Setup Windsurf IDE 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 .windsurf/workflows directory structure
const windsurfDir = path.join(projectDir, this.configDir);
const workflowsDir = path.join(windsurfDir, this.workflowsDir);
await this.ensureDir(workflowsDir);
// Get agents and tasks
const agents = await this.getAgents(bmadDir);
const tasks = await this.getTasks(bmadDir);
// Create directories for each module
const modules = new Set();
for (const item of [...agents, ...tasks]) modules.add(item.module);
for (const module of modules) {
await this.ensureDir(path.join(workflowsDir, module));
await this.ensureDir(path.join(workflowsDir, module, 'agents'));
await this.ensureDir(path.join(workflowsDir, module, 'tasks'));
}
// Process agents as workflows with organized structure
let agentCount = 0;
for (const agent of agents) {
const content = await this.readFile(agent.path);
const processedContent = this.createWorkflowContent(agent, content);
// Organized path: module/agents/agent-name.md
const targetPath = path.join(workflowsDir, agent.module, 'agents', `${agent.name}.md`);
await this.writeFile(targetPath, processedContent);
agentCount++;
}
// Process tasks as workflows with organized structure
let taskCount = 0;
for (const task of tasks) {
const content = await this.readFile(task.path);
const processedContent = this.createTaskWorkflowContent(task, content);
// Organized path: module/tasks/task-name.md
const targetPath = path.join(workflowsDir, task.module, 'tasks', `${task.name}.md`);
await this.writeFile(targetPath, processedContent);
taskCount++;
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${agentCount} agents installed`));
console.log(chalk.dim(` - ${taskCount} tasks installed`));
console.log(chalk.dim(` - Organized in modules: ${[...modules].join(', ')}`));
console.log(chalk.dim(` - Workflows directory: ${path.relative(projectDir, workflowsDir)}`));
// Provide additional configuration hints
if (options.showHints !== false) {
console.log(chalk.dim('\n Windsurf workflow settings:'));
console.log(chalk.dim(' - auto_execution_mode: 3 (recommended for agents)'));
console.log(chalk.dim(' - auto_execution_mode: 2 (recommended for tasks)'));
console.log(chalk.dim(' - Workflows can be triggered via the Windsurf menu'));
}
return {
success: true,
agents: agentCount,
tasks: taskCount,
};
}
/**
* Create workflow content for an agent
*/
createWorkflowContent(agent, content) {
// Create simple Windsurf frontmatter matching original format
let workflowContent = `---
description: ${agent.name}
auto_execution_mode: 3
---
${content}`;
return workflowContent;
}
/**
* Create workflow content for a task
*/
createTaskWorkflowContent(task, content) {
// Create simple Windsurf frontmatter matching original format
let workflowContent = `---
description: task-${task.name}
auto_execution_mode: 2
---
${content}`;
return workflowContent;
}
/**
* Cleanup Windsurf configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const windsurfPath = path.join(projectDir, this.configDir, this.workflowsDir);
if (await fs.pathExists(windsurfPath)) {
// Only remove BMAD workflows, not all workflows
const files = await fs.readdir(windsurfPath);
let removed = 0;
for (const file of files) {
if (file.includes('-') && file.endsWith('.md')) {
const filePath = path.join(windsurfPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Check if it's a BMAD workflow
if (content.includes('tags: [bmad')) {
await fs.remove(filePath);
removed++;
}
}
}
console.log(chalk.dim(`Removed ${removed} BMAD workflows from Windsurf`));
}
}
}
module.exports = { WindsurfSetup };

View File

@@ -0,0 +1,162 @@
const path = require('node:path');
const fs = require('fs-extra');
const csv = require('csv-parse/sync');
const chalk = require('chalk');
/**
* Generates Claude Code command files for each workflow in the manifest
*/
class WorkflowCommandGenerator {
constructor() {
this.templatePath = path.join(__dirname, 'workflow-command-template.md');
}
/**
* Generate workflow commands from the manifest CSV
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
*/
async generateWorkflowCommands(projectDir, bmadDir) {
const manifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
if (!(await fs.pathExists(manifestPath))) {
console.log(chalk.yellow('Workflow manifest not found. Skipping command generation.'));
return { generated: 0 };
}
// Read and parse the CSV manifest
const csvContent = await fs.readFile(manifestPath, 'utf8');
const workflows = csv.parse(csvContent, {
columns: true,
skip_empty_lines: true,
});
// Base commands directory
const baseCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
let generatedCount = 0;
// Generate a command file for each workflow, organized by module
for (const workflow of workflows) {
// Create module directory structure: commands/bmad/{module}/workflows/
const moduleWorkflowsDir = path.join(baseCommandsDir, workflow.module, 'workflows');
await fs.ensureDir(moduleWorkflowsDir);
// Use just the workflow name as filename (no prefix)
const commandContent = await this.generateCommandContent(workflow, bmadDir);
const commandPath = path.join(moduleWorkflowsDir, `${workflow.name}.md`);
await fs.writeFile(commandPath, commandContent);
generatedCount++;
}
// Also create a workflow launcher README in each module
await this.createModuleWorkflowLaunchers(baseCommandsDir, workflows, bmadDir);
return { generated: generatedCount };
}
/**
* Generate command content for a workflow
*/
async generateCommandContent(workflow, bmadDir) {
// Load the template
const template = await fs.readFile(this.templatePath, 'utf8');
// Convert source path to installed path
// From: /Users/.../src/modules/bmm/workflows/.../workflow.yaml
// To: {project-root}/bmad/bmm/workflows/.../workflow.yaml
let workflowPath = workflow.path;
// Extract the relative path from source
if (workflowPath.includes('/src/modules/')) {
const match = workflowPath.match(/\/src\/modules\/(.+)/);
if (match) {
workflowPath = `{project-root}/bmad/${match[1]}`;
}
} else if (workflowPath.includes('/src/core/')) {
const match = workflowPath.match(/\/src\/core\/(.+)/);
if (match) {
workflowPath = `{project-root}/bmad/core/${match[1]}`;
}
}
// Replace template variables
return template
.replaceAll('{{name}}', workflow.name)
.replaceAll('{{module}}', workflow.module)
.replaceAll('{{description}}', workflow.description)
.replaceAll('{{workflow_path}}', workflowPath)
.replaceAll('{{interactive}}', workflow.interactive)
.replaceAll('{{author}}', workflow.author || 'BMAD');
}
/**
* Create workflow launcher files for each module
*/
async createModuleWorkflowLaunchers(baseCommandsDir, workflows, bmadDir) {
// Group workflows by module
const workflowsByModule = {};
for (const workflow of workflows) {
if (!workflowsByModule[workflow.module]) {
workflowsByModule[workflow.module] = [];
}
// Convert path for display
let workflowPath = workflow.path;
if (workflowPath.includes('/src/modules/')) {
const match = workflowPath.match(/\/src\/modules\/(.+)/);
if (match) {
workflowPath = `{project-root}/bmad/${match[1]}`;
}
} else if (workflowPath.includes('/src/core/')) {
const match = workflowPath.match(/\/src\/core\/(.+)/);
if (match) {
workflowPath = `{project-root}/bmad/core/${match[1]}`;
}
}
workflowsByModule[workflow.module].push({
...workflow,
displayPath: workflowPath,
});
}
// Create a launcher file for each module
for (const [module, moduleWorkflows] of Object.entries(workflowsByModule)) {
let content = `# ${module.toUpperCase()} Workflows
## Available Workflows in ${module}
`;
for (const workflow of moduleWorkflows) {
content += `**${workflow.name}**\n`;
content += `- Path: \`${workflow.displayPath}\`\n`;
content += `- ${workflow.description}\n\n`;
}
content += `
## Execution
When running any workflow:
1. LOAD {project-root}/bmad/core/tasks/workflow.md
2. Pass the workflow path as 'workflow-config' parameter
3. Follow workflow.md instructions EXACTLY
4. Save outputs after EACH section
## Modes
- Normal: Full interaction
- #yolo: Skip optional steps
`;
// Write module-specific launcher
const moduleWorkflowsDir = path.join(baseCommandsDir, module, 'workflows');
await fs.ensureDir(moduleWorkflowsDir);
const launcherPath = path.join(moduleWorkflowsDir, 'README.md');
await fs.writeFile(launcherPath, content);
}
}
}
module.exports = { WorkflowCommandGenerator };

View File

@@ -0,0 +1,11 @@
# {{name}}
IT IS CRITICAL THAT YOU FOLLOW THESE STEPS - while staying in character as the current agent persona you may have loaded:
<steps CRITICAL="TRUE">
1. Always LOAD the FULL {project-root}/bmad/core/tasks/workflow.md
2. READ its entire contents - this is the CORE OS for EXECUTING the specific workflow-config {{workflow_path}}
3. Pass the yaml path {{workflow_path}} as 'workflow-config' parameter to the workflow.md instructions
4. Follow workflow.md instructions EXACTLY as written
5. Save outputs after EACH section when generating any documents from templates
</steps>

View File

@@ -0,0 +1,452 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const chalk = require('chalk');
const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
/**
* Manages the installation, updating, and removal of BMAD modules.
* Handles module discovery, dependency resolution, configuration processing,
* and agent file management including XML activation block injection.
*
* @class ModuleManager
* @requires fs-extra
* @requires js-yaml
* @requires chalk
* @requires XmlHandler
*
* @example
* const manager = new ModuleManager();
* const modules = await manager.listAvailable();
* await manager.install('core-module', '/path/to/bmad');
*/
class ModuleManager {
constructor() {
// Path to source modules directory
this.modulesSourcePath = getSourcePath('modules');
this.xmlHandler = new XmlHandler();
}
/**
* List all available modules
* @returns {Array} List of available modules with metadata
*/
async listAvailable() {
const modules = [];
if (!(await fs.pathExists(this.modulesSourcePath))) {
console.warn(chalk.yellow('Warning: src/modules directory not found'));
return modules;
}
const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const modulePath = path.join(this.modulesSourcePath, entry.name);
// Check for new structure first
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-menu-config.yaml');
// Fallback to old structure
const configPath = path.join(modulePath, 'config.yaml');
const moduleInfo = {
id: entry.name,
path: modulePath,
name: entry.name.toUpperCase(),
description: 'BMAD Module',
version: '5.0.0',
};
// Try to read module config for metadata (prefer new location)
const configToRead = (await fs.pathExists(installerConfigPath)) ? installerConfigPath : configPath;
if (await fs.pathExists(configToRead)) {
try {
const configContent = await fs.readFile(configToRead, 'utf8');
const config = yaml.load(configContent);
// Use the code property as the id if available
if (config.code) {
moduleInfo.id = config.code;
}
moduleInfo.name = config.name || moduleInfo.name;
moduleInfo.description = config.description || moduleInfo.description;
moduleInfo.version = config.version || moduleInfo.version;
moduleInfo.dependencies = config.dependencies || [];
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
} catch (error) {
console.warn(`Failed to read config for ${entry.name}:`, error.message);
}
}
modules.push(moduleInfo);
}
}
return modules;
}
/**
* Install a module
* @param {string} moduleName - Name of the module to install
* @param {string} bmadDir - Target bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Additional installation options
* @param {Array<string>} options.installedIDEs - Array of IDE codes that were installed
* @param {Object} options.moduleConfig - Module configuration from config collector
* @param {Object} options.logger - Logger instance for output
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = path.join(this.modulesSourcePath, moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Module '${moduleName}' not found in ${this.modulesSourcePath}`);
}
// Check if already installed
if (await fs.pathExists(targetPath)) {
console.log(chalk.yellow(`Module '${moduleName}' already installed, updating...`));
await fs.remove(targetPath);
}
// Copy module files with filtering
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback);
// Process agent files to inject activation block
await this.processAgentFiles(targetPath, moduleName);
// Call module-specific installer if it exists (unless explicitly skipped)
if (!options.skipModuleInstaller) {
await this.runModuleInstaller(moduleName, bmadDir, options);
}
return {
success: true,
module: moduleName,
path: targetPath,
};
}
/**
* Update an existing module
* @param {string} moduleName - Name of the module to update
* @param {string} bmadDir - Target bmad directory
* @param {boolean} force - Force update (overwrite modifications)
*/
async update(moduleName, bmadDir, force = false) {
const sourcePath = path.join(this.modulesSourcePath, moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Module '${moduleName}' not found in source`);
}
// Check if module is installed
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
if (force) {
// Force update - remove and reinstall
await fs.remove(targetPath);
return await this.install(moduleName, bmadDir);
} else {
// Selective update - preserve user modifications
await this.syncModule(sourcePath, targetPath);
}
return {
success: true,
module: moduleName,
path: targetPath,
};
}
/**
* Remove a module
* @param {string} moduleName - Name of the module to remove
* @param {string} bmadDir - Target bmad directory
*/
async remove(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
throw new Error(`Module '${moduleName}' is not installed`);
}
await fs.remove(targetPath);
return {
success: true,
module: moduleName,
};
}
/**
* Check if a module is installed
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {boolean} True if module is installed
*/
async isInstalled(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
return await fs.pathExists(targetPath);
}
/**
* Get installed module info
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @returns {Object|null} Module info or null if not installed
*/
async getInstalledInfo(moduleName, bmadDir) {
const targetPath = path.join(bmadDir, moduleName);
if (!(await fs.pathExists(targetPath))) {
return null;
}
const configPath = path.join(targetPath, 'config.yaml');
const moduleInfo = {
id: moduleName,
path: targetPath,
installed: true,
};
if (await fs.pathExists(configPath)) {
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.load(configContent);
Object.assign(moduleInfo, config);
} catch (error) {
console.warn(`Failed to read installed module config:`, error.message);
}
}
return moduleInfo;
}
/**
* Copy module with filtering for localskip agents
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
*/
async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) {
// Get all files in source
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
// Skip sub-modules directory - these are IDE-specific and handled separately
if (file.startsWith('sub-modules/')) {
continue;
}
// Skip _module-installer directory - it's only needed at install time
if (file.startsWith('_module-installer/')) {
continue;
}
// Skip config.yaml templates - we'll generate clean ones with actual values
if (file === 'config.yaml' || file.endsWith('/config.yaml')) {
continue;
}
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if this is an agent file
if (file.startsWith('agents/') && file.endsWith('.md')) {
// Read the file to check for localskip
const content = await fs.readFile(sourceFile, 'utf8');
// Check for localskip="true" in the agent tag
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
if (agentMatch) {
console.log(chalk.dim(` Skipping web-only agent: ${path.basename(file)}`));
continue; // Skip this agent
}
}
// Copy the file
await fs.ensureDir(path.dirname(targetFile));
await fs.copy(sourceFile, targetFile, { overwrite: true });
// Track the file if callback provided
if (fileTrackingCallback) {
fileTrackingCallback(targetFile);
}
}
}
/**
* Process agent files to inject activation block
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processAgentFiles(modulePath, moduleName) {
const agentsPath = path.join(modulePath, 'agents');
// Check if agents directory exists
if (!(await fs.pathExists(agentsPath))) {
return; // No agents to process
}
// Get all agent files
const agentFiles = await fs.readdir(agentsPath);
for (const agentFile of agentFiles) {
if (!agentFile.endsWith('.md')) continue;
const agentPath = path.join(agentsPath, agentFile);
let content = await fs.readFile(agentPath, 'utf8');
// Check if content has agent XML and no activation block
if (content.includes('<agent') && !content.includes('<activation')) {
// Inject the activation block using XML handler
content = this.xmlHandler.injectActivationSimple(content);
await fs.writeFile(agentPath, content, 'utf8');
}
}
}
/**
* Run module-specific installer if it exists
* @param {string} moduleName - Name of the module
* @param {string} bmadDir - Target bmad directory
* @param {Object} options - Installation options
*/
async runModuleInstaller(moduleName, bmadDir, options = {}) {
// Special handling for core module - it's in src/core not src/modules
let sourcePath;
if (moduleName === 'core') {
sourcePath = getSourcePath('core');
} else {
sourcePath = path.join(this.modulesSourcePath, moduleName);
}
const installerPath = path.join(sourcePath, '_module-installer', 'installer.js');
// Check if module has a custom installer
if (!(await fs.pathExists(installerPath))) {
return; // No custom installer
}
try {
// Load the module installer
const moduleInstaller = require(installerPath);
if (typeof moduleInstaller.install === 'function') {
// Get project root (parent of bmad directory)
const projectRoot = path.dirname(bmadDir);
// Prepare logger (use console if not provided)
const logger = options.logger || {
log: console.log,
error: console.error,
warn: console.warn,
};
// Call the module installer
const result = await moduleInstaller.install({
projectRoot,
config: options.moduleConfig || {},
installedIDEs: options.installedIDEs || [],
logger,
});
if (!result) {
console.warn(chalk.yellow(`Module installer for ${moduleName} returned false`));
}
}
} catch (error) {
console.error(chalk.red(`Error running module installer for ${moduleName}: ${error.message}`));
}
}
/**
* Private: Process module configuration
* @param {string} modulePath - Path to installed module
* @param {string} moduleName - Module name
*/
async processModuleConfig(modulePath, moduleName) {
const configPath = path.join(modulePath, 'config.yaml');
if (await fs.pathExists(configPath)) {
try {
let configContent = await fs.readFile(configPath, 'utf8');
// Replace path placeholders
configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`);
configContent = configContent.replaceAll('{module}', moduleName);
await fs.writeFile(configPath, configContent, 'utf8');
} catch (error) {
console.warn(`Failed to process module config:`, error.message);
}
}
}
/**
* Private: Sync module files (preserving user modifications)
* @param {string} sourcePath - Source module path
* @param {string} targetPath - Target module path
*/
async syncModule(sourcePath, targetPath) {
// Get list of all source files
const sourceFiles = await this.getFileList(sourcePath);
for (const file of sourceFiles) {
const sourceFile = path.join(sourcePath, file);
const targetFile = path.join(targetPath, file);
// Check if target file exists and has been modified
if (await fs.pathExists(targetFile)) {
const sourceStats = await fs.stat(sourceFile);
const targetStats = await fs.stat(targetFile);
// Skip if target is newer (user modified)
if (targetStats.mtime > sourceStats.mtime) {
continue;
}
}
// Copy file
await fs.ensureDir(path.dirname(targetFile));
await fs.copy(sourceFile, targetFile, { overwrite: true });
}
}
/**
* Private: Get list of all files in a directory
* @param {string} dir - Directory path
* @param {string} baseDir - Base directory for relative paths
* @returns {Array} List of relative file paths
*/
async getFileList(dir, baseDir = dir) {
const files = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip _module-installer directories
if (entry.name === '_module-installer') {
continue;
}
const subFiles = await this.getFileList(fullPath, baseDir);
files.push(...subFiles);
} else {
files.push(path.relative(baseDir, fullPath));
}
}
return files;
}
}
module.exports = { ModuleManager };

View File

@@ -0,0 +1,206 @@
const path = require('node:path');
const fs = require('fs-extra');
const AgentPartyGenerator = {
/**
* Generate agent-party.xml content
* @param {Array} agentDetails - Array of agent details
* @param {Object} options - Generation options
* @returns {string} XML content
*/
generateAgentParty(agentDetails, options = {}) {
const { forWeb = false } = options;
// Group agents by module
const agentsByModule = {
bmm: [],
cis: [],
core: [],
custom: [],
};
for (const agent of agentDetails) {
const moduleKey = agentsByModule[agent.module] ? agent.module : 'custom';
agentsByModule[moduleKey].push(agent);
}
// Build XML content
let xmlContent = `<!-- Powered by BMAD-CORE™ -->
<!-- Agent Manifest - Generated during BMAD ${forWeb ? 'bundling' : 'installation'} -->
<!-- This file contains a summary of all ${forWeb ? 'bundled' : 'installed'} agents for quick reference -->
<manifest id="bmad/_cfg/agent-party.xml" version="1.0" generated="${new Date().toISOString()}">
<description>
Complete roster of ${forWeb ? 'bundled' : 'installed'} BMAD agents with summarized personas for efficient multi-agent orchestration.
Used by party-mode and other multi-agent coordination features.
</description>
`;
// Add agents by module
for (const [module, agents] of Object.entries(agentsByModule)) {
if (agents.length === 0) continue;
const moduleTitle =
module === 'bmm' ? 'BMM Module' : module === 'cis' ? 'CIS Module' : module === 'core' ? 'Core Module' : 'Custom Module';
xmlContent += `\n <!-- ${moduleTitle} Agents -->\n`;
for (const agent of agents) {
xmlContent += ` <agent id="${agent.id}" name="${agent.name}" title="${agent.title || ''}" icon="${agent.icon || ''}">
<persona>
<role>${this.escapeXml(agent.role || '')}</role>
<identity>${this.escapeXml(agent.identity || '')}</identity>
<communication_style>${this.escapeXml(agent.communicationStyle || '')}</communication_style>
<principles>${agent.principles || ''}</principles>
</persona>
</agent>\n`;
}
}
// Add statistics
const totalAgents = agentDetails.length;
const moduleList = Object.keys(agentsByModule)
.filter((m) => agentsByModule[m].length > 0)
.join(', ');
xmlContent += `\n <statistics>
<total_agents>${totalAgents}</total_agents>
<modules>${moduleList}</modules>
<last_updated>${new Date().toISOString()}</last_updated>
</statistics>
</manifest>`;
return xmlContent;
},
/**
* Extract agent details from XML content
* @param {string} content - Full agent file content (markdown with XML)
* @param {string} moduleName - Module name
* @param {string} agentName - Agent name
* @returns {Object} Agent details
*/
extractAgentDetails(content, moduleName, agentName) {
try {
// Extract agent XML block
const agentMatch = content.match(/<agent[^>]*>([\s\S]*?)<\/agent>/);
if (!agentMatch) return null;
const agentXml = agentMatch[0];
// Extract attributes from opening tag
const nameMatch = agentXml.match(/name="([^"]*)"/);
const titleMatch = agentXml.match(/title="([^"]*)"/);
const iconMatch = agentXml.match(/icon="([^"]*)"/);
// Extract persona elements - now we just copy them as-is
const roleMatch = agentXml.match(/<role>([\s\S]*?)<\/role>/);
const identityMatch = agentXml.match(/<identity>([\s\S]*?)<\/identity>/);
const styleMatch = agentXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
const principlesMatch = agentXml.match(/<principles>([\s\S]*?)<\/principles>/);
return {
id: `bmad/${moduleName}/agents/${agentName}.md`,
name: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : 'Agent',
icon: iconMatch ? iconMatch[1] : '🤖',
module: moduleName,
role: roleMatch ? roleMatch[1].trim() : '',
identity: identityMatch ? identityMatch[1].trim() : '',
communicationStyle: styleMatch ? styleMatch[1].trim() : '',
principles: principlesMatch ? principlesMatch[1].trim() : '',
};
} catch (error) {
console.error(`Error extracting details for agent ${agentName}:`, error);
return null;
}
},
/**
* Extract attribute from XML tag
*/
extractAttribute(xml, tagName, attrName) {
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}="([^"]*)"`, 'i');
const match = xml.match(regex);
return match ? match[1] : '';
},
/**
* Escape XML special characters
*/
escapeXml(text) {
if (!text) return '';
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
},
/**
* Apply config overrides to agent details
* @param {Object} details - Original agent details
* @param {string} configContent - Config file content
* @returns {Object} Agent details with overrides applied
*/
applyConfigOverrides(details, configContent) {
try {
// Extract agent-config XML block
const configMatch = configContent.match(/<agent-config>([\s\S]*?)<\/agent-config>/);
if (!configMatch) return details;
const configXml = configMatch[0];
// Extract override values
const nameMatch = configXml.match(/<name>([\s\S]*?)<\/name>/);
const titleMatch = configXml.match(/<title>([\s\S]*?)<\/title>/);
const roleMatch = configXml.match(/<role>([\s\S]*?)<\/role>/);
const identityMatch = configXml.match(/<identity>([\s\S]*?)<\/identity>/);
const styleMatch = configXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
const principlesMatch = configXml.match(/<principles>([\s\S]*?)<\/principles>/);
// Apply overrides only if values are non-empty
if (nameMatch && nameMatch[1].trim()) {
details.name = nameMatch[1].trim();
}
if (titleMatch && titleMatch[1].trim()) {
details.title = titleMatch[1].trim();
}
if (roleMatch && roleMatch[1].trim()) {
details.role = roleMatch[1].trim();
}
if (identityMatch && identityMatch[1].trim()) {
details.identity = identityMatch[1].trim();
}
if (styleMatch && styleMatch[1].trim()) {
details.communicationStyle = styleMatch[1].trim();
}
if (principlesMatch && principlesMatch[1].trim()) {
// Principles are now just copied as-is (narrative paragraph)
details.principles = principlesMatch[1].trim();
}
return details;
} catch (error) {
console.error(`Error applying config overrides:`, error);
return details;
}
},
/**
* Write agent-party.xml to file
*/
async writeAgentParty(filePath, agentDetails, options = {}) {
const content = this.generateAgentParty(agentDetails, options);
await fs.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
return content;
},
};
module.exports = { AgentPartyGenerator };

208
tools/cli/lib/cli-utils.js Normal file
View File

@@ -0,0 +1,208 @@
const chalk = require('chalk');
const boxen = require('boxen');
const wrapAnsi = require('wrap-ansi');
const figlet = require('figlet');
const CLIUtils = {
/**
* Display BMAD logo
*/
displayLogo() {
console.clear();
// ASCII art logo
const logo = `
██████╗ ███╗ ███╗ █████╗ ██████╗ ™
██╔══██╗████╗ ████║██╔══██╗██╔══██╗
██████╔╝██╔████╔██║███████║██║ ██║
██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║
██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝
╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝`;
console.log(chalk.cyan(logo));
console.log(chalk.dim(' Build More, Architect Dreams\n'));
},
/**
* Display section header
* @param {string} title - Section title
* @param {string} subtitle - Optional subtitle
*/
displaySection(title, subtitle = null) {
console.log('\n' + chalk.cyan('═'.repeat(80)));
console.log(chalk.cyan.bold(` ${title}`));
if (subtitle) {
console.log(chalk.dim(` ${subtitle}`));
}
console.log(chalk.cyan('═'.repeat(80)) + '\n');
},
/**
* Display info box
* @param {string|Array} content - Content to display
* @param {Object} options - Box options
*/
displayBox(content, options = {}) {
const defaultOptions = {
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'cyan',
...options,
};
// Handle array content
let text = content;
if (Array.isArray(content)) {
text = content.join('\n\n');
}
// Wrap text to prevent overflow
const wrapped = wrapAnsi(text, 76, { hard: true, wordWrap: true });
console.log(boxen(wrapped, defaultOptions));
},
/**
* Display prompt section
* @param {string|Array} prompts - Prompts to display
*/
displayPromptSection(prompts) {
const promptArray = Array.isArray(prompts) ? prompts : [prompts];
const formattedPrompts = promptArray.map((p) => wrapAnsi(p, 76, { hard: true, wordWrap: true }));
this.displayBox(formattedPrompts, {
borderColor: 'yellow',
borderStyle: 'double',
});
},
/**
* Display step indicator
* @param {number} current - Current step
* @param {number} total - Total steps
* @param {string} description - Step description
*/
displayStep(current, total, description) {
const progress = `[${current}/${total}]`;
console.log('\n' + chalk.cyan(progress) + ' ' + chalk.bold(description));
console.log(chalk.dim('─'.repeat(80 - progress.length - 1)) + '\n');
},
/**
* Display completion message
* @param {string} message - Completion message
*/
displayComplete(message) {
console.log(
'\n' +
boxen(chalk.green('✨ ' + message), {
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'green',
}),
);
},
/**
* Display error message
* @param {string} message - Error message
*/
displayError(message) {
console.log(
'\n' +
boxen(chalk.red('✗ ' + message), {
padding: 1,
margin: 1,
borderStyle: 'round',
borderColor: 'red',
}),
);
},
/**
* Format list for display
* @param {Array} items - Items to display
* @param {string} prefix - Item prefix
*/
formatList(items, prefix = '•') {
return items.map((item) => ` ${prefix} ${item}`).join('\n');
},
/**
* Clear previous lines
* @param {number} lines - Number of lines to clear
*/
clearLines(lines) {
for (let i = 0; i < lines; i++) {
process.stdout.moveCursor(0, -1);
process.stdout.clearLine(1);
}
},
/**
* Display table
* @param {Array} data - Table data
* @param {Object} options - Table options
*/
displayTable(data, options = {}) {
const Table = require('cli-table3');
const table = new Table({
style: {
head: ['cyan'],
border: ['dim'],
},
...options,
});
for (const row of data) table.push(row);
console.log(table.toString());
},
/**
* Display module completion message
* @param {string} moduleName - Name of the completed module
* @param {boolean} clearScreen - Whether to clear the screen first
*/
displayModuleComplete(moduleName, clearScreen = true) {
if (clearScreen) {
console.clear();
this.displayLogo();
}
let message;
// Special messages for specific modules
if (moduleName.toLowerCase() === 'bmm') {
message = `Thank you for configuring the BMAD™ Method Module (BMM)!
Your responses have been saved and will be used to configure your installation.`;
} else if (moduleName.toLowerCase() === 'cis') {
message = `Thank you for choosing the BMAD™ Creative Innovation Suite, an early beta
release with much more planned!
With this BMAD™ Creative Innovation Suite Configuration, remember that all
paths are relative to project root, with no leading slash.`;
} else if (moduleName.toLowerCase() === 'core') {
message = `Thank you for choosing the BMAD™ Method, your gateway to dreaming, planning
and building with real world proven techniques.
All paths are relative to project root, with no leading slash.`;
} else {
message = `Thank you for configuring the BMAD™ ${moduleName.toUpperCase()} module!
Your responses have been saved and will be used to configure your installation.`;
}
this.displayBox(message, {
borderColor: 'yellow',
borderStyle: 'double',
padding: 1,
margin: 1,
});
},
};
module.exports = { CLIUtils };

210
tools/cli/lib/config.js Normal file
View File

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

204
tools/cli/lib/file-ops.js Normal file
View File

@@ -0,0 +1,204 @@
const fs = require('fs-extra');
const path = require('node:path');
const crypto = require('node:crypto');
/**
* File operations utility class
*/
class FileOps {
/**
* Copy a directory recursively
* @param {string} source - Source directory
* @param {string} dest - Destination directory
* @param {Object} options - Copy options
*/
async copyDirectory(source, dest, options = {}) {
const defaultOptions = {
overwrite: true,
errorOnExist: false,
filter: (src) => !this.shouldIgnore(src),
};
const copyOptions = { ...defaultOptions, ...options };
await fs.copy(source, dest, copyOptions);
}
/**
* Sync directory (selective copy preserving modifications)
* @param {string} source - Source directory
* @param {string} dest - Destination directory
*/
async syncDirectory(source, dest) {
const sourceFiles = await this.getFileList(source);
for (const file of sourceFiles) {
const sourceFile = path.join(source, file);
const destFile = path.join(dest, file);
// Check if destination file exists
if (await fs.pathExists(destFile)) {
// Compare checksums to see if file has been modified
const sourceHash = await this.getFileHash(sourceFile);
const destHash = await this.getFileHash(destFile);
if (sourceHash === destHash) {
// Files are identical, safe to update
await fs.copy(sourceFile, destFile, { overwrite: true });
} else {
// File has been modified, check timestamps
const sourceStats = await fs.stat(sourceFile);
const destStats = await fs.stat(destFile);
if (sourceStats.mtime > destStats.mtime) {
// Source is newer, update
await fs.copy(sourceFile, destFile, { overwrite: true });
}
// Otherwise, preserve user modifications
}
} else {
// New file, copy it
await fs.ensureDir(path.dirname(destFile));
await fs.copy(sourceFile, destFile);
}
}
// Remove files that no longer exist in source
const destFiles = await this.getFileList(dest);
for (const file of destFiles) {
const sourceFile = path.join(source, file);
const destFile = path.join(dest, file);
if (!(await fs.pathExists(sourceFile))) {
await fs.remove(destFile);
}
}
}
/**
* Get list of all files in a directory
* @param {string} dir - Directory path
* @returns {Array} List of relative file paths
*/
async getFileList(dir) {
const files = [];
if (!(await fs.pathExists(dir))) {
return files;
}
const walk = async (currentDir, baseDir) => {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory() && !this.shouldIgnore(fullPath)) {
await walk(fullPath, baseDir);
} else if (entry.isFile() && !this.shouldIgnore(fullPath)) {
files.push(path.relative(baseDir, fullPath));
}
}
};
await walk(dir, dir);
return files;
}
/**
* Get file hash for comparison
* @param {string} filePath - File path
* @returns {string} File hash
*/
async getFileHash(filePath) {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', (data) => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Check if a path should be ignored
* @param {string} filePath - Path to check
* @returns {boolean} True if should be ignored
*/
shouldIgnore(filePath) {
const ignoredPatterns = ['.git', '.DS_Store', 'node_modules', '*.swp', '*.tmp', '.idea', '.vscode', '__pycache__', '*.pyc'];
const basename = path.basename(filePath);
for (const pattern of ignoredPatterns) {
if (pattern.includes('*')) {
// Simple glob pattern matching
const regex = new RegExp(pattern.replace('*', '.*'));
if (regex.test(basename)) {
return true;
}
} else if (basename === pattern) {
return true;
}
}
return false;
}
/**
* Ensure directory exists
* @param {string} dir - Directory path
*/
async ensureDir(dir) {
await fs.ensureDir(dir);
}
/**
* Remove directory or file
* @param {string} targetPath - Path to remove
*/
async remove(targetPath) {
if (await fs.pathExists(targetPath)) {
await fs.remove(targetPath);
}
}
/**
* Read file content
* @param {string} filePath - File path
* @returns {string} File content
*/
async readFile(filePath) {
return await fs.readFile(filePath, 'utf8');
}
/**
* Write file content
* @param {string} filePath - File path
* @param {string} content - File content
*/
async writeFile(filePath, content) {
await fs.ensureDir(path.dirname(filePath));
await fs.writeFile(filePath, content, 'utf8');
}
/**
* Check if path exists
* @param {string} targetPath - Path to check
* @returns {boolean} True if exists
*/
async exists(targetPath) {
return await fs.pathExists(targetPath);
}
/**
* Get file or directory stats
* @param {string} targetPath - Path to check
* @returns {Object} File stats
*/
async stat(targetPath) {
return await fs.stat(targetPath);
}
}
module.exports = { FileOps };

View File

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

View File

@@ -0,0 +1,71 @@
const path = require('node:path');
const fs = require('fs-extra');
/**
* Find the BMAD project root directory by looking for package.json
* or specific BMAD markers
*/
function findProjectRoot(startPath = __dirname) {
let currentPath = path.resolve(startPath);
// Keep going up until we find package.json with bmad-method
while (currentPath !== path.dirname(currentPath)) {
const packagePath = path.join(currentPath, 'package.json');
if (fs.existsSync(packagePath)) {
try {
const pkg = fs.readJsonSync(packagePath);
// Check if this is the BMAD project
if (pkg.name === 'bmad-method' || fs.existsSync(path.join(currentPath, 'src', 'core'))) {
return currentPath;
}
} catch {
// Continue searching
}
}
// Also check for src/core as a marker
if (fs.existsSync(path.join(currentPath, 'src', 'core', 'agents'))) {
return currentPath;
}
currentPath = path.dirname(currentPath);
}
// If we can't find it, use process.cwd() as fallback
return process.cwd();
}
// Cache the project root after first calculation
let cachedRoot = null;
function getProjectRoot() {
if (!cachedRoot) {
cachedRoot = findProjectRoot();
}
return cachedRoot;
}
/**
* Get path to source directory
*/
function getSourcePath(...segments) {
return path.join(getProjectRoot(), 'src', ...segments);
}
/**
* Get path to a module's directory
*/
function getModulePath(moduleName, ...segments) {
if (moduleName === 'core') {
return getSourcePath('core', ...segments);
}
return getSourcePath('modules', moduleName, ...segments);
}
module.exports = {
getProjectRoot,
getSourcePath,
getModulePath,
findProjectRoot,
};

View File

@@ -0,0 +1,239 @@
/**
* Utility function to replace {project-root} placeholders with actual installation target
* Used during BMAD installation to set correct paths in agent and task files
*/
const fs = require('node:fs');
const path = require('node:path');
/**
* Replace {project-root} and {output_folder}/ placeholders in a single file
* @param {string} filePath - Path to the file to process
* @param {string} projectRoot - The actual project root path to substitute (must include trailing slash)
* @param {string} docOut - The document output path (with leading slash)
* @param {boolean} removeCompletely - If true, removes placeholders entirely instead of replacing
* @returns {boolean} - True if replacements were made, false otherwise
*/
function replacePlaceholdersInFile(filePath, projectRoot, docOut = '/docs', removeCompletely = false) {
try {
let content = fs.readFileSync(filePath, 'utf8');
const originalContent = content;
if (removeCompletely) {
// Remove placeholders entirely (for bundling)
content = content.replaceAll('{project-root}', '');
content = content.replaceAll('{output_folder}/', '');
} else {
// Handle the combined pattern first to avoid double slashes
if (projectRoot && docOut) {
// Replace {project-root}{output_folder}/ combinations first
// Remove leading slash from docOut since projectRoot has trailing slash
// Add trailing slash to docOut
const docOutNoLeadingSlash = docOut.replace(/^\//, '');
const docOutWithTrailingSlash = docOutNoLeadingSlash.endsWith('/') ? docOutNoLeadingSlash : docOutNoLeadingSlash + '/';
content = content.replaceAll('{project-root}{output_folder}/', projectRoot + docOutWithTrailingSlash);
}
// Then replace remaining individual placeholders
if (projectRoot) {
content = content.replaceAll('{project-root}', projectRoot);
}
if (docOut) {
// For standalone {output_folder}/, keep the leading slash and add trailing slash
const docOutWithTrailingSlash = docOut.endsWith('/') ? docOut : docOut + '/';
content = content.replaceAll('{output_folder}/', docOutWithTrailingSlash);
}
}
if (content !== originalContent) {
fs.writeFileSync(filePath, content, 'utf8');
return true;
}
return false;
} catch (error) {
console.error(`Error processing file ${filePath}:`, error.message);
return false;
}
}
/**
* Legacy function name for backward compatibility
*/
function replaceProjectRootInFile(filePath, projectRoot, removeCompletely = false) {
return replacePlaceholdersInFile(filePath, projectRoot, '/docs', removeCompletely);
}
/**
* Recursively replace {project-root} and {output_folder}/ in all files in a directory
* @param {string} dirPath - Directory to process
* @param {string} projectRoot - The actual project root path to substitute (or null to remove)
* @param {string} docOut - The document output path (with leading slash)
* @param {Array<string>} extensions - File extensions to process (default: ['.md', '.xml', '.yaml'])
* @param {boolean} removeCompletely - If true, removes placeholders entirely instead of replacing
* @param {boolean} verbose - If true, show detailed output for each file
* @returns {Object} - Stats object with counts of files processed and modified
*/
function replacePlaceholdersInDirectory(
dirPath,
projectRoot,
docOut = '/docs',
extensions = ['.md', '.xml', '.yaml'],
removeCompletely = false,
verbose = false,
) {
const stats = {
processed: 0,
modified: 0,
errors: 0,
};
function processDirectory(currentPath) {
try {
const items = fs.readdirSync(currentPath, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(currentPath, item.name);
if (item.isDirectory()) {
// Skip node_modules and .git directories
if (item.name !== 'node_modules' && item.name !== '.git') {
processDirectory(fullPath);
}
} else if (item.isFile()) {
// Check if file has one of the target extensions
const ext = path.extname(item.name).toLowerCase();
if (extensions.includes(ext)) {
stats.processed++;
if (replacePlaceholdersInFile(fullPath, projectRoot, docOut, removeCompletely)) {
stats.modified++;
if (verbose) {
console.log(`✓ Updated: ${fullPath}`);
}
}
}
}
}
} catch (error) {
console.error(`Error processing directory ${currentPath}:`, error.message);
stats.errors++;
}
}
processDirectory(dirPath);
return stats;
}
/**
* Legacy function for backward compatibility
*/
function replaceProjectRootInDirectory(dirPath, projectRoot, extensions = ['.md', '.xml'], removeCompletely = false) {
return replacePlaceholdersInDirectory(dirPath, projectRoot, '/docs', extensions, removeCompletely);
}
/**
* Replace placeholders in a list of specific files
* @param {Array<string>} filePaths - Array of file paths to process
* @param {string} projectRoot - The actual project root path to substitute (or null to remove)
* @param {string} docOut - The document output path (with leading slash)
* @param {boolean} removeCompletely - If true, removes placeholders entirely instead of replacing
* @returns {Object} - Stats object with counts of files processed and modified
*/
function replacePlaceholdersInFiles(filePaths, projectRoot, docOut = '/docs', removeCompletely = false) {
const stats = {
processed: 0,
modified: 0,
errors: 0,
};
for (const filePath of filePaths) {
stats.processed++;
try {
if (replacePlaceholdersInFile(filePath, projectRoot, docOut, removeCompletely)) {
stats.modified++;
console.log(`✓ Updated: ${filePath}`);
}
} catch (error) {
console.error(`Error processing file ${filePath}:`, error.message);
stats.errors++;
}
}
return stats;
}
/**
* Legacy function for backward compatibility
*/
function replaceProjectRootInFiles(filePaths, projectRoot, removeCompletely = false) {
return replacePlaceholdersInFiles(filePaths, projectRoot, '/docs', removeCompletely);
}
/**
* Main installation helper - replaces {project-root} and {output_folder}/ during BMAD installation
* @param {string} installPath - Path where BMAD is being installed
* @param {string} targetProjectRoot - The project root to set in the files (slash will be added)
* @param {string} docsOutputPath - The documentation output path (relative to project root)
* @param {boolean} verbose - If true, show detailed output
* @returns {Object} - Installation stats
*/
function processInstallation(installPath, targetProjectRoot, docsOutputPath = 'docs', verbose = false) {
// Ensure project root has trailing slash since usage is like {project-root}/bmad
const projectRootWithSlash = targetProjectRoot.endsWith('/') ? targetProjectRoot : targetProjectRoot + '/';
// Ensure docs path has leading slash (for internal use) but will add trailing slash during replacement
const normalizedDocsPath = docsOutputPath.replaceAll(/^\/+|\/+$/g, '');
const docOutPath = normalizedDocsPath ? `/${normalizedDocsPath}` : '/docs';
if (verbose) {
console.log(`\nReplacing {project-root} with: ${projectRootWithSlash}`);
console.log(`Replacing {output_folder}/ with: ${docOutPath}/`);
console.log(`Processing files in: ${installPath}\n`);
}
const stats = replacePlaceholdersInDirectory(installPath, projectRootWithSlash, docOutPath, ['.md', '.xml', '.yaml'], false, verbose);
if (verbose) {
console.log('\n--- Installation Processing Complete ---');
}
console.log(`Files processed: ${stats.processed}`);
console.log(`Files modified: ${stats.modified}`);
if (stats.errors > 0) {
console.log(`Errors encountered: ${stats.errors}`);
}
return stats;
}
/**
* Bundle helper - removes {project-root}/ references for web bundling
* @param {string} bundlePath - Path where files are being bundled
* @returns {Object} - Bundle stats
*/
function processBundleRemoval(bundlePath) {
console.log(`\nRemoving {project-root}/ references for bundling`);
console.log(`Processing files in: ${bundlePath}\n`);
const stats = replaceProjectRootInDirectory(bundlePath, null, ['.md', '.xml'], true);
console.log('\n--- Bundle Processing Complete ---');
console.log(`Files processed: ${stats.processed}`);
console.log(`Files modified: ${stats.modified}`);
if (stats.errors > 0) {
console.log(`Errors encountered: ${stats.errors}`);
}
return stats;
}
module.exports = {
replacePlaceholdersInFile,
replacePlaceholdersInDirectory,
replacePlaceholdersInFiles,
replaceProjectRootInFile,
replaceProjectRootInDirectory,
replaceProjectRootInFiles,
processInstallation,
processBundleRemoval,
};

516
tools/cli/lib/ui.js Normal file
View File

@@ -0,0 +1,516 @@
const chalk = require('chalk');
const inquirer = require('inquirer');
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils');
/**
* UI utilities for the installer
*/
class UI {
constructor() {}
/**
* Prompt for installation configuration
* @returns {Object} Installation configuration
*/
async promptInstall() {
CLIUtils.displayLogo();
CLIUtils.displaySection('BMAD™ Setup', 'Build More, Architect Dreams');
const confirmedDirectory = await this.getConfirmedDirectory();
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
const coreConfig = await this.collectCoreConfig(confirmedDirectory);
const moduleChoices = await this.getModuleChoices(installedModuleIds);
const selectedModules = await this.selectModules(moduleChoices);
console.clear();
CLIUtils.displayLogo();
CLIUtils.displayModuleComplete('core', false); // false = don't clear the screen again
return {
directory: confirmedDirectory,
installCore: true, // Always install core
modules: selectedModules,
// IDE selection moved to after module configuration
ides: [],
skipIde: true, // Will be handled later
coreConfig: coreConfig, // Pass collected core config to installer
};
}
/**
* Prompt for tool/IDE selection (called after module configuration)
* @param {string} projectDir - Project directory to check for existing IDEs
* @param {Array} selectedModules - Selected modules from configuration
* @returns {Object} Tool configuration
*/
async promptToolSelection(projectDir, selectedModules) {
// Check for existing configured IDEs
const { Detector } = require('../installers/lib/core/detector');
const detector = new Detector();
const bmadDir = path.join(projectDir || process.cwd(), 'bmad');
const existingInstall = await detector.detect(bmadDir);
const configuredIdes = existingInstall.ides || [];
// Get IDE manager to fetch available IDEs dynamically
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
const preferredIdes = ideManager.getPreferredIdes();
const otherIdes = ideManager.getOtherIdes();
// Build IDE choices array with separators
const ideChoices = [];
const processedIdes = new Set();
// First, add previously configured IDEs at the top, marked with ✅
if (configuredIdes.length > 0) {
ideChoices.push(new inquirer.Separator('── Previously Configured ──'));
for (const ideValue of configuredIdes) {
// Find the IDE in either preferred or other lists
const preferredIde = preferredIdes.find((ide) => ide.value === ideValue);
const otherIde = otherIdes.find((ide) => ide.value === ideValue);
const ide = preferredIde || otherIde;
if (ide) {
ideChoices.push({
name: `${ide.name}`,
value: ide.value,
checked: true, // Previously configured IDEs are checked by default
});
processedIdes.add(ide.value);
}
}
}
// Add preferred tools (excluding already processed)
const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingPreferred.length > 0) {
ideChoices.push(new inquirer.Separator('── Recommended Tools ──'));
for (const ide of remainingPreferred) {
ideChoices.push({
name: `${ide.name}`,
value: ide.value,
checked: false,
});
processedIdes.add(ide.value);
}
}
// Add other tools (excluding already processed)
const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value));
if (remainingOther.length > 0) {
ideChoices.push(new inquirer.Separator('── Additional Tools ──'));
for (const ide of remainingOther) {
ideChoices.push({
name: ide.name,
value: ide.value,
checked: false,
});
}
}
CLIUtils.displaySection('Tool Integration', 'Select AI coding assistants and IDEs to configure');
const answers = await inquirer.prompt([
{
type: 'checkbox',
name: 'ides',
message: 'Select tools to configure:',
choices: ideChoices,
pageSize: 15,
},
]);
return {
ides: answers.ides || [],
skipIde: !answers.ides || answers.ides.length === 0,
};
}
/**
* Prompt for update configuration
* @returns {Object} Update configuration
*/
async promptUpdate() {
const answers = await inquirer.prompt([
{
type: 'confirm',
name: 'backupFirst',
message: 'Create backup before updating?',
default: true,
},
{
type: 'confirm',
name: 'preserveCustomizations',
message: 'Preserve local customizations?',
default: true,
},
]);
return answers;
}
/**
* Prompt for module selection
* @param {Array} modules - Available modules
* @returns {Array} Selected modules
*/
async promptModules(modules) {
const choices = modules.map((mod) => ({
name: `${mod.name} - ${mod.description}`,
value: mod.id,
checked: false,
}));
const { selectedModules } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedModules',
message: 'Select modules to add:',
choices,
validate: (answer) => {
if (answer.length === 0) {
return 'You must choose at least one module.';
}
return true;
},
},
]);
return selectedModules;
}
/**
* Confirm action
* @param {string} message - Confirmation message
* @param {boolean} defaultValue - Default value
* @returns {boolean} User confirmation
*/
async confirm(message, defaultValue = false) {
const { confirmed } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmed',
message,
default: defaultValue,
},
]);
return confirmed;
}
/**
* Display installation summary
* @param {Object} result - Installation result
*/
showInstallSummary(result) {
CLIUtils.displaySection('Installation Complete', 'BMAD™ has been successfully installed');
const summary = [
`📁 Installation Path: ${result.path}`,
`📦 Modules Installed: ${result.modules?.length > 0 ? result.modules.join(', ') : 'core only'}`,
`🔧 Tools Configured: ${result.ides?.length > 0 ? result.ides.join(', ') : 'none'}`,
];
CLIUtils.displayBox(summary.join('\n\n'), {
borderColor: 'green',
borderStyle: 'round',
});
console.log('\n' + chalk.green.bold('✨ BMAD is ready to use!'));
}
/**
* Get confirmed directory from user
* @returns {string} Confirmed directory path
*/
async getConfirmedDirectory() {
let confirmedDirectory = null;
while (!confirmedDirectory) {
const directoryAnswer = await this.promptForDirectory();
await this.displayDirectoryInfo(directoryAnswer.directory);
if (await this.confirmDirectory(directoryAnswer.directory)) {
confirmedDirectory = directoryAnswer.directory;
}
}
return confirmedDirectory;
}
/**
* Get existing installation info and installed modules
* @param {string} directory - Installation directory
* @returns {Object} Object with existingInstall and installedModuleIds
*/
async getExistingInstallation(directory) {
const { Detector } = require('../installers/lib/core/detector');
const detector = new Detector();
const bmadDir = path.join(directory, 'bmad');
const existingInstall = await detector.detect(bmadDir);
const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
return { existingInstall, installedModuleIds };
}
/**
* Collect core configuration
* @param {string} directory - Installation directory
* @returns {Object} Core configuration
*/
async collectCoreConfig(directory) {
const { ConfigCollector } = require('../installers/lib/core/config-collector');
const configCollector = new ConfigCollector();
// Load existing configs first if they exist
await configCollector.loadExistingConfig(directory);
// Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
await configCollector.collectModuleConfig('core', directory, false, true);
return configCollector.collectedConfig.core;
}
/**
* Get module choices for selection
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Array} Module choices for inquirer
*/
async getModuleChoices(installedModuleIds) {
const { ModuleManager } = require('../installers/lib/modules/manager');
const moduleManager = new ModuleManager();
const availableModules = await moduleManager.listAvailable();
const isNewInstallation = installedModuleIds.size === 0;
return availableModules.map((mod) => ({
name: mod.name,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
}));
}
/**
* Prompt for module selection
* @param {Array} moduleChoices - Available module choices
* @returns {Array} Selected module IDs
*/
async selectModules(moduleChoices) {
CLIUtils.displaySection('Module Selection', 'Choose the BMAD modules to install');
const moduleAnswer = await inquirer.prompt([
{
type: 'checkbox',
name: 'modules',
message: 'Select modules to install:',
choices: moduleChoices,
},
]);
return moduleAnswer.modules || [];
}
/**
* Prompt for directory selection
* @returns {Object} Directory answer from inquirer
*/
async promptForDirectory() {
return await inquirer.prompt([
{
type: 'input',
name: 'directory',
message: `Installation directory:`,
default: process.cwd(),
validate: async (input) => this.validateDirectory(input),
filter: (input) => {
// If empty, use the default
if (!input || input.trim() === '') {
return process.cwd();
}
return this.expandUserPath(input);
},
},
]);
}
/**
* Display directory information
* @param {string} directory - The directory path
*/
async displayDirectoryInfo(directory) {
console.log(chalk.cyan('\nResolved installation path:'), chalk.bold(directory));
const dirExists = await fs.pathExists(directory);
if (dirExists) {
// Show helpful context about the existing path
const stats = await fs.stat(directory);
if (stats.isDirectory()) {
const files = await fs.readdir(directory);
if (files.length > 0) {
console.log(
chalk.gray(`Directory exists and contains ${files.length} item(s)`) +
(files.includes('bmad') ? chalk.yellow(' including existing bmad installation') : ''),
);
} else {
console.log(chalk.gray('Directory exists and is empty'));
}
}
} else {
const existingParent = await this.findExistingParent(directory);
console.log(chalk.gray(`Will create in: ${existingParent}`));
}
}
/**
* Confirm directory selection
* @param {string} directory - The directory path
* @returns {boolean} Whether user confirmed
*/
async confirmDirectory(directory) {
const dirExists = await fs.pathExists(directory);
if (dirExists) {
const confirmAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: `Install to this directory?`,
default: true,
},
]);
if (!confirmAnswer.proceed) {
console.log(chalk.yellow("\nLet's try again with a different path.\n"));
}
return confirmAnswer.proceed;
} else {
// Ask for confirmation to create the directory
const createConfirm = await inquirer.prompt([
{
type: 'confirm',
name: 'create',
message: `The directory '${directory}' doesn't exist. Would you like to create it?`,
default: false,
},
]);
if (!createConfirm.create) {
console.log(chalk.yellow("\nLet's try again with a different path.\n"));
}
return createConfirm.create;
}
}
/**
* Validate directory path for installation
* @param {string} input - User input path
* @returns {string|true} Error message or true if valid
*/
async validateDirectory(input) {
// Allow empty input to use the default
if (!input || input.trim() === '') {
return true; // Empty means use default
}
let expandedPath;
try {
expandedPath = this.expandUserPath(input.trim());
} catch (error) {
return error.message;
}
// Check if the path exists
const pathExists = await fs.pathExists(expandedPath);
if (!pathExists) {
// Find the first existing parent directory
const existingParent = await this.findExistingParent(expandedPath);
if (!existingParent) {
return 'Cannot create directory: no existing parent directory found';
}
// Check if the existing parent is writable
try {
await fs.access(existingParent, fs.constants.W_OK);
// Path doesn't exist but can be created - will prompt for confirmation later
return true;
} catch {
// Provide a detailed error message explaining both issues
return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`;
}
}
// If it exists, validate it's a directory and writable
const stat = await fs.stat(expandedPath);
if (!stat.isDirectory()) {
return `Path exists but is not a directory: ${expandedPath}`;
}
// Check write permissions
try {
await fs.access(expandedPath, fs.constants.W_OK);
} catch {
return `Directory is not writable: ${expandedPath}`;
}
return true;
}
/**
* Find the first existing parent directory
* @param {string} targetPath - The path to check
* @returns {string|null} The first existing parent directory, or null if none found
*/
async findExistingParent(targetPath) {
let currentPath = path.resolve(targetPath);
// Walk up the directory tree until we find an existing directory
while (currentPath !== path.dirname(currentPath)) {
// Stop at root
const parent = path.dirname(currentPath);
if (await fs.pathExists(parent)) {
return parent;
}
currentPath = parent;
}
return null; // No existing parent found (shouldn't happen in practice)
}
/**
* Expands the user-provided path: handles ~ and resolves to absolute.
* @param {string} inputPath - User input path.
* @returns {string} Absolute expanded path.
*/
expandUserPath(inputPath) {
if (typeof inputPath !== 'string') {
throw new TypeError('Path must be a string.');
}
let expanded = inputPath.trim();
// Handle tilde expansion
if (expanded.startsWith('~')) {
if (expanded === '~') {
expanded = os.homedir();
} else if (expanded.startsWith('~' + path.sep)) {
const pathAfterHome = expanded.slice(2); // Remove ~/ or ~\
expanded = path.join(os.homedir(), pathAfterHome);
} else {
const restOfPath = expanded.slice(1);
const separatorIndex = restOfPath.indexOf(path.sep);
const username = separatorIndex === -1 ? restOfPath : restOfPath.slice(0, separatorIndex);
if (username) {
throw new Error(`Path expansion for ~${username} is not supported. Please use an absolute path or ~${path.sep}`);
}
}
}
// Resolve to the absolute path relative to the current working directory
return path.resolve(expanded);
}
}
module.exports = { UI };

View File

@@ -0,0 +1,183 @@
const xml2js = require('xml2js');
const fs = require('fs-extra');
const path = require('node:path');
const { getProjectRoot, getSourcePath } = require('./project-root');
/**
* XML utility functions for BMAD installer
*/
class XmlHandler {
constructor() {
this.parser = new xml2js.Parser({
preserveChildrenOrder: true,
explicitChildren: true,
explicitArray: false,
trim: false,
normalizeTags: false,
attrkey: '$',
charkey: '_',
});
this.builder = new xml2js.Builder({
renderOpts: {
pretty: true,
indent: ' ',
newline: '\n',
},
xmldec: {
version: '1.0',
encoding: 'utf8',
standalone: false,
},
headless: true, // Don't add XML declaration
attrkey: '$',
charkey: '_',
});
}
/**
* Load and parse the activation template
* @returns {Object} Parsed activation block
*/
async loadActivationTemplate() {
const templatePath = getSourcePath('utility', 'models', 'agent-activation-ide.xml');
try {
const xmlContent = await fs.readFile(templatePath, 'utf8');
// Parse the XML directly (file is now pure XML)
const parsed = await this.parser.parseStringPromise(xmlContent);
return parsed.activation;
} catch (error) {
console.error('Failed to load activation template:', error);
return null;
}
}
/**
* Inject activation block into agent XML content
* @param {string} agentContent - The agent file content
* @param {Object} metadata - Metadata containing module and name
* @returns {string} Modified content with activation block
*/
async injectActivation(agentContent, metadata = {}) {
try {
// Check if already has activation
if (agentContent.includes('<activation')) {
return agentContent;
}
// Extract the XML portion from markdown if needed
let xmlContent = agentContent;
let beforeXml = '';
let afterXml = '';
const xmlBlockMatch = agentContent.match(/([\s\S]*?)```xml\n([\s\S]*?)\n```([\s\S]*)/);
if (xmlBlockMatch) {
beforeXml = xmlBlockMatch[1] + '```xml\n';
xmlContent = xmlBlockMatch[2];
afterXml = '\n```' + xmlBlockMatch[3];
}
// Parse the agent XML
const parsed = await this.parser.parseStringPromise(xmlContent);
// Get the activation template
const activationBlock = await this.loadActivationTemplate();
if (!activationBlock) {
console.warn('Could not load activation template');
return agentContent;
}
// Find the agent node
if (
parsed.agent && // Insert activation as the first child
!parsed.agent.activation
) {
// Ensure proper structure
if (!parsed.agent.$$) {
parsed.agent.$$ = [];
}
// Create the activation node with proper structure
const activationNode = {
'#name': 'activation',
$: { critical: '1' },
$$: activationBlock.$$,
};
// Insert at the beginning
parsed.agent.$$.unshift(activationNode);
}
// Convert back to XML
let modifiedXml = this.builder.buildObject(parsed);
// Fix indentation - xml2js doesn't maintain our exact formatting
// Add 2-space base indentation to match our style
const lines = modifiedXml.split('\n');
const indentedLines = lines.map((line) => {
if (line.trim() === '') return line;
if (line.startsWith('<agent')) return line; // Keep agent at column 0
return ' ' + line; // Indent everything else
});
modifiedXml = indentedLines.join('\n');
// Reconstruct the full content
return beforeXml + modifiedXml + afterXml;
} catch (error) {
console.error('Error injecting activation:', error);
return agentContent;
}
}
/**
* Simple string-based injection (fallback method)
* This preserves formatting better than XML parsing
*/
injectActivationSimple(agentContent, metadata = {}) {
// Check if already has activation
if (agentContent.includes('<activation')) {
return agentContent;
}
// Load template file
const templatePath = getSourcePath('utility', 'models', 'agent-activation-ide.xml');
try {
const templateContent = fs.readFileSync(templatePath, 'utf8');
// The file is now pure XML, use it directly with proper indentation
// Add 2 spaces of indentation for insertion into agent
let activationBlock = templateContent
.split('\n')
.map((line) => (line ? ' ' + line : ''))
.join('\n');
// Replace {agent-filename} with actual filename if metadata provided
if (metadata.module && metadata.name) {
const agentFilename = `${metadata.module}-${metadata.name}.md`;
activationBlock = activationBlock.replace('{agent-filename}', agentFilename);
}
// Find where to insert (after <agent> tag)
const agentMatch = agentContent.match(/(<agent[^>]*>)/);
if (!agentMatch) {
return agentContent;
}
const insertPos = agentMatch.index + agentMatch[0].length;
// Insert the activation block
const before = agentContent.slice(0, insertPos);
const after = agentContent.slice(insertPos);
return before + '\n' + activationBlock + after;
} catch (error) {
console.error('Error in simple injection:', error);
return agentContent;
}
}
}
module.exports = { XmlHandler };

View File

@@ -0,0 +1,82 @@
const fs = require('node:fs');
const path = require('node:path');
function convertXmlToMarkdown(xmlFilePath) {
if (!xmlFilePath.endsWith('.xml')) {
throw new Error('Input file must be an XML file');
}
const xmlContent = fs.readFileSync(xmlFilePath, 'utf8');
const basename = path.basename(xmlFilePath, '.xml');
const dirname = path.dirname(xmlFilePath);
const mdFilePath = path.join(dirname, `${basename}.md`);
// Extract version and name/title from root element attributes
let title = basename;
let version = '';
// Match the root element and its attributes
const rootMatch = xmlContent.match(
/<[^>\s]+[^>]*?\sv="([^"]+)"[^>]*?(?:\sname="([^"]+)")?|<[^>\s]+[^>]*?(?:\sname="([^"]+)")?[^>]*?\sv="([^"]+)"/,
);
if (rootMatch) {
// Handle both v="x" name="y" and name="y" v="x" orders
version = rootMatch[1] || rootMatch[4] || '';
const nameAttr = rootMatch[2] || rootMatch[3] || '';
if (nameAttr) {
title = nameAttr;
} else {
// Try to find name in a <name> element if not in attributes
const nameElementMatch = xmlContent.match(/<name>([^<]+)<\/name>/);
if (nameElementMatch) {
title = nameElementMatch[1];
}
}
}
const heading = version ? `# ${title} v${version}` : `# ${title}`;
const markdownContent = `${heading}
\`\`\`xml
${xmlContent}
\`\`\`
`;
fs.writeFileSync(mdFilePath, markdownContent, 'utf8');
return mdFilePath;
}
function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: node xml-to-markdown.js <xml-file-path>');
process.exit(1);
}
const xmlFilePath = path.resolve(args[0]);
if (!fs.existsSync(xmlFilePath)) {
console.error(`Error: File not found: ${xmlFilePath}`);
process.exit(1);
}
try {
const mdFilePath = convertXmlToMarkdown(xmlFilePath);
console.log(`Successfully converted: ${xmlFilePath} -> ${mdFilePath}`);
} catch (error) {
console.error(`Error converting file: ${error.message}`);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = { convertXmlToMarkdown };

246
tools/cli/lib/yaml-format.js Executable file
View File

@@ -0,0 +1,246 @@
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const { execSync } = require('node:child_process');
// Dynamic import for ES module
let chalk;
// Initialize ES modules
async function initializeModules() {
if (!chalk) {
chalk = (await import('chalk')).default;
}
}
/**
* YAML Formatter and Linter for BMad-Method
* Formats and validates YAML files and YAML embedded in Markdown
*/
async function formatYamlContent(content, filename) {
await initializeModules();
try {
// First try to fix common YAML issues
let fixedContent = content
// Fix "commands :" -> "commands:"
.replaceAll(/^(\s*)(\w+)\s+:/gm, '$1$2:')
// Fix inconsistent list indentation
.replaceAll(/^(\s*)-\s{3,}/gm, '$1- ');
// Skip auto-fixing for .roomodes files - they have special nested structure
if (!filename.includes('.roomodes')) {
fixedContent = fixedContent
// Fix unquoted list items that contain special characters or multiple parts
.replaceAll(/^(\s*)-\s+(.*)$/gm, (match, indent, content) => {
// Skip if already quoted
if (content.startsWith('"') && content.endsWith('"')) {
return match;
}
// If the content contains special YAML characters or looks complex, quote it
// BUT skip if it looks like a proper YAML key-value pair (like "key: value")
if (
(content.includes(':') || content.includes('-') || content.includes('{') || content.includes('}')) &&
!/^\w+:\s/.test(content)
) {
// Remove any existing quotes first, escape internal quotes, then add proper quotes
const cleanContent = content.replaceAll(/^["']|["']$/g, '').replaceAll('"', String.raw`\"`);
return `${indent}- "${cleanContent}"`;
}
return match;
});
}
// Debug: show what we're trying to parse
if (fixedContent !== content) {
console.log(chalk.blue(`🔧 Applied YAML fixes to ${filename}`));
}
// Parse and re-dump YAML to format it
const parsed = yaml.load(fixedContent);
const formatted = yaml.dump(parsed, {
indent: 2,
lineWidth: -1, // Disable line wrapping
noRefs: true,
sortKeys: false, // Preserve key order
});
return formatted;
} catch (error) {
console.error(chalk.red(`❌ YAML syntax error in ${filename}:`), error.message);
console.error(chalk.yellow(`💡 Try manually fixing the YAML structure first`));
return null;
}
}
async function processMarkdownFile(filePath) {
await initializeModules();
const content = fs.readFileSync(filePath, 'utf8');
let modified = false;
let newContent = content;
// Fix untyped code blocks by adding 'text' type
// Match ``` at start of line followed by newline, but only if it's an opening fence
newContent = newContent.replaceAll(/^```\n([\s\S]*?)\n```$/gm, '```text\n$1\n```');
if (newContent !== content) {
modified = true;
console.log(chalk.blue(`🔧 Added 'text' type to untyped code blocks in ${filePath}`));
}
// Find YAML code blocks
const yamlBlockRegex = /```ya?ml\n([\s\S]*?)\n```/g;
let match;
const replacements = [];
while ((match = yamlBlockRegex.exec(newContent)) !== null) {
const [fullMatch, yamlContent] = match;
const formatted = await formatYamlContent(yamlContent, filePath);
if (formatted !== null) {
// Remove trailing newline that js-yaml adds
const trimmedFormatted = formatted.replace(/\n$/, '');
if (trimmedFormatted !== yamlContent) {
modified = true;
console.log(chalk.green(`✓ Formatted YAML in ${filePath}`));
}
replacements.push({
start: match.index,
end: match.index + fullMatch.length,
replacement: `\`\`\`yaml\n${trimmedFormatted}\n\`\`\``,
});
}
}
// Apply replacements in reverse order to maintain indices
for (let index = replacements.length - 1; index >= 0; index--) {
const { start, end, replacement } = replacements[index];
newContent = newContent.slice(0, start) + replacement + newContent.slice(end);
}
if (modified) {
fs.writeFileSync(filePath, newContent);
return true;
}
return false;
}
async function processYamlFile(filePath) {
await initializeModules();
const content = fs.readFileSync(filePath, 'utf8');
const formatted = await formatYamlContent(content, filePath);
if (formatted === null) {
return false; // Syntax error
}
if (formatted !== content) {
fs.writeFileSync(filePath, formatted);
return true;
}
return false;
}
async function lintYamlFile(filePath) {
await initializeModules();
try {
// Use yaml-lint for additional validation
execSync(`npx yaml-lint "${filePath}"`, { stdio: 'pipe' });
return true;
} catch (error) {
console.error(chalk.red(`❌ YAML lint error in ${filePath}:`));
console.error(error.stdout?.toString() || error.message);
return false;
}
}
async function main() {
await initializeModules();
const arguments_ = process.argv.slice(2);
const glob = require('glob');
if (arguments_.length === 0) {
console.error('Usage: node yaml-format.js <file1> [file2] ...');
process.exit(1);
}
let hasErrors = false;
let hasChanges = false;
let filesProcessed = [];
// Expand glob patterns and collect all files
const allFiles = [];
for (const argument of arguments_) {
if (argument.includes('*')) {
// It's a glob pattern
const matches = glob.sync(argument);
allFiles.push(...matches);
} else {
// It's a direct file path
allFiles.push(argument);
}
}
for (const filePath of allFiles) {
if (!fs.existsSync(filePath)) {
// Skip silently for glob patterns that don't match anything
if (!arguments_.some((argument) => argument.includes('*') && filePath === argument)) {
console.error(chalk.red(`❌ File not found: ${filePath}`));
hasErrors = true;
}
continue;
}
const extension = path.extname(filePath).toLowerCase();
const basename = path.basename(filePath).toLowerCase();
try {
let changed = false;
if (extension === '.md') {
changed = await processMarkdownFile(filePath);
} else if (
extension === '.yaml' ||
extension === '.yml' ||
basename.includes('roomodes') ||
basename.includes('.yaml') ||
basename.includes('.yml')
) {
// Handle YAML files and special cases like .roomodes
changed = await processYamlFile(filePath);
// Also run linting
const lintPassed = await lintYamlFile(filePath);
if (!lintPassed) hasErrors = true;
} else {
// Skip silently for unsupported files
continue;
}
if (changed) {
hasChanges = true;
filesProcessed.push(filePath);
}
} catch (error) {
console.error(chalk.red(`❌ Error processing ${filePath}:`), error.message);
hasErrors = true;
}
}
if (hasChanges) {
console.log(chalk.green(`\n✨ YAML formatting completed! Modified ${filesProcessed.length} files:`));
for (const file of filesProcessed) console.log(chalk.blue(` 📝 ${file}`));
}
if (hasErrors) {
console.error(chalk.red('\n💥 Some files had errors. Please fix them before committing.'));
process.exit(1);
}
}
if (require.main === module) {
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});
}
module.exports = { formatYamlContent, processMarkdownFile, processYamlFile };

View File

@@ -0,0 +1,28 @@
const path = require('node:path');
const { ManifestGenerator } = require('./installers/lib/core/manifest-generator');
async function regenerateManifests() {
const generator = new ManifestGenerator();
const targetDir = process.argv[2] || 'z1';
const bmadDir = path.join(process.cwd(), targetDir, 'bmad');
// List of modules to include in manifests
const selectedModules = ['bmb', 'bmm', 'cis'];
console.log('Regenerating manifests with relative paths...');
console.log('Target directory:', bmadDir);
try {
const result = await generator.generateManifests(bmadDir, selectedModules);
console.log('✓ Manifests generated successfully:');
console.log(` - ${result.workflows} workflows`);
console.log(` - ${result.agents} agents`);
console.log(` - ${result.tasks} tasks`);
console.log(` - ${result.files} files in files-manifest.csv`);
} catch (error) {
console.error('Error generating manifests:', error);
process.exit(1);
}
}
regenerateManifests();