const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); const { XmlHandler } = require('../../../lib/xml-handler'); const { getSourcePath } = require('../../../lib/project-root'); /** * Base class for IDE-specific setup * All IDE handlers should extend this class */ class BaseIdeSetup { constructor(name, displayName = null, preferred = false) { this.name = name; this.displayName = displayName || name; // Human-readable name for UI this.preferred = preferred; // Whether this IDE should be shown in preferred list this.configDir = null; // Override in subclasses this.rulesDir = null; // Override in subclasses this.configFile = null; // Override in subclasses when detection is file-based this.detectionPaths = []; // Additional paths that indicate the IDE is configured this.xmlHandler = new XmlHandler(); this.bmadFolderName = 'bmad'; // Default, can be overridden } /** * Set the bmad folder name for placeholder replacement * @param {string} bmadFolderName - The bmad folder name */ setBmadFolderName(bmadFolderName) { this.bmadFolderName = bmadFolderName; } /** * Get the agent command activation header from the central template * @returns {string} The activation header text (without XML tags) */ async getAgentCommandHeader() { const headerPath = path.join(getSourcePath(), 'src', 'utility', 'models', 'agent-command-header.md'); try { const content = await fs.readFile(headerPath, 'utf8'); // Strip the tags to get plain text return content.replaceAll(/|<\/critical>/g, '').trim(); } catch { // Fallback if file doesn't exist return "You must fully embody this agent's persona and follow all activation instructions, steps and rules exactly as specified. NEVER break character until given an exit command."; } } /** * Main setup method - must be implemented by subclasses * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Object} options - Setup options */ async setup(projectDir, bmadDir, options = {}) { throw new Error(`setup() must be implemented by ${this.name} handler`); } /** * Cleanup IDE configuration * @param {string} projectDir - Project directory */ async cleanup(projectDir) { // Default implementation - can be overridden if (this.configDir) { const configPath = path.join(projectDir, this.configDir); if (await fs.pathExists(configPath)) { const bmadRulesPath = path.join(configPath, 'bmad'); if (await fs.pathExists(bmadRulesPath)) { await fs.remove(bmadRulesPath); console.log(chalk.dim(`Removed ${this.name} BMAD configuration`)); } } } } /** * Detect whether this IDE already has configuration in the project * Subclasses can override for custom logic * @param {string} projectDir - Project directory * @returns {boolean} */ async detect(projectDir) { const pathsToCheck = []; if (this.configDir) { pathsToCheck.push(path.join(projectDir, this.configDir)); } if (this.configFile) { pathsToCheck.push(path.join(projectDir, this.configFile)); } if (Array.isArray(this.detectionPaths)) { for (const candidate of this.detectionPaths) { if (!candidate) continue; const resolved = path.isAbsolute(candidate) ? candidate : path.join(projectDir, candidate); pathsToCheck.push(resolved); } } for (const candidate of pathsToCheck) { if (await fs.pathExists(candidate)) { return true; } } return false; } /** * Get list of agents from BMAD installation * @param {string} bmadDir - BMAD installation directory * @returns {Array} List of agent files */ async getAgents(bmadDir) { const agents = []; // Get core agents const coreAgentsPath = path.join(bmadDir, 'core', 'agents'); if (await fs.pathExists(coreAgentsPath)) { const coreAgents = await this.scanDirectory(coreAgentsPath, '.md'); agents.push( ...coreAgents.map((a) => ({ ...a, module: 'core', })), ); } // Get module agents const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg' && entry.name !== 'agents') { const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents'); if (await fs.pathExists(moduleAgentsPath)) { const moduleAgents = await this.scanDirectory(moduleAgentsPath, '.md'); agents.push( ...moduleAgents.map((a) => ({ ...a, module: entry.name, })), ); } } } // Get standalone agents from bmad/agents/ directory const standaloneAgentsDir = path.join(bmadDir, 'agents'); if (await fs.pathExists(standaloneAgentsDir)) { const agentDirs = await fs.readdir(standaloneAgentsDir, { withFileTypes: true }); for (const agentDir of agentDirs) { if (!agentDir.isDirectory()) continue; const agentDirPath = path.join(standaloneAgentsDir, agentDir.name); const agentFiles = await fs.readdir(agentDirPath); for (const file of agentFiles) { if (!file.endsWith('.md')) continue; if (file.includes('.customize.')) continue; const filePath = path.join(agentDirPath, file); const content = await fs.readFile(filePath, 'utf8'); if (content.includes('localskip="true"')) continue; agents.push({ name: file.replace('.md', ''), path: filePath, relativePath: path.relative(standaloneAgentsDir, filePath), filename: file, module: 'standalone', // Mark as standalone agent }); } } } return agents; } /** * Get list of tasks from BMAD installation * @param {string} bmadDir - BMAD installation directory * @param {boolean} standaloneOnly - If true, only return standalone tasks * @returns {Array} List of task files */ async getTasks(bmadDir, standaloneOnly = false) { const tasks = []; // Get core tasks (scan for both .md and .xml) const coreTasksPath = path.join(bmadDir, 'core', 'tasks'); if (await fs.pathExists(coreTasksPath)) { const coreTasks = await this.scanDirectoryWithStandalone(coreTasksPath, ['.md', '.xml']); tasks.push( ...coreTasks.map((t) => ({ ...t, module: 'core', })), ); } // Get module tasks const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg' && entry.name !== 'agents') { const moduleTasksPath = path.join(bmadDir, entry.name, 'tasks'); if (await fs.pathExists(moduleTasksPath)) { const moduleTasks = await this.scanDirectoryWithStandalone(moduleTasksPath, ['.md', '.xml']); tasks.push( ...moduleTasks.map((t) => ({ ...t, module: entry.name, })), ); } } } // Filter by standalone if requested if (standaloneOnly) { return tasks.filter((t) => t.standalone === true); } return tasks; } /** * Get list of tools from BMAD installation * @param {string} bmadDir - BMAD installation directory * @param {boolean} standaloneOnly - If true, only return standalone tools * @returns {Array} List of tool files */ async getTools(bmadDir, standaloneOnly = false) { const tools = []; // Get core tools (scan for both .md and .xml) const coreToolsPath = path.join(bmadDir, 'core', 'tools'); if (await fs.pathExists(coreToolsPath)) { const coreTools = await this.scanDirectoryWithStandalone(coreToolsPath, ['.md', '.xml']); tools.push( ...coreTools.map((t) => ({ ...t, module: 'core', })), ); } // Get module tools const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg' && entry.name !== 'agents') { const moduleToolsPath = path.join(bmadDir, entry.name, 'tools'); if (await fs.pathExists(moduleToolsPath)) { const moduleTools = await this.scanDirectoryWithStandalone(moduleToolsPath, ['.md', '.xml']); tools.push( ...moduleTools.map((t) => ({ ...t, module: entry.name, })), ); } } } // Filter by standalone if requested if (standaloneOnly) { return tools.filter((t) => t.standalone === true); } return tools; } /** * Get list of workflows from BMAD installation * @param {string} bmadDir - BMAD installation directory * @param {boolean} standaloneOnly - If true, only return standalone workflows * @returns {Array} List of workflow files */ async getWorkflows(bmadDir, standaloneOnly = false) { const workflows = []; // Get core workflows const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows'); if (await fs.pathExists(coreWorkflowsPath)) { const coreWorkflows = await this.findWorkflowYamlFiles(coreWorkflowsPath); workflows.push( ...coreWorkflows.map((w) => ({ ...w, module: 'core', })), ); } // Get module workflows const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg' && entry.name !== 'agents') { const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows'); if (await fs.pathExists(moduleWorkflowsPath)) { const moduleWorkflows = await this.findWorkflowYamlFiles(moduleWorkflowsPath); workflows.push( ...moduleWorkflows.map((w) => ({ ...w, module: entry.name, })), ); } } } // Filter by standalone if requested if (standaloneOnly) { return workflows.filter((w) => w.standalone === true); } return workflows; } /** * Recursively find workflow.yaml files * @param {string} dir - Directory to search * @returns {Array} List of workflow file info objects */ async findWorkflowYamlFiles(dir) { const workflows = []; if (!(await fs.pathExists(dir))) { return workflows; } const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Recursively search subdirectories const subWorkflows = await this.findWorkflowYamlFiles(fullPath); workflows.push(...subWorkflows); } else if (entry.isFile() && entry.name === 'workflow.yaml') { // Read workflow.yaml to get name and standalone property try { const yaml = require('js-yaml'); const content = await fs.readFile(fullPath, 'utf8'); const workflowData = yaml.load(content); if (workflowData && workflowData.name) { workflows.push({ name: workflowData.name, path: fullPath, relativePath: path.relative(dir, fullPath), filename: entry.name, description: workflowData.description || '', standalone: workflowData.standalone === true, // Check standalone property }); } } catch { // Skip invalid workflow files } } } return workflows; } /** * Scan a directory for files with specific extension(s) * @param {string} dir - Directory to scan * @param {string|Array} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) * @returns {Array} List of file info objects */ async scanDirectory(dir, ext) { const files = []; if (!(await fs.pathExists(dir))) { return files; } // Normalize ext to array const extensions = Array.isArray(ext) ? ext : [ext]; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Recursively scan subdirectories const subFiles = await this.scanDirectory(fullPath, ext); files.push(...subFiles); } else if (entry.isFile()) { // Check if file matches any of the extensions const matchedExt = extensions.find((e) => entry.name.endsWith(e)); if (matchedExt) { files.push({ name: path.basename(entry.name, matchedExt), path: fullPath, relativePath: path.relative(dir, fullPath), filename: entry.name, }); } } } return files; } /** * Scan a directory for files with specific extension(s) and check standalone attribute * @param {string} dir - Directory to scan * @param {string|Array} ext - File extension(s) to match (e.g., '.md' or ['.md', '.xml']) * @returns {Array} List of file info objects with standalone property */ async scanDirectoryWithStandalone(dir, ext) { const files = []; if (!(await fs.pathExists(dir))) { return files; } // Normalize ext to array const extensions = Array.isArray(ext) ? ext : [ext]; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Recursively scan subdirectories const subFiles = await this.scanDirectoryWithStandalone(fullPath, ext); files.push(...subFiles); } else if (entry.isFile()) { // Check if file matches any of the extensions const matchedExt = extensions.find((e) => entry.name.endsWith(e)); if (matchedExt) { // Read file content to check for standalone attribute let standalone = false; try { const content = await fs.readFile(fullPath, 'utf8'); // Check for standalone="true" in XML files if (entry.name.endsWith('.xml')) { // Look for standalone="true" in the opening tag (task or tool) const standaloneMatch = content.match(/<(?:task|tool)[^>]+standalone="true"/); standalone = !!standaloneMatch; } else if (entry.name.endsWith('.md')) { // Check for standalone: true in YAML frontmatter const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (frontmatterMatch) { const yaml = require('js-yaml'); try { const frontmatter = yaml.load(frontmatterMatch[1]); standalone = frontmatter.standalone === true; } catch { // Ignore YAML parse errors } } } } catch { // If we can't read the file, assume not standalone standalone = false; } files.push({ name: path.basename(entry.name, matchedExt), path: fullPath, relativePath: path.relative(dir, fullPath), filename: entry.name, standalone: standalone, }); } } } return files; } /** * Create IDE command/rule file from agent or task * @param {string} content - File content * @param {Object} metadata - File metadata * @param {string} projectDir - The actual project directory path * @returns {string} Processed content */ processContent(content, metadata = {}, projectDir = null) { // Replace placeholders let processed = content; // Inject activation block for agent files FIRST (before replacements) if (metadata.name && content.includes(' word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Flatten a relative path to a single filename for flat slash command naming * Example: 'module/agents/name.md' -> 'bmad-module-agents-name.md' * Used by IDEs that ignore directory structure for slash commands (e.g., Antigravity, Codex) * @param {string} relativePath - Relative path to flatten * @returns {string} Flattened filename with 'bmad-' prefix */ flattenFilename(relativePath) { const sanitized = relativePath.replaceAll(/[/\\]/g, '-'); return `bmad-${sanitized}`; } /** * Create agent configuration file * @param {string} bmadDir - BMAD installation directory * @param {Object} agent - Agent information */ async createAgentConfig(bmadDir, agent) { const agentConfigDir = path.join(bmadDir, '_cfg', 'agents'); await this.ensureDir(agentConfigDir); // Load agent config template const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md'); const templateContent = await this.readFile(templatePath); const configContent = `# Agent Config: ${agent.name} ${templateContent}`; const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`); await this.writeFile(configPath, configContent); } } module.exports = { BaseIdeSetup };