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