mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-01-30 04:32:02 +00:00
424 lines
15 KiB
JavaScript
424 lines
15 KiB
JavaScript
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<Object>} 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<Object>} 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<Object>} 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<number>} 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<number>} 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<string>} 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<string>} 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.
|
|
|
|
<agent-activation CRITICAL="TRUE">
|
|
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 <activation> section precisely
|
|
</agent-activation>
|
|
`;
|
|
}
|
|
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 };
|