const path = require('node:path'); const fs = require('fs-extra'); const { BaseIdeSetup } = require('./_base-ide'); const prompts = require('../../../lib/prompts'); const { AgentCommandGenerator } = require('./shared/agent-command-generator'); const { WorkflowCommandGenerator } = require('./shared/workflow-command-generator'); const { TaskToolCommandGenerator } = require('./shared/task-tool-command-generator'); /** * Config-driven IDE setup handler * * This class provides a standardized way to install BMAD artifacts to IDEs * based on configuration in platform-codes.yaml. It eliminates the need for * individual installer files for each IDE. * * Features: * - Config-driven from platform-codes.yaml * - Template-based content generation * - Multi-target installation support (e.g., GitHub Copilot) * - Artifact type filtering (agents, workflows, tasks, tools) */ class ConfigDrivenIdeSetup extends BaseIdeSetup { constructor(platformCode, platformConfig) { super(platformCode, platformConfig.name, platformConfig.preferred); this.platformConfig = platformConfig; this.installerConfig = platformConfig.installer || null; } /** * Main setup method - called by IdeManager * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Object} options - Setup options * @returns {Promise} Setup result */ async setup(projectDir, bmadDir, options = {}) { if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Clean up any old BMAD installation first await this.cleanup(projectDir, options); if (!this.installerConfig) { return { success: false, reason: 'no-config' }; } // Handle multi-target installations (e.g., GitHub Copilot) if (this.installerConfig.targets) { return this.installToMultipleTargets(projectDir, bmadDir, this.installerConfig.targets, options); } // Handle single-target installations if (this.installerConfig.target_dir) { return this.installToTarget(projectDir, bmadDir, this.installerConfig, options); } return { success: false, reason: 'invalid-config' }; } /** * Install to a single target directory * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Object} config - Installation configuration * @param {Object} options - Setup options * @returns {Promise} Installation result */ async installToTarget(projectDir, bmadDir, config, options) { const { target_dir, template_type, artifact_types } = config; const targetPath = path.join(projectDir, target_dir); await this.ensureDir(targetPath); const selectedModules = options.selectedModules || []; const results = { agents: 0, workflows: 0, tasks: 0, tools: 0 }; // Install agents if (!artifact_types || artifact_types.includes('agents')) { const agentGen = new AgentCommandGenerator(this.bmadFolderName); const { artifacts } = await agentGen.collectAgentArtifacts(bmadDir, selectedModules); results.agents = await this.writeAgentArtifacts(targetPath, artifacts, template_type, config); } // Install workflows if (!artifact_types || artifact_types.includes('workflows')) { const workflowGen = new WorkflowCommandGenerator(this.bmadFolderName); const { artifacts } = await workflowGen.collectWorkflowArtifacts(bmadDir); results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config); } // Install tasks and tools if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) { const taskToolGen = new TaskToolCommandGenerator(); const taskToolResult = await taskToolGen.generateDashTaskToolCommands(projectDir, bmadDir, targetPath); results.tasks = taskToolResult.tasks || 0; results.tools = taskToolResult.tools || 0; } await this.printSummary(results, target_dir, options); return { success: true, results }; } /** * Install to multiple target directories * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory * @param {Array} targets - Array of target configurations * @param {Object} options - Setup options * @returns {Promise} Installation result */ async installToMultipleTargets(projectDir, bmadDir, targets, options) { const allResults = { agents: 0, workflows: 0, tasks: 0, tools: 0 }; for (const target of targets) { const result = await this.installToTarget(projectDir, bmadDir, target, options); if (result.success) { allResults.agents += result.results.agents || 0; allResults.workflows += result.results.workflows || 0; allResults.tasks += result.results.tasks || 0; allResults.tools += result.results.tools || 0; } } return { success: true, results: allResults }; } /** * Write agent artifacts to target directory * @param {string} targetPath - Target directory path * @param {Array} artifacts - Agent artifacts * @param {string} templateType - Template type to use * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) { // Try to load platform-specific template, fall back to default-agent const template = await this.loadTemplate(templateType, 'agent', config, 'default-agent'); let count = 0; for (const artifact of artifacts) { const content = this.renderTemplate(template, artifact); const filename = this.generateFilename(artifact, 'agent'); const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); count++; } return count; } /** * Write workflow artifacts to target directory * @param {string} targetPath - Target directory path * @param {Array} artifacts - Workflow artifacts * @param {string} templateType - Template type to use * @param {Object} config - Installation configuration * @returns {Promise} Count of artifacts written */ async writeWorkflowArtifacts(targetPath, artifacts, templateType, config = {}) { let count = 0; for (const artifact of artifacts) { if (artifact.type === 'workflow-command') { // Default to 'default' template type, but allow override via config const workflowTemplateType = config.md_workflow_template || `${templateType}-workflow`; // Fall back to default template if the requested one doesn't exist const finalTemplateType = 'default-workflow'; const template = await this.loadTemplate(workflowTemplateType, 'workflow', config, finalTemplateType); const content = this.renderTemplate(template, artifact); const filename = this.generateFilename(artifact, 'workflow'); const filePath = path.join(targetPath, filename); await this.writeFile(filePath, content); count++; } } return count; } /** * Load template based on type and configuration * @param {string} templateType - Template type (claude, windsurf, etc.) * @param {string} artifactType - Artifact type (agent, workflow, task, tool) * @param {Object} config - Installation configuration * @param {string} fallbackTemplateType - Fallback template type if requested template not found * @returns {Promise} Template content */ async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) { const { header_template, body_template } = config; // Check for separate header/body templates if (header_template || body_template) { return await this.loadSplitTemplates(templateType, artifactType, header_template, body_template); } // Load combined template const templateName = `${templateType}-${artifactType}.md`; const templatePath = path.join(__dirname, 'templates', 'combined', templateName); if (await fs.pathExists(templatePath)) { return await fs.readFile(templatePath, 'utf8'); } // Fall back to default template (if provided) if (fallbackTemplateType) { const fallbackPath = path.join(__dirname, 'templates', 'combined', `${fallbackTemplateType}.md`); if (await fs.pathExists(fallbackPath)) { return await fs.readFile(fallbackPath, 'utf8'); } } // Ultimate fallback - minimal template return this.getDefaultTemplate(artifactType); } /** * Load split templates (header + body) * @param {string} templateType - Template type * @param {string} artifactType - Artifact type * @param {string} headerTpl - Header template name * @param {string} bodyTpl - Body template name * @returns {Promise} Combined template content */ async loadSplitTemplates(templateType, artifactType, headerTpl, bodyTpl) { let header = ''; let body = ''; // Load header template if (headerTpl) { const headerPath = path.join(__dirname, 'templates', 'split', headerTpl); if (await fs.pathExists(headerPath)) { header = await fs.readFile(headerPath, 'utf8'); } } else { // Use default header for template type const defaultHeaderPath = path.join(__dirname, 'templates', 'split', templateType, 'header.md'); if (await fs.pathExists(defaultHeaderPath)) { header = await fs.readFile(defaultHeaderPath, 'utf8'); } } // Load body template if (bodyTpl) { const bodyPath = path.join(__dirname, 'templates', 'split', bodyTpl); if (await fs.pathExists(bodyPath)) { body = await fs.readFile(bodyPath, 'utf8'); } } else { // Use default body for template type const defaultBodyPath = path.join(__dirname, 'templates', 'split', templateType, 'body.md'); if (await fs.pathExists(defaultBodyPath)) { body = await fs.readFile(defaultBodyPath, 'utf8'); } } // Combine header and body return `${header}\n${body}`; } /** * Get default minimal template * @param {string} artifactType - Artifact type * @returns {string} Default template */ getDefaultTemplate(artifactType) { if (artifactType === 'agent') { return `--- name: '{{name}}' description: '{{description}}' --- You must fully embody this agent's persona and follow all activation instructions exactly as specified. 1. LOAD the FULL agent file from {project-root}/{{bmadFolderName}}/{{path}} 2. READ its entire contents - this contains the complete agent persona, menu, and instructions 3. FOLLOW every step in the section precisely `; } return `--- name: '{{name}}' description: '{{description}}' --- # {{name}} LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} `; } /** * Render template with artifact data * @param {string} template - Template content * @param {Object} artifact - Artifact data * @returns {string} Rendered content */ renderTemplate(template, artifact) { // Use the appropriate path property based on artifact type let pathToUse = artifact.relativePath || ''; if (artifact.type === 'agent-launcher') { pathToUse = artifact.agentPath || artifact.relativePath || ''; } else if (artifact.type === 'workflow-command') { pathToUse = artifact.workflowPath || artifact.relativePath || ''; } let rendered = template .replaceAll('{{name}}', artifact.name || '') .replaceAll('{{module}}', artifact.module || 'core') .replaceAll('{{path}}', pathToUse) .replaceAll('{{description}}', artifact.description || `${artifact.name} ${artifact.type || ''}`) .replaceAll('{{workflow_path}}', pathToUse); // Replace _bmad placeholder with actual folder name rendered = rendered.replaceAll('_bmad', this.bmadFolderName); // Replace {{bmadFolderName}} placeholder if present rendered = rendered.replaceAll('{{bmadFolderName}}', this.bmadFolderName); return rendered; } /** * Generate filename for artifact * @param {Object} artifact - Artifact data * @param {string} artifactType - Artifact type (agent, workflow, task, tool) * @returns {string} Generated filename */ generateFilename(artifact, artifactType) { const { toDashPath } = require('./shared/path-utils'); // toDashPath already handles the .agent.md suffix for agents correctly // No need to add it again here return toDashPath(artifact.relativePath); } /** * Print installation summary * @param {Object} results - Installation results * @param {string} targetDir - Target directory (relative) */ async printSummary(results, targetDir, options = {}) { if (options.silent) return; const parts = []; if (results.agents > 0) parts.push(`${results.agents} agents`); if (results.workflows > 0) parts.push(`${results.workflows} workflows`); if (results.tasks > 0) parts.push(`${results.tasks} tasks`); if (results.tools > 0) parts.push(`${results.tools} tools`); await prompts.log.success(`${this.name} configured: ${parts.join(', ')} → ${targetDir}`); } /** * Cleanup IDE configuration * @param {string} projectDir - Project directory */ async cleanup(projectDir, options = {}) { // Clean all target directories if (this.installerConfig?.targets) { for (const target of this.installerConfig.targets) { await this.cleanupTarget(projectDir, target.target_dir, options); } } else if (this.installerConfig?.target_dir) { await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); } } /** * Cleanup a specific target directory * @param {string} projectDir - Project directory * @param {string} targetDir - Target directory to clean */ async cleanupTarget(projectDir, targetDir, options = {}) { const targetPath = path.join(projectDir, targetDir); if (!(await fs.pathExists(targetPath))) { return; } // Remove all bmad* files let entries; try { entries = await fs.readdir(targetPath); } catch { // Directory exists but can't be read - skip cleanup return; } if (!entries || !Array.isArray(entries)) { return; } let removedCount = 0; for (const entry of entries) { if (!entry || typeof entry !== 'string') { continue; } if (entry.startsWith('bmad')) { const entryPath = path.join(targetPath, entry); try { await fs.remove(entryPath); removedCount++; } catch { // Skip entries that can't be removed (broken symlinks, permission errors) } } } if (removedCount > 0 && !options.silent) { await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); } } } module.exports = { ConfigDrivenIdeSetup };