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:
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 };
|
||||
Reference in New Issue
Block a user