BMAD-METHOD/tools/cli/lib/agent/installer.js
2025-12-07 21:41:37 -06:00

837 lines
26 KiB
JavaScript

/**
* 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<Object>} User answers
*/
async function promptInstallQuestions(installConfig, defaults, presetAnswers = {}) {
if (!installConfig || !installConfig.questions || installConfig.questions.length === 0) {
return { ...defaults, ...presetAnswers };
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const question = (prompt) =>
new Promise((resolve) => {
rl.question(prompt, resolve);
});
const answers = { ...defaults, ...presetAnswers };
console.log('\n📝 Agent Configuration\n');
if (installConfig.description) {
console.log(` ${installConfig.description}\n`);
}
for (const q of installConfig.questions) {
// Skip questions for variables that are already set (e.g., custom_name set upfront)
if (answers[q.var] !== undefined && answers[q.var] !== defaults[q.var]) {
console.log(chalk.dim(` ${q.var}: ${answers[q.var]} (already set)`));
continue;
}
let response;
switch (q.type) {
case 'text': {
const defaultHint = q.default ? ` (default: ${q.default})` : '';
response = await question(` ${q.prompt}${defaultHint}: `);
answers[q.var] = response || q.default || '';
break;
}
case 'boolean': {
const defaultHint = q.default ? ' [Y/n]' : ' [y/N]';
response = await question(` ${q.prompt}${defaultHint}: `);
if (response === '') {
answers[q.var] = q.default;
} else {
answers[q.var] = response.toLowerCase().startsWith('y');
}
break;
}
case 'choice': {
console.log(` ${q.prompt}`);
for (const [idx, opt] of q.options.entries()) {
const marker = opt.value === q.default ? '* ' : ' ';
console.log(` ${marker}${idx + 1}. ${opt.label}`);
}
const defaultIdx = q.options.findIndex((o) => o.value === q.default) + 1;
let validChoice = false;
let choiceIdx;
while (!validChoice) {
response = await question(` Choice (default: ${defaultIdx}): `);
if (response) {
choiceIdx = parseInt(response, 10) - 1;
if (isNaN(choiceIdx) || choiceIdx < 0 || choiceIdx >= q.options.length) {
console.log(` Invalid choice. Please enter 1-${q.options.length}`);
} else {
validChoice = true;
}
} else {
choiceIdx = defaultIdx - 1;
validChoice = true;
}
}
answers[q.var] = q.options[choiceIdx].value;
break;
}
// No default
}
}
rl.close();
return answers;
}
/**
* Install a compiled agent to target location
* @param {Object} agentInfo - Agent discovery info
* @param {Object} answers - User answers for install_config
* @param {string} targetPath - Target installation directory
* @param {Object} options - Additional options including config
* @returns {Object} Installation result
*/
function installAgent(agentInfo, answers, targetPath, options = {}) {
// Compile the agent
const { xml, metadata, processedYaml } = compileAgent(fs.readFileSync(agentInfo.yamlFile, 'utf8'), answers);
// Determine target agent folder name
// Use the folder name from agentInfo, NOT the persona name from metadata
const agentFolderName = agentInfo.name;
const agentTargetDir = path.join(targetPath, agentFolderName);
// Create target directory
if (!fs.existsSync(agentTargetDir)) {
fs.mkdirSync(agentTargetDir, { recursive: true });
}
// Write compiled XML (.md)
const compiledFileName = `${agentFolderName}.md`;
const compiledPath = path.join(agentTargetDir, compiledFileName);
fs.writeFileSync(compiledPath, xml, 'utf8');
const result = {
success: true,
agentName: metadata.name || agentInfo.name,
targetDir: agentTargetDir,
compiledFile: compiledPath,
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(/(<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', 'bmad'];
for (const name of possibleNames) {
const bmadFolder = path.join(checkPath, name);
const cfgFolder = path.join(bmadFolder, '_cfg');
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 _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<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', '_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]*?)</${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,
copySidecarFiles,
copyAgentSidecarFiles,
updateAgentId,
detectBmadProject,
addToManifest,
extractManifestData,
escapeCsvField,
checkManifestForAgent,
checkManifestForPath,
updateManifestEntry,
saveAgentSource,
createIdeSlashCommands,
updateManifestYaml,
};