agent updates
This commit is contained in:
297
tools/cli/commands/build.js
Normal file
297
tools/cli/commands/build.js
Normal file
@@ -0,0 +1,297 @@
|
||||
const chalk = require('chalk');
|
||||
const path = require('node:path');
|
||||
const fs = require('fs-extra');
|
||||
const { YamlXmlBuilder } = require('../lib/yaml-xml-builder');
|
||||
const { getProjectRoot } = require('../lib/project-root');
|
||||
|
||||
const builder = new YamlXmlBuilder();
|
||||
|
||||
/**
|
||||
* Find .claude directory by searching up from current directory
|
||||
*/
|
||||
async function findClaudeDir(startDir) {
|
||||
let currentDir = startDir;
|
||||
const root = path.parse(currentDir).root;
|
||||
|
||||
while (currentDir !== root) {
|
||||
const claudeDir = path.join(currentDir, '.claude');
|
||||
if (await fs.pathExists(claudeDir)) {
|
||||
return claudeDir;
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
command: 'build [agent]',
|
||||
description: 'Build agent XML files from YAML sources',
|
||||
options: [
|
||||
['-a, --all', 'Rebuild all agents'],
|
||||
['-d, --directory <path>', 'Project directory', '.'],
|
||||
['--force', 'Force rebuild even if up to date'],
|
||||
],
|
||||
action: async (agentName, options) => {
|
||||
try {
|
||||
let projectDir = path.resolve(options.directory);
|
||||
|
||||
// Auto-detect .claude directory (search up from current dir)
|
||||
const claudeDir = await findClaudeDir(projectDir);
|
||||
if (!claudeDir) {
|
||||
console.log(chalk.yellow('\n⚠️ No .claude directory found'));
|
||||
console.log(chalk.dim('Run this command from your project directory or'));
|
||||
console.log(chalk.dim('use --directory flag to specify location'));
|
||||
console.log(chalk.dim('\nExample: npx bmad-method build pm --directory /path/to/project'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use the directory containing .claude
|
||||
projectDir = path.dirname(claudeDir);
|
||||
console.log(chalk.dim(`Using project: ${projectDir}\n`));
|
||||
|
||||
console.log(chalk.cyan('🔨 Building Agent Files\n'));
|
||||
|
||||
if (options.all) {
|
||||
// Build all agents
|
||||
await buildAllAgents(projectDir, options.force);
|
||||
} else if (agentName) {
|
||||
// Build specific agent
|
||||
await buildAgent(projectDir, agentName, options.force);
|
||||
} else {
|
||||
// No agent specified, check what needs rebuilding
|
||||
await checkBuildStatus(projectDir);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\nError:'), error.message);
|
||||
if (process.env.DEBUG) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a specific agent
|
||||
*/
|
||||
async function buildAgent(projectDir, agentName, force = false) {
|
||||
// Find the agent YAML file in .claude/commands/bmad/
|
||||
const bmadCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
|
||||
|
||||
// Search all module directories for the agent
|
||||
const modules = await fs.readdir(bmadCommandsDir);
|
||||
let found = false;
|
||||
|
||||
for (const module of modules) {
|
||||
const agentYamlPath = path.join(bmadCommandsDir, module, 'agents', `${agentName}.agent.yaml`);
|
||||
const outputPath = path.join(bmadCommandsDir, module, 'agents', `${agentName}.md`);
|
||||
|
||||
if (await fs.pathExists(agentYamlPath)) {
|
||||
found = true;
|
||||
|
||||
// Check if rebuild needed
|
||||
if (!force && (await fs.pathExists(outputPath))) {
|
||||
const needsRebuild = await checkIfNeedsRebuild(agentYamlPath, outputPath, projectDir, agentName);
|
||||
if (!needsRebuild) {
|
||||
console.log(chalk.dim(` ${agentName}: already up to date`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the agent
|
||||
console.log(chalk.cyan(` Building ${agentName}...`));
|
||||
|
||||
const customizePath = path.join(projectDir, '.claude', '_cfg', 'agents', `${agentName}.customize.yaml`);
|
||||
const customizeExists = await fs.pathExists(customizePath);
|
||||
|
||||
await builder.buildAgent(agentYamlPath, customizeExists ? customizePath : null, outputPath, { includeMetadata: true });
|
||||
|
||||
console.log(chalk.green(` ✓ ${agentName} built successfully`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.log(chalk.yellow(` ⚠️ Agent '${agentName}' not found`));
|
||||
console.log(chalk.dim(' Available agents:'));
|
||||
await listAvailableAgents(bmadCommandsDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all agents
|
||||
*/
|
||||
async function buildAllAgents(projectDir, force = false) {
|
||||
const bmadCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
|
||||
const modules = await fs.readdir(bmadCommandsDir);
|
||||
|
||||
let builtCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const module of modules) {
|
||||
const agentsDir = path.join(bmadCommandsDir, module, 'agents');
|
||||
|
||||
if (!(await fs.pathExists(agentsDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(agentsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.agent.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentName = file.replace('.agent.yaml', '');
|
||||
const agentYamlPath = path.join(agentsDir, file);
|
||||
const outputPath = path.join(agentsDir, `${agentName}.md`);
|
||||
|
||||
// Check if rebuild needed
|
||||
if (!force && (await fs.pathExists(outputPath))) {
|
||||
const needsRebuild = await checkIfNeedsRebuild(agentYamlPath, outputPath, projectDir, agentName);
|
||||
if (!needsRebuild) {
|
||||
console.log(chalk.dim(` ${agentName}: up to date`));
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.cyan(` Building ${agentName}...`));
|
||||
|
||||
const customizePath = path.join(projectDir, '.claude', '_cfg', 'agents', `${agentName}.customize.yaml`);
|
||||
const customizeExists = await fs.pathExists(customizePath);
|
||||
|
||||
await builder.buildAgent(agentYamlPath, customizeExists ? customizePath : null, outputPath, { includeMetadata: true });
|
||||
|
||||
console.log(chalk.green(` ✓ ${agentName}`));
|
||||
builtCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.green(`\n✓ Built ${builtCount} agent(s)`));
|
||||
if (skippedCount > 0) {
|
||||
console.log(chalk.dim(` Skipped ${skippedCount} (already up to date)`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what needs rebuilding
|
||||
*/
|
||||
async function checkBuildStatus(projectDir) {
|
||||
const bmadCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad');
|
||||
const modules = await fs.readdir(bmadCommandsDir);
|
||||
|
||||
const needsRebuild = [];
|
||||
const upToDate = [];
|
||||
|
||||
for (const module of modules) {
|
||||
const agentsDir = path.join(bmadCommandsDir, module, 'agents');
|
||||
|
||||
if (!(await fs.pathExists(agentsDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(agentsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.agent.yaml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const agentName = file.replace('.agent.yaml', '');
|
||||
const agentYamlPath = path.join(agentsDir, file);
|
||||
const outputPath = path.join(agentsDir, `${agentName}.md`);
|
||||
|
||||
if (!(await fs.pathExists(outputPath))) {
|
||||
needsRebuild.push(agentName);
|
||||
} else if (await checkIfNeedsRebuild(agentYamlPath, outputPath, projectDir, agentName)) {
|
||||
needsRebuild.push(agentName);
|
||||
} else {
|
||||
upToDate.push(agentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsRebuild.length === 0) {
|
||||
console.log(chalk.green('✓ All agents are up to date'));
|
||||
} else {
|
||||
console.log(chalk.yellow(`${needsRebuild.length} agent(s) need rebuilding:`));
|
||||
for (const agent of needsRebuild) {
|
||||
console.log(chalk.dim(` - ${agent}`));
|
||||
}
|
||||
console.log(chalk.dim('\nRun "bmad build --all" to rebuild all agents'));
|
||||
}
|
||||
|
||||
if (upToDate.length > 0) {
|
||||
console.log(chalk.dim(`\n${upToDate.length} agent(s) up to date`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent needs rebuilding by comparing hashes
|
||||
*/
|
||||
async function checkIfNeedsRebuild(yamlPath, outputPath, projectDir, agentName) {
|
||||
// Read the output file to check its metadata
|
||||
const outputContent = await fs.readFile(outputPath, 'utf8');
|
||||
|
||||
// Extract hash from BUILD-META comment
|
||||
const metaMatch = outputContent.match(/source:.*\(hash: ([a-f0-9]+)\)/);
|
||||
if (!metaMatch) {
|
||||
// No metadata, needs rebuild
|
||||
return true;
|
||||
}
|
||||
|
||||
const storedHash = metaMatch[1];
|
||||
|
||||
// Calculate current hash
|
||||
const currentHash = await builder.calculateFileHash(yamlPath);
|
||||
|
||||
if (storedHash !== currentHash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check customize file if it exists
|
||||
const customizePath = path.join(projectDir, '.claude', '_cfg', 'agents', `${agentName}.customize.yaml`);
|
||||
if (await fs.pathExists(customizePath)) {
|
||||
const customizeMetaMatch = outputContent.match(/customize:.*\(hash: ([a-f0-9]+)\)/);
|
||||
if (!customizeMetaMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storedCustomizeHash = customizeMetaMatch[1];
|
||||
const currentCustomizeHash = await builder.calculateFileHash(customizePath);
|
||||
|
||||
if (storedCustomizeHash !== currentCustomizeHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available agents
|
||||
*/
|
||||
async function listAvailableAgents(bmadCommandsDir) {
|
||||
const modules = await fs.readdir(bmadCommandsDir);
|
||||
|
||||
for (const module of modules) {
|
||||
const agentsDir = path.join(bmadCommandsDir, module, 'agents');
|
||||
|
||||
if (!(await fs.pathExists(agentsDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(agentsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.agent.yaml')) {
|
||||
const agentName = file.replace('.agent.yaml', '');
|
||||
console.log(chalk.dim(` - ${agentName} (${module})`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,17 @@ module.exports = {
|
||||
action: async () => {
|
||||
try {
|
||||
const config = await ui.promptInstall();
|
||||
|
||||
// Handle agent compilation separately
|
||||
if (config.actionType === 'compile') {
|
||||
const result = await installer.compileAgents(config);
|
||||
console.log(chalk.green('\n✨ Agent compilation complete!'));
|
||||
console.log(chalk.cyan(`Rebuilt ${result.agentCount} agents and ${result.taskCount} tasks`));
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular install/update flow
|
||||
const result = await installer.install(config);
|
||||
|
||||
console.log(chalk.green('\n✨ Installation complete!'));
|
||||
|
||||
@@ -327,18 +327,8 @@ class Installer {
|
||||
spinner.succeed('Module configurations generated');
|
||||
|
||||
// Create agent configuration files
|
||||
spinner.start('Creating agent configurations...');
|
||||
// Get user info from collected config if available
|
||||
const userInfo = {
|
||||
userName: moduleConfigs.core?.['user_name'] || null,
|
||||
responseLanguage: moduleConfigs.core?.['communication_language'] || null,
|
||||
};
|
||||
const agentConfigResult = await this.createAgentConfigs(bmadDir, userInfo);
|
||||
if (agentConfigResult.skipped > 0) {
|
||||
spinner.succeed(`Agent configurations: ${agentConfigResult.created} created, ${agentConfigResult.skipped} preserved`);
|
||||
} else {
|
||||
spinner.succeed(`Agent configurations created: ${agentConfigResult.created}`);
|
||||
}
|
||||
// Note: Legacy createAgentConfigs removed - using YAML customize system instead
|
||||
// Customize templates are now created in processAgentFiles when building YAML agents
|
||||
|
||||
// Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
|
||||
const cfgDir = path.join(bmadDir, '_cfg');
|
||||
@@ -770,6 +760,10 @@ class Installer {
|
||||
},
|
||||
);
|
||||
|
||||
// Process agent files to build YAML agents and create customize templates
|
||||
const modulePath = path.join(bmadDir, moduleName);
|
||||
await this.processAgentFiles(modulePath, moduleName);
|
||||
|
||||
// Dependencies are already included in full module install
|
||||
}
|
||||
|
||||
@@ -939,8 +933,8 @@ class Installer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent files to inject activation block
|
||||
* @param {string} modulePath - Path to module
|
||||
* Process agent files to build YAML agents and inject activation blocks
|
||||
* @param {string} modulePath - Path to module in bmad/ installation
|
||||
* @param {string} moduleName - Module name
|
||||
*/
|
||||
async processAgentFiles(modulePath, moduleName) {
|
||||
@@ -951,21 +945,137 @@ class Installer {
|
||||
return; // No agents to process
|
||||
}
|
||||
|
||||
// Determine project directory (parent of bmad/ directory)
|
||||
const bmadDir = path.dirname(modulePath);
|
||||
const projectDir = path.dirname(bmadDir);
|
||||
const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
|
||||
|
||||
// Ensure _cfg/agents directory exists
|
||||
await fs.ensureDir(cfgAgentsDir);
|
||||
|
||||
// Get all agent files
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
|
||||
for (const agentFile of agentFiles) {
|
||||
if (!agentFile.endsWith('.md')) continue;
|
||||
// Handle YAML agents - build them to .md
|
||||
if (agentFile.endsWith('.agent.yaml')) {
|
||||
const agentName = agentFile.replace('.agent.yaml', '');
|
||||
const yamlPath = path.join(agentsPath, agentFile);
|
||||
const mdPath = path.join(agentsPath, `${agentName}.md`);
|
||||
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
||||
|
||||
const agentPath = path.join(agentsPath, agentFile);
|
||||
let content = await fs.readFile(agentPath, 'utf8');
|
||||
// Create customize template if it doesn't exist
|
||||
if (!(await fs.pathExists(customizePath))) {
|
||||
const genericTemplatePath = getSourcePath('utility', 'templates', 'agent.customize.template.yaml');
|
||||
if (await fs.pathExists(genericTemplatePath)) {
|
||||
await fs.copy(genericTemplatePath, customizePath);
|
||||
console.log(chalk.dim(` Created customize: ${moduleName}-${agentName}.customize.yaml`));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if content has agent XML and no activation block
|
||||
if (content.includes('<agent') && !content.includes('<activation')) {
|
||||
// Inject the activation block using XML handler
|
||||
content = this.xmlHandler.injectActivationSimple(content);
|
||||
await fs.writeFile(agentPath, content, 'utf8');
|
||||
// Build YAML + customize to .md
|
||||
const customizeExists = await fs.pathExists(customizePath);
|
||||
const xmlContent = await this.xmlHandler.buildFromYaml(yamlPath, customizeExists ? customizePath : null, {
|
||||
includeMetadata: true,
|
||||
});
|
||||
|
||||
// Replace {project-root} placeholder
|
||||
const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
|
||||
|
||||
// Write the built .md file
|
||||
await fs.writeFile(mdPath, processedContent, 'utf8');
|
||||
this.installedFiles.push(mdPath);
|
||||
|
||||
console.log(chalk.dim(` Built agent: ${agentName}.md`));
|
||||
}
|
||||
// Handle legacy .md agents - inject activation if needed
|
||||
else if (agentFile.endsWith('.md')) {
|
||||
const agentPath = path.join(agentsPath, agentFile);
|
||||
let content = await fs.readFile(agentPath, 'utf8');
|
||||
|
||||
// Check if content has agent XML and no activation block
|
||||
if (content.includes('<agent') && !content.includes('<activation')) {
|
||||
// Inject the activation block using XML handler
|
||||
content = this.xmlHandler.injectActivationSimple(content);
|
||||
await fs.writeFile(agentPath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile/rebuild all agents and tasks for quick updates
|
||||
* @param {Object} config - Compilation configuration
|
||||
* @returns {Object} Compilation results
|
||||
*/
|
||||
async compileAgents(config) {
|
||||
const ora = require('ora');
|
||||
const spinner = ora('Starting agent compilation...').start();
|
||||
|
||||
try {
|
||||
const projectDir = path.resolve(config.directory);
|
||||
const bmadDir = path.join(projectDir, 'bmad');
|
||||
|
||||
// Check if bmad directory exists
|
||||
if (!(await fs.pathExists(bmadDir))) {
|
||||
spinner.fail('No BMAD installation found');
|
||||
throw new Error(`BMAD not installed at ${bmadDir}`);
|
||||
}
|
||||
|
||||
let agentCount = 0;
|
||||
let taskCount = 0;
|
||||
|
||||
// Process all modules in bmad directory
|
||||
spinner.text = 'Rebuilding agent files...';
|
||||
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name !== '_cfg') {
|
||||
const modulePath = path.join(bmadDir, entry.name);
|
||||
|
||||
// Process agents
|
||||
const agentsPath = path.join(modulePath, 'agents');
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
await this.processAgentFiles(modulePath, entry.name);
|
||||
const agentFiles = await fs.readdir(agentsPath);
|
||||
agentCount += agentFiles.filter((f) => f.endsWith('.md')).length;
|
||||
}
|
||||
|
||||
// Count tasks (already built)
|
||||
const tasksPath = path.join(modulePath, 'tasks');
|
||||
if (await fs.pathExists(tasksPath)) {
|
||||
const taskFiles = await fs.readdir(tasksPath);
|
||||
taskCount += taskFiles.filter((f) => f.endsWith('.md')).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ask for IDE to update
|
||||
spinner.stop();
|
||||
// Note: UI lives in tools/cli/lib/ui.js; from installers/lib/core use '../../../lib/ui'
|
||||
const { UI } = require('../../../lib/ui');
|
||||
const ui = new UI();
|
||||
const toolConfig = await ui.promptToolSelection(projectDir, []);
|
||||
|
||||
if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
|
||||
spinner.start('Updating IDE configurations...');
|
||||
|
||||
for (const ide of toolConfig.ides) {
|
||||
spinner.text = `Updating ${ide}...`;
|
||||
await this.ideManager.setup(ide, projectDir, bmadDir, {
|
||||
selectedModules: entries.filter((e) => e.isDirectory() && e.name !== '_cfg').map((e) => e.name),
|
||||
skipModuleInstall: true, // Skip module installation, just update IDE files
|
||||
verbose: config.verbose,
|
||||
});
|
||||
}
|
||||
|
||||
spinner.succeed('IDE configurations updated');
|
||||
}
|
||||
|
||||
return { agentCount, taskCount };
|
||||
} catch (error) {
|
||||
spinner.fail('Compilation failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,8 +177,9 @@ class BaseIdeSetup {
|
||||
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
|
||||
}
|
||||
|
||||
// Use the actual project directory path if provided, otherwise default to 'bmad/'
|
||||
const projectRoot = projectDir ? projectDir + '/' : 'bmad/';
|
||||
// Use the actual project directory path if provided, otherwise default to 'bmad'
|
||||
// Note: Don't add trailing slash - paths in source include leading slash
|
||||
const projectRoot = projectDir || 'bmad';
|
||||
|
||||
// Common replacements (including in the activation block)
|
||||
processed = processed.replaceAll('{project-root}', projectRoot);
|
||||
|
||||
@@ -92,11 +92,10 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
|
||||
await this.ensureDir(bmadCommandsDir);
|
||||
|
||||
// Get agents and tasks from SOURCE, not installed location
|
||||
// This ensures we process files with {project-root} placeholders intact
|
||||
const sourceDir = getSourcePath('modules');
|
||||
const agents = await this.getAgentsFromSource(sourceDir, options.selectedModules || []);
|
||||
const tasks = await this.getTasksFromSource(sourceDir, options.selectedModules || []);
|
||||
// Get agents and tasks from INSTALLED bmad/ directory
|
||||
// Base installer has already built .md files from .agent.yaml sources
|
||||
const agents = await this.getAgentsFromBmad(bmadDir, options.selectedModules || []);
|
||||
const tasks = await this.getTasksFromBmad(bmadDir, options.selectedModules || []);
|
||||
|
||||
// Create directories for each module
|
||||
const modules = new Set();
|
||||
@@ -108,30 +107,32 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
await this.ensureDir(path.join(bmadCommandsDir, module, 'tasks'));
|
||||
}
|
||||
|
||||
// Process and copy agents
|
||||
// Copy agents from bmad/ to .claude/commands/
|
||||
let agentCount = 0;
|
||||
for (const agent of agents) {
|
||||
const content = await this.readAndProcess(agent.path, {
|
||||
const sourcePath = agent.path;
|
||||
const targetPath = path.join(bmadCommandsDir, agent.module, 'agents', `${agent.name}.md`);
|
||||
|
||||
const content = await this.readAndProcess(sourcePath, {
|
||||
module: agent.module,
|
||||
name: agent.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadCommandsDir, agent.module, 'agents', `${agent.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
agentCount++;
|
||||
}
|
||||
|
||||
// Process and copy tasks
|
||||
// Copy tasks from bmad/ to .claude/commands/
|
||||
let taskCount = 0;
|
||||
for (const task of tasks) {
|
||||
const content = await this.readAndProcess(task.path, {
|
||||
const sourcePath = task.path;
|
||||
const targetPath = path.join(bmadCommandsDir, task.module, 'tasks', `${task.name}.md`);
|
||||
|
||||
const content = await this.readAndProcess(sourcePath, {
|
||||
module: task.module,
|
||||
name: task.name,
|
||||
});
|
||||
|
||||
const targetPath = path.join(bmadCommandsDir, task.module, 'tasks', `${task.name}.md`);
|
||||
|
||||
await this.writeFile(targetPath, content);
|
||||
taskCount++;
|
||||
}
|
||||
@@ -185,6 +186,58 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
return super.processContent(content, metadata, this.projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents from installed bmad/ directory
|
||||
*/
|
||||
async getAgentsFromBmad(bmadDir, selectedModules) {
|
||||
const fs = require('fs-extra');
|
||||
const agents = [];
|
||||
|
||||
// Add core agents
|
||||
if (await fs.pathExists(path.join(bmadDir, 'core', 'agents'))) {
|
||||
const coreAgents = await this.getAgentsFromDir(path.join(bmadDir, 'core', 'agents'), 'core');
|
||||
agents.push(...coreAgents);
|
||||
}
|
||||
|
||||
// Add module agents
|
||||
for (const moduleName of selectedModules) {
|
||||
const agentsPath = path.join(bmadDir, moduleName, 'agents');
|
||||
|
||||
if (await fs.pathExists(agentsPath)) {
|
||||
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
|
||||
agents.push(...moduleAgents);
|
||||
}
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks from installed bmad/ directory
|
||||
*/
|
||||
async getTasksFromBmad(bmadDir, selectedModules) {
|
||||
const fs = require('fs-extra');
|
||||
const tasks = [];
|
||||
|
||||
// Add core tasks
|
||||
if (await fs.pathExists(path.join(bmadDir, 'core', 'tasks'))) {
|
||||
const coreTasks = await this.getTasksFromDir(path.join(bmadDir, 'core', 'tasks'), 'core');
|
||||
tasks.push(...coreTasks);
|
||||
}
|
||||
|
||||
// Add module tasks
|
||||
for (const moduleName of selectedModules) {
|
||||
const tasksPath = path.join(bmadDir, moduleName, 'tasks');
|
||||
|
||||
if (await fs.pathExists(tasksPath)) {
|
||||
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
|
||||
tasks.push(...moduleTasks);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agents from source modules (not installed location)
|
||||
*/
|
||||
@@ -243,14 +296,23 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
|
||||
/**
|
||||
* Get agents from a specific directory
|
||||
* When reading from bmad/, this returns built .md files
|
||||
*/
|
||||
async getAgentsFromDir(dirPath, moduleName) {
|
||||
const fs = require('fs-extra');
|
||||
const agents = [];
|
||||
|
||||
const files = await fs.readdir(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
// Only process .md files (base installer has already built .agent.yaml to .md)
|
||||
if (file.endsWith('.md')) {
|
||||
// Skip customize templates
|
||||
if (file.includes('.customize.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseName = file.replace('.md', '');
|
||||
const filePath = path.join(dirPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
@@ -261,7 +323,7 @@ class ClaudeCodeSetup extends BaseIdeSetup {
|
||||
|
||||
agents.push({
|
||||
path: filePath,
|
||||
name: file.replace('.md', ''),
|
||||
name: baseName,
|
||||
module: moduleName,
|
||||
});
|
||||
}
|
||||
|
||||
160
tools/cli/lib/activation-builder.js
Normal file
160
tools/cli/lib/activation-builder.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const { getSourcePath } = require('./project-root');
|
||||
|
||||
/**
|
||||
* Builds activation blocks from fragments based on agent profile
|
||||
*/
|
||||
class ActivationBuilder {
|
||||
constructor() {
|
||||
this.fragmentsDir = getSourcePath('utility', 'models', 'fragments');
|
||||
this.fragmentCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a fragment file
|
||||
* @param {string} fragmentName - Name of fragment file (e.g., 'activation-init.xml')
|
||||
* @returns {string} Fragment content
|
||||
*/
|
||||
async loadFragment(fragmentName) {
|
||||
// Check cache first
|
||||
if (this.fragmentCache.has(fragmentName)) {
|
||||
return this.fragmentCache.get(fragmentName);
|
||||
}
|
||||
|
||||
const fragmentPath = path.join(this.fragmentsDir, fragmentName);
|
||||
|
||||
if (!(await fs.pathExists(fragmentPath))) {
|
||||
throw new Error(`Fragment not found: ${fragmentName}`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(fragmentPath, 'utf8');
|
||||
this.fragmentCache.set(fragmentName, content);
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete activation block based on agent profile
|
||||
* @param {Object} profile - Agent profile from AgentAnalyzer
|
||||
* @param {Object} metadata - Agent metadata (module, name, etc.)
|
||||
* @param {Array} agentSpecificActions - Optional agent-specific critical actions
|
||||
* @returns {string} Complete activation block XML
|
||||
*/
|
||||
async buildActivation(profile, metadata = {}, agentSpecificActions = []) {
|
||||
let activation = '<activation critical="MANDATORY">\n';
|
||||
|
||||
// 1. Build sequential steps
|
||||
const steps = await this.buildSteps(metadata, agentSpecificActions);
|
||||
activation += this.indent(steps, 2) + '\n';
|
||||
|
||||
// 2. Build menu handlers section with dynamic handlers
|
||||
const menuHandlers = await this.loadFragment('menu-handlers.xml');
|
||||
|
||||
// Build extract list (comma-separated list of used attributes)
|
||||
const extractList = profile.usedAttributes.join(', ');
|
||||
|
||||
// Build handlers (load only needed handlers)
|
||||
const handlers = await this.buildHandlers(profile);
|
||||
|
||||
const processedHandlers = menuHandlers.replace('{DYNAMIC_EXTRACT_LIST}', extractList).replace('{DYNAMIC_HANDLERS}', handlers);
|
||||
|
||||
activation += '\n' + this.indent(processedHandlers, 2) + '\n';
|
||||
|
||||
// 3. Always include rules
|
||||
const rules = await this.loadFragment('activation-rules.xml');
|
||||
activation += this.indent(rules, 2) + '\n';
|
||||
|
||||
activation += '</activation>';
|
||||
|
||||
return activation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build handlers section based on profile
|
||||
* @param {Object} profile - Agent profile
|
||||
* @returns {string} Handlers XML
|
||||
*/
|
||||
async buildHandlers(profile) {
|
||||
const handlerFragments = [];
|
||||
|
||||
for (const attrType of profile.usedAttributes) {
|
||||
const fragmentName = `handler-${attrType}.xml`;
|
||||
try {
|
||||
const handler = await this.loadFragment(fragmentName);
|
||||
handlerFragments.push(handler);
|
||||
} catch {
|
||||
console.warn(`Warning: Handler fragment not found: ${fragmentName}`);
|
||||
}
|
||||
}
|
||||
|
||||
return handlerFragments.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sequential activation steps
|
||||
* @param {Object} metadata - Agent metadata
|
||||
* @param {Array} agentSpecificActions - Optional agent-specific actions
|
||||
* @returns {string} Steps XML
|
||||
*/
|
||||
async buildSteps(metadata = {}, agentSpecificActions = []) {
|
||||
const stepsTemplate = await this.loadFragment('activation-steps.xml');
|
||||
|
||||
// Extract basename from agent ID (e.g., "bmad/bmm/agents/pm.md" → "pm")
|
||||
const agentBasename = metadata.id ? metadata.id.split('/').pop().replace('.md', '') : metadata.name || 'agent';
|
||||
|
||||
// Build agent-specific steps
|
||||
let agentStepsXml = '';
|
||||
let currentStepNum = 4; // Steps 1-3 are standard
|
||||
|
||||
if (agentSpecificActions && agentSpecificActions.length > 0) {
|
||||
agentStepsXml = agentSpecificActions
|
||||
.map((action) => {
|
||||
const step = `<step n="${currentStepNum}">${action}</step>`;
|
||||
currentStepNum++;
|
||||
return step;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Calculate final step numbers
|
||||
const menuStep = currentStepNum;
|
||||
const haltStep = currentStepNum + 1;
|
||||
const inputStep = currentStepNum + 2;
|
||||
const executeStep = currentStepNum + 3;
|
||||
|
||||
// Replace placeholders
|
||||
const processed = stepsTemplate
|
||||
.replace('{agent-file-basename}', agentBasename)
|
||||
.replace('{module}', metadata.module || 'core')
|
||||
.replace('{AGENT_SPECIFIC_STEPS}', agentStepsXml)
|
||||
.replace('{MENU_STEP}', menuStep.toString())
|
||||
.replace('{HALT_STEP}', haltStep.toString())
|
||||
.replace('{INPUT_STEP}', inputStep.toString())
|
||||
.replace('{EXECUTE_STEP}', executeStep.toString());
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indent XML content
|
||||
* @param {string} content - Content to indent
|
||||
* @param {number} spaces - Number of spaces to indent
|
||||
* @returns {string} Indented content
|
||||
*/
|
||||
indent(content, spaces) {
|
||||
const indentation = ' '.repeat(spaces);
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => (line ? indentation + line : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear fragment cache (useful for testing or hot reload)
|
||||
*/
|
||||
clearCache() {
|
||||
this.fragmentCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ActivationBuilder };
|
||||
81
tools/cli/lib/agent-analyzer.js
Normal file
81
tools/cli/lib/agent-analyzer.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
/**
|
||||
* Analyzes agent YAML files to detect which handlers are needed
|
||||
*/
|
||||
class AgentAnalyzer {
|
||||
/**
|
||||
* Analyze an agent YAML structure to determine which handlers it needs
|
||||
* @param {Object} agentYaml - Parsed agent YAML object
|
||||
* @returns {Object} Profile of needed handlers
|
||||
*/
|
||||
analyzeAgentObject(agentYaml) {
|
||||
const profile = {
|
||||
usedAttributes: new Set(),
|
||||
hasPrompts: false,
|
||||
menuItems: [],
|
||||
};
|
||||
|
||||
// Check if agent has prompts section
|
||||
if (agentYaml.agent && agentYaml.agent.prompts) {
|
||||
profile.hasPrompts = true;
|
||||
}
|
||||
|
||||
// Analyze menu items (support both 'menu' and legacy 'commands')
|
||||
const menuItems = agentYaml.agent?.menu || agentYaml.agent?.commands || [];
|
||||
|
||||
for (const item of menuItems) {
|
||||
// Track the menu item
|
||||
profile.menuItems.push(item);
|
||||
|
||||
// Check for each possible attribute
|
||||
if (item.workflow) {
|
||||
profile.usedAttributes.add('workflow');
|
||||
}
|
||||
if (item['validate-workflow']) {
|
||||
profile.usedAttributes.add('validate-workflow');
|
||||
}
|
||||
if (item.exec) {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
if (item.tmpl) {
|
||||
profile.usedAttributes.add('tmpl');
|
||||
}
|
||||
if (item.data) {
|
||||
profile.usedAttributes.add('data');
|
||||
}
|
||||
if (item.action) {
|
||||
profile.usedAttributes.add('action');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Set to Array for easier use
|
||||
profile.usedAttributes = [...profile.usedAttributes];
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze an agent YAML file
|
||||
* @param {string} filePath - Path to agent YAML file
|
||||
* @returns {Object} Profile of needed handlers
|
||||
*/
|
||||
async analyzeAgentFile(filePath) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const agentYaml = yaml.load(content);
|
||||
return this.analyzeAgentObject(agentYaml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent needs a specific handler
|
||||
* @param {Object} profile - Agent profile from analyze
|
||||
* @param {string} handlerType - Handler type to check
|
||||
* @returns {boolean} True if handler is needed
|
||||
*/
|
||||
needsHandler(profile, handlerType) {
|
||||
return profile.usedAttributes.includes(handlerType);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AgentAnalyzer };
|
||||
@@ -20,6 +20,35 @@ class UI {
|
||||
CLIUtils.displaySection('BMAD™ Setup', 'Build More, Architect Dreams');
|
||||
|
||||
const confirmedDirectory = await this.getConfirmedDirectory();
|
||||
|
||||
// Check if there's an existing BMAD installation
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const bmadDir = path.join(confirmedDirectory, 'bmad');
|
||||
const hasExistingInstall = await fs.pathExists(bmadDir);
|
||||
|
||||
// Only show action menu if there's an existing installation
|
||||
if (hasExistingInstall) {
|
||||
const { actionType } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'actionType',
|
||||
message: 'What would you like to do?',
|
||||
choices: [
|
||||
{ name: 'Update BMAD Installation', value: 'install' },
|
||||
{ name: 'Compile Agents (Quick rebuild of all agent .md files)', value: 'compile' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Handle agent compilation separately
|
||||
if (actionType === 'compile') {
|
||||
return {
|
||||
actionType: 'compile',
|
||||
directory: confirmedDirectory,
|
||||
};
|
||||
}
|
||||
}
|
||||
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
|
||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory);
|
||||
const moduleChoices = await this.getModuleChoices(installedModuleIds);
|
||||
@@ -30,6 +59,7 @@ class UI {
|
||||
CLIUtils.displayModuleComplete('core', false); // false = don't clear the screen again
|
||||
|
||||
return {
|
||||
actionType: 'install', // Explicitly set action type
|
||||
directory: confirmedDirectory,
|
||||
installCore: true, // Always install core
|
||||
modules: selectedModules,
|
||||
|
||||
@@ -2,9 +2,11 @@ const xml2js = require('xml2js');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const { getProjectRoot, getSourcePath } = require('./project-root');
|
||||
const { YamlXmlBuilder } = require('./yaml-xml-builder');
|
||||
|
||||
/**
|
||||
* XML utility functions for BMAD installer
|
||||
* Now supports both legacy XML agents and new YAML-based agents
|
||||
*/
|
||||
class XmlHandler {
|
||||
constructor() {
|
||||
@@ -33,6 +35,8 @@ class XmlHandler {
|
||||
attrkey: '$',
|
||||
charkey: '_',
|
||||
});
|
||||
|
||||
this.yamlBuilder = new YamlXmlBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +136,7 @@ class XmlHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string-based injection (fallback method)
|
||||
* Simple string-based injection (fallback method for legacy XML agents)
|
||||
* This preserves formatting better than XML parsing
|
||||
*/
|
||||
injectActivationSimple(agentContent, metadata = {}) {
|
||||
@@ -178,6 +182,47 @@ class XmlHandler {
|
||||
return agentContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent from YAML source
|
||||
* @param {string} yamlPath - Path to .agent.yaml file
|
||||
* @param {string} customizePath - Path to .customize.yaml file (optional)
|
||||
* @param {Object} metadata - Build metadata
|
||||
* @returns {string} Generated XML content
|
||||
*/
|
||||
async buildFromYaml(yamlPath, customizePath = null, metadata = {}) {
|
||||
try {
|
||||
// Use YamlXmlBuilder to convert YAML to XML
|
||||
const mergedAgent = await this.yamlBuilder.loadAndMergeAgent(yamlPath, customizePath);
|
||||
|
||||
// Build metadata
|
||||
const buildMetadata = {
|
||||
sourceFile: path.basename(yamlPath),
|
||||
sourceHash: await this.yamlBuilder.calculateFileHash(yamlPath),
|
||||
customizeFile: customizePath ? path.basename(customizePath) : null,
|
||||
customizeHash: customizePath ? await this.yamlBuilder.calculateFileHash(customizePath) : null,
|
||||
builderVersion: '1.0.0',
|
||||
includeMetadata: metadata.includeMetadata !== false,
|
||||
};
|
||||
|
||||
// Convert to XML
|
||||
const xml = await this.yamlBuilder.convertToXml(mergedAgent, buildMetadata);
|
||||
|
||||
return xml;
|
||||
} catch (error) {
|
||||
console.error('Error building agent from YAML:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a YAML agent file
|
||||
* @param {string} filePath - Path to check
|
||||
* @returns {boolean} True if it's a YAML agent file
|
||||
*/
|
||||
isYamlAgent(filePath) {
|
||||
return filePath.endsWith('.agent.yaml');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { XmlHandler };
|
||||
|
||||
370
tools/cli/lib/yaml-xml-builder.js
Normal file
370
tools/cli/lib/yaml-xml-builder.js
Normal file
@@ -0,0 +1,370 @@
|
||||
const yaml = require('js-yaml');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { AgentAnalyzer } = require('./agent-analyzer');
|
||||
const { ActivationBuilder } = require('./activation-builder');
|
||||
|
||||
/**
|
||||
* Converts agent YAML files to XML format with smart activation injection
|
||||
*/
|
||||
class YamlXmlBuilder {
|
||||
constructor() {
|
||||
this.analyzer = new AgentAnalyzer();
|
||||
this.activationBuilder = new ActivationBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects (for customize.yaml + agent.yaml)
|
||||
* @param {Object} target - Target object
|
||||
* @param {Object} source - Source object to merge in
|
||||
* @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 if (Array.isArray(source[key])) {
|
||||
// For arrays, append rather than replace (for commands)
|
||||
if (Array.isArray(target[key])) {
|
||||
output[key] = [...target[key], ...source[key]];
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if value is an object
|
||||
*/
|
||||
isObject(item) {
|
||||
return item && typeof item === 'object' && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and merge agent YAML with customization
|
||||
* @param {string} agentYamlPath - Path to base agent YAML
|
||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
||||
* @returns {Object} Merged agent configuration
|
||||
*/
|
||||
async loadAndMergeAgent(agentYamlPath, customizeYamlPath = null) {
|
||||
// Load base agent
|
||||
const agentContent = await fs.readFile(agentYamlPath, 'utf8');
|
||||
const agentYaml = yaml.load(agentContent);
|
||||
|
||||
// Load customization if exists
|
||||
let merged = agentYaml;
|
||||
if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) {
|
||||
const customizeContent = await fs.readFile(customizeYamlPath, 'utf8');
|
||||
const customizeYaml = yaml.load(customizeContent);
|
||||
|
||||
if (customizeYaml) {
|
||||
// Special handling: persona fields are merged, but only non-empty values override
|
||||
if (customizeYaml.persona) {
|
||||
const basePersona = merged.agent.persona || {};
|
||||
const customPersona = {};
|
||||
|
||||
// Only copy non-empty customize values
|
||||
for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
||||
if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
||||
customPersona[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge non-empty customize values over base
|
||||
if (Object.keys(customPersona).length > 0) {
|
||||
merged.agent.persona = { ...basePersona, ...customPersona };
|
||||
}
|
||||
}
|
||||
|
||||
// Merge metadata (only non-empty values)
|
||||
if (customizeYaml.agent && customizeYaml.agent.metadata) {
|
||||
const nonEmptyMetadata = {};
|
||||
for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
||||
if (value !== '' && value !== null) {
|
||||
nonEmptyMetadata[key] = value;
|
||||
}
|
||||
}
|
||||
merged.agent.metadata = { ...merged.agent.metadata, ...nonEmptyMetadata };
|
||||
}
|
||||
|
||||
// Append menu items (support both 'menu' and legacy 'commands')
|
||||
const customMenuItems = customizeYaml.menu || customizeYaml.commands;
|
||||
if (customMenuItems) {
|
||||
// Determine if base uses 'menu' or 'commands'
|
||||
if (merged.agent.menu) {
|
||||
merged.agent.menu = [...merged.agent.menu, ...customMenuItems];
|
||||
} else if (merged.agent.commands) {
|
||||
merged.agent.commands = [...merged.agent.commands, ...customMenuItems];
|
||||
} else {
|
||||
// Default to 'menu' for new agents
|
||||
merged.agent.menu = customMenuItems;
|
||||
}
|
||||
}
|
||||
|
||||
// Append critical actions
|
||||
if (customizeYaml.critical_actions) {
|
||||
merged.agent.critical_actions = [...(merged.agent.critical_actions || []), ...customizeYaml.critical_actions];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert agent YAML to XML
|
||||
* @param {Object} agentYaml - Parsed agent YAML object
|
||||
* @param {Object} buildMetadata - Metadata about the build (file paths, hashes, etc.)
|
||||
* @returns {string} XML content
|
||||
*/
|
||||
async convertToXml(agentYaml, buildMetadata = {}) {
|
||||
const agent = agentYaml.agent;
|
||||
const metadata = agent.metadata || {};
|
||||
|
||||
// Analyze agent to determine needed handlers
|
||||
const profile = this.analyzer.analyzeAgentObject(agentYaml);
|
||||
|
||||
// Build activation block
|
||||
const activationBlock = await this.activationBuilder.buildActivation(profile, metadata, agent.critical_actions || []);
|
||||
|
||||
// Start building XML
|
||||
let xml = '<!-- Powered by BMAD-CORE™ -->\n\n';
|
||||
xml += `# ${metadata.title || 'Agent'}\n\n`;
|
||||
|
||||
// Add build metadata as comment
|
||||
if (buildMetadata.includeMetadata) {
|
||||
xml += this.buildMetadataComment(buildMetadata);
|
||||
}
|
||||
|
||||
xml += '```xml\n';
|
||||
|
||||
// Agent opening tag
|
||||
const agentAttrs = [
|
||||
`id="${metadata.id || ''}"`,
|
||||
`name="${metadata.name || ''}"`,
|
||||
`title="${metadata.title || ''}"`,
|
||||
`icon="${metadata.icon || '🤖'}"`,
|
||||
];
|
||||
|
||||
// Add localskip attribute if present
|
||||
if (metadata.localskip === true) {
|
||||
agentAttrs.push('localskip="true"');
|
||||
}
|
||||
|
||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
||||
|
||||
// Activation block
|
||||
xml += activationBlock + '\n';
|
||||
|
||||
// Persona section
|
||||
xml += this.buildPersonaXml(agent.persona);
|
||||
|
||||
// Prompts section (if exists)
|
||||
if (agent.prompts) {
|
||||
xml += this.buildPromptsXml(agent.prompts);
|
||||
}
|
||||
|
||||
// Menu section (support both 'menu' and legacy 'commands')
|
||||
const menuItems = agent.menu || agent.commands || [];
|
||||
xml += this.buildCommandsXml(menuItems);
|
||||
|
||||
xml += '</agent>\n';
|
||||
xml += '```\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata comment
|
||||
*/
|
||||
buildMetadataComment(metadata) {
|
||||
const lines = ['<!-- BUILD-META', ` source: ${metadata.sourceFile || 'unknown'} (hash: ${metadata.sourceHash || 'unknown'})`];
|
||||
|
||||
if (metadata.customizeFile) {
|
||||
lines.push(` customize: ${metadata.customizeFile} (hash: ${metadata.customizeHash || 'unknown'})`);
|
||||
}
|
||||
|
||||
lines.push(` built: ${new Date().toISOString()}`, ` builder-version: ${metadata.builderVersion || '1.0.0'}`, '-->\n');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build persona XML section
|
||||
*/
|
||||
buildPersonaXml(persona) {
|
||||
if (!persona) return '';
|
||||
|
||||
let xml = ' <persona>\n';
|
||||
|
||||
if (persona.role) {
|
||||
xml += ` <role>${this.escapeXml(persona.role)}</role>\n`;
|
||||
}
|
||||
|
||||
if (persona.identity) {
|
||||
xml += ` <identity>${this.escapeXml(persona.identity)}</identity>\n`;
|
||||
}
|
||||
|
||||
if (persona.communication_style) {
|
||||
xml += ` <communication_style>${this.escapeXml(persona.communication_style)}</communication_style>\n`;
|
||||
}
|
||||
|
||||
if (persona.principles) {
|
||||
// Principles can be array or string
|
||||
let principlesText;
|
||||
if (Array.isArray(persona.principles)) {
|
||||
principlesText = persona.principles.join(' ');
|
||||
} else {
|
||||
principlesText = persona.principles;
|
||||
}
|
||||
xml += ` <principles>${this.escapeXml(principlesText)}</principles>\n`;
|
||||
}
|
||||
|
||||
xml += ' </persona>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompts XML section
|
||||
*/
|
||||
buildPromptsXml(prompts) {
|
||||
if (!prompts || prompts.length === 0) return '';
|
||||
|
||||
let xml = ' <prompts>\n';
|
||||
|
||||
for (const prompt of prompts) {
|
||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
||||
xml += ` <![CDATA[\n`;
|
||||
xml += ` ${prompt.content || ''}\n`;
|
||||
xml += ` ]]>\n`;
|
||||
xml += ` </prompt>\n`;
|
||||
}
|
||||
|
||||
xml += ' </prompts>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build menu XML section (renamed from commands for clarity)
|
||||
* Auto-injects *help and *exit, adds * prefix to all triggers
|
||||
*/
|
||||
buildCommandsXml(menuItems) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject *help first
|
||||
xml += ` <item cmd="*help">Show numbered menu</item>\n`;
|
||||
|
||||
// Add user-defined menu items with * prefix
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
for (const item of menuItems) {
|
||||
// Build command attributes - add * prefix if not present
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
}
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
if (item.workflow) attrs.push(`workflow="${item.workflow}"`);
|
||||
if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${this.escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject *exit last
|
||||
xml += ` <item cmd="*exit">Exit with confirmation</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
escapeXml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file hash for build tracking
|
||||
*/
|
||||
async calculateFileHash(filePath) {
|
||||
if (!(await fs.pathExists(filePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent XML from YAML files
|
||||
* @param {string} agentYamlPath - Path to agent YAML
|
||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
||||
* @param {string} outputPath - Path to write XML file
|
||||
* @param {Object} options - Build options
|
||||
*/
|
||||
async buildAgent(agentYamlPath, customizeYamlPath, outputPath, options = {}) {
|
||||
// Load and merge YAML files
|
||||
const mergedAgent = await this.loadAndMergeAgent(agentYamlPath, customizeYamlPath);
|
||||
|
||||
// Calculate hashes for build tracking
|
||||
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
||||
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
||||
|
||||
// Build metadata
|
||||
const buildMetadata = {
|
||||
sourceFile: path.basename(agentYamlPath),
|
||||
sourceHash,
|
||||
customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null,
|
||||
customizeHash,
|
||||
builderVersion: '1.0.0',
|
||||
includeMetadata: options.includeMetadata !== false,
|
||||
};
|
||||
|
||||
// Convert to XML
|
||||
const xml = await this.convertToXml(mergedAgent, buildMetadata);
|
||||
|
||||
// Write output file
|
||||
await fs.ensureDir(path.dirname(outputPath));
|
||||
await fs.writeFile(outputPath, xml, 'utf8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputPath,
|
||||
sourceHash,
|
||||
customizeHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { YamlXmlBuilder };
|
||||
43
tools/cli/test-yaml-builder.js
Normal file
43
tools/cli/test-yaml-builder.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Test script for YAML → XML agent builder
|
||||
* Usage: node tools/cli/test-yaml-builder.js
|
||||
*/
|
||||
|
||||
const path = require('node:path');
|
||||
const { YamlXmlBuilder } = require('./lib/yaml-xml-builder');
|
||||
const { getProjectRoot } = require('./lib/project-root');
|
||||
|
||||
async function test() {
|
||||
console.log('Testing YAML → XML Agent Builder\n');
|
||||
|
||||
const builder = new YamlXmlBuilder();
|
||||
const projectRoot = getProjectRoot();
|
||||
|
||||
// Paths
|
||||
const agentYamlPath = path.join(projectRoot, 'src/modules/bmm/agents/pm.agent.yaml');
|
||||
const outputPath = path.join(projectRoot, 'test-output-pm.md');
|
||||
|
||||
console.log(`Source: ${agentYamlPath}`);
|
||||
console.log(`Output: ${outputPath}\n`);
|
||||
|
||||
try {
|
||||
const result = await builder.buildAgent(
|
||||
agentYamlPath,
|
||||
null, // No customize file for this test
|
||||
outputPath,
|
||||
{ includeMetadata: true },
|
||||
);
|
||||
|
||||
console.log('✓ Build successful!');
|
||||
console.log(` Output: ${result.outputPath}`);
|
||||
console.log(` Source hash: ${result.sourceHash}`);
|
||||
console.log('\nGenerated XML file at:', outputPath);
|
||||
console.log('Review the output to verify correctness.\n');
|
||||
} catch (error) {
|
||||
console.error('✗ Build failed:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
Reference in New Issue
Block a user