BMAD-METHOD/tools/cli/installers/lib/ide/roo.js

261 lines
9.2 KiB
JavaScript

const path = require('node:path');
const { BaseIdeSetup } = require('./_base-ide');
const chalk = require('chalk');
const { AgentCommandGenerator } = require('./shared/agent-command-generator');
/**
* Roo IDE setup handler
* Creates custom commands in .roo/commands directory
*/
class RooSetup extends BaseIdeSetup {
constructor() {
super('roo', 'Roo Code');
this.configDir = '.roo';
this.commandsDir = 'commands';
}
/**
* Setup Roo IDE configuration
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {Object} options - Setup options
*/
async setup(projectDir, bmadDir, options = {}) {
console.log(chalk.cyan(`Setting up ${this.name}...`));
// Create .roo/commands directory
const rooCommandsDir = path.join(projectDir, this.configDir, this.commandsDir);
await this.ensureDir(rooCommandsDir);
// Generate agent launchers
const agentGen = new AgentCommandGenerator(this.bmadFolderName);
const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []);
let addedCount = 0;
let skippedCount = 0;
for (const artifact of agentArtifacts) {
const commandName = `bmad-${artifact.module}-agent-${artifact.name}`;
const commandPath = path.join(rooCommandsDir, `${commandName}.md`);
// Skip if already exists
if (await this.pathExists(commandPath)) {
console.log(chalk.dim(` Skipping ${commandName} - already exists`));
skippedCount++;
continue;
}
// Read the actual agent file from _bmad for metadata extraction (installed agents are .md files)
const agentPath = path.join(bmadDir, artifact.module, 'agents', `${artifact.name}.md`);
const content = await this.readFile(agentPath);
// Create command file that references the actual _bmad agent
await this.createCommandFile({ module: artifact.module, name: artifact.name, path: agentPath }, content, commandPath, projectDir);
addedCount++;
console.log(chalk.green(` ✓ Added command: ${commandName}`));
}
console.log(chalk.green(`${this.name} configured:`));
console.log(chalk.dim(` - ${addedCount} commands added`));
if (skippedCount > 0) {
console.log(chalk.dim(` - ${skippedCount} commands skipped (already exist)`));
}
console.log(chalk.dim(` - Commands directory: ${this.configDir}/${this.commandsDir}/bmad/`));
console.log(chalk.dim(` Commands will be available when you open this project in Roo Code`));
return {
success: true,
commands: addedCount,
skipped: skippedCount,
};
}
/**
* Create a unified command file for agents
* @param {string} commandPath - Path where to write the command file
* @param {Object} options - Command options
* @param {string} options.name - Display name for the command
* @param {string} options.description - Description for the command
* @param {string} options.agentPath - Path to the agent file (relative to project root)
* @param {string} [options.icon] - Icon emoji (defaults to 🤖)
* @param {string} [options.extraContent] - Additional content to include before activation
*/
async createAgentCommandFile(commandPath, options) {
const { name, description, agentPath, icon = '🤖', extraContent = '' } = options;
// Build command content with YAML frontmatter
let commandContent = `---\n`;
commandContent += `name: '${icon} ${name}'\n`;
commandContent += `description: '${description}'\n`;
commandContent += `---\n\n`;
commandContent += `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`;
// Add any extra content (e.g., warnings for custom agents)
if (extraContent) {
commandContent += `${extraContent}\n\n`;
}
commandContent += `<agent-activation CRITICAL="TRUE">\n`;
commandContent += `1. LOAD the FULL agent file from @${agentPath}\n`;
commandContent += `2. READ its entire contents - this contains the complete agent persona, menu, and instructions\n`;
commandContent += `3. Execute ALL activation steps exactly as written in the agent file\n`;
commandContent += `4. Follow the agent's persona and menu system precisely\n`;
commandContent += `5. Stay in character throughout the session\n`;
commandContent += `</agent-activation>\n`;
// Write command file
await this.writeFile(commandPath, commandContent);
}
/**
* Create a command file for an agent
*/
async createCommandFile(agent, content, commandPath, projectDir) {
// Extract metadata from agent content
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agent.name);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
// Get relative path
const relativePath = path.relative(projectDir, agent.path).replaceAll('\\', '/');
// Use unified method
await this.createAgentCommandFile(commandPath, {
name: title,
description: whenToUse,
agentPath: relativePath,
icon: icon,
});
}
/**
* Format name as title
*/
formatTitle(name) {
return name
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
/**
* Cleanup Roo configuration
*/
async cleanup(projectDir) {
const fs = require('fs-extra');
const rooCommandsDir = path.join(projectDir, this.configDir, this.commandsDir);
if (await fs.pathExists(rooCommandsDir)) {
const files = await fs.readdir(rooCommandsDir);
let removedCount = 0;
for (const file of files) {
if (file.startsWith('bmad-') && file.endsWith('.md')) {
await fs.remove(path.join(rooCommandsDir, file));
removedCount++;
}
}
if (removedCount > 0) {
console.log(chalk.dim(`Removed ${removedCount} BMAD commands from .roo/commands/`));
}
}
// Also clean up old .roomodes file if it exists
const roomodesPath = path.join(projectDir, '.roomodes');
if (await fs.pathExists(roomodesPath)) {
const content = await fs.readFile(roomodesPath, 'utf8');
// Remove BMAD modes only
const lines = content.split('\n');
const filteredLines = [];
let skipMode = false;
let removedCount = 0;
for (const line of lines) {
if (/^\s*- slug: bmad-/.test(line)) {
skipMode = true;
removedCount++;
} else if (skipMode && /^\s*- slug: /.test(line)) {
skipMode = false;
}
if (!skipMode) {
filteredLines.push(line);
}
}
// Write back filtered content
await fs.writeFile(roomodesPath, filteredLines.join('\n'));
if (removedCount > 0) {
console.log(chalk.dim(`Removed ${removedCount} BMAD modes from legacy .roomodes file`));
}
}
}
/**
* Install a custom agent launcher for Roo
* @param {string} projectDir - Project directory
* @param {string} agentName - Agent name (e.g., "fred-commit-poet")
* @param {string} agentPath - Path to compiled agent (relative to project root)
* @param {Object} metadata - Agent metadata (unused, kept for compatibility)
* @returns {Object} Installation result
*/
async installCustomAgentLauncher(projectDir, agentName, agentPath, metadata) {
const rooCommandsDir = path.join(projectDir, this.configDir, this.commandsDir);
await this.ensureDir(rooCommandsDir);
const commandName = `bmad-custom-agent-${agentName.toLowerCase()}`;
const commandPath = path.join(rooCommandsDir, `${commandName}.md`);
// Check if command already exists
if (await this.pathExists(commandPath)) {
return {
ide: 'roo',
path: path.join(this.configDir, this.commandsDir, `${commandName}.md`),
command: commandName,
type: 'custom-agent-launcher',
alreadyExists: true,
};
}
// Read the custom agent file to extract metadata (same as regular agents)
const fullAgentPath = path.join(projectDir, agentPath);
const content = await this.readFile(fullAgentPath);
// Extract metadata from agent content
const titleMatch = content.match(/title="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : this.formatTitle(agentName);
const iconMatch = content.match(/icon="([^"]+)"/);
const icon = iconMatch ? iconMatch[1] : '🤖';
const whenToUseMatch = content.match(/whenToUse="([^"]+)"/);
const whenToUse = whenToUseMatch ? whenToUseMatch[1] : `Use for ${title} tasks`;
// Use unified method without extra content (clean)
await this.createAgentCommandFile(commandPath, {
name: title,
description: whenToUse,
agentPath: agentPath,
icon: icon,
});
return {
ide: 'roo',
path: path.join(this.configDir, this.commandsDir, `${commandName}.md`),
command: commandName,
type: 'custom-agent-launcher',
};
}
}
module.exports = { RooSetup };