mirror of
https://github.com/bmadcode/BMAD-METHOD.git
synced 2025-12-29 16:14:59 +00:00
feat: v6.0.0-alpha.0 - the future is now
This commit is contained in:
42
tools/cli/bmad-cli.js
Executable file
42
tools/cli/bmad-cli.js
Executable 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
157
tools/cli/bundlers/bundle-web.js
Executable 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();
|
||||
}
|
||||
28
tools/cli/bundlers/test-analyst.js
Normal file
28
tools/cli/bundlers/test-analyst.js
Normal 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();
|
||||
118
tools/cli/bundlers/test-bundler.js
Executable file
118
tools/cli/bundlers/test-bundler.js
Executable 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);
|
||||
});
|
||||
880
tools/cli/bundlers/web-bundler.js
Normal file
880
tools/cli/bundlers/web-bundler.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
41
tools/cli/commands/install.js
Normal file
41
tools/cli/commands/install.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
28
tools/cli/commands/list.js
Normal file
28
tools/cli/commands/list.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
47
tools/cli/commands/status.js
Normal file
47
tools/cli/commands/status.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
44
tools/cli/commands/uninstall.js
Normal file
44
tools/cli/commands/uninstall.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
28
tools/cli/commands/update.js
Normal file
28
tools/cli/commands/update.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
383
tools/cli/installers/lib/core/config-collector.js
Normal file
383
tools/cli/installers/lib/core/config-collector.js
Normal 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 };
|
||||
721
tools/cli/installers/lib/core/dependency-resolver.js
Normal file
721
tools/cli/installers/lib/core/dependency-resolver.js
Normal 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 };
|
||||
208
tools/cli/installers/lib/core/detector.js
Normal file
208
tools/cli/installers/lib/core/detector.js
Normal 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 };
|
||||
1070
tools/cli/installers/lib/core/installer.js
Normal file
1070
tools/cli/installers/lib/core/installer.js
Normal file
File diff suppressed because it is too large
Load Diff
385
tools/cli/installers/lib/core/manifest-generator.js
Normal file
385
tools/cli/installers/lib/core/manifest-generator.js
Normal 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 };
|
||||
484
tools/cli/installers/lib/core/manifest.js
Normal file
484
tools/cli/installers/lib/core/manifest.js
Normal 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 };
|
||||
281
tools/cli/installers/lib/ide/_base-ide.js
Normal file
281
tools/cli/installers/lib/ide/_base-ide.js
Normal 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 };
|
||||
271
tools/cli/installers/lib/ide/auggie.js
Normal file
271
tools/cli/installers/lib/ide/auggie.js
Normal 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 };
|
||||
625
tools/cli/installers/lib/ide/claude-code.js
Normal file
625
tools/cli/installers/lib/ide/claude-code.js
Normal 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 };
|
||||
301
tools/cli/installers/lib/ide/cline.js
Normal file
301
tools/cli/installers/lib/ide/cline.js
Normal 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 };
|
||||
267
tools/cli/installers/lib/ide/codex.js
Normal file
267
tools/cli/installers/lib/ide/codex.js
Normal 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 };
|
||||
204
tools/cli/installers/lib/ide/crush.js
Normal file
204
tools/cli/installers/lib/ide/crush.js
Normal 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 };
|
||||
224
tools/cli/installers/lib/ide/cursor.js
Normal file
224
tools/cli/installers/lib/ide/cursor.js
Normal 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 };
|
||||
160
tools/cli/installers/lib/ide/gemini.js
Normal file
160
tools/cli/installers/lib/ide/gemini.js
Normal 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 };
|
||||
289
tools/cli/installers/lib/ide/github-copilot.js
Normal file
289
tools/cli/installers/lib/ide/github-copilot.js
Normal 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 };
|
||||
142
tools/cli/installers/lib/ide/iflow.js
Normal file
142
tools/cli/installers/lib/ide/iflow.js
Normal 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 };
|
||||
171
tools/cli/installers/lib/ide/kilo.js
Normal file
171
tools/cli/installers/lib/ide/kilo.js
Normal 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 };
|
||||
203
tools/cli/installers/lib/ide/manager.js
Normal file
203
tools/cli/installers/lib/ide/manager.js
Normal 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 };
|
||||
188
tools/cli/installers/lib/ide/qwen.js
Normal file
188
tools/cli/installers/lib/ide/qwen.js
Normal 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 };
|
||||
288
tools/cli/installers/lib/ide/roo.js
Normal file
288
tools/cli/installers/lib/ide/roo.js
Normal 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 };
|
||||
182
tools/cli/installers/lib/ide/trae.js
Normal file
182
tools/cli/installers/lib/ide/trae.js
Normal 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 };
|
||||
149
tools/cli/installers/lib/ide/windsurf.js
Normal file
149
tools/cli/installers/lib/ide/windsurf.js
Normal 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 };
|
||||
162
tools/cli/installers/lib/ide/workflow-command-generator.js
Normal file
162
tools/cli/installers/lib/ide/workflow-command-generator.js
Normal 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 };
|
||||
11
tools/cli/installers/lib/ide/workflow-command-template.md
Normal file
11
tools/cli/installers/lib/ide/workflow-command-template.md
Normal 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>
|
||||
452
tools/cli/installers/lib/modules/manager.js
Normal file
452
tools/cli/installers/lib/modules/manager.js
Normal 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 };
|
||||
206
tools/cli/lib/agent-party-generator.js
Normal file
206
tools/cli/lib/agent-party-generator.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
208
tools/cli/lib/cli-utils.js
Normal 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
210
tools/cli/lib/config.js
Normal 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
204
tools/cli/lib/file-ops.js
Normal 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 };
|
||||
116
tools/cli/lib/platform-codes.js
Normal file
116
tools/cli/lib/platform-codes.js
Normal 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();
|
||||
71
tools/cli/lib/project-root.js
Normal file
71
tools/cli/lib/project-root.js
Normal 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,
|
||||
};
|
||||
239
tools/cli/lib/replace-project-root.js
Normal file
239
tools/cli/lib/replace-project-root.js
Normal 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
516
tools/cli/lib/ui.js
Normal 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 };
|
||||
183
tools/cli/lib/xml-handler.js
Normal file
183
tools/cli/lib/xml-handler.js
Normal 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 };
|
||||
82
tools/cli/lib/xml-to-markdown.js
Normal file
82
tools/cli/lib/xml-to-markdown.js
Normal 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
246
tools/cli/lib/yaml-format.js
Executable 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 };
|
||||
28
tools/cli/regenerate-manifests.js
Normal file
28
tools/cli/regenerate-manifests.js
Normal 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();
|
||||
Reference in New Issue
Block a user