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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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