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'); /** * 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 += '\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 += `\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.buildCommandsXml(menuItems, buildMetadata.forWebBundle); xml += '\n'; xml += '```\n'; return xml; } /** * Build persona XML section */ buildPersonaXml(persona) { if (!persona) return ''; let xml = ' \n'; if (persona.role) { xml += ` ${escapeXml(persona.role)}\n`; } if (persona.identity) { xml += ` ${escapeXml(persona.identity)}\n`; } if (persona.communication_style) { xml += ` ${escapeXml(persona.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 += ` ${escapeXml(principlesText)}\n`; } xml += ' \n'; return xml; } /** * Build memories XML section */ buildMemoriesXml(memories) { if (!memories || memories.length === 0) return ''; let xml = ' \n'; for (const memory of memories) { xml += ` ${escapeXml(memory)}\n`; } xml += ' \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 = ' \n'; for (const prompt of promptsArray) { xml += ` \n`; xml += ` \n`; xml += `${escapeXml(prompt.content || '')}\n`; xml += ` \n`; xml += ` \n`; } xml += ' \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 */ buildCommandsXml(menuItems, forWebBundle = false) { let xml = ' \n'; // Always inject menu display option first xml += ` [M] Redisplay Menu Options\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 += ` ${escapeXml(item.multi)}\n`; xml += this.buildNestedHandlers(item.triggers); xml += ` \n`; } // Handle legacy format menu items else if (item.trigger) { // For legacy items, keep using cmd with * format let trigger = item.trigger || ''; if (!trigger.startsWith('*')) { trigger = '*' + 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 += ` ${escapeXml(item.description || '')}\n`; } } } // Always inject dismiss last xml += ` [D] Dismiss Agent\n`; xml += ' \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)) { // Build trigger with * prefix let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName; // 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}"`); // 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 += ` \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} 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, }; } } module.exports = { YamlXmlBuilder };