BMAD-METHOD/tools/cli/installers/lib/ide/shared/workflow-command-generator.js

338 lines
11 KiB
JavaScript

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 };