refactor(installer): remove dead agent compilation pipeline
Delete 9 files (~2,600 lines) that compiled .agent.yaml to .md. No .agent.yaml files exist in the source tree — agents now ship as pre-built SKILL.md. Clean up all references in installer, module manager, custom handler, base IDE, UI, and tests.
This commit is contained in:
parent
1a6f8d52bc
commit
5c9704c8c8
|
|
@ -14,7 +14,6 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { YamlXmlBuilder } = require('../tools/cli/lib/yaml-xml-builder');
|
|
||||||
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
|
const { ManifestGenerator } = require('../tools/cli/installers/lib/core/manifest-generator');
|
||||||
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
|
const { IdeManager } = require('../tools/cli/installers/lib/ide/manager');
|
||||||
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
|
const { clearCache, loadPlatformCodes } = require('../tools/cli/installers/lib/ide/platform-codes');
|
||||||
|
|
@ -149,77 +148,10 @@ async function runTests() {
|
||||||
|
|
||||||
const projectRoot = path.join(__dirname, '..');
|
const projectRoot = path.join(__dirname, '..');
|
||||||
|
|
||||||
// Test 1: Removed — old YAML→XML agent compilation no longer applies (agents now use SKILL.md format)
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Test 2: Customization Merging
|
// Test 1: Windsurf Native Skills Install
|
||||||
// ============================================================
|
// ============================================================
|
||||||
console.log(`${colors.yellow}Test Suite 2: Customization Merging${colors.reset}\n`);
|
console.log(`${colors.yellow}Test Suite 1: Windsurf Native Skills${colors.reset}\n`);
|
||||||
|
|
||||||
try {
|
|
||||||
const builder = new YamlXmlBuilder();
|
|
||||||
|
|
||||||
// Test deepMerge function
|
|
||||||
const base = {
|
|
||||||
agent: {
|
|
||||||
metadata: { name: 'John', title: 'PM' },
|
|
||||||
persona: { role: 'Product Manager', style: 'Analytical' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const customize = {
|
|
||||||
agent: {
|
|
||||||
metadata: { name: 'Sarah' }, // Override name only
|
|
||||||
persona: { style: 'Concise' }, // Override style only
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const merged = builder.deepMerge(base, customize);
|
|
||||||
|
|
||||||
assert(merged.agent.metadata.name === 'Sarah', 'Deep merge overrides customized name');
|
|
||||||
|
|
||||||
assert(merged.agent.metadata.title === 'PM', 'Deep merge preserves non-overridden title');
|
|
||||||
|
|
||||||
assert(merged.agent.persona.role === 'Product Manager', 'Deep merge preserves non-overridden role');
|
|
||||||
|
|
||||||
assert(merged.agent.persona.style === 'Concise', 'Deep merge overrides customized style');
|
|
||||||
} catch (error) {
|
|
||||||
assert(false, 'Customization merging works', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Test 3: Path Resolution
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Test Suite 3: Path Variable Resolution${colors.reset}\n`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const builder = new YamlXmlBuilder();
|
|
||||||
|
|
||||||
// Test path resolution logic (if exposed)
|
|
||||||
// This would test {project-root}, {installed_path}, {config_source} resolution
|
|
||||||
|
|
||||||
const testPath = '{project-root}/bmad/bmm/config.yaml';
|
|
||||||
const expectedPattern = /\/bmad\/bmm\/config\.yaml$/;
|
|
||||||
|
|
||||||
assert(
|
|
||||||
true, // Placeholder - would test actual resolution
|
|
||||||
'Path variable resolution pattern matches expected format',
|
|
||||||
'Note: This test validates path resolution logic exists',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
assert(false, 'Path resolution works', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Test 4: Windsurf Native Skills Install
|
|
||||||
// ============================================================
|
|
||||||
console.log(`${colors.yellow}Test Suite 4: Windsurf Native Skills${colors.reset}\n`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
clearCache();
|
clearCache();
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ module.exports = {
|
||||||
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
|
'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.',
|
||||||
],
|
],
|
||||||
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
|
['--custom-content <paths>', 'Comma-separated list of paths to custom modules/agents/workflows'],
|
||||||
['--action <type>', 'Action type for existing installations: install, update, quick-update, or compile-agents'],
|
['--action <type>', 'Action type for existing installations: install, update, or quick-update'],
|
||||||
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
['--user-name <name>', 'Name for agents to use (default: system username)'],
|
||||||
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
['--communication-language <lang>', 'Language for agent communication (default: English)'],
|
||||||
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
['--document-output-language <lang>', 'Language for document output (default: English)'],
|
||||||
|
|
@ -49,13 +49,6 @@ module.exports = {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle compile agents separately
|
|
||||||
if (config.actionType === 'compile-agents') {
|
|
||||||
const result = await installer.compileAgents(config);
|
|
||||||
await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular install/update flow
|
// Regular install/update flow
|
||||||
const result = await installer.install(config);
|
const result = await installer.install(config);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ const { ModuleManager } = require('../modules/manager');
|
||||||
const { IdeManager } = require('../ide/manager');
|
const { IdeManager } = require('../ide/manager');
|
||||||
const { FileOps } = require('../../../lib/file-ops');
|
const { FileOps } = require('../../../lib/file-ops');
|
||||||
const { Config } = require('../../../lib/config');
|
const { Config } = require('../../../lib/config');
|
||||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
|
||||||
const { DependencyResolver } = require('./dependency-resolver');
|
const { DependencyResolver } = require('./dependency-resolver');
|
||||||
const { ConfigCollector } = require('./config-collector');
|
const { ConfigCollector } = require('./config-collector');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||||
|
|
@ -25,7 +24,6 @@ class Installer {
|
||||||
this.ideManager = new IdeManager();
|
this.ideManager = new IdeManager();
|
||||||
this.fileOps = new FileOps();
|
this.fileOps = new FileOps();
|
||||||
this.config = new Config();
|
this.config = new Config();
|
||||||
this.xmlHandler = new XmlHandler();
|
|
||||||
this.dependencyResolver = new DependencyResolver();
|
this.dependencyResolver = new DependencyResolver();
|
||||||
this.configCollector = new ConfigCollector();
|
this.configCollector = new ConfigCollector();
|
||||||
this.ideConfigManager = new IdeConfigManager();
|
this.ideConfigManager = new IdeConfigManager();
|
||||||
|
|
@ -2114,10 +2112,6 @@ class Installer {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process agent files to build YAML agents and create customize templates
|
|
||||||
const modulePath = path.join(bmadDir, moduleName);
|
|
||||||
await this.processAgentFiles(modulePath, moduleName);
|
|
||||||
|
|
||||||
// Dependencies are already included in full module install
|
// Dependencies are already included in full module install
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2227,16 +2221,8 @@ class Installer {
|
||||||
const sourcePath = getModulePath('core');
|
const sourcePath = getModulePath('core');
|
||||||
const targetPath = path.join(bmadDir, 'core');
|
const targetPath = path.join(bmadDir, 'core');
|
||||||
|
|
||||||
// Copy core files (skip .agent.yaml files like modules do)
|
// Copy core files
|
||||||
await this.copyCoreFiles(sourcePath, targetPath);
|
await this.copyCoreFiles(sourcePath, targetPath);
|
||||||
|
|
||||||
// Compile agents using the same compiler as modules
|
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this);
|
|
||||||
|
|
||||||
// Process agent files to inject activation block
|
|
||||||
await this.processAgentFiles(targetPath, 'core');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2254,16 +2240,6 @@ class Installer {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip sidecar directories - they are handled separately during agent compilation
|
|
||||||
if (
|
|
||||||
path
|
|
||||||
.dirname(file)
|
|
||||||
.split('/')
|
|
||||||
.some((dir) => dir.toLowerCase().includes('sidecar'))
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip module.yaml at root - it's only needed at install time
|
// Skip module.yaml at root - it's only needed at install time
|
||||||
if (file === 'module.yaml') {
|
if (file === 'module.yaml') {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2274,27 +2250,9 @@ class Installer {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip .agent.yaml files - they will be compiled separately
|
|
||||||
if (file.endsWith('.agent.yaml')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceFile = path.join(sourcePath, file);
|
const sourceFile = path.join(sourcePath, file);
|
||||||
const targetFile = path.join(targetPath, file);
|
const targetFile = path.join(targetPath, file);
|
||||||
|
|
||||||
// Check if this is an agent file
|
|
||||||
if (file.startsWith('agents/') && file.endsWith('.md')) {
|
|
||||||
// Read the file to check for localskip
|
|
||||||
const content = await fs.readFile(sourceFile, 'utf8');
|
|
||||||
|
|
||||||
// Check for localskip="true" in the agent tag
|
|
||||||
const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
|
|
||||||
if (agentMatch) {
|
|
||||||
await prompts.log.message(` Skipping web-only agent: ${path.basename(file)}`);
|
|
||||||
continue; // Skip this agent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the file with placeholder replacement
|
// Copy the file with placeholder replacement
|
||||||
await fs.ensureDir(path.dirname(targetFile));
|
await fs.ensureDir(path.dirname(targetFile));
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
||||||
|
|
@ -2328,58 +2286,6 @@ class Installer {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process agent files to build YAML agents and inject activation blocks
|
|
||||||
* @param {string} modulePath - Path to module in bmad/ installation
|
|
||||||
* @param {string} moduleName - Module name
|
|
||||||
*/
|
|
||||||
async processAgentFiles(modulePath, moduleName) {
|
|
||||||
const agentsPath = path.join(modulePath, 'agents');
|
|
||||||
|
|
||||||
// Check if agents directory exists
|
|
||||||
if (!(await fs.pathExists(agentsPath))) {
|
|
||||||
return; // No agents to process
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine project directory (parent of bmad/ directory)
|
|
||||||
const bmadDir = path.dirname(modulePath);
|
|
||||||
const cfgAgentsDir = path.join(bmadDir, '_config', 'agents');
|
|
||||||
|
|
||||||
// Ensure _config/agents directory exists
|
|
||||||
await fs.ensureDir(cfgAgentsDir);
|
|
||||||
|
|
||||||
// Get all agent files
|
|
||||||
const agentFiles = await fs.readdir(agentsPath);
|
|
||||||
|
|
||||||
for (const agentFile of agentFiles) {
|
|
||||||
// Skip .agent.yaml files - they should already be compiled by compileModuleAgents
|
|
||||||
if (agentFile.endsWith('.agent.yaml')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process .md files (already compiled from YAML)
|
|
||||||
if (!agentFile.endsWith('.md')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentName = agentFile.replace('.md', '');
|
|
||||||
const mdPath = path.join(agentsPath, agentFile);
|
|
||||||
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
|
||||||
|
|
||||||
// For .md files that are already compiled, we don't need to do much
|
|
||||||
// Just ensure the customize template exists
|
|
||||||
if (!(await fs.pathExists(customizePath))) {
|
|
||||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
|
||||||
if (await fs.pathExists(genericTemplatePath)) {
|
|
||||||
await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath);
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private: Update core
|
* Private: Update core
|
||||||
*/
|
*/
|
||||||
|
|
@ -2393,12 +2299,6 @@ class Installer {
|
||||||
} else {
|
} else {
|
||||||
// Selective update - preserve user modifications
|
// Selective update - preserve user modifications
|
||||||
await this.fileOps.syncDirectory(sourcePath, targetPath);
|
await this.fileOps.syncDirectory(sourcePath, targetPath);
|
||||||
|
|
||||||
// Recompile agents (#1133)
|
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir, this);
|
|
||||||
await this.processAgentFiles(targetPath, 'core');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2643,114 +2543,6 @@ class Installer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile agents with customizations only
|
|
||||||
* @param {Object} config - Configuration with directory
|
|
||||||
* @returns {Object} Compilation result
|
|
||||||
*/
|
|
||||||
async compileAgents(config) {
|
|
||||||
// Using @clack prompts
|
|
||||||
const { ModuleManager } = require('../modules/manager');
|
|
||||||
const { getSourcePath } = require('../../../lib/project-root');
|
|
||||||
|
|
||||||
const spinner = await prompts.spinner();
|
|
||||||
spinner.start('Recompiling agents with customizations...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const projectDir = path.resolve(config.directory);
|
|
||||||
const { bmadDir } = await this.findBmadDir(projectDir);
|
|
||||||
|
|
||||||
// Check if bmad directory exists
|
|
||||||
if (!(await fs.pathExists(bmadDir))) {
|
|
||||||
spinner.stop('No BMAD installation found');
|
|
||||||
throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect existing installation
|
|
||||||
const existingInstall = await this.detector.detect(bmadDir);
|
|
||||||
const installedModules = existingInstall.modules.map((m) => m.id);
|
|
||||||
|
|
||||||
// Initialize module manager
|
|
||||||
const moduleManager = new ModuleManager();
|
|
||||||
moduleManager.setBmadFolderName(path.basename(bmadDir));
|
|
||||||
|
|
||||||
let totalAgentCount = 0;
|
|
||||||
|
|
||||||
// Get custom module sources from cache
|
|
||||||
const customModuleSources = new Map();
|
|
||||||
const cacheDir = path.join(bmadDir, '_config', 'custom');
|
|
||||||
if (await fs.pathExists(cacheDir)) {
|
|
||||||
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const cachedModule of cachedModules) {
|
|
||||||
if (cachedModule.isDirectory()) {
|
|
||||||
const moduleId = cachedModule.name;
|
|
||||||
const cachedPath = path.join(cacheDir, moduleId);
|
|
||||||
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
|
|
||||||
|
|
||||||
// Check if this is actually a custom module
|
|
||||||
if (await fs.pathExists(moduleYamlPath)) {
|
|
||||||
// Check if this is an external official module - skip cache for those
|
|
||||||
const isExternal = await this.moduleManager.isExternalModule(moduleId);
|
|
||||||
if (isExternal) {
|
|
||||||
// External modules are handled via cloneExternalModule, not from cache
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
customModuleSources.set(moduleId, cachedPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each installed module
|
|
||||||
for (const moduleId of installedModules) {
|
|
||||||
spinner.message(`Recompiling agents in ${moduleId}...`);
|
|
||||||
|
|
||||||
// Get source path
|
|
||||||
let sourcePath;
|
|
||||||
if (moduleId === 'core') {
|
|
||||||
sourcePath = getSourcePath('core-skills');
|
|
||||||
} else {
|
|
||||||
// First check if it's in the custom cache
|
|
||||||
if (customModuleSources.has(moduleId)) {
|
|
||||||
sourcePath = customModuleSources.get(moduleId);
|
|
||||||
} else {
|
|
||||||
sourcePath = await moduleManager.findModuleSource(moduleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourcePath) {
|
|
||||||
await prompts.log.warn(`Source not found for module ${moduleId}, skipping...`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPath = path.join(bmadDir, moduleId);
|
|
||||||
|
|
||||||
// Compile agents for this module
|
|
||||||
await moduleManager.compileModuleAgents(sourcePath, targetPath, moduleId, bmadDir, this);
|
|
||||||
|
|
||||||
// Count agents (rough estimate based on files)
|
|
||||||
const agentsPath = path.join(targetPath, 'agents');
|
|
||||||
if (await fs.pathExists(agentsPath)) {
|
|
||||||
const agentFiles = await fs.readdir(agentsPath);
|
|
||||||
const agentCount = agentFiles.filter((f) => f.endsWith('.md')).length;
|
|
||||||
totalAgentCount += agentCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spinner.stop('Agent recompilation complete!');
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
agentCount: totalAgentCount,
|
|
||||||
modules: installedModules,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
spinner.error('Agent recompilation failed');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private: Prompt for update action
|
* Private: Prompt for update action
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
const { FileOps } = require('../../../lib/file-ops');
|
const { FileOps } = require('../../../lib/file-ops');
|
||||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for custom content (custom.yaml)
|
* Handler for custom content (custom.yaml)
|
||||||
|
|
@ -12,7 +11,6 @@ const { XmlHandler } = require('../../../lib/xml-handler');
|
||||||
class CustomHandler {
|
class CustomHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.fileOps = new FileOps();
|
this.fileOps = new FileOps();
|
||||||
this.xmlHandler = new XmlHandler();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,13 +141,13 @@ class CustomHandler {
|
||||||
await fs.ensureDir(bmadAgentsDir);
|
await fs.ensureDir(bmadAgentsDir);
|
||||||
await fs.ensureDir(bmadWorkflowsDir);
|
await fs.ensureDir(bmadWorkflowsDir);
|
||||||
|
|
||||||
// Process agents - compile and copy agents
|
// Copy agents directory
|
||||||
const agentsDir = path.join(customPath, 'agents');
|
const agentsDir = path.join(customPath, 'agents');
|
||||||
if (await fs.pathExists(agentsDir)) {
|
if (await fs.pathExists(agentsDir)) {
|
||||||
await this.compileAndCopyAgents(agentsDir, bmadAgentsDir, bmadDir, config, fileTrackingCallback, results);
|
await this.copyDirectory(agentsDir, bmadAgentsDir, results, fileTrackingCallback, config);
|
||||||
|
|
||||||
// Count agent files
|
// Count agent files
|
||||||
const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']);
|
const agentFiles = await this.findFilesRecursively(agentsDir, ['.md']);
|
||||||
results.agentsInstalled = agentFiles.length;
|
results.agentsInstalled = agentFiles.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,78 +279,6 @@ class CustomHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile .agent.yaml files to .md format and handle sidecars
|
|
||||||
* @param {string} sourceAgentsPath - Source agents directory
|
|
||||||
* @param {string} targetAgentsPath - Target agents directory
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Object} config - Configuration for placeholder replacement
|
|
||||||
* @param {Function} fileTrackingCallback - Optional callback to track installed files
|
|
||||||
* @param {Object} results - Results object to update
|
|
||||||
*/
|
|
||||||
async compileAndCopyAgents(sourceAgentsPath, targetAgentsPath, bmadDir, config, fileTrackingCallback, results) {
|
|
||||||
// Get all .agent.yaml files recursively
|
|
||||||
const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']);
|
|
||||||
|
|
||||||
for (const agentFile of agentFiles) {
|
|
||||||
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
|
|
||||||
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
|
||||||
|
|
||||||
await fs.ensureDir(targetDir);
|
|
||||||
|
|
||||||
const agentName = path.basename(agentFile, '.agent.yaml');
|
|
||||||
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
|
||||||
// Use the actual bmadDir if available (for when installing to temp dir)
|
|
||||||
const actualBmadDir = config._bmadDir || bmadDir;
|
|
||||||
const customizePath = path.join(actualBmadDir, '_config', 'agents', `custom-${agentName}.customize.yaml`);
|
|
||||||
|
|
||||||
// Read and compile the YAML
|
|
||||||
try {
|
|
||||||
const yamlContent = await fs.readFile(agentFile, 'utf8');
|
|
||||||
const { compileAgent } = require('../../../lib/agent/compiler');
|
|
||||||
|
|
||||||
// Create customize template if it doesn't exist
|
|
||||||
if (!(await fs.pathExists(customizePath))) {
|
|
||||||
const { getSourcePath } = require('../../../lib/project-root');
|
|
||||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
|
||||||
if (await fs.pathExists(genericTemplatePath)) {
|
|
||||||
let templateContent = await fs.readFile(genericTemplatePath, 'utf8');
|
|
||||||
await fs.writeFile(customizePath, templateContent, 'utf8');
|
|
||||||
// Only show customize creation in verbose mode
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(' Created customize: custom-' + agentName + '.customize.yaml');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile the agent
|
|
||||||
const { xml } = compileAgent(yamlContent, {}, agentName, relativePath, { config });
|
|
||||||
|
|
||||||
// Replace placeholders in the compiled content
|
|
||||||
let processedXml = xml;
|
|
||||||
processedXml = processedXml.replaceAll('{user_name}', config.user_name || 'User');
|
|
||||||
processedXml = processedXml.replaceAll('{communication_language}', config.communication_language || 'English');
|
|
||||||
processedXml = processedXml.replaceAll('{output_folder}', config.output_folder || 'docs');
|
|
||||||
|
|
||||||
// Write the compiled MD file
|
|
||||||
await fs.writeFile(targetMdPath, processedXml, 'utf8');
|
|
||||||
|
|
||||||
// Track the file
|
|
||||||
if (fileTrackingCallback) {
|
|
||||||
fileTrackingCallback(targetMdPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show compilation details in verbose mode
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(' Compiled agent: ' + agentName + ' -> ' + path.relative(targetAgentsPath, targetMdPath));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(' Failed to compile agent ' + agentName + ': ' + error.message);
|
|
||||||
results.errors.push(`Failed to compile agent ${agentName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { CustomHandler };
|
module.exports = { CustomHandler };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
const { getSourcePath } = require('../../../lib/project-root');
|
const { getSourcePath } = require('../../../lib/project-root');
|
||||||
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
const { BMAD_FOLDER_NAME } = require('./shared/path-utils');
|
||||||
|
|
@ -18,7 +17,6 @@ class BaseIdeSetup {
|
||||||
this.rulesDir = null; // Override in subclasses
|
this.rulesDir = null; // Override in subclasses
|
||||||
this.configFile = null; // Override in subclasses when detection is file-based
|
this.configFile = null; // Override in subclasses when detection is file-based
|
||||||
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
|
this.detectionPaths = []; // Additional paths that indicate the IDE is configured
|
||||||
this.xmlHandler = new XmlHandler();
|
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,15 +28,6 @@ class BaseIdeSetup {
|
||||||
this.bmadFolderName = bmadFolderName;
|
this.bmadFolderName = bmadFolderName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the agent command activation header from the central template
|
|
||||||
* @returns {string} The activation header text
|
|
||||||
*/
|
|
||||||
async getAgentCommandHeader() {
|
|
||||||
const headerPath = getSourcePath('utility', 'agent-components', 'agent-command-header.md');
|
|
||||||
return await fs.readFile(headerPath, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main setup method - must be implemented by subclasses
|
* Main setup method - must be implemented by subclasses
|
||||||
* @param {string} projectDir - Project directory
|
* @param {string} projectDir - Project directory
|
||||||
|
|
@ -511,11 +500,6 @@ class BaseIdeSetup {
|
||||||
// Replace placeholders
|
// Replace placeholders
|
||||||
let processed = content;
|
let processed = content;
|
||||||
|
|
||||||
// Inject activation block for agent files FIRST (before replacements)
|
|
||||||
if (metadata.name && content.includes('<agent')) {
|
|
||||||
processed = this.xmlHandler.injectActivationSimple(processed, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only replace {project-root} if a specific projectDir is provided
|
// Only replace {project-root} if a specific projectDir is provided
|
||||||
// Otherwise leave the placeholder intact
|
// Otherwise leave the placeholder intact
|
||||||
// Note: Don't add trailing slash - paths in source include leading slash
|
// Note: Don't add trailing slash - paths in source include leading slash
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,18 @@ const path = require('node:path');
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
const { XmlHandler } = require('../../../lib/xml-handler');
|
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||||
const { filterCustomizationData } = require('../../../lib/agent/compiler');
|
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the installation, updating, and removal of BMAD modules.
|
* Manages the installation, updating, and removal of BMAD modules.
|
||||||
* Handles module discovery, dependency resolution, configuration processing,
|
* Handles module discovery, dependency resolution, and configuration processing.
|
||||||
* and agent file management including XML activation block injection.
|
|
||||||
*
|
*
|
||||||
* @class ModuleManager
|
* @class ModuleManager
|
||||||
* @requires fs-extra
|
* @requires fs-extra
|
||||||
* @requires yaml
|
* @requires yaml
|
||||||
* @requires prompts
|
* @requires prompts
|
||||||
* @requires XmlHandler
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const manager = new ModuleManager();
|
* const manager = new ModuleManager();
|
||||||
|
|
@ -26,7 +22,6 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
||||||
*/
|
*/
|
||||||
class ModuleManager {
|
class ModuleManager {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.xmlHandler = new XmlHandler();
|
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
||||||
this.customModulePaths = new Map(); // Initialize custom module paths
|
this.customModulePaths = new Map(); // Initialize custom module paths
|
||||||
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
||||||
|
|
@ -88,103 +83,6 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy sidecar directory to _bmad/_memory location with update-safe handling
|
|
||||||
* @param {string} sourceSidecarPath - Source sidecar directory path
|
|
||||||
* @param {string} agentName - Name of the agent (for naming)
|
|
||||||
* @param {string} bmadMemoryPath - This should ALWAYS be _bmad/_memory
|
|
||||||
* @param {boolean} isUpdate - Whether this is an update (default: false)
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Object} installer - Installer instance for file tracking
|
|
||||||
*/
|
|
||||||
async copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate = false, bmadDir = null, installer = null) {
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const sidecarTargetDir = path.join(bmadMemoryPath, `${agentName}-sidecar`);
|
|
||||||
|
|
||||||
// Ensure target directory exists
|
|
||||||
await fs.ensureDir(bmadMemoryPath);
|
|
||||||
await fs.ensureDir(sidecarTargetDir);
|
|
||||||
|
|
||||||
// Get existing files manifest for update checking
|
|
||||||
let existingFilesManifest = [];
|
|
||||||
if (isUpdate && installer) {
|
|
||||||
existingFilesManifest = await installer.readFilesManifest(bmadDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build map of existing sidecar files with their hashes
|
|
||||||
const existingSidecarFiles = new Map();
|
|
||||||
for (const fileEntry of existingFilesManifest) {
|
|
||||||
if (fileEntry.path && fileEntry.path.includes(`${agentName}-sidecar/`)) {
|
|
||||||
existingSidecarFiles.set(fileEntry.path, fileEntry.hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all files in source sidecar
|
|
||||||
const sourceFiles = await this.getFileList(sourceSidecarPath);
|
|
||||||
|
|
||||||
for (const file of sourceFiles) {
|
|
||||||
const sourceFilePath = path.join(sourceSidecarPath, file);
|
|
||||||
const targetFilePath = path.join(sidecarTargetDir, file);
|
|
||||||
|
|
||||||
// Calculate current source file hash
|
|
||||||
const sourceHash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(await fs.readFile(sourceFilePath))
|
|
||||||
.digest('hex');
|
|
||||||
|
|
||||||
// Path relative to bmad directory
|
|
||||||
const relativeToBmad = path.join('_memory', `${agentName}-sidecar`, file);
|
|
||||||
|
|
||||||
if (isUpdate && (await fs.pathExists(targetFilePath))) {
|
|
||||||
// Calculate current target file hash
|
|
||||||
const currentTargetHash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(await fs.readFile(targetFilePath))
|
|
||||||
.digest('hex');
|
|
||||||
|
|
||||||
// Get the last known hash from files-manifest
|
|
||||||
const lastKnownHash = existingSidecarFiles.get(relativeToBmad);
|
|
||||||
|
|
||||||
if (lastKnownHash) {
|
|
||||||
// We have a record of this file
|
|
||||||
if (currentTargetHash === lastKnownHash) {
|
|
||||||
// File hasn't been modified by user, safe to update
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Updated sidecar file: ${relativeToBmad}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User has modified the file, preserve it
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Preserving user-modified file: ${relativeToBmad}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// First time seeing this file in manifest, copy it
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Added new sidecar file: ${relativeToBmad}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New installation
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFilePath, targetFilePath, true);
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Copied sidecar file: ${relativeToBmad}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track the file in the installer's file tracking system
|
|
||||||
if (installer && installer.installedFiles) {
|
|
||||||
installer.installedFiles.add(targetFilePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return list of files that were processed
|
|
||||||
const processedFiles = sourceFiles.map((file) => path.join('_memory', `${agentName}-sidecar`, file));
|
|
||||||
return processedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all available modules (excluding core which is always installed)
|
* List all available modules (excluding core which is always installed)
|
||||||
* bmm is the only built-in module, directly under src/bmm-skills
|
* bmm is the only built-in module, directly under src/bmm-skills
|
||||||
|
|
@ -566,12 +464,6 @@ class ModuleManager {
|
||||||
// Copy module files with filtering
|
// Copy module files with filtering
|
||||||
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig);
|
||||||
|
|
||||||
// Compile any .agent.yaml files to .md format
|
|
||||||
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
|
||||||
|
|
||||||
// Process agent files to inject activation block
|
|
||||||
await this.processAgentFiles(targetPath, moduleName);
|
|
||||||
|
|
||||||
// Create directories declared in module.yaml (unless explicitly skipped)
|
// Create directories declared in module.yaml (unless explicitly skipped)
|
||||||
if (!options.skipModuleInstaller) {
|
if (!options.skipModuleInstaller) {
|
||||||
await this.createModuleDirectories(moduleName, bmadDir, options);
|
await this.createModuleDirectories(moduleName, bmadDir, options);
|
||||||
|
|
@ -624,10 +516,6 @@ class ModuleManager {
|
||||||
} else {
|
} else {
|
||||||
// Selective update - preserve user modifications
|
// Selective update - preserve user modifications
|
||||||
await this.syncModule(sourcePath, targetPath);
|
await this.syncModule(sourcePath, targetPath);
|
||||||
|
|
||||||
// Recompile agents (#1133)
|
|
||||||
await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, options.installer);
|
|
||||||
await this.processAgentFiles(targetPath, moduleName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -773,236 +661,6 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile .agent.yaml files to .md format in modules
|
|
||||||
* @param {string} sourcePath - Source module path
|
|
||||||
* @param {string} targetPath - Target module path
|
|
||||||
* @param {string} moduleName - Module name
|
|
||||||
* @param {string} bmadDir - BMAD installation directory
|
|
||||||
* @param {Object} installer - Installer instance for file tracking
|
|
||||||
*/
|
|
||||||
async compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir, installer = null) {
|
|
||||||
const sourceAgentsPath = path.join(sourcePath, 'agents');
|
|
||||||
const targetAgentsPath = path.join(targetPath, 'agents');
|
|
||||||
const cfgAgentsDir = path.join(bmadDir, '_config', 'agents');
|
|
||||||
|
|
||||||
// Check if agents directory exists in source
|
|
||||||
if (!(await fs.pathExists(sourceAgentsPath))) {
|
|
||||||
return; // No agents to compile
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all agent YAML files recursively
|
|
||||||
const agentFiles = await this.findAgentFiles(sourceAgentsPath);
|
|
||||||
|
|
||||||
for (const agentFile of agentFiles) {
|
|
||||||
if (!agentFile.endsWith('.agent.yaml')) continue;
|
|
||||||
|
|
||||||
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
|
|
||||||
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
|
|
||||||
|
|
||||||
await fs.ensureDir(targetDir);
|
|
||||||
|
|
||||||
const agentName = path.basename(agentFile, '.agent.yaml');
|
|
||||||
const sourceYamlPath = agentFile;
|
|
||||||
const targetMdPath = path.join(targetDir, `${agentName}.md`);
|
|
||||||
const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
|
|
||||||
|
|
||||||
// Read and compile the YAML
|
|
||||||
try {
|
|
||||||
const yamlContent = await fs.readFile(sourceYamlPath, 'utf8');
|
|
||||||
const { compileAgent } = require('../../../lib/agent/compiler');
|
|
||||||
|
|
||||||
// Create customize template if it doesn't exist
|
|
||||||
if (!(await fs.pathExists(customizePath))) {
|
|
||||||
const { getSourcePath } = require('../../../lib/project-root');
|
|
||||||
const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml');
|
|
||||||
if (await fs.pathExists(genericTemplatePath)) {
|
|
||||||
await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath);
|
|
||||||
// Only show customize creation in verbose mode
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(` Created customize: ${moduleName}-${agentName}.customize.yaml`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original hash for modification detection
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
|
||||||
const originalHash = crypto.createHash('sha256').update(customizeContent).digest('hex');
|
|
||||||
|
|
||||||
// Store in main manifest
|
|
||||||
const manifestPath = path.join(bmadDir, '_config', 'manifest.yaml');
|
|
||||||
let manifestData = {};
|
|
||||||
if (await fs.pathExists(manifestPath)) {
|
|
||||||
const manifestContent = await fs.readFile(manifestPath, 'utf8');
|
|
||||||
const yaml = require('yaml');
|
|
||||||
manifestData = yaml.parse(manifestContent);
|
|
||||||
}
|
|
||||||
if (!manifestData.agentCustomizations) {
|
|
||||||
manifestData.agentCustomizations = {};
|
|
||||||
}
|
|
||||||
manifestData.agentCustomizations[path.relative(bmadDir, customizePath)] = originalHash;
|
|
||||||
|
|
||||||
// Write back to manifest
|
|
||||||
const yaml = require('yaml');
|
|
||||||
// Clean the manifest data to remove any non-serializable values
|
|
||||||
const cleanManifestData = structuredClone(manifestData);
|
|
||||||
|
|
||||||
const updatedContent = yaml.stringify(cleanManifestData, {
|
|
||||||
indent: 2,
|
|
||||||
lineWidth: 0,
|
|
||||||
});
|
|
||||||
await fs.writeFile(manifestPath, updatedContent, 'utf8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for customizations and build answers object
|
|
||||||
let customizedFields = [];
|
|
||||||
let answers = {};
|
|
||||||
if (await fs.pathExists(customizePath)) {
|
|
||||||
const customizeContent = await fs.readFile(customizePath, 'utf8');
|
|
||||||
const customizeData = yaml.parse(customizeContent);
|
|
||||||
customizedFields = customizeData.customized_fields || [];
|
|
||||||
|
|
||||||
// Build answers object from customizations
|
|
||||||
if (customizeData.persona) {
|
|
||||||
answers.persona = customizeData.persona;
|
|
||||||
}
|
|
||||||
if (customizeData.agent?.metadata) {
|
|
||||||
const filteredMetadata = filterCustomizationData(customizeData.agent.metadata);
|
|
||||||
if (Object.keys(filteredMetadata).length > 0) {
|
|
||||||
Object.assign(answers, { metadata: filteredMetadata });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (customizeData.critical_actions && customizeData.critical_actions.length > 0) {
|
|
||||||
answers.critical_actions = customizeData.critical_actions;
|
|
||||||
}
|
|
||||||
if (customizeData.memories && customizeData.memories.length > 0) {
|
|
||||||
answers.memories = customizeData.memories;
|
|
||||||
}
|
|
||||||
if (customizeData.menu && customizeData.menu.length > 0) {
|
|
||||||
answers.menu = customizeData.menu;
|
|
||||||
}
|
|
||||||
if (customizeData.prompts && customizeData.prompts.length > 0) {
|
|
||||||
answers.prompts = customizeData.prompts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if agent has sidecar
|
|
||||||
let hasSidecar = false;
|
|
||||||
try {
|
|
||||||
const agentYaml = yaml.parse(yamlContent);
|
|
||||||
hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true;
|
|
||||||
} catch {
|
|
||||||
// Continue without sidecar processing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compile with customizations if any
|
|
||||||
const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: this.coreConfig || {} });
|
|
||||||
|
|
||||||
// Write the compiled agent
|
|
||||||
await fs.writeFile(targetMdPath, xml, 'utf8');
|
|
||||||
|
|
||||||
// Handle sidecar copying if present
|
|
||||||
if (hasSidecar) {
|
|
||||||
// Get the agent's directory to look for sidecar
|
|
||||||
const agentDir = path.dirname(agentFile);
|
|
||||||
const sidecarDirName = `${agentName}-sidecar`;
|
|
||||||
const sourceSidecarPath = path.join(agentDir, sidecarDirName);
|
|
||||||
|
|
||||||
// Check if sidecar directory exists
|
|
||||||
if (await fs.pathExists(sourceSidecarPath)) {
|
|
||||||
// Memory is always in _bmad/_memory
|
|
||||||
const bmadMemoryPath = path.join(bmadDir, '_memory');
|
|
||||||
|
|
||||||
// Determine if this is an update (by checking if agent already exists)
|
|
||||||
const isUpdate = await fs.pathExists(targetMdPath);
|
|
||||||
|
|
||||||
// Copy sidecar to memory location with update-safe handling
|
|
||||||
const copiedFiles = await this.copySidecarToMemory(sourceSidecarPath, agentName, bmadMemoryPath, isUpdate, bmadDir, installer);
|
|
||||||
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true' && copiedFiles.length > 0) {
|
|
||||||
await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`);
|
|
||||||
}
|
|
||||||
} else if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy any non-sidecar files from agent directory (e.g., foo.md)
|
|
||||||
const agentDir = path.dirname(agentFile);
|
|
||||||
const agentEntries = await fs.readdir(agentDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of agentEntries) {
|
|
||||||
if (entry.isFile() && !entry.name.endsWith('.agent.yaml') && !entry.name.endsWith('.md')) {
|
|
||||||
// Copy additional files (like foo.md) to the agent target directory
|
|
||||||
const sourceFile = path.join(agentDir, entry.name);
|
|
||||||
const targetFile = path.join(targetDir, entry.name);
|
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show compilation details in verbose mode
|
|
||||||
if (process.env.BMAD_VERBOSE_INSTALL === 'true') {
|
|
||||||
await prompts.log.message(
|
|
||||||
` Compiled agent: ${agentName} -> ${path.relative(targetPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await prompts.log.warn(` Failed to compile agent ${agentName}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all .agent.yaml files recursively in a directory
|
|
||||||
* @param {string} dir - Directory to search
|
|
||||||
* @returns {Array} List of .agent.yaml file paths
|
|
||||||
*/
|
|
||||||
async findAgentFiles(dir) {
|
|
||||||
const agentFiles = [];
|
|
||||||
|
|
||||||
async function searchDirectory(searchDir) {
|
|
||||||
const entries = await fs.readdir(searchDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(searchDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
|
|
||||||
agentFiles.push(fullPath);
|
|
||||||
} else if (entry.isDirectory()) {
|
|
||||||
await searchDirectory(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await searchDirectory(dir);
|
|
||||||
return agentFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process agent files to inject activation block
|
|
||||||
* @param {string} modulePath - Path to installed module
|
|
||||||
* @param {string} moduleName - Module name
|
|
||||||
*/
|
|
||||||
async processAgentFiles(modulePath, moduleName) {
|
|
||||||
// const agentsPath = path.join(modulePath, 'agents');
|
|
||||||
// // Check if agents directory exists
|
|
||||||
// if (!(await fs.pathExists(agentsPath))) {
|
|
||||||
// return; // No agents to process
|
|
||||||
// }
|
|
||||||
// // Get all agent MD files recursively
|
|
||||||
// const agentFiles = await this.findAgentMdFiles(agentsPath);
|
|
||||||
// for (const agentFile of agentFiles) {
|
|
||||||
// if (!agentFile.endsWith('.md')) continue;
|
|
||||||
// let content = await fs.readFile(agentFile, 'utf8');
|
|
||||||
// // Check if content has agent XML and no activation block
|
|
||||||
// if (content.includes('<agent') && !content.includes('<activation')) {
|
|
||||||
// // Inject the activation block using XML handler
|
|
||||||
// content = this.xmlHandler.injectActivationSimple(content);
|
|
||||||
// await fs.writeFile(agentFile, content, 'utf8');
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all .md agent files recursively in a directory
|
* Find all .md agent files recursively in a directory
|
||||||
* @param {string} dir - Directory to search
|
* @param {string} dir - Directory to search
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { getSourcePath } = require('./project-root');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds activation blocks from fragments based on agent profile
|
|
||||||
*/
|
|
||||||
class ActivationBuilder {
|
|
||||||
constructor() {
|
|
||||||
this.agentComponents = getSourcePath('utility', 'agent-components');
|
|
||||||
this.fragmentCache = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a fragment file
|
|
||||||
* @param {string} fragmentName - Name of fragment file (e.g., 'activation-init.txt')
|
|
||||||
* @returns {string} Fragment content
|
|
||||||
*/
|
|
||||||
async loadFragment(fragmentName) {
|
|
||||||
// Check cache first
|
|
||||||
if (this.fragmentCache.has(fragmentName)) {
|
|
||||||
return this.fragmentCache.get(fragmentName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fragmentPath = path.join(this.agentComponents, fragmentName);
|
|
||||||
|
|
||||||
if (!(await fs.pathExists(fragmentPath))) {
|
|
||||||
throw new Error(`Fragment not found: ${fragmentName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(fragmentPath, 'utf8');
|
|
||||||
this.fragmentCache.set(fragmentName, content);
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build complete activation block based on agent profile
|
|
||||||
* @param {Object} profile - Agent profile from AgentAnalyzer
|
|
||||||
* @param {Object} metadata - Agent metadata (module, name, etc.)
|
|
||||||
* @param {Array} agentSpecificActions - Optional agent-specific critical actions
|
|
||||||
* @param {boolean} forWebBundle - Whether this is for a web bundle
|
|
||||||
* @returns {string} Complete activation block XML
|
|
||||||
*/
|
|
||||||
async buildActivation(profile, metadata = {}, agentSpecificActions = [], forWebBundle = false) {
|
|
||||||
let activation = '<activation critical="MANDATORY">\n';
|
|
||||||
|
|
||||||
// 1. Build sequential steps (use web-specific steps for web bundles)
|
|
||||||
const steps = await this.buildSteps(metadata, agentSpecificActions, forWebBundle);
|
|
||||||
activation += this.indent(steps, 2) + '\n';
|
|
||||||
|
|
||||||
// 2. Build menu handlers section with dynamic handlers
|
|
||||||
const menuHandlers = await this.loadFragment('menu-handlers.txt');
|
|
||||||
|
|
||||||
// Build handlers (load only needed handlers)
|
|
||||||
const handlers = await this.buildHandlers(profile);
|
|
||||||
|
|
||||||
// Remove the extract line from the final output - it's just build metadata
|
|
||||||
// The extract list tells us which attributes to look for during processing
|
|
||||||
// but shouldn't appear in the final agent file
|
|
||||||
const processedHandlers = menuHandlers
|
|
||||||
.replace('<extract>{DYNAMIC_EXTRACT_LIST}</extract>\n', '') // Remove the entire extract line
|
|
||||||
.replace('{DYNAMIC_HANDLERS}', handlers);
|
|
||||||
|
|
||||||
activation += '\n' + this.indent(processedHandlers, 2) + '\n';
|
|
||||||
|
|
||||||
const rules = await this.loadFragment('activation-rules.txt');
|
|
||||||
activation += this.indent(rules, 2) + '\n';
|
|
||||||
|
|
||||||
activation += '</activation>';
|
|
||||||
|
|
||||||
return activation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build handlers section based on profile
|
|
||||||
* @param {Object} profile - Agent profile
|
|
||||||
* @returns {string} Handlers XML
|
|
||||||
*/
|
|
||||||
async buildHandlers(profile) {
|
|
||||||
const handlerFragments = [];
|
|
||||||
|
|
||||||
for (const attrType of profile.usedAttributes) {
|
|
||||||
const fragmentName = `handler-${attrType}.txt`;
|
|
||||||
try {
|
|
||||||
const handler = await this.loadFragment(fragmentName);
|
|
||||||
handlerFragments.push(handler);
|
|
||||||
} catch {
|
|
||||||
console.warn(`Warning: Handler fragment not found: ${fragmentName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return handlerFragments.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build sequential activation steps
|
|
||||||
* @param {Object} metadata - Agent metadata
|
|
||||||
* @param {Array} agentSpecificActions - Optional agent-specific actions
|
|
||||||
* @param {boolean} forWebBundle - Whether this is for a web bundle
|
|
||||||
* @returns {string} Steps XML
|
|
||||||
*/
|
|
||||||
async buildSteps(metadata = {}, agentSpecificActions = [], forWebBundle = false) {
|
|
||||||
const stepsTemplate = await this.loadFragment('activation-steps.txt');
|
|
||||||
|
|
||||||
// Extract basename from agent ID (e.g., "bmad/bmm/agents/pm.md" → "pm")
|
|
||||||
const agentBasename = metadata.id ? metadata.id.split('/').pop().replace('.md', '') : metadata.name || 'agent';
|
|
||||||
|
|
||||||
// Build agent-specific steps
|
|
||||||
let agentStepsXml = '';
|
|
||||||
let currentStepNum = 4; // Steps 1-3 are standard
|
|
||||||
|
|
||||||
if (agentSpecificActions && agentSpecificActions.length > 0) {
|
|
||||||
agentStepsXml = agentSpecificActions
|
|
||||||
.map((action) => {
|
|
||||||
const step = `<step n="${currentStepNum}">${action}</step>`;
|
|
||||||
currentStepNum++;
|
|
||||||
return step;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate final step numbers
|
|
||||||
const menuStep = currentStepNum;
|
|
||||||
const helpStep = currentStepNum + 1;
|
|
||||||
const haltStep = currentStepNum + 2;
|
|
||||||
const inputStep = currentStepNum + 3;
|
|
||||||
const executeStep = currentStepNum + 4;
|
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
const processed = stepsTemplate
|
|
||||||
.replace('{agent-file-basename}', agentBasename)
|
|
||||||
.replace('{{module}}', metadata.module || 'core') // Fixed to use {{module}}
|
|
||||||
.replace('{AGENT_SPECIFIC_STEPS}', agentStepsXml)
|
|
||||||
.replace('{MENU_STEP}', menuStep.toString())
|
|
||||||
.replace('{HELP_STEP}', helpStep.toString())
|
|
||||||
.replace('{HALT_STEP}', haltStep.toString())
|
|
||||||
.replace('{INPUT_STEP}', inputStep.toString())
|
|
||||||
.replace('{EXECUTE_STEP}', executeStep.toString());
|
|
||||||
|
|
||||||
return processed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indent XML content
|
|
||||||
* @param {string} content - Content to indent
|
|
||||||
* @param {number} spaces - Number of spaces to indent
|
|
||||||
* @returns {string} Indented content
|
|
||||||
*/
|
|
||||||
indent(content, spaces) {
|
|
||||||
const indentation = ' '.repeat(spaces);
|
|
||||||
return content
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => (line ? indentation + line : line))
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear fragment cache (useful for testing or hot reload)
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.fragmentCache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { ActivationBuilder };
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
const yaml = require('yaml');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyzes agent YAML files to detect which handlers are needed
|
|
||||||
*/
|
|
||||||
class AgentAnalyzer {
|
|
||||||
/**
|
|
||||||
* Analyze an agent YAML structure to determine which handlers it needs
|
|
||||||
* @param {Object} agentYaml - Parsed agent YAML object
|
|
||||||
* @returns {Object} Profile of needed handlers
|
|
||||||
*/
|
|
||||||
analyzeAgentObject(agentYaml) {
|
|
||||||
const profile = {
|
|
||||||
usedAttributes: new Set(),
|
|
||||||
hasPrompts: false,
|
|
||||||
menuItems: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if agent has prompts section
|
|
||||||
if (agentYaml.agent && agentYaml.agent.prompts) {
|
|
||||||
profile.hasPrompts = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analyze menu items (support both 'menu' and legacy 'commands')
|
|
||||||
const menuItems = agentYaml.agent?.menu || agentYaml.agent?.commands || [];
|
|
||||||
|
|
||||||
for (const item of menuItems) {
|
|
||||||
// Track the menu item
|
|
||||||
profile.menuItems.push(item);
|
|
||||||
|
|
||||||
// Check for multi format items
|
|
||||||
if (item.multi && item.triggers) {
|
|
||||||
profile.usedAttributes.add('multi');
|
|
||||||
|
|
||||||
// Also check attributes in nested handlers
|
|
||||||
for (const triggerGroup of item.triggers) {
|
|
||||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
|
||||||
if (Array.isArray(execArray)) {
|
|
||||||
for (const exec of execArray) {
|
|
||||||
if (exec.route) {
|
|
||||||
profile.usedAttributes.add('exec');
|
|
||||||
}
|
|
||||||
if (exec.action) profile.usedAttributes.add('action');
|
|
||||||
if (exec.type && ['exec', 'action'].includes(exec.type)) {
|
|
||||||
profile.usedAttributes.add(exec.type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check for each possible attribute in legacy items
|
|
||||||
if (item.exec) {
|
|
||||||
profile.usedAttributes.add('exec');
|
|
||||||
}
|
|
||||||
if (item.tmpl) {
|
|
||||||
profile.usedAttributes.add('tmpl');
|
|
||||||
}
|
|
||||||
if (item.data) {
|
|
||||||
profile.usedAttributes.add('data');
|
|
||||||
}
|
|
||||||
if (item.action) {
|
|
||||||
profile.usedAttributes.add('action');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Set to Array for easier use
|
|
||||||
profile.usedAttributes = [...profile.usedAttributes];
|
|
||||||
|
|
||||||
return profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Analyze an agent YAML file
|
|
||||||
* @param {string} filePath - Path to agent YAML file
|
|
||||||
* @returns {Object} Profile of needed handlers
|
|
||||||
*/
|
|
||||||
async analyzeAgentFile(filePath) {
|
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
|
||||||
const agentYaml = yaml.parse(content);
|
|
||||||
return this.analyzeAgentObject(agentYaml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an agent needs a specific handler
|
|
||||||
* @param {Object} profile - Agent profile from analyze
|
|
||||||
* @param {string} handlerType - Handler type to check
|
|
||||||
* @returns {boolean} True if handler is needed
|
|
||||||
*/
|
|
||||||
needsHandler(profile, handlerType) {
|
|
||||||
return profile.usedAttributes.includes(handlerType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { AgentAnalyzer };
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const { escapeXml } = require('../../lib/xml-utils');
|
|
||||||
|
|
||||||
const AgentPartyGenerator = {
|
|
||||||
/**
|
|
||||||
* Generate agent-manifest.csv content
|
|
||||||
* @param {Array} agentDetails - Array of agent details
|
|
||||||
* @param {Object} options - Generation options
|
|
||||||
* @returns {string} XML content
|
|
||||||
*/
|
|
||||||
generateAgentParty(agentDetails, options = {}) {
|
|
||||||
const { forWeb = false } = options;
|
|
||||||
|
|
||||||
// Group agents by module
|
|
||||||
const agentsByModule = {
|
|
||||||
bmm: [],
|
|
||||||
cis: [],
|
|
||||||
core: [],
|
|
||||||
custom: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const agent of agentDetails) {
|
|
||||||
const moduleKey = agentsByModule[agent.module] ? agent.module : 'custom';
|
|
||||||
agentsByModule[moduleKey].push(agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build XML content
|
|
||||||
let xmlContent = `<!-- Powered by BMAD-CORE™ -->
|
|
||||||
<!-- Agent Manifest - Generated during BMAD ${forWeb ? 'bundling' : 'installation'} -->
|
|
||||||
<!-- This file contains a summary of all ${forWeb ? 'bundled' : 'installed'} agents for quick reference -->
|
|
||||||
<manifest id="bmad/_config/agent-manifest.csv" version="1.0" generated="${new Date().toISOString()}">
|
|
||||||
<description>
|
|
||||||
Complete roster of ${forWeb ? 'bundled' : 'installed'} BMAD agents with summarized personas for efficient multi-agent orchestration.
|
|
||||||
Used by party-mode and other multi-agent coordination features.
|
|
||||||
</description>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add agents by module
|
|
||||||
for (const [module, agents] of Object.entries(agentsByModule)) {
|
|
||||||
if (agents.length === 0) continue;
|
|
||||||
|
|
||||||
const moduleTitle =
|
|
||||||
module === 'bmm' ? 'BMM Module' : module === 'cis' ? 'CIS Module' : module === 'core' ? 'Core Module' : 'Custom Module';
|
|
||||||
|
|
||||||
xmlContent += `\n <!-- ${moduleTitle} Agents -->\n`;
|
|
||||||
|
|
||||||
for (const agent of agents) {
|
|
||||||
xmlContent += ` <agent id="${agent.id}" name="${agent.name}" title="${agent.title || ''}" icon="${agent.icon || ''}">
|
|
||||||
<persona>
|
|
||||||
<role>${escapeXml(agent.role || '')}</role>
|
|
||||||
<identity>${escapeXml(agent.identity || '')}</identity>
|
|
||||||
<communication_style>${escapeXml(agent.communicationStyle || '')}</communication_style>
|
|
||||||
<principles>${agent.principles || ''}</principles>
|
|
||||||
</persona>
|
|
||||||
</agent>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add statistics
|
|
||||||
const totalAgents = agentDetails.length;
|
|
||||||
const moduleList = Object.keys(agentsByModule)
|
|
||||||
.filter((m) => agentsByModule[m].length > 0)
|
|
||||||
.join(', ');
|
|
||||||
|
|
||||||
xmlContent += `\n <statistics>
|
|
||||||
<total_agents>${totalAgents}</total_agents>
|
|
||||||
<modules>${moduleList}</modules>
|
|
||||||
<last_updated>${new Date().toISOString()}</last_updated>
|
|
||||||
</statistics>
|
|
||||||
</manifest>`;
|
|
||||||
|
|
||||||
return xmlContent;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract agent details from XML content
|
|
||||||
* @param {string} content - Full agent file content (markdown with XML)
|
|
||||||
* @param {string} moduleName - Module name
|
|
||||||
* @param {string} agentName - Agent name
|
|
||||||
* @returns {Object} Agent details
|
|
||||||
*/
|
|
||||||
extractAgentDetails(content, moduleName, agentName) {
|
|
||||||
try {
|
|
||||||
// Extract agent XML block
|
|
||||||
const agentMatch = content.match(/<agent[^>]*>([\s\S]*?)<\/agent>/);
|
|
||||||
if (!agentMatch) return null;
|
|
||||||
|
|
||||||
const agentXml = agentMatch[0];
|
|
||||||
|
|
||||||
// Extract attributes from opening tag
|
|
||||||
const nameMatch = agentXml.match(/name="([^"]*)"/);
|
|
||||||
const titleMatch = agentXml.match(/title="([^"]*)"/);
|
|
||||||
const iconMatch = agentXml.match(/icon="([^"]*)"/);
|
|
||||||
|
|
||||||
// Extract persona elements - now we just copy them as-is
|
|
||||||
const roleMatch = agentXml.match(/<role>([\s\S]*?)<\/role>/);
|
|
||||||
const identityMatch = agentXml.match(/<identity>([\s\S]*?)<\/identity>/);
|
|
||||||
const styleMatch = agentXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
|
|
||||||
const principlesMatch = agentXml.match(/<principles>([\s\S]*?)<\/principles>/);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `bmad/${moduleName}/agents/${agentName}.md`,
|
|
||||||
name: nameMatch ? nameMatch[1] : agentName,
|
|
||||||
title: titleMatch ? titleMatch[1] : 'Agent',
|
|
||||||
icon: iconMatch ? iconMatch[1] : '🤖',
|
|
||||||
module: moduleName,
|
|
||||||
role: roleMatch ? roleMatch[1].trim() : '',
|
|
||||||
identity: identityMatch ? identityMatch[1].trim() : '',
|
|
||||||
communicationStyle: styleMatch ? styleMatch[1].trim() : '',
|
|
||||||
principles: principlesMatch ? principlesMatch[1].trim() : '',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error extracting details for agent ${agentName}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract attribute from XML tag
|
|
||||||
*/
|
|
||||||
extractAttribute(xml, tagName, attrName) {
|
|
||||||
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}="([^"]*)"`, 'i');
|
|
||||||
const match = xml.match(regex);
|
|
||||||
return match ? match[1] : '';
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply config overrides to agent details
|
|
||||||
* @param {Object} details - Original agent details
|
|
||||||
* @param {string} configContent - Config file content
|
|
||||||
* @returns {Object} Agent details with overrides applied
|
|
||||||
*/
|
|
||||||
applyConfigOverrides(details, configContent) {
|
|
||||||
try {
|
|
||||||
// Extract agent-config XML block
|
|
||||||
const configMatch = configContent.match(/<agent-config>([\s\S]*?)<\/agent-config>/);
|
|
||||||
if (!configMatch) return details;
|
|
||||||
|
|
||||||
const configXml = configMatch[0];
|
|
||||||
|
|
||||||
// Extract override values
|
|
||||||
const nameMatch = configXml.match(/<name>([\s\S]*?)<\/name>/);
|
|
||||||
const titleMatch = configXml.match(/<title>([\s\S]*?)<\/title>/);
|
|
||||||
const roleMatch = configXml.match(/<role>([\s\S]*?)<\/role>/);
|
|
||||||
const identityMatch = configXml.match(/<identity>([\s\S]*?)<\/identity>/);
|
|
||||||
const styleMatch = configXml.match(/<communication_style>([\s\S]*?)<\/communication_style>/);
|
|
||||||
const principlesMatch = configXml.match(/<principles>([\s\S]*?)<\/principles>/);
|
|
||||||
|
|
||||||
// Apply overrides only if values are non-empty
|
|
||||||
if (nameMatch && nameMatch[1].trim()) {
|
|
||||||
details.name = nameMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (titleMatch && titleMatch[1].trim()) {
|
|
||||||
details.title = titleMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleMatch && roleMatch[1].trim()) {
|
|
||||||
details.role = roleMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (identityMatch && identityMatch[1].trim()) {
|
|
||||||
details.identity = identityMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (styleMatch && styleMatch[1].trim()) {
|
|
||||||
details.communicationStyle = styleMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (principlesMatch && principlesMatch[1].trim()) {
|
|
||||||
// Principles are now just copied as-is (narrative paragraph)
|
|
||||||
details.principles = principlesMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return details;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error applying config overrides:`, error);
|
|
||||||
return details;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write agent-manifest.csv to file
|
|
||||||
*/
|
|
||||||
async writeAgentParty(filePath, agentDetails, options = {}) {
|
|
||||||
const content = this.generateAgentParty(agentDetails, options);
|
|
||||||
await fs.ensureDir(path.dirname(filePath));
|
|
||||||
await fs.writeFile(filePath, content, 'utf8');
|
|
||||||
return content;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = { AgentPartyGenerator };
|
|
||||||
|
|
@ -1,516 +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="MH or fuzzy match on menu or help">[MH] Redisplay Menu Help</item>\n`;
|
|
||||||
xml += ` <item cmd="CH or fuzzy match on chat">[CH] Chat with the Agent about anything</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`;
|
|
||||||
}
|
|
||||||
// Handle legacy format menu items
|
|
||||||
else if (item.trigger) {
|
|
||||||
let trigger = item.trigger || '';
|
|
||||||
|
|
||||||
const attrs = [`cmd="${trigger}"`];
|
|
||||||
|
|
||||||
// Add handler attributes
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ` <item cmd="PM or fuzzy match on party-mode" exec="skill:bmad-party-mode">[PM] Start Party Mode</item>\n`;
|
|
||||||
xml += ` <item cmd="DA or fuzzy match on exit, leave, goodbye or dismiss agent">[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)) {
|
|
||||||
// Build trigger with * prefix
|
|
||||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
|
||||||
|
|
||||||
// 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.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,
|
|
||||||
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) {
|
|
||||||
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 || '🤖'}"`,
|
|
||||||
];
|
|
||||||
if (meta.capabilities) {
|
|
||||||
agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compile agent file to .md
|
|
||||||
* @param {string} yamlPath - Path to agent YAML file
|
|
||||||
* @param {Object} options - { answers: {}, outputPath: string }
|
|
||||||
* @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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write compiled XML
|
|
||||||
fs.writeFileSync(outputPath, xml, 'utf8');
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
xml,
|
|
||||||
outputPath,
|
|
||||||
sourcePath: yamlPath,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
compileToXml,
|
|
||||||
compileAgent,
|
|
||||||
compileAgentFile,
|
|
||||||
escapeXml,
|
|
||||||
buildFrontmatter,
|
|
||||||
buildPersonaXml,
|
|
||||||
buildPromptsXml,
|
|
||||||
buildMemoriesXml,
|
|
||||||
buildMenuXml,
|
|
||||||
filterCustomizationData,
|
|
||||||
};
|
|
||||||
|
|
@ -1,680 +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 prompts = require('../prompts');
|
|
||||||
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 answers = { ...defaults, ...presetAnswers };
|
|
||||||
|
|
||||||
await prompts.note(installConfig.description || '', 'Agent Configuration');
|
|
||||||
|
|
||||||
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]) {
|
|
||||||
await prompts.log.message(` ${q.var}: ${answers[q.var]} (already set)`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (q.type) {
|
|
||||||
case 'text': {
|
|
||||||
const response = await prompts.text({
|
|
||||||
message: q.prompt,
|
|
||||||
default: q.default ?? '',
|
|
||||||
});
|
|
||||||
answers[q.var] = response ?? q.default ?? '';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'boolean': {
|
|
||||||
const response = await prompts.confirm({
|
|
||||||
message: q.prompt,
|
|
||||||
default: q.default,
|
|
||||||
});
|
|
||||||
answers[q.var] = response;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'choice': {
|
|
||||||
const response = await prompts.select({
|
|
||||||
message: q.prompt,
|
|
||||||
options: q.options.map((o) => ({ value: o.value, label: o.label })),
|
|
||||||
initialValue: q.default,
|
|
||||||
});
|
|
||||||
answers[q.var] = response;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// No default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
/**
|
|
||||||
* Template Engine for BMAD Agent Install Configuration
|
|
||||||
* Processes {{variable}}, {{#if}}, {{#unless}}, and {{/if}} blocks
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all template syntax in a string
|
|
||||||
* @param {string} content - Content with template syntax
|
|
||||||
* @param {Object} variables - Key-value pairs from install_config answers
|
|
||||||
* @returns {string} Processed content
|
|
||||||
*/
|
|
||||||
function processTemplate(content, variables = {}) {
|
|
||||||
let result = content;
|
|
||||||
|
|
||||||
// Process conditionals first (they may contain variables)
|
|
||||||
result = processConditionals(result, variables);
|
|
||||||
|
|
||||||
// Then process simple variable replacements
|
|
||||||
result = processVariables(result, variables);
|
|
||||||
|
|
||||||
// Clean up any empty lines left by removed conditionals
|
|
||||||
result = cleanupEmptyLines(result);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process {{#if}}, {{#unless}}, {{/if}}, {{/unless}} blocks
|
|
||||||
*/
|
|
||||||
function processConditionals(content, variables) {
|
|
||||||
let result = content;
|
|
||||||
|
|
||||||
// Process {{#if variable == "value"}} blocks
|
|
||||||
// Handle both regular quotes and JSON-escaped quotes (\")
|
|
||||||
const ifEqualsPattern = /\{\{#if\s+(\w+)\s*==\s*\\?"([^"\\]+)\\?"\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
||||||
result = result.replaceAll(ifEqualsPattern, (match, varName, value, block) => {
|
|
||||||
return variables[varName] === value ? block : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process {{#if variable}} blocks (boolean or truthy check)
|
|
||||||
const ifBoolPattern = /\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g;
|
|
||||||
result = result.replaceAll(ifBoolPattern, (match, varName, block) => {
|
|
||||||
const val = variables[varName];
|
|
||||||
// Treat as truthy: true, non-empty string, non-zero number
|
|
||||||
const isTruthy = val === true || (typeof val === 'string' && val.length > 0) || (typeof val === 'number' && val !== 0);
|
|
||||||
return isTruthy ? block : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process {{#unless variable}} blocks (inverse of if)
|
|
||||||
const unlessPattern = /\{\{#unless\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/unless\}\}/g;
|
|
||||||
result = result.replaceAll(unlessPattern, (match, varName, block) => {
|
|
||||||
const val = variables[varName];
|
|
||||||
const isFalsy = val === false || val === '' || val === null || val === undefined || val === 0;
|
|
||||||
return isFalsy ? block : '';
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process {{variable}} replacements
|
|
||||||
*/
|
|
||||||
function processVariables(content, variables) {
|
|
||||||
let result = content;
|
|
||||||
|
|
||||||
// Replace {{variable}} with value
|
|
||||||
const varPattern = /\{\{(\w+)\}\}/g;
|
|
||||||
result = result.replaceAll(varPattern, (match, varName) => {
|
|
||||||
if (Object.hasOwn(variables, varName)) {
|
|
||||||
return String(variables[varName]);
|
|
||||||
}
|
|
||||||
// If variable not found, leave as-is (might be runtime variable like {user_name})
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up excessive empty lines left after removing conditional blocks
|
|
||||||
*/
|
|
||||||
function cleanupEmptyLines(content) {
|
|
||||||
// Replace 3+ consecutive newlines with 2
|
|
||||||
return content.replaceAll(/\n{3,}/g, '\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract install_config from agent YAML object
|
|
||||||
* @param {Object} agentYaml - Parsed agent YAML
|
|
||||||
* @returns {Object|null} install_config section or null
|
|
||||||
*/
|
|
||||||
function extractInstallConfig(agentYaml) {
|
|
||||||
return agentYaml?.agent?.install_config || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove install_config from agent YAML (after processing)
|
|
||||||
* @param {Object} agentYaml - Parsed agent YAML
|
|
||||||
* @returns {Object} Agent YAML without install_config
|
|
||||||
*/
|
|
||||||
function stripInstallConfig(agentYaml) {
|
|
||||||
const result = structuredClone(agentYaml);
|
|
||||||
if (result.agent) {
|
|
||||||
delete result.agent.install_config;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process entire agent YAML object with template variables
|
|
||||||
* @param {Object} agentYaml - Parsed agent YAML
|
|
||||||
* @param {Object} variables - Answers from install_config questions
|
|
||||||
* @returns {Object} Processed agent YAML
|
|
||||||
*/
|
|
||||||
function processAgentYaml(agentYaml, variables) {
|
|
||||||
// Convert to JSON string, process templates, parse back
|
|
||||||
const jsonString = JSON.stringify(agentYaml, null, 2);
|
|
||||||
const processed = processTemplate(jsonString, variables);
|
|
||||||
return JSON.parse(processed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default values from install_config questions
|
|
||||||
* @param {Object} installConfig - install_config section
|
|
||||||
* @returns {Object} Default values keyed by variable name
|
|
||||||
*/
|
|
||||||
function getDefaultValues(installConfig) {
|
|
||||||
const defaults = {};
|
|
||||||
|
|
||||||
if (!installConfig?.questions) {
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const question of installConfig.questions) {
|
|
||||||
if (question.var && question.default !== undefined) {
|
|
||||||
defaults[question.var] = question.default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
processTemplate,
|
|
||||||
processConditionals,
|
|
||||||
processVariables,
|
|
||||||
extractInstallConfig,
|
|
||||||
stripInstallConfig,
|
|
||||||
processAgentYaml,
|
|
||||||
getDefaultValues,
|
|
||||||
cleanupEmptyLines,
|
|
||||||
};
|
|
||||||
|
|
@ -208,14 +208,6 @@ class UI {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add custom agent compilation option
|
|
||||||
if (installedVersion !== 'unknown') {
|
|
||||||
choices.push({
|
|
||||||
name: 'Recompile Agents (apply customizations only)',
|
|
||||||
value: 'compile-agents',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common actions
|
// Common actions
|
||||||
choices.push({ name: 'Modify BMAD Installation', value: 'update' });
|
choices.push({ name: 'Modify BMAD Installation', value: 'update' });
|
||||||
|
|
||||||
|
|
@ -291,17 +283,6 @@ class UI {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle compile agents separately
|
|
||||||
if (actionType === 'compile-agents') {
|
|
||||||
// Only recompile agents with customizations, don't update any files
|
|
||||||
return {
|
|
||||||
actionType: 'compile-agents',
|
|
||||||
directory: confirmedDirectory,
|
|
||||||
customContent: { hasCustomContent: false },
|
|
||||||
skipPrompts: options.yes || false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// If actionType === 'update', handle it with the new flow
|
// If actionType === 'update', handle it with the new flow
|
||||||
// Return early with modify configuration
|
// Return early with modify configuration
|
||||||
if (actionType === 'update') {
|
if (actionType === 'update') {
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
const xml2js = require('xml2js');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { getProjectRoot, getSourcePath } = require('./project-root');
|
|
||||||
const { YamlXmlBuilder } = require('./yaml-xml-builder');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* XML utility functions for BMAD installer
|
|
||||||
* Now supports both legacy XML agents and new YAML-based agents
|
|
||||||
*/
|
|
||||||
class XmlHandler {
|
|
||||||
constructor() {
|
|
||||||
this.parser = new xml2js.Parser({
|
|
||||||
preserveChildrenOrder: true,
|
|
||||||
explicitChildren: true,
|
|
||||||
explicitArray: false,
|
|
||||||
trim: false,
|
|
||||||
normalizeTags: false,
|
|
||||||
attrkey: '$',
|
|
||||||
charkey: '_',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.builder = new xml2js.Builder({
|
|
||||||
renderOpts: {
|
|
||||||
pretty: true,
|
|
||||||
indent: ' ',
|
|
||||||
newline: '\n',
|
|
||||||
},
|
|
||||||
xmldec: {
|
|
||||||
version: '1.0',
|
|
||||||
encoding: 'utf8',
|
|
||||||
standalone: false,
|
|
||||||
},
|
|
||||||
headless: true, // Don't add XML declaration
|
|
||||||
attrkey: '$',
|
|
||||||
charkey: '_',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.yamlBuilder = new YamlXmlBuilder();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and parse the activation template
|
|
||||||
* @returns {Object} Parsed activation block
|
|
||||||
*/
|
|
||||||
async loadActivationTemplate() {
|
|
||||||
console.error('Failed to load activation template:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject activation block into agent XML content
|
|
||||||
* @param {string} agentContent - The agent file content
|
|
||||||
* @param {Object} metadata - Metadata containing module and name
|
|
||||||
* @returns {string} Modified content with activation block
|
|
||||||
*/
|
|
||||||
async injectActivation(agentContent, metadata = {}) {
|
|
||||||
try {
|
|
||||||
// Check if already has activation
|
|
||||||
if (agentContent.includes('<activation')) {
|
|
||||||
return agentContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the XML portion from markdown if needed
|
|
||||||
let xmlContent = agentContent;
|
|
||||||
let beforeXml = '';
|
|
||||||
let afterXml = '';
|
|
||||||
|
|
||||||
const xmlBlockMatch = agentContent.match(/([\s\S]*?)```xml\n([\s\S]*?)\n```([\s\S]*)/);
|
|
||||||
if (xmlBlockMatch) {
|
|
||||||
beforeXml = xmlBlockMatch[1] + '```xml\n';
|
|
||||||
xmlContent = xmlBlockMatch[2];
|
|
||||||
afterXml = '\n```' + xmlBlockMatch[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the agent XML
|
|
||||||
const parsed = await this.parser.parseStringPromise(xmlContent);
|
|
||||||
|
|
||||||
// Get the activation template
|
|
||||||
const activationBlock = await this.loadActivationTemplate();
|
|
||||||
if (!activationBlock) {
|
|
||||||
console.warn('Could not load activation template');
|
|
||||||
return agentContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the agent node
|
|
||||||
if (
|
|
||||||
parsed.agent && // Insert activation as the first child
|
|
||||||
!parsed.agent.activation
|
|
||||||
) {
|
|
||||||
// Ensure proper structure
|
|
||||||
if (!parsed.agent.$$) {
|
|
||||||
parsed.agent.$$ = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the activation node with proper structure
|
|
||||||
const activationNode = {
|
|
||||||
'#name': 'activation',
|
|
||||||
$: { critical: '1' },
|
|
||||||
$$: activationBlock.$$,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Insert at the beginning
|
|
||||||
parsed.agent.$$.unshift(activationNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert back to XML
|
|
||||||
let modifiedXml = this.builder.buildObject(parsed);
|
|
||||||
|
|
||||||
// Fix indentation - xml2js doesn't maintain our exact formatting
|
|
||||||
// Add 2-space base indentation to match our style
|
|
||||||
const lines = modifiedXml.split('\n');
|
|
||||||
const indentedLines = lines.map((line) => {
|
|
||||||
if (line.trim() === '') return line;
|
|
||||||
if (line.startsWith('<agent')) return line; // Keep agent at column 0
|
|
||||||
return ' ' + line; // Indent everything else
|
|
||||||
});
|
|
||||||
modifiedXml = indentedLines.join('\n');
|
|
||||||
|
|
||||||
// Reconstruct the full content
|
|
||||||
return beforeXml + modifiedXml + afterXml;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error injecting activation:', error);
|
|
||||||
return agentContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: DELETE THIS METHOD
|
|
||||||
*/
|
|
||||||
injectActivationSimple(agentContent, metadata = {}) {
|
|
||||||
console.error('Error in simple injection:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build agent from YAML source
|
|
||||||
* @param {string} yamlPath - Path to .agent.yaml file
|
|
||||||
* @param {string} customizePath - Path to .customize.yaml file (optional)
|
|
||||||
* @param {Object} metadata - Build metadata
|
|
||||||
* @returns {string} Generated XML content
|
|
||||||
*/
|
|
||||||
async buildFromYaml(yamlPath, customizePath = null, metadata = {}) {
|
|
||||||
try {
|
|
||||||
// Use YamlXmlBuilder to convert YAML to XML
|
|
||||||
const mergedAgent = await this.yamlBuilder.loadAndMergeAgent(yamlPath, customizePath);
|
|
||||||
|
|
||||||
// Build metadata
|
|
||||||
const buildMetadata = {
|
|
||||||
sourceFile: path.basename(yamlPath),
|
|
||||||
sourceHash: await this.yamlBuilder.calculateFileHash(yamlPath),
|
|
||||||
customizeFile: customizePath ? path.basename(customizePath) : null,
|
|
||||||
customizeHash: customizePath ? await this.yamlBuilder.calculateFileHash(customizePath) : null,
|
|
||||||
builderVersion: '1.0.0',
|
|
||||||
includeMetadata: metadata.includeMetadata !== false,
|
|
||||||
forWebBundle: metadata.forWebBundle || false, // Pass through forWebBundle flag
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert to XML
|
|
||||||
const xml = await this.yamlBuilder.convertToXml(mergedAgent, buildMetadata);
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error building agent from YAML:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a path is a YAML agent file
|
|
||||||
* @param {string} filePath - Path to check
|
|
||||||
* @returns {boolean} True if it's a YAML agent file
|
|
||||||
*/
|
|
||||||
isYamlAgent(filePath) {
|
|
||||||
return filePath.endsWith('.agent.yaml');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { XmlHandler };
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
function convertXmlToMarkdown(xmlFilePath) {
|
|
||||||
if (!xmlFilePath.endsWith('.xml')) {
|
|
||||||
throw new Error('Input file must be an XML file');
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlContent = fs.readFileSync(xmlFilePath, 'utf8');
|
|
||||||
|
|
||||||
const basename = path.basename(xmlFilePath, '.xml');
|
|
||||||
const dirname = path.dirname(xmlFilePath);
|
|
||||||
const mdFilePath = path.join(dirname, `${basename}.md`);
|
|
||||||
|
|
||||||
// Extract version and name/title from root element attributes
|
|
||||||
let title = basename;
|
|
||||||
let version = '';
|
|
||||||
|
|
||||||
// Match the root element and its attributes
|
|
||||||
const rootMatch = xmlContent.match(
|
|
||||||
/<[^>\s]+[^>]*?\sv="([^"]+)"[^>]*?(?:\sname="([^"]+)")?|<[^>\s]+[^>]*?(?:\sname="([^"]+)")?[^>]*?\sv="([^"]+)"/,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rootMatch) {
|
|
||||||
// Handle both v="x" name="y" and name="y" v="x" orders
|
|
||||||
version = rootMatch[1] || rootMatch[4] || '';
|
|
||||||
const nameAttr = rootMatch[2] || rootMatch[3] || '';
|
|
||||||
|
|
||||||
if (nameAttr) {
|
|
||||||
title = nameAttr;
|
|
||||||
} else {
|
|
||||||
// Try to find name in a <name> element if not in attributes
|
|
||||||
const nameElementMatch = xmlContent.match(/<name>([^<]+)<\/name>/);
|
|
||||||
if (nameElementMatch) {
|
|
||||||
title = nameElementMatch[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const heading = version ? `# ${title} v${version}` : `# ${title}`;
|
|
||||||
|
|
||||||
const markdownContent = `${heading}
|
|
||||||
|
|
||||||
\`\`\`xml
|
|
||||||
${xmlContent}
|
|
||||||
\`\`\`
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(mdFilePath, markdownContent, 'utf8');
|
|
||||||
|
|
||||||
return mdFilePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (args.length === 0) {
|
|
||||||
console.error('Usage: node xml-to-markdown.js <xml-file-path>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xmlFilePath = path.resolve(args[0]);
|
|
||||||
|
|
||||||
if (!fs.existsSync(xmlFilePath)) {
|
|
||||||
console.error(`Error: File not found: ${xmlFilePath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mdFilePath = convertXmlToMarkdown(xmlFilePath);
|
|
||||||
console.log(`Successfully converted: ${xmlFilePath} -> ${mdFilePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error converting file: ${error.message}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { convertXmlToMarkdown };
|
|
||||||
|
|
@ -1,572 +0,0 @@
|
||||||
const yaml = require('yaml');
|
|
||||||
const fs = require('fs-extra');
|
|
||||||
const path = require('node:path');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const { AgentAnalyzer } = require('./agent-analyzer');
|
|
||||||
const { ActivationBuilder } = require('./activation-builder');
|
|
||||||
const { escapeXml } = require('../../lib/xml-utils');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts agent YAML files to XML format with smart activation injection
|
|
||||||
*/
|
|
||||||
class YamlXmlBuilder {
|
|
||||||
constructor() {
|
|
||||||
this.analyzer = new AgentAnalyzer();
|
|
||||||
this.activationBuilder = new ActivationBuilder();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep merge two objects (for customize.yaml + agent.yaml)
|
|
||||||
* @param {Object} target - Target object
|
|
||||||
* @param {Object} source - Source object to merge in
|
|
||||||
* @returns {Object} Merged object
|
|
||||||
*/
|
|
||||||
deepMerge(target, source) {
|
|
||||||
const output = { ...target };
|
|
||||||
|
|
||||||
if (this.isObject(target) && this.isObject(source)) {
|
|
||||||
for (const key of Object.keys(source)) {
|
|
||||||
if (this.isObject(source[key])) {
|
|
||||||
if (key in target) {
|
|
||||||
output[key] = this.deepMerge(target[key], source[key]);
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(source[key])) {
|
|
||||||
// For arrays, append rather than replace (for commands)
|
|
||||||
if (Array.isArray(target[key])) {
|
|
||||||
output[key] = [...target[key], ...source[key]];
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
output[key] = source[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if value is an object
|
|
||||||
*/
|
|
||||||
isObject(item) {
|
|
||||||
return item && typeof item === 'object' && !Array.isArray(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load and merge agent YAML with customization
|
|
||||||
* @param {string} agentYamlPath - Path to base agent YAML
|
|
||||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
||||||
* @returns {Object} Merged agent configuration
|
|
||||||
*/
|
|
||||||
async loadAndMergeAgent(agentYamlPath, customizeYamlPath = null) {
|
|
||||||
// Load base agent
|
|
||||||
const agentContent = await fs.readFile(agentYamlPath, 'utf8');
|
|
||||||
const agentYaml = yaml.parse(agentContent);
|
|
||||||
|
|
||||||
// Load customization if exists
|
|
||||||
let merged = agentYaml;
|
|
||||||
if (customizeYamlPath && (await fs.pathExists(customizeYamlPath))) {
|
|
||||||
const customizeContent = await fs.readFile(customizeYamlPath, 'utf8');
|
|
||||||
const customizeYaml = yaml.parse(customizeContent);
|
|
||||||
|
|
||||||
if (customizeYaml) {
|
|
||||||
// Special handling: persona fields are merged, but only non-empty values override
|
|
||||||
if (customizeYaml.persona) {
|
|
||||||
const basePersona = merged.agent.persona || {};
|
|
||||||
const customPersona = {};
|
|
||||||
|
|
||||||
// Only copy non-empty customize values
|
|
||||||
for (const [key, value] of Object.entries(customizeYaml.persona)) {
|
|
||||||
if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
|
|
||||||
customPersona[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge non-empty customize values over base
|
|
||||||
if (Object.keys(customPersona).length > 0) {
|
|
||||||
merged.agent.persona = { ...basePersona, ...customPersona };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge metadata (only non-empty values)
|
|
||||||
if (customizeYaml.agent && customizeYaml.agent.metadata) {
|
|
||||||
const nonEmptyMetadata = {};
|
|
||||||
for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
|
|
||||||
if (value !== '' && value !== null) {
|
|
||||||
nonEmptyMetadata[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged.agent.metadata = { ...merged.agent.metadata, ...nonEmptyMetadata };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append menu items (support both 'menu' and legacy 'commands')
|
|
||||||
const customMenuItems = customizeYaml.menu || customizeYaml.commands;
|
|
||||||
if (customMenuItems) {
|
|
||||||
// Determine if base uses 'menu' or 'commands'
|
|
||||||
if (merged.agent.menu) {
|
|
||||||
merged.agent.menu = [...merged.agent.menu, ...customMenuItems];
|
|
||||||
} else if (merged.agent.commands) {
|
|
||||||
merged.agent.commands = [...merged.agent.commands, ...customMenuItems];
|
|
||||||
} else {
|
|
||||||
// Default to 'menu' for new agents
|
|
||||||
merged.agent.menu = customMenuItems;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append critical actions
|
|
||||||
if (customizeYaml.critical_actions) {
|
|
||||||
merged.agent.critical_actions = [...(merged.agent.critical_actions || []), ...customizeYaml.critical_actions];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append prompts
|
|
||||||
if (customizeYaml.prompts) {
|
|
||||||
merged.agent.prompts = [...(merged.agent.prompts || []), ...customizeYaml.prompts];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append memories
|
|
||||||
if (customizeYaml.memories) {
|
|
||||||
merged.agent.memories = [...(merged.agent.memories || []), ...customizeYaml.memories];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert agent YAML to XML
|
|
||||||
* @param {Object} agentYaml - Parsed agent YAML object
|
|
||||||
* @param {Object} buildMetadata - Metadata about the build (file paths, hashes, etc.)
|
|
||||||
* @returns {string} XML content
|
|
||||||
*/
|
|
||||||
async convertToXml(agentYaml, buildMetadata = {}) {
|
|
||||||
const agent = agentYaml.agent;
|
|
||||||
const metadata = agent.metadata || {};
|
|
||||||
|
|
||||||
// Add module from buildMetadata if available
|
|
||||||
if (buildMetadata.module) {
|
|
||||||
metadata.module = buildMetadata.module;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Analyze agent to determine needed handlers
|
|
||||||
const profile = this.analyzer.analyzeAgentObject(agentYaml);
|
|
||||||
|
|
||||||
// Build activation block only if not skipped
|
|
||||||
let activationBlock = '';
|
|
||||||
if (!buildMetadata.skipActivation) {
|
|
||||||
activationBlock = await this.activationBuilder.buildActivation(
|
|
||||||
profile,
|
|
||||||
metadata,
|
|
||||||
agent.critical_actions || [],
|
|
||||||
buildMetadata.forWebBundle || false, // Pass web bundle flag
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start building XML
|
|
||||||
let xml = '';
|
|
||||||
|
|
||||||
if (buildMetadata.forWebBundle) {
|
|
||||||
// Web bundle: keep existing format
|
|
||||||
xml += '<!-- Powered by BMAD-CORE™ -->\n\n';
|
|
||||||
xml += `# ${metadata.title || 'Agent'}\n\n`;
|
|
||||||
} else {
|
|
||||||
// Installation: use YAML frontmatter + instruction
|
|
||||||
// Extract name from filename: "cli-chief.yaml" or "pm.agent.yaml" -> "cli chief" or "pm"
|
|
||||||
const filename = buildMetadata.sourceFile || 'agent.yaml';
|
|
||||||
let nameFromFile = path.basename(filename, path.extname(filename)); // Remove .yaml/.md extension
|
|
||||||
nameFromFile = nameFromFile.replace(/\.agent$/, ''); // Remove .agent suffix if present
|
|
||||||
nameFromFile = nameFromFile.replaceAll('-', ' '); // Replace dashes with spaces
|
|
||||||
|
|
||||||
xml += '---\n';
|
|
||||||
xml += `name: "${nameFromFile}"\n`;
|
|
||||||
xml += `description: "${metadata.title || 'BMAD Agent'}"\n`;
|
|
||||||
xml += '---\n\n';
|
|
||||||
xml +=
|
|
||||||
"You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command.\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += '```xml\n';
|
|
||||||
|
|
||||||
// Agent opening tag
|
|
||||||
const agentAttrs = [
|
|
||||||
`id="${metadata.id || ''}"`,
|
|
||||||
`name="${metadata.name || ''}"`,
|
|
||||||
`title="${metadata.title || ''}"`,
|
|
||||||
`icon="${metadata.icon || '🤖'}"`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add localskip attribute if present
|
|
||||||
if (metadata.localskip === true) {
|
|
||||||
agentAttrs.push('localskip="true"');
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += `<agent ${agentAttrs.join(' ')}>\n`;
|
|
||||||
|
|
||||||
// Activation block (only if not skipped)
|
|
||||||
if (activationBlock) {
|
|
||||||
xml += activationBlock + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persona section
|
|
||||||
xml += this.buildPersonaXml(agent.persona);
|
|
||||||
|
|
||||||
// Memories section (if exists)
|
|
||||||
if (agent.memories) {
|
|
||||||
xml += this.buildMemoriesXml(agent.memories);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompts section (if exists)
|
|
||||||
if (agent.prompts) {
|
|
||||||
xml += this.buildPromptsXml(agent.prompts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu section (support both 'menu' and legacy 'commands')
|
|
||||||
const menuItems = agent.menu || agent.commands || [];
|
|
||||||
xml += this.buildCommandsXml(menuItems, buildMetadata.forWebBundle);
|
|
||||||
|
|
||||||
xml += '</agent>\n';
|
|
||||||
xml += '```\n';
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build persona XML section
|
|
||||||
*/
|
|
||||||
buildPersonaXml(persona) {
|
|
||||||
if (!persona) return '';
|
|
||||||
|
|
||||||
let xml = ' <persona>\n';
|
|
||||||
|
|
||||||
if (persona.role) {
|
|
||||||
xml += ` <role>${escapeXml(persona.role)}</role>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persona.identity) {
|
|
||||||
xml += ` <identity>${escapeXml(persona.identity)}</identity>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persona.communication_style) {
|
|
||||||
xml += ` <communication_style>${escapeXml(persona.communication_style)}</communication_style>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persona.principles) {
|
|
||||||
// Principles can be array or string
|
|
||||||
let principlesText;
|
|
||||||
if (Array.isArray(persona.principles)) {
|
|
||||||
principlesText = persona.principles.join(' ');
|
|
||||||
} else {
|
|
||||||
principlesText = persona.principles;
|
|
||||||
}
|
|
||||||
xml += ` <principles>${escapeXml(principlesText)}</principles>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ' </persona>\n';
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build memories XML section
|
|
||||||
*/
|
|
||||||
buildMemoriesXml(memories) {
|
|
||||||
if (!memories || memories.length === 0) return '';
|
|
||||||
|
|
||||||
let xml = ' <memories>\n';
|
|
||||||
|
|
||||||
for (const memory of memories) {
|
|
||||||
xml += ` <memory>${escapeXml(memory)}</memory>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ' </memories>\n';
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build prompts XML section
|
|
||||||
* Handles both array format and object/dictionary format
|
|
||||||
*/
|
|
||||||
buildPromptsXml(prompts) {
|
|
||||||
if (!prompts) return '';
|
|
||||||
|
|
||||||
// Handle object/dictionary format: { promptId: 'content', ... }
|
|
||||||
// Convert to array format for processing
|
|
||||||
let promptsArray = prompts;
|
|
||||||
if (!Array.isArray(prompts)) {
|
|
||||||
// Check if it's an object with no length property (dictionary format)
|
|
||||||
if (typeof prompts === 'object' && prompts.length === undefined) {
|
|
||||||
promptsArray = Object.entries(prompts).map(([id, content]) => ({
|
|
||||||
id: id,
|
|
||||||
content: content,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
return ''; // Not a valid prompts format
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (promptsArray.length === 0) return '';
|
|
||||||
|
|
||||||
let xml = ' <prompts>\n';
|
|
||||||
|
|
||||||
for (const prompt of promptsArray) {
|
|
||||||
xml += ` <prompt id="${prompt.id || ''}">\n`;
|
|
||||||
xml += ` <content>\n`;
|
|
||||||
xml += `${escapeXml(prompt.content || '')}\n`;
|
|
||||||
xml += ` </content>\n`;
|
|
||||||
xml += ` </prompt>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += ' </prompts>\n';
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build menu XML section (renamed from commands for clarity)
|
|
||||||
* Auto-injects *help and *exit, adds * prefix to all triggers
|
|
||||||
* Supports both legacy format and new multi format with nested handlers
|
|
||||||
* @param {Array} menuItems - Menu items from YAML
|
|
||||||
* @param {boolean} forWebBundle - Whether building for web bundle
|
|
||||||
*/
|
|
||||||
buildCommandsXml(menuItems, forWebBundle = false) {
|
|
||||||
let xml = ' <menu>\n';
|
|
||||||
|
|
||||||
// Always inject menu display option first
|
|
||||||
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
|
|
||||||
|
|
||||||
// Add user-defined menu items with * prefix
|
|
||||||
if (menuItems && menuItems.length > 0) {
|
|
||||||
for (const item of menuItems) {
|
|
||||||
// Skip ide-only items when building for web bundles
|
|
||||||
if (forWebBundle && item['ide-only'] === true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Skip web-only items when NOT building for web bundles (i.e., IDE/local installation)
|
|
||||||
if (!forWebBundle && item['web-only'] === true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multi format menu items with nested handlers
|
|
||||||
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
|
|
||||||
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
|
|
||||||
xml += this.buildNestedHandlers(item.triggers);
|
|
||||||
xml += ` </item>\n`;
|
|
||||||
}
|
|
||||||
// Handle legacy format menu items
|
|
||||||
else if (item.trigger) {
|
|
||||||
// For legacy items, keep using cmd with *<trigger> format
|
|
||||||
let trigger = item.trigger || '';
|
|
||||||
if (!trigger.startsWith('*')) {
|
|
||||||
trigger = '*' + trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrs = [`cmd="${trigger}"`];
|
|
||||||
|
|
||||||
// Add handler attributes
|
|
||||||
if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`);
|
|
||||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
|
||||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
|
||||||
if (item.data) attrs.push(`data="${item.data}"`);
|
|
||||||
if (item.action) attrs.push(`action="${item.action}"`);
|
|
||||||
|
|
||||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always inject dismiss last
|
|
||||||
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
|
|
||||||
|
|
||||||
xml += ' </menu>\n';
|
|
||||||
|
|
||||||
return xml;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build nested handlers for multi format menu items
|
|
||||||
* @param {Array} triggers - Triggers array from multi format
|
|
||||||
* @returns {string} Handler XML
|
|
||||||
*/
|
|
||||||
buildNestedHandlers(triggers) {
|
|
||||||
let xml = '';
|
|
||||||
|
|
||||||
for (const triggerGroup of triggers) {
|
|
||||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
|
||||||
// Build trigger with * prefix
|
|
||||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
|
||||||
|
|
||||||
// Extract the relevant execution data
|
|
||||||
const execData = this.processExecArray(execArray);
|
|
||||||
|
|
||||||
// For nested handlers in multi items, we don't need cmd attribute
|
|
||||||
// The match attribute will handle fuzzy matching
|
|
||||||
const attrs = [`match="${escapeXml(execData.description || '')}"`];
|
|
||||||
|
|
||||||
// Add handler attributes based on exec data
|
|
||||||
if (execData.route) attrs.push(`exec="${execData.route}"`);
|
|
||||||
if (execData.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
|
|
||||||
*/
|
|
||||||
processExecArray(execArray) {
|
|
||||||
const result = {
|
|
||||||
description: '',
|
|
||||||
route: 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) {
|
|
||||||
result.route = exec.route;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exec.data !== null && exec.data !== undefined) {
|
|
||||||
result.data = exec.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exec.action) {
|
|
||||||
result.action = exec.action;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exec.type) {
|
|
||||||
result.type = exec.type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate file hash for build tracking
|
|
||||||
*/
|
|
||||||
async calculateFileHash(filePath) {
|
|
||||||
if (!(await fs.pathExists(filePath))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(filePath, 'utf8');
|
|
||||||
return crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build agent XML from YAML files and return as string (for in-memory use)
|
|
||||||
* @param {string} agentYamlPath - Path to agent YAML
|
|
||||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
||||||
* @param {Object} options - Build options
|
|
||||||
* @returns {Promise<string>} XML content as string
|
|
||||||
*/
|
|
||||||
async buildFromYaml(agentYamlPath, customizeYamlPath = null, options = {}) {
|
|
||||||
// Load and merge YAML files
|
|
||||||
const mergedAgent = await this.loadAndMergeAgent(agentYamlPath, customizeYamlPath);
|
|
||||||
|
|
||||||
// Calculate hashes for build tracking
|
|
||||||
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
|
||||||
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
|
||||||
|
|
||||||
// Extract module from path (e.g., /path/to/modules/bmm/agents/pm.yaml -> bmm)
|
|
||||||
// or /path/to/bmad/bmm/agents/pm.yaml -> bmm
|
|
||||||
// or /path/to/src/bmm-skills/agents/pm.yaml -> bmm
|
|
||||||
let module = 'core'; // default to core
|
|
||||||
const pathParts = agentYamlPath.split(path.sep);
|
|
||||||
|
|
||||||
// Look for module indicators in the path
|
|
||||||
const modulesIndex = pathParts.indexOf('modules');
|
|
||||||
const bmadIndex = pathParts.indexOf('bmad');
|
|
||||||
const srcIndex = pathParts.indexOf('src');
|
|
||||||
|
|
||||||
if (modulesIndex !== -1 && pathParts[modulesIndex + 1]) {
|
|
||||||
// Path contains /modules/{module}/
|
|
||||||
module = pathParts[modulesIndex + 1];
|
|
||||||
} else if (bmadIndex !== -1 && pathParts[bmadIndex + 1]) {
|
|
||||||
// Path contains /bmad/{module}/
|
|
||||||
const potentialModule = pathParts[bmadIndex + 1];
|
|
||||||
// Check if it's a known module, not 'agents' or '_config'
|
|
||||||
if (['bmm', 'bmb', 'cis', 'core'].includes(potentialModule)) {
|
|
||||||
module = potentialModule;
|
|
||||||
}
|
|
||||||
} else if (srcIndex !== -1 && pathParts[srcIndex + 1]) {
|
|
||||||
// Path contains /src/{module}/ (bmm-skills and core-skills are directly under src/)
|
|
||||||
const potentialModule = pathParts[srcIndex + 1];
|
|
||||||
if (potentialModule === 'bmm-skills') {
|
|
||||||
module = 'bmm';
|
|
||||||
} else if (potentialModule === 'core-skills') {
|
|
||||||
module = 'core';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build metadata
|
|
||||||
const buildMetadata = {
|
|
||||||
sourceFile: path.basename(agentYamlPath),
|
|
||||||
sourceHash,
|
|
||||||
customizeFile: customizeYamlPath ? path.basename(customizeYamlPath) : null,
|
|
||||||
customizeHash,
|
|
||||||
builderVersion: '1.0.0',
|
|
||||||
includeMetadata: options.includeMetadata !== false,
|
|
||||||
skipActivation: options.skipActivation === true,
|
|
||||||
forWebBundle: options.forWebBundle === true,
|
|
||||||
module: module, // Add module to buildMetadata
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert to XML and return
|
|
||||||
return await this.convertToXml(mergedAgent, buildMetadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build agent XML from YAML files
|
|
||||||
* @param {string} agentYamlPath - Path to agent YAML
|
|
||||||
* @param {string} customizeYamlPath - Path to customize YAML (optional)
|
|
||||||
* @param {string} outputPath - Path to write XML file
|
|
||||||
* @param {Object} options - Build options
|
|
||||||
*/
|
|
||||||
async buildAgent(agentYamlPath, customizeYamlPath, outputPath, options = {}) {
|
|
||||||
// Use buildFromYaml to get XML content
|
|
||||||
const xml = await this.buildFromYaml(agentYamlPath, customizeYamlPath, options);
|
|
||||||
|
|
||||||
// Write output file
|
|
||||||
await fs.ensureDir(path.dirname(outputPath));
|
|
||||||
await fs.writeFile(outputPath, xml, 'utf8');
|
|
||||||
|
|
||||||
// Calculate hashes for return value
|
|
||||||
const sourceHash = await this.calculateFileHash(agentYamlPath);
|
|
||||||
const customizeHash = customizeYamlPath ? await this.calculateFileHash(customizeYamlPath) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
outputPath,
|
|
||||||
sourceHash,
|
|
||||||
customizeHash,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { YamlXmlBuilder };
|
|
||||||
Loading…
Reference in New Issue