consolidate and remove some duplication

This commit is contained in:
Brian Madison
2025-12-22 10:13:56 +08:00
parent 9c268f8190
commit 1e721f7fd0
18 changed files with 196 additions and 1293 deletions

View File

@@ -0,0 +1,163 @@
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.agentComponents = getSourcePath('utility', 'agent-components');
this.fragmentCache = new Map();
}
/**
* Load a fragment file
* @param {string} fragmentName - Name of fragment file (e.g., 'activation-init.txt')
* @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.agentComponents, 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
* @param {boolean} forWebBundle - Whether this is for a web bundle
* @returns {string} Complete activation block XML
*/
async buildActivation(profile, metadata = {}, agentSpecificActions = [], forWebBundle = false) {
let activation = '<activation critical="MANDATORY">\n';
// 1. Build sequential steps (use web-specific steps for web bundles)
const steps = await this.buildSteps(metadata, agentSpecificActions, forWebBundle);
activation += this.indent(steps, 2) + '\n';
// 2. Build menu handlers section with dynamic handlers
const menuHandlers = await this.loadFragment('menu-handlers.txt');
// Build handlers (load only needed handlers)
const handlers = await this.buildHandlers(profile);
// Remove the extract line from the final output - it's just build metadata
// The extract list tells us which attributes to look for during processing
// but shouldn't appear in the final agent file
const processedHandlers = menuHandlers
.replace('<extract>{DYNAMIC_EXTRACT_LIST}</extract>\n', '') // Remove the entire extract line
.replace('{DYNAMIC_HANDLERS}', handlers);
activation += '\n' + this.indent(processedHandlers, 2) + '\n';
const rules = await this.loadFragment('activation-rules.txt');
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}.txt`;
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
* @param {boolean} forWebBundle - Whether this is for a web bundle
* @returns {string} Steps XML
*/
async buildSteps(metadata = {}, agentSpecificActions = [], forWebBundle = false) {
const stepsTemplate = await this.loadFragment('activation-steps.txt');
// 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') // Fixed to use {{module}}
.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 };

View File

@@ -0,0 +1,109 @@
const yaml = require('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 multi format items
if (item.multi && item.triggers) {
profile.usedAttributes.add('multi');
// Also check attributes in nested handlers
for (const triggerGroup of item.triggers) {
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
if (Array.isArray(execArray)) {
for (const exec of execArray) {
if (exec.route) {
// Check if route is a workflow or exec
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
profile.usedAttributes.add('workflow');
} else {
profile.usedAttributes.add('exec');
}
}
if (exec.workflow) profile.usedAttributes.add('workflow');
if (exec.action) profile.usedAttributes.add('action');
if (exec.type && ['exec', 'action', 'workflow'].includes(exec.type)) {
profile.usedAttributes.add(exec.type);
}
}
}
}
}
} else {
// Check for each possible attribute in legacy items
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.parse(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 };

View File

@@ -1,554 +0,0 @@
/**
* BMAD Agent Compiler
* Transforms agent YAML to compiled XML (.md) format
* Uses the existing BMAD builder infrastructure for proper formatting
*/
const yaml = require('yaml');
const fs = require('node:fs');
const path = require('node:path');
const { processAgentYaml, extractInstallConfig, stripInstallConfig, getDefaultValues } = require('./template-engine');
const { escapeXml } = require('../../../lib/xml-utils');
const { ActivationBuilder } = require('../activation-builder');
const { AgentAnalyzer } = require('../agent-analyzer');
/**
* Build frontmatter for agent
* @param {Object} metadata - Agent metadata
* @param {string} agentName - Final agent name
* @returns {string} YAML frontmatter
*/
function buildFrontmatter(metadata, agentName) {
const nameFromFile = agentName.replaceAll('-', ' ');
const description = metadata.title || 'BMAD Agent';
return `---
name: "${nameFromFile}"
description: "${description}"
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.
`;
}
// buildSimpleActivation function removed - replaced by ActivationBuilder for proper fragment loading from src/utility/agent-components/
/**
* Build persona XML section
* @param {Object} persona - Persona object
* @returns {string} Persona XML
*/
function buildPersonaXml(persona) {
if (!persona) return '';
let xml = ' <persona>\n';
if (persona.role) {
const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <role>${escapeXml(roleText)}</role>\n`;
}
if (persona.identity) {
const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <identity>${escapeXml(identityText)}</identity>\n`;
}
if (persona.communication_style) {
const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ');
xml += ` <communication_style>${escapeXml(styleText)}</communication_style>\n`;
}
if (persona.principles) {
let principlesText;
if (Array.isArray(persona.principles)) {
principlesText = persona.principles.join(' ');
} else {
principlesText = persona.principles.trim().replaceAll(/\n+/g, ' ');
}
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
}
xml += ' </persona>\n';
return xml;
}
/**
* Build prompts XML section
* @param {Array} prompts - Prompts array
* @returns {string} Prompts XML
*/
function buildPromptsXml(prompts) {
if (!prompts || prompts.length === 0) return '';
let xml = ' <prompts>\n';
for (const prompt of prompts) {
xml += ` <prompt id="${prompt.id || ''}">\n`;
xml += ` <content>\n`;
// Don't escape prompt content - it's meant to be read as-is
xml += `${prompt.content || ''}\n`;
xml += ` </content>\n`;
xml += ` </prompt>\n`;
}
xml += ' </prompts>\n';
return xml;
}
/**
* Build memories XML section
* @param {Array} memories - Memories array
* @returns {string} Memories XML
*/
function buildMemoriesXml(memories) {
if (!memories || memories.length === 0) return '';
let xml = ' <memories>\n';
for (const memory of memories) {
xml += ` <memory>${escapeXml(String(memory))}</memory>\n`;
}
xml += ' </memories>\n';
return xml;
}
/**
* Build menu XML section
* Supports both legacy and multi format menu items
* Multi items display as a single menu item with nested handlers
* @param {Array} menuItems - Menu items
* @returns {string} Menu XML
*/
function buildMenuXml(menuItems) {
let xml = ' <menu>\n';
// Always inject menu display option first
xml += ` <item cmd="HM or fuzzy match on help">[HM] Redisplay Help Menu Options</item>\n`;
// Add user-defined menu items
if (menuItems && menuItems.length > 0) {
for (const item of menuItems) {
// Handle multi format menu items with nested handlers
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
xml += buildNestedHandlers(item.triggers);
xml += ` </item>\n`;
} else if (item.trigger) {
let trigger = item.trigger || '';
const attrs = [`cmd="${trigger}"`];
// Add handler attributes
if (item.workflow) attrs.push(`workflow="${item.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(' ')}>${escapeXml(item.description || '')}</item>\n`;
}
}
}
// Always inject dismiss last
xml += ` <item cmd="DA or fuzzy match on dismiss">[DA] Dismiss Agent</item>\n`;
xml += ' </menu>\n';
return xml;
}
/**
* Build nested handlers for multi format menu items
* @param {Array} triggers - Triggers array from multi format
* @returns {string} Handler XML
*/
function buildNestedHandlers(triggers) {
let xml = '';
for (const triggerGroup of triggers) {
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
// Extract the relevant execution data
const execData = processExecArray(execArray);
// For nested handlers in multi items, we use match attribute for fuzzy matching
const attrs = [`match="${escapeXml(execData.description || '')}"`];
// Add handler attributes based on exec data
if (execData.route) attrs.push(`exec="${execData.route}"`);
if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`);
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
}
}
return xml;
}
/**
* Process the execution array from multi format triggers
* Extracts relevant data for XML attributes
* @param {Array} execArray - Array of execution objects
* @returns {Object} Processed execution data
*/
function processExecArray(execArray) {
const result = {
description: '',
route: null,
workflow: null,
data: null,
action: null,
type: null,
};
if (!Array.isArray(execArray)) {
return result;
}
for (const exec of execArray) {
if (exec.input) {
// Use input as description if no explicit description is provided
result.description = exec.input;
}
if (exec.route) {
// Determine if it's a workflow or exec based on file extension or context
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
result.workflow = exec.route;
} else {
result.route = exec.route;
}
}
if (exec.data !== null && exec.data !== undefined) {
result.data = exec.data;
}
if (exec.action) {
result.action = exec.action;
}
if (exec.type) {
result.type = exec.type;
}
}
return result;
}
/**
* Compile agent YAML to proper XML format
* @param {Object} agentYaml - Parsed and processed agent YAML
* @param {string} agentName - Final agent name (for ID and frontmatter)
* @param {string} targetPath - Target path for agent ID
* @returns {Promise<string>} Compiled XML string with frontmatter
*/
async function compileToXml(agentYaml, agentName = '', targetPath = '') {
const agent = agentYaml.agent;
const meta = agent.metadata;
let xml = '';
// Build frontmatter
xml += buildFrontmatter(meta, agentName || meta.name || 'agent');
// Start code fence
xml += '```xml\n';
// Agent opening tag
const agentAttrs = [
`id="${targetPath || meta.id || ''}"`,
`name="${meta.name || ''}"`,
`title="${meta.title || ''}"`,
`icon="${meta.icon || '🤖'}"`,
];
xml += `<agent ${agentAttrs.join(' ')}>\n`;
// Activation block - use ActivationBuilder for proper fragment loading
const activationBuilder = new ActivationBuilder();
const analyzer = new AgentAnalyzer();
const profile = analyzer.analyzeAgentObject(agentYaml);
xml += await activationBuilder.buildActivation(
profile,
meta,
agent.critical_actions || [],
false, // forWebBundle - set to false for IDE deployment
);
// Persona section
xml += buildPersonaXml(agent.persona);
// Prompts section (if present)
if (agent.prompts && agent.prompts.length > 0) {
xml += buildPromptsXml(agent.prompts);
}
// Memories section (if present)
if (agent.memories && agent.memories.length > 0) {
xml += buildMemoriesXml(agent.memories);
}
// Menu section
xml += buildMenuXml(agent.menu || []);
// Closing agent tag
xml += '</agent>\n';
// Close code fence
xml += '```\n';
return xml;
}
/**
* Full compilation pipeline
* @param {string} yamlContent - Raw YAML string
* @param {Object} answers - Answers from install_config questions (or defaults)
* @param {string} agentName - Optional final agent name (user's custom persona name)
* @param {string} targetPath - Optional target path for agent ID
* @param {Object} options - Additional options including config
* @returns {Promise<Object>} { xml: string, metadata: Object }
*/
async function compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) {
// Parse YAML
let agentYaml = yaml.parse(yamlContent);
// Apply customization merges before template processing
// Handle metadata overrides (like name)
if (answers.metadata) {
// Filter out empty values from metadata
const filteredMetadata = filterCustomizationData(answers.metadata);
if (Object.keys(filteredMetadata).length > 0) {
agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata };
}
// Remove from answers so it doesn't get processed as template variables
const { metadata, ...templateAnswers } = answers;
answers = templateAnswers;
}
// Handle other customization properties
// These should be merged into the agent structure, not processed as template variables
const customizationKeys = ['persona', 'critical_actions', 'memories', 'menu', 'prompts'];
const customizations = {};
const remainingAnswers = { ...answers };
for (const key of customizationKeys) {
if (answers[key]) {
let filtered;
// Handle different data types
if (Array.isArray(answers[key])) {
// For arrays, filter out empty/null/undefined values
filtered = answers[key].filter((item) => item !== null && item !== undefined && item !== '');
} else {
// For objects, use filterCustomizationData
filtered = filterCustomizationData(answers[key]);
}
// Check if we have valid content
const hasContent = Array.isArray(filtered) ? filtered.length > 0 : Object.keys(filtered).length > 0;
if (hasContent) {
customizations[key] = filtered;
}
delete remainingAnswers[key];
}
}
// Merge customizations into agentYaml
if (Object.keys(customizations).length > 0) {
// For persona: replace entire section
if (customizations.persona) {
agentYaml.agent.persona = customizations.persona;
}
// For critical_actions: append to existing or create new
if (customizations.critical_actions) {
const existing = agentYaml.agent.critical_actions || [];
agentYaml.agent.critical_actions = [...existing, ...customizations.critical_actions];
}
// For memories: append to existing or create new
if (customizations.memories) {
const existing = agentYaml.agent.memories || [];
agentYaml.agent.memories = [...existing, ...customizations.memories];
}
// For menu: append to existing or create new
if (customizations.menu) {
const existing = agentYaml.agent.menu || [];
agentYaml.agent.menu = [...existing, ...customizations.menu];
}
// For prompts: append to existing or create new (by id)
if (customizations.prompts) {
const existing = agentYaml.agent.prompts || [];
// Merge by id, with customizations taking precedence
const mergedPrompts = [...existing];
for (const customPrompt of customizations.prompts) {
const existingIndex = mergedPrompts.findIndex((p) => p.id === customPrompt.id);
if (existingIndex === -1) {
mergedPrompts.push(customPrompt);
} else {
mergedPrompts[existingIndex] = customPrompt;
}
}
agentYaml.agent.prompts = mergedPrompts;
}
}
// Use remaining answers for template processing
answers = remainingAnswers;
// Extract install_config
const installConfig = extractInstallConfig(agentYaml);
// Merge defaults with provided answers
let finalAnswers = answers;
if (installConfig) {
const defaults = getDefaultValues(installConfig);
finalAnswers = { ...defaults, ...answers };
}
// Process templates with answers
const processedYaml = processAgentYaml(agentYaml, finalAnswers);
// Strip install_config from output
const cleanYaml = stripInstallConfig(processedYaml);
let xml = await compileToXml(cleanYaml, agentName, targetPath);
// Ensure xml is a string before attempting replaceAll
if (typeof xml !== 'string') {
throw new TypeError('compileToXml did not return a string');
}
return {
xml,
metadata: cleanYaml.agent.metadata,
processedYaml: cleanYaml,
};
}
/**
* Filter customization data to remove empty/null values
* @param {Object} data - Raw customization data
* @returns {Object} Filtered customization data
*/
function filterCustomizationData(data) {
const filtered = {};
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined || value === '') {
continue; // Skip null/undefined/empty values
}
if (Array.isArray(value)) {
if (value.length > 0) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
const nested = filterCustomizationData(value);
if (Object.keys(nested).length > 0) {
filtered[key] = nested;
}
} else {
filtered[key] = value;
}
}
return filtered;
}
/**
* Process TTS injection markers in content
* @param {string} content - Content to process
* @param {boolean} enableAgentVibes - Whether AgentVibes is enabled
* @returns {Object} { content: string, hadInjection: boolean }
*/
function processTTSInjectionPoints(content, enableAgentVibes) {
const hasAgentTTS = content.includes('<!-- TTS_INJECTION:agent-tts -->');
if (enableAgentVibes && hasAgentTTS) {
// Replace agent-tts injection marker with TTS rule
content = content.replaceAll(
'<!-- TTS_INJECTION:agent-tts -->',
`- When responding to user messages, speak your responses using TTS:
Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response
Replace {agent-id} with YOUR agent ID from <agent id="..."> tag at top of this file
Replace {response-text} with the text you just output to the user
IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes
Run in background (&) to avoid blocking`,
);
return { content, hadInjection: true };
} else if (!enableAgentVibes && hasAgentTTS) {
// Strip injection markers when disabled
content = content.replaceAll(/<!-- TTS_INJECTION:agent-tts -->\n?/g, '');
}
return { content, hadInjection: false };
}
/**
* Compile agent file to .md
* @param {string} yamlPath - Path to agent YAML file
* @param {Object} options - { answers: {}, outputPath: string, enableAgentVibes: boolean }
* @returns {Object} Compilation result
*/
function compileAgentFile(yamlPath, options = {}) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
const result = compileAgent(yamlContent, options.answers || {});
// Determine output path
let outputPath = options.outputPath;
if (!outputPath) {
// Default: same directory, same name, .md extension
const dir = path.dirname(yamlPath);
const basename = path.basename(yamlPath, '.agent.yaml');
outputPath = path.join(dir, `${basename}.md`);
}
// Process TTS injection points if enableAgentVibes option is provided
let xml = result.xml;
let ttsInjected = false;
if (options.enableAgentVibes !== undefined) {
const ttsResult = processTTSInjectionPoints(xml, options.enableAgentVibes);
xml = ttsResult.content;
ttsInjected = ttsResult.hadInjection;
}
// Write compiled XML
fs.writeFileSync(outputPath, xml, 'utf8');
return {
...result,
xml,
outputPath,
sourcePath: yamlPath,
ttsInjected,
};
}
module.exports = {
compileToXml,
compileAgent,
compileAgentFile,
escapeXml,
buildFrontmatter,
buildPersonaXml,
buildPromptsXml,
buildMemoriesXml,
buildMenuXml,
filterCustomizationData,
};

View File

@@ -1,716 +0,0 @@
/**
* BMAD Agent Installer
* Discovers, prompts, compiles, and installs agents
*/
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('yaml');
const readline = require('node:readline');
const { compileAgent, compileAgentFile } = require('./compiler');
const { extractInstallConfig, getDefaultValues } = require('./template-engine');
/**
* Find BMAD config file in project
* @param {string} startPath - Starting directory to search from
* @returns {Object|null} Config data or null
*/
function findBmadConfig(startPath = process.cwd()) {
// Look for common BMAD folder names
const possibleNames = ['_bmad'];
for (const name of possibleNames) {
const configPath = path.join(startPath, name, 'bmb', 'config.yaml');
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf8');
const config = yaml.parse(content);
return {
...config,
bmadFolder: path.join(startPath, name),
projectRoot: startPath,
};
}
}
return null;
}
/**
* Resolve path variables like {project-root} and {bmad-folder}
* @param {string} pathStr - Path with variables
* @param {Object} context - Contains projectRoot, bmadFolder
* @returns {string} Resolved path
*/
function resolvePath(pathStr, context) {
return pathStr.replaceAll('{project-root}', context.projectRoot).replaceAll('{bmad-folder}', context_bmadFolder);
}
/**
* Discover available agents in the custom agent location recursively
* @param {string} searchPath - Path to search for agents
* @returns {Array} List of agent info objects
*/
function discoverAgents(searchPath) {
if (!fs.existsSync(searchPath)) {
return [];
}
const agents = [];
// Helper function to recursively search
function searchDirectory(dir, relativePath = '') {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const agentRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
// Simple agent (single file)
// The agent name is based on the filename
const agentName = entry.name.replace('.agent.yaml', '');
agents.push({
type: 'simple',
name: agentName,
path: fullPath,
yamlFile: fullPath,
relativePath: agentRelativePath.replace('.agent.yaml', ''),
});
} else if (entry.isDirectory()) {
// Check if this directory contains an .agent.yaml file
try {
const dirContents = fs.readdirSync(fullPath);
const yamlFiles = dirContents.filter((f) => f.endsWith('.agent.yaml'));
if (yamlFiles.length > 0) {
// Found .agent.yaml files in this directory
for (const yamlFile of yamlFiles) {
const agentYamlPath = path.join(fullPath, yamlFile);
const agentName = path.basename(yamlFile, '.agent.yaml');
agents.push({
type: 'expert',
name: agentName,
path: fullPath,
yamlFile: agentYamlPath,
relativePath: agentRelativePath,
});
}
} else {
// No .agent.yaml in this directory, recurse deeper
searchDirectory(fullPath, agentRelativePath);
}
} catch {
// Skip directories we can't read
}
}
}
}
searchDirectory(searchPath);
return agents;
}
/**
* Load agent YAML and extract install_config
* @param {string} yamlPath - Path to agent YAML file
* @returns {Object} Agent YAML and install config
*/
function loadAgentConfig(yamlPath) {
const content = fs.readFileSync(yamlPath, 'utf8');
const agentYaml = yaml.parse(content);
const installConfig = extractInstallConfig(agentYaml);
const defaults = installConfig ? getDefaultValues(installConfig) : {};
// Check for saved_answers (from previously installed custom agents)
// These take precedence over defaults
const savedAnswers = agentYaml?.saved_answers || {};
const metadata = agentYaml?.agent?.metadata || {};
return {
yamlContent: content,
agentYaml,
installConfig,
defaults: { ...defaults, ...savedAnswers }, // saved_answers override defaults
metadata,
hasSidecar: metadata.hasSidecar === true,
};
}
/**
* Interactive prompt for install_config questions
* @param {Object} installConfig - Install configuration with questions
* @param {Object} defaults - Default values
* @returns {Promise<Object>} User answers
*/
async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) {
if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) {
return { ...defaults, ...presetAnswers };
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (prompt) =>
new Promise((resolve) => {
rl.question(prompt, resolve);
});
const answers = { ...defaults, ...presetAnswers };
console.log('\n📝 Agent Configuration\n');
if (installConfig.description) {
console.log(` ${installConfig.description}\n`);
}
for (const q of installConfig.questions) {
// Skip questions for variables that are already set (e.g., custom_name set upfront)
if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) {
console.log(chalk.dim(` ${q.var}: ${answers[q.var]} (already set)`));
continue;
}
let response;
switch (q.type) {
case 'text': {
const defaultHint = q.default ? ` (default: ${q.default})` : '';
response = await question(` ${q.prompt}${defaultHint}: `);
answers[q.var] = response || q.default || '';
break;
}
case 'boolean': {
const defaultHint = q.default ? ' [Y/n]' : ' [y/N]';
response = await question(` ${q.prompt}${defaultHint}: `);
if (response === '') {
answers[q.var] = q.default;
} else {
answers[q.var] = response.toLowerCase().startsWith('y');
}
break;
}
case 'choice': {
console.log(` ${q.prompt}`);
for (const [idx, opt] of q.options.entries()) {
const marker = opt.value === q.default ? '* ' : ' ';
console.log(` ${marker}${idx + 1}. ${opt.label}`);
}
const defaultIdx = q.options.findIndex((o) => o.value === q.default) + 1;
let validChoice = false;
let choiceIdx;
while (!validChoice) {
response = await question(` Choice (default: ${defaultIdx}): `);
if (response) {
choiceIdx = parseInt(response, 10) - 1;
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= q.options.length) {
console.log(` Invalid choice. Please enter 1-${q.options.length}`);
} else {
validChoice = true;
}
} else {
choiceIdx = defaultIdx - 1;
validChoice = true;
}
}
answers[q.var] = q.options[choiceIdx].value;
break;
}
// No default
}
}
rl.close();
return answers;
}
/**
* Install a compiled agent to target location
* @param {Object} agentInfo - Agent discovery info
* @param {Object} answers - User answers for install_config
* @param {string} targetPath - Target installation directory
* @param {Object} options - Additional options including config
* @returns {Object} Installation result
*/
function installAgent(agentInfo, answers, targetPath, options = {}) {
// Compile the agent
const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers);
// Determine target agent folder name
// Use the folder name from agentInfo, NOT the persona name from metadata
const agentFolderName = agentInfo.name;
const agentTargetDir = path.join(targetPath, agentFolderName);
// Create target directory
if (!fs.existsSync(agentTargetDir)) {
fs.mkdirSync(agentTargetDir, { recursive: true });
}
// Write compiled XML (.md)
const compiledFileName = `${agentFolderName}.md`;
const compiledPath = path.join(agentTargetDir, compiledFileName);
fs.writeFileSync(compiledPath, xml, 'utf8');
const result = {
success: true,
agentName: metadata.name || agentInfo.name,
targetDir: agentTargetDir,
compiledFile: compiledPath,
};
return result;
}
/**
* Update agent metadata ID to reflect installed location
* @param {string} compiledContent - Compiled XML content
* @param {string} targetPath - Target installation path relative to project
* @returns {string} Updated content
*/
function updateAgentId(compiledContent, targetPath) {
// Update the id attribute in the opening agent tag
return compiledContent.replace(/(<agent\s+id=")[^"]*(")/, `$1${targetPath}$2`);
}
/**
* Detect if a path is within a BMAD project
* @param {string} targetPath - Path to check
* @returns {Object|null} Project info with bmadFolder and cfgFolder
*/
function detectBmadProject(targetPath) {
let checkPath = path.resolve(targetPath);
const root = path.parse(checkPath).root;
// Walk up directory tree looking for BMAD installation
while (checkPath !== root) {
const possibleNames = ['_bmad'];
for (const name of possibleNames) {
const bmadFolder = path.join(checkPath, name);
const cfgFolder = path.join(bmadFolder, '_config');
const manifestFile = path.join(cfgFolder, 'agent-manifest.csv');
if (fs.existsSync(manifestFile)) {
return {
projectRoot: checkPath,
bmadFolder,
cfgFolder,
manifestFile,
};
}
}
checkPath = path.dirname(checkPath);
}
return null;
}
/**
* Escape CSV field value
* @param {string} value - Value to escape
* @returns {string} Escaped value
*/
function escapeCsvField(value) {
if (typeof value !== 'string') value = String(value);
// If contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return '"' + value.replaceAll('"', '""') + '"';
}
return value;
}
/**
* Parse CSV line respecting quoted fields
* @param {string} line - CSV line
* @returns {Array} Parsed fields
*/
function parseCsvLine(line) {
const fields = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"' && !inQuotes) {
inQuotes = true;
} else if (char === '"' && inQuotes) {
if (nextChar === '"') {
current += '"';
i++; // Skip escaped quote
} else {
inQuotes = false;
}
} else if (char === ',' && !inQuotes) {
fields.push(current);
current = '';
} else {
current += char;
}
}
fields.push(current);
return fields;
}
/**
* Check if agent name exists in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {string} agentName - Agent name to check
* @returns {Object|null} Existing entry or null
*/
function checkManifestForAgent(manifestFile, agentName) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 2) return null;
const header = parseCsvLine(lines[0]);
const nameIndex = header.indexOf('name');
if (nameIndex === -1) return null;
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
if (fields[nameIndex] === agentName) {
const entry = {};
for (const [idx, col] of header.entries()) {
entry[col] = fields[idx] || '';
}
entry._lineNumber = i;
return entry;
}
}
return null;
}
/**
* Check if agent path exists in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {string} agentPath - Agent path to check
* @returns {Object|null} Existing entry or null
*/
function checkManifestForPath(manifestFile, agentPath) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
if (lines.length < 2) return null;
const header = parseCsvLine(lines[0]);
const pathIndex = header.indexOf('path');
if (pathIndex === -1) return null;
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
if (fields[pathIndex] === agentPath) {
const entry = {};
for (const [idx, col] of header.entries()) {
entry[col] = fields[idx] || '';
}
entry._lineNumber = i;
return entry;
}
}
return null;
}
/**
* Update existing entry in manifest
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {Object} agentData - New agent data
* @param {number} lineNumber - Line number to replace (1-indexed, excluding header)
* @returns {boolean} Success
*/
function updateManifestEntry(manifestFile, agentData, lineNumber) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
const header = lines[0];
const columns = header.split(',');
// Build the new row
const row = columns.map((col) => {
const value = agentData[col] || '';
return escapeCsvField(value);
});
// Replace the line
lines[lineNumber] = row.join(',');
fs.writeFileSync(manifestFile, lines.join('\n') + '\n', 'utf8');
return true;
}
/**
* Add agent to manifest CSV
* @param {string} manifestFile - Path to agent-manifest.csv
* @param {Object} agentData - Agent metadata and path info
* @returns {boolean} Success
*/
function addToManifest(manifestFile, agentData) {
const content = fs.readFileSync(manifestFile, 'utf8');
const lines = content.trim().split('\n');
// Parse header to understand column order
const header = lines[0];
const columns = header.split(',');
// Build the new row based on header columns
const row = columns.map((col) => {
const value = agentData[col] || '';
return escapeCsvField(value);
});
// Append new row
const newLine = row.join(',');
const updatedContent = content.trim() + '\n' + newLine + '\n';
fs.writeFileSync(manifestFile, updatedContent, 'utf8');
return true;
}
/**
* Save agent source YAML to _config/custom/agents/ for reinstallation
* Stores user answers in a top-level saved_answers section (cleaner than overwriting defaults)
* @param {Object} agentInfo - Agent info (path, type, etc.)
* @param {string} cfgFolder - Path to _config folder
* @param {string} agentName - Final agent name (e.g., "fred-commit-poet")
* @param {Object} answers - User answers to save for reinstallation
* @returns {Object} Info about saved source
*/
function saveAgentSource(agentInfo, cfgFolder, agentName, answers = {}) {
// Save to _config/custom/agents/ instead of _config/agents/
const customAgentsCfgDir = path.join(cfgFolder, 'custom', 'agents');
if (!fs.existsSync(customAgentsCfgDir)) {
fs.mkdirSync(customAgentsCfgDir, { recursive: true });
}
const yamlLib = require('yaml');
/**
* Add saved_answers section to store user's actual answers
*/
function addSavedAnswers(agentYaml, answers) {
// Store answers in a clear, separate section
agentYaml.saved_answers = answers;
return agentYaml;
}
if (agentInfo.type === 'simple') {
// Simple agent: copy YAML with saved_answers section
const targetYaml = path.join(customAgentsCfgDir, `${agentName}.agent.yaml`);
const originalContent = fs.readFileSync(agentInfo.yamlFile, 'utf8');
const agentYaml = yamlLib.parse(originalContent);
// Add saved_answers section with user's choices
addSavedAnswers(agentYaml, answers);
fs.writeFileSync(targetYaml, yamlLib.stringify(agentYaml), 'utf8');
return { type: 'simple', path: targetYaml };
} else {
// Expert agent with sidecar: copy entire folder with saved_answers
const targetFolder = path.join(customAgentsCfgDir, agentName);
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder, { recursive: true });
}
// Copy YAML and entire sidecar structure
const sourceDir = agentInfo.path;
const copied = [];
function copyDir(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else if (entry.name.endsWith('.agent.yaml')) {
// For the agent YAML, add saved_answers section
const originalContent = fs.readFileSync(srcPath, 'utf8');
const agentYaml = yamlLib.parse(originalContent);
addSavedAnswers(agentYaml, answers);
// Rename YAML to match final agent name
const newYamlPath = path.join(dest, `${agentName}.agent.yaml`);
fs.writeFileSync(newYamlPath, yamlLib.stringify(agentYaml), 'utf8');
copied.push(newYamlPath);
} else {
fs.copyFileSync(srcPath, destPath);
copied.push(destPath);
}
}
}
copyDir(sourceDir, targetFolder);
return { type: 'expert', path: targetFolder, files: copied };
}
}
/**
* Create IDE slash command wrapper for agent
* Leverages IdeManager to dispatch to IDE-specific handlers
* @param {string} projectRoot - Project root path
* @param {string} agentName - Agent name (e.g., "commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata
* @returns {Promise<Object>} Info about created slash commands
*/
async function createIdeSlashCommands(projectRoot, agentName, agentPath, metadata) {
// Read manifest.yaml to get installed IDEs
const manifestPath = path.join(projectRoot, '_bmad', '_config', 'manifest.yaml');
let installedIdes = ['claude-code']; // Default to Claude Code if no manifest
if (fs.existsSync(manifestPath)) {
const yamlLib = require('yaml');
const manifestContent = fs.readFileSync(manifestPath, 'utf8');
const manifest = yamlLib.parse(manifestContent);
if (manifest.ides && Array.isArray(manifest.ides)) {
installedIdes = manifest.ides;
}
}
// Use IdeManager to install custom agent launchers for all configured IDEs
const { IdeManager } = require('../../installers/lib/ide/manager');
const ideManager = new IdeManager();
const results = await ideManager.installCustomAgentLaunchers(installedIdes, projectRoot, agentName, agentPath, metadata);
return results;
}
/**
* Update manifest.yaml to track custom agent
* @param {string} manifestPath - Path to manifest.yaml
* @param {string} agentName - Agent name
* @param {string} agentType - Agent type (source name)
* @returns {boolean} Success
*/
function updateManifestYaml(manifestPath, agentName, agentType) {
if (!fs.existsSync(manifestPath)) {
return false;
}
const yamlLib = require('yaml');
const content = fs.readFileSync(manifestPath, 'utf8');
const manifest = yamlLib.parse(content);
// Initialize custom_agents array if not exists
if (!manifest.custom_agents) {
manifest.custom_agents = [];
}
// Check if this agent is already registered
const existingIndex = manifest.custom_agents.findIndex((a) => a.name === agentName || (typeof a === 'string' && a === agentName));
const agentEntry = {
name: agentName,
type: agentType,
installed: new Date().toISOString(),
};
if (existingIndex === -1) {
// Add new entry
manifest.custom_agents.push(agentEntry);
} else {
// Update existing entry
manifest.custom_agents[existingIndex] = agentEntry;
}
// Update lastUpdated timestamp
if (manifest.installation) {
manifest.installation.lastUpdated = new Date().toISOString();
}
// Write back
const newContent = yamlLib.stringify(manifest);
fs.writeFileSync(manifestPath, newContent, 'utf8');
return true;
}
/**
* Extract manifest data from compiled agent XML
* @param {string} xmlContent - Compiled agent XML
* @param {Object} metadata - Agent metadata from YAML
* @param {string} agentPath - Relative path to agent file
* @param {string} moduleName - Module name (default: 'custom')
* @returns {Object} Manifest row data
*/
function extractManifestData(xmlContent, metadata, agentPath, moduleName = 'custom') {
// Extract data from XML using regex (simple parsing)
const extractTag = (tag) => {
const match = xmlContent.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`));
if (!match) return '';
// Collapse multiple lines into single line, normalize whitespace
return match[1].trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim();
};
// Extract attributes from agent tag
const extractAgentAttribute = (attr) => {
const match = xmlContent.match(new RegExp(`<agent[^>]*\\s${attr}=["']([^"']+)["']`));
return match ? match[1] : '';
};
const extractPrinciples = () => {
const match = xmlContent.match(/<principles>([\s\S]*?)<\/principles>/);
if (!match) return '';
// Extract individual principle lines
const principles = match[1]
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0)
.join(' ');
return principles;
};
// Prioritize XML extraction over metadata for agent persona info
const xmlTitle = extractAgentAttribute('title') || extractTag('name');
const xmlIcon = extractAgentAttribute('icon');
return {
name: metadata.id ? path.basename(metadata.id, '.md') : metadata.name.toLowerCase().replaceAll(/\s+/g, '-'),
displayName: xmlTitle || metadata.name || '',
title: xmlTitle || metadata.title || '',
icon: xmlIcon || metadata.icon || '',
role: extractTag('role'),
identity: extractTag('identity'),
communicationStyle: extractTag('communication_style'),
principles: extractPrinciples(),
module: moduleName,
path: agentPath,
};
}
module.exports = {
findBmadConfig,
resolvePath,
discoverAgents,
loadAgentConfig,
promptInstallQuestions,
installAgent,
updateAgentId,
detectBmadProject,
addToManifest,
extractManifestData,
escapeCsvField,
checkManifestForAgent,
checkManifestForPath,
updateManifestEntry,
saveAgentSource,
createIdeSlashCommands,
updateManifestYaml,
};

View File

@@ -140,6 +140,36 @@ function getDefaultValues(installConfig) {
return defaults;
}
/**
* Filter out empty/null/undefined values from customization data
* @param {Object} data - Data to filter
* @returns {Object} Filtered data
*/
function filterCustomizationData(data) {
const filtered = {};
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined || value === '') {
continue; // Skip null/undefined/empty values
}
if (Array.isArray(value)) {
if (value.length > 0) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
const nested = filterCustomizationData(value);
if (Object.keys(nested).length > 0) {
filtered[key] = nested;
}
} else {
filtered[key] = value;
}
}
return filtered;
}
module.exports = {
processTemplate,
processConditionals,
@@ -149,4 +179,5 @@ module.exports = {
processAgentYaml,
getDefaultValues,
cleanupEmptyLines,
filterCustomizationData,
};

View File

@@ -0,0 +1,177 @@
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() {
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: '_',
});
this.yamlBuilder = new YamlXmlBuilder();
}
/**
* Load and parse the activation template
* @returns {Object} Parsed activation block
*/
async loadActivationTemplate() {
console.error('Failed to load activation template:', error);
}
/**
* 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;
}
}
/**
* TODO: DELETE THIS METHOD
*/
injectActivationSimple(agentContent, metadata = {}) {
console.error('Error in simple injection:', error);
}
/**
* 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,
forWebBundle: metadata.forWebBundle || false, // Pass through forWebBundle flag
};
// 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 };

View File

@@ -0,0 +1,715 @@
const yaml = require('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');
const { escapeXml } = require('../../../lib/xml-utils');
const {
processAgentYaml,
extractInstallConfig,
stripInstallConfig,
getDefaultValues,
filterCustomizationData,
} = require('./template-engine');
/**
* 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.parse(agentContent);
// Load customization if exists
let merged = agentYaml;
if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) {
const customizeContent = await fs.readFile(customizeYamlPath, 'utf8');
const customizeYaml = yaml.parse(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];
}
// Append prompts
if (customizeYaml.prompts) {
merged.agent.prompts = [...(merged.agent.prompts || []), ...customizeYaml.prompts];
}
// Append memories
if (customizeYaml.memories) {
merged.agent.memories = [...(merged.agent.memories || []), ...customizeYaml.memories];
}
}
}
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 || {};
// Add module from buildMetadata if available
if (buildMetadata.module) {
metadata.module = buildMetadata.module;
}
// Analyze agent to determine needed handlers
const profile = this.analyzer.analyzeAgentObject(agentYaml);
// Build activation block only if not skipped
let activationBlock = '';
if (!buildMetadata.skipActivation) {
activationBlock = await this.activationBuilder.buildActivation(
profile,
metadata,
agent.critical_actions || [],
buildMetadata.forWebBundle || false, // Pass web bundle flag
);
}
// Start building XML
let xml = '';
if (buildMetadata.forWebBundle) {
// Web bundle: keep existing format
xml += '<!-- Powered by BMAD-CORE™ -->\n\n';
xml += `# ${metadata.title || 'Agent'}\n\n`;
} else {
// Installation: use YAML frontmatter + instruction
// Extract name from filename: "cli-chief.yaml" or "pm.agent.yaml" -> "cli chief" or "pm"
const filename = buildMetadata.sourceFile || 'agent.yaml';
let nameFromFile = path.basename(filename, path.extname(filename)); // Remove .yaml/.md extension
nameFromFile = nameFromFile.replace(/\.agent$/, ''); // Remove .agent suffix if present
nameFromFile = nameFromFile.replaceAll('-', ' '); // Replace dashes with spaces
xml += '---\n';
xml += `name: "${nameFromFile}"\n`;
xml += `description: "${metadata.title || 'BMAD Agent'}"\n`;
xml += '---\n\n';
xml +=
"You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.\n\n";
}
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 (only if not skipped)
if (activationBlock) {
xml += activationBlock + '\n';
}
// Persona section
xml += this.buildPersonaXml(agent.persona);
// Memories section (if exists)
if (agent.memories) {
xml += this.buildMemoriesXml(agent.memories);
}
// 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.buildMenuXml(menuItems, buildMetadata.forWebBundle);
xml += '</agent>\n';
xml += '```\n';
return xml;
}
/**
* Build persona XML section
*/
buildPersonaXml(persona) {
if (!persona) return '';
let xml = ' <persona>\n';
if (persona.role) {
xml += ` <role>${escapeXml(persona.role)}</role>\n`;
}
if (persona.identity) {
xml += ` <identity>${escapeXml(persona.identity)}</identity>\n`;
}
if (persona.communication_style) {
xml += ` <communication_style>${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>${escapeXml(principlesText)}</principles>\n`;
}
xml += ' </persona>\n';
return xml;
}
/**
* Build memories XML section
*/
buildMemoriesXml(memories) {
if (!memories || memories.length === 0) return '';
let xml = ' <memories>\n';
for (const memory of memories) {
xml += ` <memory>${escapeXml(memory)}</memory>\n`;
}
xml += ' </memories>\n';
return xml;
}
/**
* Build prompts XML section
* Handles both array format and object/dictionary format
*/
buildPromptsXml(prompts) {
if (!prompts) return '';
// Handle object/dictionary format: { promptId: 'content', ... }
// Convert to array format for processing
let promptsArray = prompts;
if (!Array.isArray(prompts)) {
// Check if it's an object with no length property (dictionary format)
if (typeof prompts === 'object' && prompts.length === undefined) {
promptsArray = Object.entries(prompts).map(([id, content]) => ({
id: id,
content: content,
}));
} else {
return ''; // Not a valid prompts format
}
}
if (promptsArray.length === 0) return '';
let xml = ' <prompts>\n';
for (const prompt of promptsArray) {
xml += ` <prompt id="${prompt.id || ''}">\n`;
xml += ` <content>\n`;
xml += `${prompt.content || ''}\n`; // Don't escape prompt content - it's meant to be read as-is
xml += ` </content>\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
* Supports both legacy format and new multi format with nested handlers
* @param {Array} menuItems - Menu items from YAML
* @param {boolean} forWebBundle - Whether building for web bundle
*/
buildMenuXml(menuItems, forWebBundle = false) {
let xml = ' <menu>\n';
// Always inject menu display option first
xml += ` <item cmd="HM or fuzzy match on help">[HM] Redisplay Help Menu Options</item>\n`;
// Add user-defined menu items with * prefix
if (menuItems && menuItems.length > 0) {
for (const item of menuItems) {
// Skip ide-only items when building for web bundles
if (forWebBundle && item['ide-only'] === true) {
continue;
}
// Skip web-only items when NOT building for web bundles (i.e., IDE/local installation)
if (!forWebBundle && item['web-only'] === true) {
continue;
}
// Handle multi format menu items with nested handlers
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
xml += this.buildNestedHandlers(item.triggers);
xml += ` </item>\n`;
}
// Handle legacy format menu items
else if (item.trigger) {
let trigger = item.trigger || '';
const attrs = [`cmd="${trigger}"`];
// Add handler attributes
// If workflow-install exists, use its value for workflow attribute (vendoring)
// workflow-install is build-time metadata - tells installer where to copy workflows
// The final XML should only have workflow pointing to the install location
if (item['workflow-install']) {
attrs.push(`workflow="${item['workflow-install']}"`);
} else 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(' ')}>${escapeXml(item.description || '')}</item>\n`;
}
}
}
// Always inject dismiss last
xml += ` <item cmd="DA or fuzzy match on dismiss">[DA] Dismiss Agent</item>\n`;
xml += ' </menu>\n';
return xml;
}
/**
* Build nested handlers for multi format menu items
* @param {Array} triggers - Triggers array from multi format
* @returns {string} Handler XML
*/
buildNestedHandlers(triggers) {
let xml = '';
for (const triggerGroup of triggers) {
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
// Extract the relevant execution data
const execData = this.processExecArray(execArray);
// For nested handlers in multi items, we don't need cmd attribute
// The match attribute will handle fuzzy matching
const attrs = [`match="${escapeXml(execData.description || '')}"`];
// Add handler attributes based on exec data
if (execData.route) attrs.push(`exec="${execData.route}"`);
if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`);
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
if (execData.action) attrs.push(`action="${execData.action}"`);
if (execData.data) attrs.push(`data="${execData.data}"`);
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
}
}
return xml;
}
/**
* Process the execution array from multi format triggers
* Extracts relevant data for XML attributes
* @param {Array} execArray - Array of execution objects
* @returns {Object} Processed execution data
*/
processExecArray(execArray) {
const result = {
description: '',
route: null,
workflow: null,
data: null,
action: null,
type: null,
};
if (!Array.isArray(execArray)) {
return result;
}
for (const exec of execArray) {
if (exec.input) {
// Use input as description if no explicit description is provided
result.description = exec.input;
}
if (exec.route) {
// Determine if it's a workflow or exec based on file extension or context
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
result.workflow = exec.route;
} else {
result.route = exec.route;
}
}
if (exec.data !== null && exec.data !== undefined) {
result.data = exec.data;
}
if (exec.action) {
result.action = exec.action;
}
if (exec.type) {
result.type = exec.type;
}
}
return result;
}
/**
* 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 and return as string (for in-memory use)
* @param {string} agentYamlPath - Path to agent YAML
* @param {string} customizeYamlPath - Path to customize YAML (optional)
* @param {Object} options - Build options
* @returns {Promise<string>} XML content as string
*/
async buildFromYaml(agentYamlPath, customizeYamlPath = null, 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;
// Extract module from path (e.g., /path/to/modules/bmm/agents/pm.yaml -> bmm)
// or /path/to/bmad/bmm/agents/pm.yaml -> bmm
let module = 'core'; // default to core
const pathParts = agentYamlPath.split(path.sep);
// Look for module indicators in the path
const modulesIndex = pathParts.indexOf('modules');
const bmadIndex = pathParts.indexOf('bmad');
if (modulesIndex !== -1 && pathParts[modulesIndex + 1]) {
// Path contains /modules/{module}/
module = pathParts[modulesIndex + 1];
} else if (bmadIndex !== -1 && pathParts[bmadIndex + 1]) {
// Path contains /bmad/{module}/
const potentialModule = pathParts[bmadIndex + 1];
// Check if it's a known module, not 'agents' or '_config'
if (['bmm', 'bmb', 'cis', 'core'].includes(potentialModule)) {
module = potentialModule;
}
}
// Build metadata
const buildMetadata = {
sourceFile: path.basename(agentYamlPath),
sourceHash,
customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null,
customizeHash,
builderVersion: '1.0.0',
includeMetadata: options.includeMetadata !== false,
skipActivation: options.skipActivation === true,
forWebBundle: options.forWebBundle === true,
module: module, // Add module to buildMetadata
};
// Convert to XML and return
return await this.convertToXml(mergedAgent, buildMetadata);
}
/**
* 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 = {}) {
// Use buildFromYaml to get XML content
const xml = await this.buildFromYaml(agentYamlPath, customizeYamlPath, options);
// Write output file
await fs.ensureDir(path.dirname(outputPath));
await fs.writeFile(outputPath, xml, 'utf8');
// Calculate hashes for return value
const sourceHash = await this.calculateFileHash(agentYamlPath);
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
return {
success: true,
outputPath,
sourceHash,
customizeHash,
};
}
/**
* Process TTS injection points in XML content
* @param {string} content - XML content with TTS markers
* @param {boolean} enableAgentVibes - Whether to process AgentVibes markers
* @returns {string} Processed content
*/
processTTSInjectionPoints(content, enableAgentVibes = false) {
let result = content;
if (enableAgentVibes) {
// Process AgentVibes markers
result = result.replaceAll(/<AgentVibes\s+id="([^"]+)"\s*>/g, (match, id) => {
// Look for AgentVibes function in agent-analyzer data
if (this.analyzer.agentData && this.analyzer.agentData[id]) {
const functionText = this.analyzer.agentData[id];
return `<AgentVibes id="${id}">\n${functionText}\n</AgentVibes>`;
}
return match; // Keep original if not found
});
}
return result;
}
/**
* Legacy compatibility: compileAgent function for backward compatibility
* @param {string} yamlContent - YAML content
* @param {Object} answers - Template answers
* @param {string} agentName - Agent name
* @param {string} targetPath - Target path
* @param {Object} options - Additional options
* @returns {Object} Compilation result
*/
async compileAgent(yamlContent, answers = {}, agentName = '', targetPath = '', options = {}) {
// Parse YAML
let agentYaml = yaml.parse(yamlContent);
// Apply customization merges before template processing
// Handle metadata overrides (like name)
if (answers.metadata) {
// Filter out empty values from metadata
const filteredMetadata = filterCustomizationData(answers.metadata);
if (Object.keys(filteredMetadata).length > 0) {
agentYaml.agent.metadata = { ...agentYaml.agent.metadata, ...filteredMetadata };
}
// Remove from answers so it doesn't get processed as template variables
const { metadata, ...templateAnswers } = answers;
answers = templateAnswers;
}
// Handle other customization properties
// These should be merged into the agent structure, not processed as template variables
if (
answers.critical_actions && // Handle critical_actions merging
Array.isArray(answers.critical_actions)
) {
agentYaml.agent.critical_actions = [...(agentYaml.agent.critical_actions || []), ...answers.critical_actions];
}
// Extract install_config and process templates
const installConfig = extractInstallConfig(agentYaml);
const defaults = installConfig ? getDefaultValues(installConfig) : {};
// Process template variables
const processedYaml = processAgentYaml(agentYaml, { ...defaults, ...answers });
// Remove install_config after processing
const cleanYaml = stripInstallConfig(processedYaml);
// Convert to XML using our enhanced builder
const buildMetadata = {
sourceFile: targetPath,
module: cleanYaml.agent?.metadata?.module || 'core',
forWebBundle: options.forWebBundle || false,
skipActivation: options.skipActivation || false,
};
const xml = await this.convertToXml(cleanYaml, buildMetadata);
return {
xml,
metadata: cleanYaml.agent.metadata,
processedYaml: cleanYaml,
};
}
/**
* Legacy compatibility: compileAgentFile function
* @param {string} yamlPath - Path to YAML file
* @param {Object} options - Options
* @returns {Object} Compilation result
*/
async compileAgentFile(yamlPath, options = {}) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
const result = await this.compileAgent(yamlContent, options.answers || {});
// Determine output path
let outputPath = options.outputPath;
if (!outputPath) {
// Default: same directory, same name, .md extension
const parsedPath = path.parse(yamlPath);
outputPath = path.join(parsedPath.dir, `${parsedPath.name}.md`);
}
// Process TTS injection if enabled
let finalXml = result.xml;
if (options.enableTTS) {
finalXml = this.processTTSInjectionPoints(finalXml, true);
}
// Write output file
fs.writeFileSync(outputPath, finalXml, 'utf8');
return {
outputPath,
xml: finalXml,
metadata: result.metadata,
};
}
}
// Export both the class and legacy functions for backward compatibility
module.exports = {
YamlXmlBuilder,
// Legacy exports for backward compatibility
compileAgent: (yamlContent, answers, agentName, targetPath, options) => {
const builder = new YamlXmlBuilder();
return builder.compileAgent(yamlContent, answers, agentName, targetPath, options);
},
compileAgentFile: (yamlPath, options) => {
const builder = new YamlXmlBuilder();
return builder.compileAgentFile(yamlPath, options);
},
filterCustomizationData,
processAgentYaml,
extractInstallConfig,
stripInstallConfig,
getDefaultValues,
};