const chalk = require('chalk'); const path = require('node:path'); const fs = require('node:fs'); const readline = require('node:readline'); const { findBmadConfig, resolvePath, discoverAgents, loadAgentConfig, promptInstallQuestions, detectBmadProject, addToManifest, extractManifestData, checkManifestForPath, updateManifestEntry, saveAgentSource, createIdeSlashCommands, updateManifestYaml, } = require('../lib/agent/installer'); module.exports = { command: 'agent-install', description: 'Install and compile BMAD agents with personalization', options: [ ['-s, --source ', 'Path to specific agent YAML file or folder'], ['-d, --defaults', 'Use default values without prompting'], ['-t, --destination ', 'Target installation directory (default: current project BMAD installation)'], ], action: async (options) => { try { console.log(chalk.cyan('\nšŸ”§ BMAD Agent Installer\n')); // Find BMAD config const config = findBmadConfig(); if (!config) { console.log(chalk.yellow('No BMAD installation found in current directory.')); console.log(chalk.dim('Looking for .bmad/bmb/config.yaml...')); console.log(chalk.red('\nPlease run this command from a project with BMAD installed.')); process.exit(1); } console.log(chalk.dim(`Found BMAD at: ${config.bmadFolder}`)); let selectedAgent = null; // If source provided, use it directly if (options.source) { const providedPath = path.resolve(options.source); if (!fs.existsSync(providedPath)) { console.log(chalk.red(`Path not found: ${providedPath}`)); process.exit(1); } const stat = fs.statSync(providedPath); if (stat.isFile() && providedPath.endsWith('.agent.yaml')) { selectedAgent = { type: 'simple', name: path.basename(providedPath, '.agent.yaml'), path: providedPath, yamlFile: providedPath, }; } else if (stat.isDirectory()) { const yamlFiles = fs.readdirSync(providedPath).filter((f) => f.endsWith('.agent.yaml')); if (yamlFiles.length === 1) { selectedAgent = { type: 'expert', name: path.basename(providedPath), path: providedPath, yamlFile: path.join(providedPath, yamlFiles[0]), hasSidecar: true, }; } else { console.log(chalk.red('Directory must contain exactly one .agent.yaml file')); process.exit(1); } } else { console.log(chalk.red('Path must be an .agent.yaml file or a folder containing one')); process.exit(1); } } else { // Discover agents from custom location const customAgentLocation = config.custom_agent_location ? resolvePath(config.custom_agent_location, config) : path.join(config.bmadFolder, 'custom', 'src', 'agents'); console.log(chalk.dim(`Searching for agents in: ${customAgentLocation}\n`)); const agents = discoverAgents(customAgentLocation); if (agents.length === 0) { console.log(chalk.yellow('No agents found in custom agent location.')); console.log(chalk.dim(`Expected location: ${customAgentLocation}`)); console.log(chalk.dim('\nCreate agents using the BMad Builder workflow or place .agent.yaml files there.')); process.exit(0); } // List available agents console.log(chalk.cyan('Available Agents:\n')); for (const [idx, agent] of agents.entries()) { const typeIcon = agent.type === 'expert' ? 'šŸ“š' : 'šŸ“„'; console.log(` ${idx + 1}. ${typeIcon} ${chalk.bold(agent.name)} ${chalk.dim(`(${agent.type})`)}`); } // Prompt for selection const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const selection = await new Promise((resolve) => { rl.question('\nSelect agent to install (number): ', resolve); }); rl.close(); const selectedIdx = parseInt(selection, 10) - 1; if (isNaN(selectedIdx) || selectedIdx < 0 || selectedIdx >= agents.length) { console.log(chalk.red('Invalid selection')); process.exit(1); } selectedAgent = agents[selectedIdx]; } console.log(chalk.cyan(`\nSelected: ${chalk.bold(selectedAgent.name)}`)); // Load agent configuration const agentConfig = loadAgentConfig(selectedAgent.yamlFile); if (agentConfig.metadata.name) { console.log(chalk.dim(`Agent Name: ${agentConfig.metadata.name}`)); } if (agentConfig.metadata.title) { console.log(chalk.dim(`Title: ${agentConfig.metadata.title}`)); } // Get the agent type (source name) const agentType = selectedAgent.name; // e.g., "commit-poet" // Confirm/customize agent persona name const rl1 = readline.createInterface({ input: process.stdin, output: process.stdout, }); const defaultPersonaName = agentConfig.metadata.name || agentType; console.log(chalk.cyan('\nšŸ“› Agent Persona Name\n')); console.log(chalk.dim(` Agent type: ${agentType}`)); console.log(chalk.dim(` Default persona: ${defaultPersonaName}`)); console.log(chalk.dim(' Leave blank to use default, or provide a custom name.')); console.log(chalk.dim(' Examples:')); console.log(chalk.dim(` - (blank) → "${defaultPersonaName}" as ${agentType}.md`)); console.log(chalk.dim(` - "Fred" → "Fred" as fred-${agentType}.md`)); console.log(chalk.dim(` - "Captain Code" → "Captain Code" as captain-code-${agentType}.md`)); const customPersonaName = await new Promise((resolve) => { rl1.question(`\n Custom name (or Enter for default): `, resolve); }); rl1.close(); // Determine final agent file name based on persona name let finalAgentName; let personaName; if (customPersonaName.trim()) { personaName = customPersonaName.trim(); const namePrefix = personaName.toLowerCase().replaceAll(/\s+/g, '-'); finalAgentName = `${namePrefix}-${agentType}`; } else { personaName = defaultPersonaName; finalAgentName = agentType; } console.log(chalk.dim(` Persona: ${personaName}`)); console.log(chalk.dim(` File: ${finalAgentName}.md`)); // Get answers (prompt or use defaults) let presetAnswers = {}; // If custom persona name provided, inject it as custom_name for template processing if (customPersonaName.trim()) { presetAnswers.custom_name = personaName; } let answers; if (agentConfig.installConfig && !options.defaults) { answers = await promptInstallQuestions(agentConfig.installConfig, agentConfig.defaults, presetAnswers); } else if (agentConfig.installConfig && options.defaults) { console.log(chalk.dim('\nUsing default configuration values.')); answers = { ...agentConfig.defaults, ...presetAnswers }; } else { answers = { ...agentConfig.defaults, ...presetAnswers }; } // Determine target directory let targetDir = options.destination ? path.resolve(options.destination) : null; // If no target specified, prompt for it if (targetDir) { // If target provided via --destination, check if it's a project root and adjust const otherProject = detectBmadProject(targetDir); if (otherProject && !targetDir.includes('agents')) { // User specified project root, redirect to custom agents folder targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents'); console.log(chalk.dim(` Auto-selecting custom agents folder: ${targetDir}`)); } } else { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log(chalk.cyan('\nšŸ“‚ Installation Target\n')); // Option 1: Current project's custom agents folder const currentCustom = path.join(config.bmadFolder, 'custom', 'agents'); console.log(` 1. Current project: ${chalk.dim(currentCustom)}`); // Option 2: Specify another project path console.log(` 2. Another project (enter path)`); const choice = await new Promise((resolve) => { rl.question('\n Select option (1 or path): ', resolve); }); if (choice.trim() === '1' || choice.trim() === '') { targetDir = currentCustom; } else if (choice.trim() === '2') { const projectPath = await new Promise((resolve) => { rl.question(' Project path: ', resolve); }); // Detect if it's a BMAD project and use its custom folder const otherProject = detectBmadProject(path.resolve(projectPath)); if (otherProject) { targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents'); console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`)); } else { targetDir = path.resolve(projectPath); } } else { // User entered a path directly const otherProject = detectBmadProject(path.resolve(choice)); if (otherProject) { targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents'); console.log(chalk.dim(` Found BMAD project, using: ${targetDir}`)); } else { targetDir = path.resolve(choice); } } rl.close(); } if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } console.log(chalk.dim(`\nInstalling to: ${targetDir}`)); // Detect if target is within a BMAD project const targetProject = detectBmadProject(targetDir); if (targetProject) { console.log(chalk.cyan(` Detected BMAD project at: ${targetProject.projectRoot}`)); } // Check for duplicate in manifest by path (not by type) let shouldUpdateExisting = false; let existingEntry = null; if (targetProject) { // Check if this exact installed name already exists const expectedPath = `.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`; existingEntry = checkManifestForPath(targetProject.manifestFile, expectedPath); if (existingEntry) { const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log(chalk.yellow(`\nāš ļø Agent "${finalAgentName}" already installed`)); console.log(chalk.dim(` Type: ${agentType}`)); console.log(chalk.dim(` Path: ${existingEntry.path}`)); const overwrite = await new Promise((resolve) => { rl2.question(' Overwrite existing installation? [Y/n]: ', resolve); }); rl2.close(); if (overwrite.toLowerCase() === 'n') { console.log(chalk.yellow('Installation cancelled.')); process.exit(0); } shouldUpdateExisting = true; } } // Install the agent with custom name // Override the folder name with finalAgentName const agentTargetDir = path.join(targetDir, finalAgentName); if (!fs.existsSync(agentTargetDir)) { fs.mkdirSync(agentTargetDir, { recursive: true }); } // Compile and install const { compileAgent } = require('../lib/agent/compiler'); // Calculate target path for agent ID const projectRoot = targetProject ? targetProject.projectRoot : config.projectRoot; const compiledFileName = `${finalAgentName}.md`; const compiledPath = path.join(agentTargetDir, compiledFileName); const relativePath = path.relative(projectRoot, compiledPath); // Compile with proper name and path const { xml, metadata, processedYaml } = compileAgent( fs.readFileSync(selectedAgent.yamlFile, 'utf8'), answers, finalAgentName, relativePath, ); // Write compiled XML (.md) with custom name fs.writeFileSync(compiledPath, xml, 'utf8'); const result = { success: true, agentName: finalAgentName, targetDir: agentTargetDir, compiledFile: compiledPath, sidecarCopied: false, }; // Copy sidecar files for expert agents if (selectedAgent.hasSidecar && selectedAgent.type === 'expert') { const { copySidecarFiles } = require('../lib/agent/installer'); const sidecarFiles = copySidecarFiles(selectedAgent.path, agentTargetDir, selectedAgent.yamlFile); result.sidecarCopied = true; result.sidecarFiles = sidecarFiles; } console.log(chalk.green('\n✨ Agent installed successfully!')); console.log(chalk.cyan(` Name: ${result.agentName}`)); console.log(chalk.cyan(` Location: ${result.targetDir}`)); console.log(chalk.cyan(` Compiled: ${path.basename(result.compiledFile)}`)); if (result.sidecarCopied) { console.log(chalk.cyan(` Sidecar files: ${result.sidecarFiles.length} files copied`)); } // Save source YAML to _cfg/custom/agents/ and register in manifest if (targetProject) { // Save source for reinstallation with embedded answers console.log(chalk.dim(`\nSaving source to: ${targetProject.cfgFolder}/custom/agents/`)); saveAgentSource(selectedAgent, targetProject.cfgFolder, finalAgentName, answers); console.log(chalk.green(` āœ“ Source saved for reinstallation`)); // Register/update in manifest console.log(chalk.dim(`Registering in manifest: ${targetProject.manifestFile}`)); const manifestData = extractManifestData(xml, { ...metadata, name: finalAgentName }, relativePath, 'custom'); // Use finalAgentName as the manifest name field (unique identifier) manifestData.name = finalAgentName; // Use compiled metadata.name (persona name after template processing), not source agentConfig manifestData.displayName = metadata.name || agentType; // Store the actual installed path/name manifestData.path = relativePath; if (shouldUpdateExisting && existingEntry) { updateManifestEntry(targetProject.manifestFile, manifestData, existingEntry._lineNumber); console.log(chalk.green(` āœ“ Updated existing entry in agent-manifest.csv`)); } else { addToManifest(targetProject.manifestFile, manifestData); console.log(chalk.green(` āœ“ Added to agent-manifest.csv`)); } // Create IDE slash commands const ideResults = await createIdeSlashCommands(targetProject.projectRoot, finalAgentName, relativePath, metadata); if (Object.keys(ideResults).length > 0) { console.log(chalk.green(` āœ“ Created IDE commands:`)); for (const [ideName, result] of Object.entries(ideResults)) { console.log(chalk.dim(` ${ideName}: ${result.command}`)); } } // Update manifest.yaml with custom_agents tracking const manifestYamlPath = path.join(targetProject.cfgFolder, 'manifest.yaml'); if (updateManifestYaml(manifestYamlPath, finalAgentName, agentType)) { console.log(chalk.green(` āœ“ Updated manifest.yaml custom_agents`)); } } console.log(chalk.dim(`\nAgent ID: ${relativePath}`)); if (targetProject) { console.log(chalk.yellow('\nAgent is now registered and available in the target project!')); } else { console.log(chalk.yellow('\nTo use this agent, reference it in your manifest or load it directly.')); } process.exit(0); } catch (error) { console.error(chalk.red('Agent installation failed:'), error.message); console.error(chalk.dim(error.stack)); process.exit(1); } }, };