consolidate and remove some duplication
This commit is contained in:
parent
9c268f8190
commit
1e721f7fd0
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
/**
|
||||
|
|
@ -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 += '</agent>\n';
|
||||
xml += '```\n';
|
||||
|
|
@ -315,7 +322,7 @@ class YamlXmlBuilder {
|
|||
for (const prompt of promptsArray) {
|
||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
||||
xml += ` <content>\n`;
|
||||
xml += `${escapeXml(prompt.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`;
|
||||
}
|
||||
|
|
@ -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 = ' <menu>\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 += ` <handler ${attrs.join(' ')}></handler>\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(/<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
Loading…
Reference in New Issue