const path = require('node:path'); const fs = require('fs-extra'); const csv = require('csv-parse/sync'); const prompts = require('../../../../lib/prompts'); const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD_FOLDER_NAME } = require('./path-utils'); /** * Generates command files for each workflow in the manifest */ class WorkflowCommandGenerator { constructor(bmadFolderName = BMAD_FOLDER_NAME) { this.templatePath = path.join(__dirname, '../templates/workflow-command-template.md'); this.bmadFolderName = bmadFolderName; } /** * Escape regex metacharacters in dynamic path segments. * @param {string} value - Raw string value * @returns {string} Regex-escaped value */ escapeRegex(value) { return String(value).replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); } /** * Normalize workflow paths to a relative path inside the BMAD folder. * Result never starts with '/', '{project-root}/', or '{bmadFolderName}/'. * @param {string} workflowPath - Raw workflow path * @returns {string} Normalized relative workflow path */ normalizeWorkflowRelativePath(workflowPath) { if (!workflowPath || typeof workflowPath !== 'string') { return ''; } let normalized = workflowPath.replaceAll('\\', '/').trim(); // Strip {project-root}/ prefix if present. normalized = normalized.replace(/^\{project-root\}\//, ''); // Normalize source paths (e.g. .../src/bmm/... -> bmm/...). const srcMatch = normalized.match(/(?:^|\/)src\/([^/]+)\/(.+)/); if (srcMatch) { normalized = `${srcMatch[1]}/${srcMatch[2]}`; } else { // Strip configured/legacy BMAD folder prefixes from absolute or relative paths. const configuredFolderMatch = normalized.match(new RegExp(`(?:^|/)${this.escapeRegex(this.bmadFolderName)}/(.+)$`)); if (configuredFolderMatch) { normalized = configuredFolderMatch[1]; } else { const legacyFolderMatch = normalized.match(/(?:^|\/)_bmad\/(.+)$/); if (legacyFolderMatch) { normalized = legacyFolderMatch[1]; } } } // Final cleanup for relative-only contract. normalized = normalized.replace(/^[A-Za-z]:\//, ''); // Windows drive prefix normalized = normalized.replace(/^\.\/+/, ''); normalized = normalized.replace(/^\/+/, ''); normalized = normalized.replaceAll(/\/{2,}/g, '/'); return normalized; } /** * Generate workflow commands from the manifest CSV * @param {string} projectDir - Project directory * @param {string} bmadDir - BMAD installation directory */ async generateWorkflowCommands(projectDir, bmadDir) { const workflows = await this.loadWorkflowManifest(bmadDir); if (!workflows) { await prompts.log.warn('Workflow manifest not found. Skipping command generation.'); return { generated: 0 }; } // ALL workflows now generate commands - no standalone filtering const allWorkflows = workflows; // Base commands directory const baseCommandsDir = path.join(projectDir, '.claude', 'commands', 'bmad'); let generatedCount = 0; // Generate a command file for each workflow, organized by module for (const workflow of allWorkflows) { const moduleWorkflowsDir = path.join(baseCommandsDir, workflow.module, 'workflows'); await fs.ensureDir(moduleWorkflowsDir); const commandContent = await this.generateCommandContent(workflow, bmadDir); const commandPath = path.join(moduleWorkflowsDir, `${workflow.name}.md`); await fs.writeFile(commandPath, commandContent); generatedCount++; } // Also create a workflow launcher README in each module const groupedWorkflows = this.groupWorkflowsByModule(allWorkflows); await this.createModuleWorkflowLaunchers(baseCommandsDir, groupedWorkflows); return { generated: generatedCount }; } async collectWorkflowArtifacts(bmadDir) { const workflows = await this.loadWorkflowManifest(bmadDir); if (!workflows) { return { artifacts: [], counts: { commands: 0, launchers: 0 } }; } // ALL workflows now generate commands - no standalone filtering const allWorkflows = workflows; const artifacts = []; for (const workflow of allWorkflows) { const commandContent = await this.generateCommandContent(workflow, bmadDir); // Calculate a relative workflow path (e.g., bmm/workflows/.../workflow.yaml). const workflowRelPath = this.normalizeWorkflowRelativePath(workflow.path || ''); // Determine if this is a YAML workflow (use normalized path which is guaranteed to be a string) const isYamlWorkflow = workflowRelPath.endsWith('.yaml') || workflowRelPath.endsWith('.yml'); artifacts.push({ type: 'workflow-command', isYamlWorkflow: isYamlWorkflow, // For template selection name: workflow.name, description: workflow.description || `${workflow.name} workflow`, module: workflow.module, relativePath: path.join(workflow.module, 'workflows', `${workflow.name}.md`), workflowPath: workflowRelPath, // Relative path to actual workflow file content: commandContent, sourcePath: workflow.path, }); } const groupedWorkflows = this.groupWorkflowsByModule(allWorkflows); for (const [module, launcherContent] of Object.entries(this.buildModuleWorkflowLaunchers(groupedWorkflows))) { artifacts.push({ type: 'workflow-launcher', module, relativePath: path.join(module, 'workflows', 'README.md'), content: launcherContent, sourcePath: null, }); } return { artifacts, counts: { commands: allWorkflows.length, launchers: Object.keys(groupedWorkflows).length, }, }; } /** * Generate command content for a workflow */ async generateCommandContent(workflow, bmadDir) { const normalizedWorkflowPath = this.normalizeWorkflowRelativePath(workflow.path || ''); // Determine template based on workflow file type const isMarkdownWorkflow = normalizedWorkflowPath.endsWith('workflow.md'); const templateName = isMarkdownWorkflow ? 'workflow-commander.md' : 'workflow-command-template.md'; const templatePath = path.join(path.dirname(this.templatePath), templateName); // Load the appropriate template const template = await fs.readFile(templatePath, 'utf8'); // Replace template variables return template .replaceAll('{{name}}', workflow.name) .replaceAll('{{module}}', workflow.module) .replaceAll('{{description}}', workflow.description) .replaceAll('{{workflow_path}}', normalizedWorkflowPath) .replaceAll('{{bmadFolderName}}', this.bmadFolderName) .replaceAll('_bmad', this.bmadFolderName); } /** * Create workflow launcher files for each module */ async createModuleWorkflowLaunchers(baseCommandsDir, workflowsByModule) { for (const [module, moduleWorkflows] of Object.entries(workflowsByModule)) { const content = this.buildLauncherContent(module, moduleWorkflows); const moduleWorkflowsDir = path.join(baseCommandsDir, module, 'workflows'); await fs.ensureDir(moduleWorkflowsDir); const launcherPath = path.join(moduleWorkflowsDir, 'README.md'); await fs.writeFile(launcherPath, content); } } groupWorkflowsByModule(workflows) { const workflowsByModule = {}; for (const workflow of workflows) { if (!workflowsByModule[workflow.module]) { workflowsByModule[workflow.module] = []; } workflowsByModule[workflow.module].push({ ...workflow, displayPath: this.transformWorkflowPath(workflow.path), }); } return workflowsByModule; } buildModuleWorkflowLaunchers(groupedWorkflows) { const launchers = {}; for (const [module, moduleWorkflows] of Object.entries(groupedWorkflows)) { launchers[module] = this.buildLauncherContent(module, moduleWorkflows); } return launchers; } buildLauncherContent(module, moduleWorkflows) { let content = `# ${module.toUpperCase()} Workflows ## Available Workflows in ${module} `; for (const workflow of moduleWorkflows) { content += `**${workflow.name}**\n`; content += `- Path: \`${workflow.displayPath}\`\n`; content += `- ${workflow.description}\n\n`; } content += ` ## Execution When running any workflow: 1. LOAD {project-root}/${this.bmadFolderName}/core/tasks/workflow.xml 2. Pass the workflow path as 'workflow-config' parameter 3. Follow workflow.xml instructions EXACTLY 4. Save outputs after EACH section ## Modes - Normal: Full interaction - #yolo: Skip optional steps `; return content; } transformWorkflowPath(workflowPath) { let transformed = workflowPath; if (workflowPath.includes('/src/bmm/')) { const match = workflowPath.match(/\/src\/bmm\/(.+)/); if (match) { transformed = `{project-root}/${this.bmadFolderName}/bmm/${match[1]}`; } } else if (workflowPath.includes('/src/core/')) { const match = workflowPath.match(/\/src\/core\/(.+)/); if (match) { transformed = `{project-root}/${this.bmadFolderName}/core/${match[1]}`; } } return transformed; } async loadWorkflowManifest(bmadDir) { const manifestPath = path.join(bmadDir, '_config', 'workflow-manifest.csv'); if (!(await fs.pathExists(manifestPath))) { return null; } const csvContent = await fs.readFile(manifestPath, 'utf8'); return csv.parse(csvContent, { columns: true, skip_empty_lines: true, }); } /** * Write workflow command artifacts using underscore format (Windows-compatible) * Creates flat files like: bmad_bmm_correct-course.md * * @param {string} baseCommandsDir - Base commands directory for the IDE * @param {Array} artifacts - Workflow artifacts * @returns {number} Count of commands written */ async writeColonArtifacts(baseCommandsDir, artifacts) { let writtenCount = 0; for (const artifact of artifacts) { if (artifact.type === 'workflow-command') { // Convert relativePath to underscore format: bmm/workflows/correct-course.md → bmad_bmm_correct-course.md const flatName = toColonPath(artifact.relativePath); const commandPath = path.join(baseCommandsDir, flatName); await fs.ensureDir(path.dirname(commandPath)); await fs.writeFile(commandPath, artifact.content); writtenCount++; } } return writtenCount; } /** * Write workflow command artifacts using dash format (NEW STANDARD) * Creates flat files like: bmad-bmm-correct-course.md * * Note: Workflows do NOT have bmad-agent- prefix - only agents do. * * @param {string} baseCommandsDir - Base commands directory for the IDE * @param {Array} artifacts - Workflow artifacts * @returns {number} Count of commands written */ async writeDashArtifacts(baseCommandsDir, artifacts) { let writtenCount = 0; for (const artifact of artifacts) { if (artifact.type === 'workflow-command') { // Convert relativePath to dash format: bmm/workflows/correct-course.md → bmad-bmm-correct-course.md const flatName = toDashPath(artifact.relativePath); const commandPath = path.join(baseCommandsDir, flatName); await fs.ensureDir(path.dirname(commandPath)); await fs.writeFile(commandPath, artifact.content); writtenCount++; } } return writtenCount; } } module.exports = { WorkflowCommandGenerator };