const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); const { BaseIdeSetup } = require('./_base-ide'); 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 = {}) { console.log(chalk.cyan(`Setting up ${this.name}...`)); // Clean up any old BMAD installation first await this.cleanup(projectDir); 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; } this.printSummary(results, target_dir); 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') { // Use different template based on workflow type (YAML vs MD) // Default to 'default' template type, but allow override via config const workflowTemplateType = artifact.isYamlWorkflow ? config.yaml_workflow_template || `${templateType}-workflow-yaml` : config.md_workflow_template || `${templateType}-workflow`; // Fall back to default templates if specific ones don't exist const finalTemplateType = artifact.isYamlWorkflow ? 'default-workflow-yaml' : '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) */ printSummary(results, targetDir) { console.log(chalk.green(`\n✓ ${this.name} configured:`)); if (results.agents > 0) { console.log(chalk.dim(` - ${results.agents} agents installed`)); } if (results.workflows > 0) { console.log(chalk.dim(` - ${results.workflows} workflow commands generated`)); } if (results.tasks > 0 || results.tools > 0) { console.log(chalk.dim(` - ${results.tasks + results.tools} task/tool commands generated`)); } console.log(chalk.dim(` - Destination: ${targetDir}`)); } /** * Cleanup IDE configuration * @param {string} projectDir - Project directory */ async cleanup(projectDir) { // Clean all target directories if (this.installerConfig?.targets) { for (const target of this.installerConfig.targets) { await this.cleanupTarget(projectDir, target.target_dir); } } else if (this.installerConfig?.target_dir) { await this.cleanupTarget(projectDir, this.installerConfig.target_dir); } } /** * Cleanup a specific target directory * @param {string} projectDir - Project directory * @param {string} targetDir - Target directory to clean */ async cleanupTarget(projectDir, targetDir) { 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) { // Skip non-strings or undefined entries if (!entry || typeof entry !== 'string') { continue; } if (entry.startsWith('bmad')) { const entryPath = path.join(targetPath, entry); const stat = await fs.stat(entryPath); if (stat.isFile()) { await fs.remove(entryPath); removedCount++; } else if (stat.isDirectory()) { await fs.remove(entryPath); removedCount++; } } } if (removedCount > 0) { console.log(chalk.dim(` Cleaned ${removedCount} BMAD files from ${targetDir}`)); } } } module.exports = { ConfigDrivenIdeSetup };