BMAD-METHOD/tools/cli/commands/agent-install.js

642 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const chalk = require('chalk');
const path = require('node:path');
const fs = require('node:fs');
const readline = require('node:readline');
const yaml = require('js-yaml');
const inquirer = require('inquirer');
const {
findBmadConfig,
resolvePath,
discoverAgents,
loadAgentConfig,
promptInstallQuestions,
detectBmadProject,
addToManifest,
extractManifestData,
checkManifestForPath,
updateManifestEntry,
saveAgentSource,
createIdeSlashCommands,
updateManifestYaml,
} = require('../lib/agent/installer');
/**
* Initialize BMAD core infrastructure in a directory
* @param {string} projectDir - Project directory where .bmad should be created
* @param {string} bmadFolderName - Name of the BMAD folder (default: .bmad)
* @returns {Promise<Object>} BMAD project info
*/
async function initializeBmadCore(projectDir, bmadFolderName = '.bmad') {
const bmadDir = path.join(projectDir, bmadFolderName);
const cfgDir = path.join(bmadDir, '_cfg');
console.log(chalk.cyan('\n🏗 Initializing BMAD Core Infrastructure\n'));
// Use the ConfigCollector to ask proper core configuration questions
const { ConfigCollector } = require('../installers/lib/core/config-collector');
const configCollector = new ConfigCollector();
// Collect core configuration answers
await configCollector.loadExistingConfig(projectDir);
await configCollector.collectModuleConfig('core', projectDir, true, true);
// Extract core answers from allAnswers (they are prefixed with 'core_')
const coreAnswers = {};
if (configCollector.allAnswers) {
for (const [key, value] of Object.entries(configCollector.allAnswers)) {
if (key.startsWith('core_')) {
const configKey = key.slice(5); // Remove 'core_' prefix
coreAnswers[configKey] = value;
}
}
}
// Ask for IDE selection
console.log(chalk.cyan('\n💻 IDE Configuration\n'));
console.log(chalk.dim('Select IDEs to integrate with the installed agents:'));
const { UI } = require('../lib/ui');
const ui = new UI();
const ideConfig = await ui.promptToolSelection(projectDir, ['core']);
const selectedIdes = ideConfig.ides || [];
// Create directory structure
console.log(chalk.dim('\nCreating directory structure...'));
await fs.promises.mkdir(bmadDir, { recursive: true });
await fs.promises.mkdir(cfgDir, { recursive: true });
await fs.promises.mkdir(path.join(bmadDir, 'core'), { recursive: true });
await fs.promises.mkdir(path.join(bmadDir, 'custom', 'agents'), { recursive: true });
await fs.promises.mkdir(path.join(cfgDir, 'agents'), { recursive: true });
await fs.promises.mkdir(path.join(cfgDir, 'custom', 'agents'), { recursive: true });
// Create core config.yaml file
const coreConfigFile = {
'# CORE Module Configuration': 'Generated by BMAD Agent Installer',
Version: require(path.join(__dirname, '../../../package.json')).version,
Date: new Date().toISOString(),
bmad_folder: bmadFolderName,
...coreAnswers,
};
const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml');
await fs.promises.writeFile(coreConfigPath, yaml.dump(coreConfigFile), 'utf8');
// Create manifest.yaml with complete structure
const manifest = {
version: require(path.join(__dirname, '../../../package.json')).version,
date: new Date().toISOString(),
user_name: coreAnswers.user_name,
communication_language: coreAnswers.communication_language,
document_output_language: coreAnswers.document_output_language,
output_folder: coreAnswers.output_folder,
install_user_docs: coreAnswers.install_user_docs,
bmad_folder: bmadFolderName,
modules: ['core'],
ides: selectedIdes,
custom_agents: [],
};
const manifestPath = path.join(cfgDir, 'manifest.yaml');
await fs.promises.writeFile(manifestPath, yaml.dump(manifest), 'utf8');
// Create empty manifests
const agentManifestPath = path.join(cfgDir, 'agent-manifest.csv');
await fs.promises.writeFile(agentManifestPath, 'type,subtype,name,path,display_name,description,author,version,tags\n', 'utf8');
// Setup IDE configurations
if (selectedIdes.length > 0) {
console.log(chalk.dim('\nSetting up IDE configurations...'));
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of selectedIdes) {
await ideManager.setup(ide, projectDir, bmadDir, {
selectedModules: ['core'],
skipModuleInstall: false,
verbose: false,
preCollectedConfig: coreAnswers,
});
}
}
console.log(chalk.green('\n✓ BMAD core infrastructure initialized'));
console.log(chalk.dim(` BMAD folder: ${bmadDir}`));
console.log(chalk.dim(` Core config: ${coreConfigPath}`));
console.log(chalk.dim(` Manifest: ${manifestPath}`));
if (selectedIdes.length > 0) {
console.log(chalk.dim(` IDEs configured: ${selectedIdes.join(', ')}`));
}
return {
projectRoot: projectDir,
bmadFolder: bmadDir,
cfgFolder: cfgDir,
manifestFile: agentManifestPath,
ides: selectedIdes,
};
}
module.exports = {
command: 'agent-install',
description: 'Install and compile BMAD agents with personalization',
options: [
['-s, --source <path>', 'Path to specific agent YAML file or folder'],
['-d, --defaults', 'Use default values without prompting'],
['-t, --destination <path>', '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_stand_alone_location
? resolvePath(config.custom_stand_alone_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);
// Check if agent has sidecar
if (agentConfig.metadata.hasSidecar) {
selectedAgent.hasSidecar = true;
}
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}`));
}
if (agentConfig.metadata.hasSidecar) {
console.log(chalk.dim(`Sidecar: Yes`));
}
// 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) {
// Check if target has BMAD infrastructure
const otherProject = detectBmadProject(targetDir);
if (!otherProject) {
// No BMAD infrastructure found - offer to initialize
console.log(chalk.yellow(`\n⚠️ No BMAD infrastructure found in: ${targetDir}`));
const initResponse = await inquirer.prompt([
{
type: 'confirm',
name: 'initialize',
message: 'Initialize BMAD core infrastructure here? (Choose No for direct installation)',
default: true,
},
]);
if (initResponse.initialize) {
// Initialize BMAD core
targetDir = path.resolve(targetDir);
await initializeBmadCore(targetDir, '.bmad');
// Set targetDir to the custom agents folder
targetDir = path.join(targetDir, '.bmad', 'custom', 'agents');
console.log(chalk.dim(` Agent will be installed to: ${targetDir}`));
} else {
// User declined - keep original targetDir
console.log(chalk.yellow(` Installing agent directly to: ${targetDir}`));
}
} else if (otherProject && !targetDir.includes('agents')) {
console.log(chalk.yellow(`\n⚠️ Path is inside BMAD project: ${otherProject.projectRoot}`));
const projectChoice = await inquirer.prompt([
{
type: 'list',
name: 'choice',
message: 'Choose installation method:',
choices: [
{ name: `Install to BMAD's custom agents folder (${otherProject.bmadFolder}/custom/agents)`, value: 'bmad' },
{ name: `Install directly to specified path (${targetDir})`, value: 'direct' },
],
default: 'bmad',
},
]);
if (projectChoice.choice === 'bmad') {
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
console.log(chalk.dim(` Installing to BMAD custom agents folder: ${targetDir}`));
} else {
console.log(chalk.yellow(` Installing directly to: ${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)}`);
console.log(` 2. Enter path directly (e.g., /Users/brianmadison/dev/test)`);
const choice = await new Promise((resolve) => {
rl.question('\n Select option (1 or 2): ', resolve);
});
if (choice.trim() === '1' || choice.trim() === '') {
targetDir = currentCustom;
} else if (choice.trim() === '2') {
const userPath = await new Promise((resolve) => {
rl.question(' Enter path: ', resolve);
});
// Detect if it's a BMAD project and use its custom folder
const otherProject = detectBmadProject(path.resolve(userPath));
if (otherProject) {
console.log(chalk.yellow(`\n⚠️ Path is inside BMAD project: ${otherProject.projectRoot}`));
const projectChoice = await inquirer.prompt([
{
type: 'list',
name: 'choice',
message: 'Choose installation method:',
choices: [
{ name: `Install to BMAD's custom agents folder (${otherProject.bmadFolder}/custom/agents)`, value: 'bmad' },
{ name: `Install directly to specified path (${userPath})`, value: 'direct' },
],
default: 'bmad',
},
]);
if (projectChoice.choice === 'bmad') {
targetDir = path.join(otherProject.bmadFolder, 'custom', 'agents');
console.log(chalk.dim(` Installing to BMAD custom agents folder: ${targetDir}`));
} else {
targetDir = path.resolve(userPath);
console.log(chalk.yellow(` Installing directly to: ${targetDir}`));
}
} else {
// No BMAD found - offer to initialize
console.log(chalk.yellow(`\n⚠️ No BMAD infrastructure found in: ${userPath}`));
const initResponse = await inquirer.prompt([
{
type: 'confirm',
name: 'initialize',
message: 'Initialize BMAD core infrastructure here? (Choose No for direct installation)',
default: true,
},
]);
if (initResponse.initialize) {
await initializeBmadCore(path.resolve(userPath), '.bmad');
targetDir = path.join(path.resolve(userPath), '.bmad', 'custom', 'agents');
console.log(chalk.dim(` Agent will be installed to: ${targetDir}`));
} else {
// User declined - create the directory and install directly
targetDir = path.resolve(userPath);
console.log(chalk.yellow(` Installing agent directly to: ${targetDir}`));
}
}
} else {
console.log(chalk.red(' Invalid selection. Please choose 1 or 2.'));
rl.close();
process.exit(1);
}
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);
// Read core config to get agent_sidecar_folder
const coreConfigPath = path.join(config.bmadFolder, 'bmb', 'config.yaml');
let coreConfig = {};
if (fs.existsSync(coreConfigPath)) {
const yamlLib = require('yaml');
const content = fs.readFileSync(coreConfigPath, 'utf8');
coreConfig = yamlLib.parse(content);
}
// Compile with proper name and path
const { xml, metadata, processedYaml } = compileAgent(
fs.readFileSync(selectedAgent.yamlFile, 'utf8'),
answers,
finalAgentName,
relativePath,
{ config: coreConfig },
);
// Write compiled XML (.md) with custom name
fs.writeFileSync(compiledPath, xml, 'utf8');
const result = {
success: true,
agentName: finalAgentName,
targetDir: agentTargetDir,
compiledFile: compiledPath,
sidecarCopied: false,
};
// Handle sidecar files for agents with hasSidecar flag
if (selectedAgent.hasSidecar === true && selectedAgent.type === 'expert') {
const { copyAgentSidecarFiles } = require('../lib/agent/installer');
// Get agent sidecar folder from config or use default
const agentSidecarFolder = coreConfig?.agent_sidecar_folder || '{project-root}/.myagent-data';
// Resolve path variables
const resolvedSidecarFolder = agentSidecarFolder
.replaceAll('{project-root}', projectRoot)
.replaceAll('{bmad_folder}', config.bmadFolder);
// Create sidecar directory for this agent
const agentSidecarDir = path.join(resolvedSidecarFolder, finalAgentName);
if (!fs.existsSync(agentSidecarDir)) {
fs.mkdirSync(agentSidecarDir, { recursive: true });
}
// Find and copy sidecar folder
const sidecarFiles = copyAgentSidecarFiles(selectedAgent.path, agentSidecarDir, selectedAgent.yamlFile);
result.sidecarCopied = true;
result.sidecarFiles = sidecarFiles;
result.sidecarDir = agentSidecarDir;
console.log(chalk.dim(` Sidecar copied to: ${agentSidecarDir}`));
}
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);
}
},
};