From 1e721f7fd0a514d2cff06a26e99e808130d633a9 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Mon, 22 Dec 2025 10:13:56 +0800 Subject: [PATCH] consolidate and remove some duplication --- .../toolsmith-sidecar/knowledge/installers.md | 3 +- .../bmb/docs/agents/agent-compilation.md | 2 +- src/modules/bmb/docs/agents/index.md | 2 +- .../docs/agents/simple-agent-architecture.md | 2 +- .../templates/simple-agent.template.md | 2 +- test/test-installation-components.js | 2 +- tools/cli/commands/build.js | 2 +- tools/cli/installers/lib/core/installer.js | 2 +- tools/cli/installers/lib/custom/handler.js | 4 +- tools/cli/installers/lib/ide/_base-ide.js | 2 +- tools/cli/installers/lib/modules/manager.js | 6 +- .../cli/lib/{ => agent}/activation-builder.js | 2 +- tools/cli/lib/{ => agent}/agent-analyzer.js | 0 tools/cli/lib/agent/compiler.js | 554 -------------- tools/cli/lib/agent/installer.js | 716 ------------------ tools/cli/lib/agent/template-engine.js | 31 + tools/cli/lib/{ => agent}/xml-handler.js | 2 +- tools/cli/lib/{ => agent}/yaml-xml-builder.js | 155 +++- 18 files changed, 196 insertions(+), 1293 deletions(-) rename tools/cli/lib/{ => agent}/activation-builder.js (99%) rename tools/cli/lib/{ => agent}/agent-analyzer.js (100%) delete mode 100644 tools/cli/lib/agent/compiler.js delete mode 100644 tools/cli/lib/agent/installer.js rename tools/cli/lib/{ => agent}/xml-handler.js (98%) rename tools/cli/lib/{ => agent}/yaml-xml-builder.js (77%) diff --git a/docs/sample-custom-modules/sample-unitary-module/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md b/docs/sample-custom-modules/sample-unitary-module/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md index 71498d59..75c925f6 100644 --- a/docs/sample-custom-modules/sample-unitary-module/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md +++ b/docs/sample-custom-modules/sample-unitary-module/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md @@ -49,8 +49,7 @@ - @/tools/cli/lib/xml-handler.js - XML processing - @/tools/cli/lib/yaml-format.js - YAML formatting - @/tools/cli/lib/file-ops.js - File operations -- @/tools/cli/lib/agent/compiler.js - Agent YAML to XML compilation -- @/tools/cli/lib/agent/installer.js - Agent installation +- @/tools/cli/lib/agent/yaml-xml-builder.js - Agent YAML to XML compilation - @/tools/cli/lib/agent/template-engine.js - Template processing ## IDE Handler Registry (16 IDEs) diff --git a/src/modules/bmb/docs/agents/agent-compilation.md b/src/modules/bmb/docs/agents/agent-compilation.md index 32af63fd..b960bb5f 100644 --- a/src/modules/bmb/docs/agents/agent-compilation.md +++ b/src/modules/bmb/docs/agents/agent-compilation.md @@ -8,7 +8,7 @@ What the compiler auto-injects. **DO NOT duplicate these in your YAML.** agent.yaml → Handlebars processing → XML generation → frontmatter.md ``` -Source: `tools/cli/lib/agent/compiler.js` +Source: `tools/cli/lib/agent/yaml-xml-builder.js` ## File Naming Convention diff --git a/src/modules/bmb/docs/agents/index.md b/src/modules/bmb/docs/agents/index.md index a1dd92e3..ac476161 100644 --- a/src/modules/bmb/docs/agents/index.md +++ b/src/modules/bmb/docs/agents/index.md @@ -52,4 +52,4 @@ Agents are authored in YAML with Handlebars templating. The compiler auto-inject **Critical:** See [Agent Compilation](./agent-compilation.md) to avoid duplicating auto-injected content. -Source: `tools/cli/lib/agent/compiler.js` +Source: `tools/cli/lib/agent/yaml-xml-builder.js` diff --git a/src/modules/bmb/docs/agents/simple-agent-architecture.md b/src/modules/bmb/docs/agents/simple-agent-architecture.md index e68a3c56..d7f35f44 100644 --- a/src/modules/bmb/docs/agents/simple-agent-architecture.md +++ b/src/modules/bmb/docs/agents/simple-agent-architecture.md @@ -178,7 +178,7 @@ Content when false ## What Gets Injected at Compile Time -The `tools/cli/lib/agent/compiler.js` automatically adds: +The `tools/cli/lib/agent/yaml-xml-builder.js` automatically adds: 1. **YAML Frontmatter** diff --git a/src/modules/bmb/workflows/create-agent/templates/simple-agent.template.md b/src/modules/bmb/workflows/create-agent/templates/simple-agent.template.md index e68a3c56..d7f35f44 100644 --- a/src/modules/bmb/workflows/create-agent/templates/simple-agent.template.md +++ b/src/modules/bmb/workflows/create-agent/templates/simple-agent.template.md @@ -178,7 +178,7 @@ Content when false ## What Gets Injected at Compile Time -The `tools/cli/lib/agent/compiler.js` automatically adds: +The `tools/cli/lib/agent/yaml-xml-builder.js` automatically adds: 1. **YAML Frontmatter** diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1f9c99dd..41775e6b 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -13,7 +13,7 @@ const path = require('node:path'); const fs = require('fs-extra'); -const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder'); +const { YamlXmlBuilder } = require('../tools/cli/lib/agent/yaml-xml-builder'); const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator'); // ANSI colors diff --git a/tools/cli/commands/build.js b/tools/cli/commands/build.js index 467fcd65..50c12b83 100644 --- a/tools/cli/commands/build.js +++ b/tools/cli/commands/build.js @@ -1,7 +1,7 @@ const chalk = require('chalk'); const path = require('node:path'); const fs = require('fs-extra'); -const { YamlXmlBuilder } = require('../lib/yaml-xml-builder'); +const { YamlXmlBuilder } = require('../lib/agent/yaml-xml-builder'); const { getProjectRoot } = require('../lib/project-root'); const builder = new YamlXmlBuilder(); diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 5b403972..8e6f5a4b 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -9,7 +9,7 @@ const { ModuleManager } = require('../modules/manager'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../../../lib/file-ops'); const { Config } = require('../../../lib/config'); -const { XmlHandler } = require('../../../lib/xml-handler'); +const { XmlHandler } = require('../../../lib/agent/xml-handler'); const { DependencyResolver } = require('./dependency-resolver'); const { ConfigCollector } = require('./config-collector'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index c8aa52ee..41d8f0ac 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -3,7 +3,7 @@ const fs = require('fs-extra'); const chalk = require('chalk'); const yaml = require('yaml'); const { FileOps } = require('../../../lib/file-ops'); -const { XmlHandler } = require('../../../lib/xml-handler'); +const { XmlHandler } = require('../../../lib/agent/xml-handler'); /** * Handler for custom content (custom.yaml) @@ -311,7 +311,7 @@ class CustomHandler { // Read and compile the YAML try { const yamlContent = await fs.readFile(agentFile, 'utf8'); - const { compileAgent } = require('../../../lib/agent/compiler'); + const { compileAgent } = require('../../../lib/agent/yaml-xml-builder'); // Create customize template if it doesn't exist if (!(await fs.pathExists(customizePath))) { diff --git a/tools/cli/installers/lib/ide/_base-ide.js b/tools/cli/installers/lib/ide/_base-ide.js index b53eb977..1aedceb7 100644 --- a/tools/cli/installers/lib/ide/_base-ide.js +++ b/tools/cli/installers/lib/ide/_base-ide.js @@ -1,7 +1,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); -const { XmlHandler } = require('../../../lib/xml-handler'); +const { XmlHandler } = require('../../../lib/agent/xml-handler'); const { getSourcePath } = require('../../../lib/project-root'); /** diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 4844f243..c2acb34a 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -2,9 +2,9 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const chalk = require('chalk'); -const { XmlHandler } = require('../../../lib/xml-handler'); +const { XmlHandler } = require('../../../lib/agent/xml-handler'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); -const { filterCustomizationData } = require('../../../lib/agent/compiler'); +const { filterCustomizationData } = require('../../../lib/agent/yaml-xml-builder'); /** * Manages the installation, updating, and removal of BMAD modules. @@ -757,7 +757,7 @@ class ModuleManager { // Read and compile the YAML try { const yamlContent = await fs.readFile(sourceYamlPath, 'utf8'); - const { compileAgent } = require('../../../lib/agent/compiler'); + const { compileAgent } = require('../../../lib/agent/yaml-xml-builder'); // Create customize template if it doesn't exist if (!(await fs.pathExists(customizePath))) { diff --git a/tools/cli/lib/activation-builder.js b/tools/cli/lib/agent/activation-builder.js similarity index 99% rename from tools/cli/lib/activation-builder.js rename to tools/cli/lib/agent/activation-builder.js index 9b91c2a9..e45447fb 100644 --- a/tools/cli/lib/activation-builder.js +++ b/tools/cli/lib/agent/activation-builder.js @@ -1,6 +1,6 @@ const fs = require('fs-extra'); const path = require('node:path'); -const { getSourcePath } = require('./project-root'); +const { getSourcePath } = require('../project-root'); /** * Builds activation blocks from fragments based on agent profile diff --git a/tools/cli/lib/agent-analyzer.js b/tools/cli/lib/agent/agent-analyzer.js similarity index 100% rename from tools/cli/lib/agent-analyzer.js rename to tools/cli/lib/agent/agent-analyzer.js diff --git a/tools/cli/lib/agent/compiler.js b/tools/cli/lib/agent/compiler.js deleted file mode 100644 index 0d805451..00000000 --- a/tools/cli/lib/agent/compiler.js +++ /dev/null @@ -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 = ' \n'; - - if (persona.role) { - const roleText = persona.role.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` ${escapeXml(roleText)}\n`; - } - - if (persona.identity) { - const identityText = persona.identity.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` ${escapeXml(identityText)}\n`; - } - - if (persona.communication_style) { - const styleText = persona.communication_style.trim().replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' '); - xml += ` ${escapeXml(styleText)}\n`; - } - - if (persona.principles) { - let principlesText; - if (Array.isArray(persona.principles)) { - principlesText = persona.principles.join(' '); - } else { - principlesText = persona.principles.trim().replaceAll(/\n+/g, ' '); - } - xml += ` ${escapeXml(principlesText)}\n`; - } - - xml += ' \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 = ' \n'; - - for (const prompt of prompts) { - xml += ` \n`; - xml += ` \n`; - // Don't escape prompt content - it's meant to be read as-is - xml += `${prompt.content || ''}\n`; - xml += ` \n`; - xml += ` \n`; - } - - xml += ' \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 = ' \n'; - - for (const memory of memories) { - xml += ` ${escapeXml(String(memory))}\n`; - } - - xml += ' \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 = ' \n'; - - // Always inject menu display option first - xml += ` [HM] Redisplay Help Menu Options\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 += ` ${escapeXml(item.multi)}\n`; - xml += buildNestedHandlers(item.triggers); - xml += ` \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 += ` ${escapeXml(item.description || '')}\n`; - } - } - } - - // Always inject dismiss last - xml += ` [DA] 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 - */ -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 += ` \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} 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 += `\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 += '\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} { 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(''); - - if (enableAgentVibes && hasAgentTTS) { - // Replace agent-tts injection marker with TTS rule - content = content.replaceAll( - '', - `- 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 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(/\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, -}; diff --git a/tools/cli/lib/agent/installer.js b/tools/cli/lib/agent/installer.js deleted file mode 100644 index b55502ed..00000000 --- a/tools/cli/lib/agent/installer.js +++ /dev/null @@ -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} 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(/( { - 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} 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]*?)`)); - 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(`]*\\s${attr}=["']([^"']+)["']`)); - return match ? match[1] : ''; - }; - - const extractPrinciples = () => { - const match = xmlContent.match(/([\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, -}; diff --git a/tools/cli/lib/agent/template-engine.js b/tools/cli/lib/agent/template-engine.js index 01281fb1..b093e2e9 100644 --- a/tools/cli/lib/agent/template-engine.js +++ b/tools/cli/lib/agent/template-engine.js @@ -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, }; diff --git a/tools/cli/lib/xml-handler.js b/tools/cli/lib/agent/xml-handler.js similarity index 98% rename from tools/cli/lib/xml-handler.js rename to tools/cli/lib/agent/xml-handler.js index a6111b1a..a514e3a3 100644 --- a/tools/cli/lib/xml-handler.js +++ b/tools/cli/lib/agent/xml-handler.js @@ -1,7 +1,7 @@ const xml2js = require('xml2js'); const fs = require('fs-extra'); const path = require('node:path'); -const { getProjectRoot, getSourcePath } = require('./project-root'); +const { getProjectRoot, getSourcePath } = require('../project-root'); const { YamlXmlBuilder } = require('./yaml-xml-builder'); /** diff --git a/tools/cli/lib/yaml-xml-builder.js b/tools/cli/lib/agent/yaml-xml-builder.js similarity index 77% rename from tools/cli/lib/yaml-xml-builder.js rename to tools/cli/lib/agent/yaml-xml-builder.js index 8603be14..6ff97ea8 100644 --- a/tools/cli/lib/yaml-xml-builder.js +++ b/tools/cli/lib/agent/yaml-xml-builder.js @@ -4,7 +4,14 @@ 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 { 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 @@ -225,7 +232,7 @@ class YamlXmlBuilder { // Menu section (support both 'menu' and legacy 'commands') const menuItems = agent.menu || agent.commands || []; - xml += this.buildCommandsXml(menuItems, buildMetadata.forWebBundle); + xml += this.buildMenuXml(menuItems, buildMetadata.forWebBundle); xml += '\n'; xml += '```\n'; @@ -315,7 +322,7 @@ class YamlXmlBuilder { for (const prompt of promptsArray) { xml += ` \n`; xml += ` \n`; - xml += `${escapeXml(prompt.content || '')}\n`; + xml += `${prompt.content || ''}\n`; // Don't escape prompt content - it's meant to be read as-is xml += ` \n`; xml += ` \n`; } @@ -332,7 +339,7 @@ class YamlXmlBuilder { * @param {Array} menuItems - Menu items from YAML * @param {boolean} forWebBundle - Whether building for web bundle */ - buildCommandsXml(menuItems, forWebBundle = false) { + buildMenuXml(menuItems, forWebBundle = false) { let xml = ' \n'; // Always inject menu display option first @@ -415,7 +422,6 @@ class YamlXmlBuilder { 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`; @@ -567,6 +573,143 @@ class YamlXmlBuilder { 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(//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 `\n${functionText}\n`; + } + 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, + }; + } } -module.exports = { YamlXmlBuilder }; +// 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, +};