refactor: remove YAML workflow code paths from CLI installer pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Verkhovsky 2026-03-08 15:09:21 -06:00
parent 6ceacf7dc1
commit 73d19baf7e
8 changed files with 97 additions and 258 deletions

View File

@ -148,7 +148,7 @@ class ManifestGenerator {
/**
* Recursively walk a module directory tree, collecting skill directories.
* A skill directory is one that contains both a bmad-skill-manifest.yaml with
* type: skill AND a workflow.md (or workflow.yaml) file.
* type: skill AND a workflow.md file.
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
*/
async collectSkills() {
@ -172,76 +172,66 @@ class ManifestGenerator {
// Check this directory for skill manifest + workflow file
const manifest = await this.loadSkillManifest(dir);
// Try both workflow.md and workflow.yaml
const workflowFilenames = ['workflow.md', 'workflow.yaml'];
for (const workflowFile of workflowFilenames) {
const workflowPath = path.join(dir, workflowFile);
if (!(await fs.pathExists(workflowPath))) continue;
const workflowFile = 'workflow.md';
const workflowPath = path.join(dir, workflowFile);
if (await fs.pathExists(workflowPath)) {
const artifactType = this.getArtifactType(manifest, workflowFile);
if (artifactType !== 'skill') continue;
if (artifactType === 'skill') {
// Read and parse the workflow file
try {
const rawContent = await fs.readFile(workflowPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
// Read and parse the workflow file
try {
const rawContent = await fs.readFile(workflowPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
let workflow;
if (workflowFile === 'workflow.yaml') {
workflow = yaml.parse(content);
} else {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (debug) console.log(`[DEBUG] collectSkills: skipped (no frontmatter): ${workflowPath}`);
continue;
} else {
const workflow = yaml.parse(frontmatterMatch[1]);
if (!workflow || !workflow.name || !workflow.description) {
if (debug) console.log(`[DEBUG] collectSkills: skipped (missing name/description): ${workflowPath}`);
} else {
// Build path relative from module root
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${workflowFile}`
: `${this.bmadFolderName}/${moduleName}/${workflowFile}`;
// Skills derive canonicalId from directory name — never from manifest
if (manifest && manifest.__single && manifest.__single.canonicalId) {
console.warn(
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
);
}
const canonicalId = path.basename(dir);
this.skills.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
module: moduleName,
path: installPath,
canonicalId,
install_to_bmad: this.getInstallToBmad(manifest, workflowFile),
});
// Add to files list
this.files.push({
type: 'skill',
name: workflow.name,
module: moduleName,
path: installPath,
});
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${workflow.name}" as ${canonicalId} at ${dir}`);
}
}
}
workflow = yaml.parse(frontmatterMatch[1]);
} catch (error) {
if (debug) console.log(`[DEBUG] collectSkills: failed to parse ${workflowPath}: ${error.message}`);
}
if (!workflow || !workflow.name || !workflow.description) {
if (debug) console.log(`[DEBUG] collectSkills: skipped (missing name/description): ${workflowPath}`);
continue;
}
// Build path relative from module root
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${workflowFile}`
: `${this.bmadFolderName}/${moduleName}/${workflowFile}`;
// Skills derive canonicalId from directory name — never from manifest
if (manifest && manifest.__single && manifest.__single.canonicalId) {
console.warn(
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
);
}
const canonicalId = path.basename(dir);
this.skills.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
module: moduleName,
path: installPath,
canonicalId,
install_to_bmad: this.getInstallToBmad(manifest, workflowFile),
});
// Add to files list
this.files.push({
type: 'skill',
name: workflow.name,
module: moduleName,
path: installPath,
});
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${workflow.name}" as ${canonicalId} at ${dir}`);
}
break; // Successfully claimed — skip remaining workflow filenames
} catch (error) {
if (debug) console.log(`[DEBUG] collectSkills: failed to parse ${workflowPath}: ${error.message}`);
}
}
@ -260,11 +250,11 @@ class ManifestGenerator {
}
}
if (hasSkillType && debug) {
const hasWorkflow = workflowFilenames.some((f) => entries.some((e) => e.name === f));
const hasWorkflow = entries.some((e) => e.name === workflowFile);
if (hasWorkflow) {
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but workflow file failed to parse: ${dir}`);
} else {
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but no workflow.md/workflow.yaml: ${dir}`);
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but no workflow.md: ${dir}`);
}
}
}
@ -308,7 +298,7 @@ class ManifestGenerator {
}
/**
* Recursively find and parse workflow.yaml and workflow.md files
* Recursively find and parse workflow.md files
*/
async getWorkflowsFromPath(basePath, moduleName, subDir = 'workflows') {
const workflows = [];
@ -326,7 +316,7 @@ class ManifestGenerator {
return workflows;
}
// Recursively find workflow.yaml files
// Recursively find workflow.md files
const findWorkflows = async (dir, relativePath = '') => {
// Skip directories already claimed as skills
if (this.skillClaimedDirs && this.skillClaimedDirs.has(dir)) return;
@ -345,7 +335,6 @@ class ManifestGenerator {
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await findWorkflows(fullPath, newRelativePath);
} else if (
entry.name === 'workflow.yaml' ||
entry.name === 'workflow.md' ||
(entry.name.startsWith('workflow-') && entry.name.endsWith('.md'))
) {
@ -358,21 +347,15 @@ class ManifestGenerator {
const rawContent = await fs.readFile(fullPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
let workflow;
if (entry.name === 'workflow.yaml') {
// Parse YAML workflow
workflow = yaml.parse(content);
} else {
// Parse MD workflow with YAML frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (debug) {
console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`);
}
continue; // Skip MD files without frontmatter
// Parse MD workflow with YAML frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
if (debug) {
console.log(`[DEBUG] Skipped (no frontmatter): ${fullPath}`);
}
workflow = yaml.parse(frontmatterMatch[1]);
continue; // Skip MD files without frontmatter
}
const workflow = yaml.parse(frontmatterMatch[1]);
if (debug) {
console.log(`[DEBUG] Parsed: name="${workflow.name}", description=${workflow.description ? 'OK' : 'MISSING'}`);
@ -1343,7 +1326,7 @@ class ManifestGenerator {
// Check for manifest in this directory
const manifest = await this.loadSkillManifest(dir);
if (manifest) {
const type = this.getArtifactType(manifest, 'workflow.md') || this.getArtifactType(manifest, 'workflow.yaml');
const type = this.getArtifactType(manifest, 'workflow.md');
if (type === 'skill') return true;
}

View File

@ -289,7 +289,7 @@ class BaseIdeSetup {
// Get core workflows
const coreWorkflowsPath = path.join(bmadDir, 'core', 'workflows');
if (await fs.pathExists(coreWorkflowsPath)) {
const coreWorkflows = await this.findWorkflowYamlFiles(coreWorkflowsPath);
const coreWorkflows = await this.findWorkflowFiles(coreWorkflowsPath);
workflows.push(
...coreWorkflows.map((w) => ({
...w,
@ -304,7 +304,7 @@ class BaseIdeSetup {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_config' && entry.name !== 'agents') {
const moduleWorkflowsPath = path.join(bmadDir, entry.name, 'workflows');
if (await fs.pathExists(moduleWorkflowsPath)) {
const moduleWorkflows = await this.findWorkflowYamlFiles(moduleWorkflowsPath);
const moduleWorkflows = await this.findWorkflowFiles(moduleWorkflowsPath);
workflows.push(
...moduleWorkflows.map((w) => ({
...w,
@ -324,11 +324,11 @@ class BaseIdeSetup {
}
/**
* Recursively find workflow.yaml files
* Recursively find workflow.md files
* @param {string} dir - Directory to search
* @returns {Array} List of workflow file info objects
*/
async findWorkflowYamlFiles(dir) {
async findWorkflowFiles(dir) {
const workflows = [];
if (!(await fs.pathExists(dir))) {
@ -342,14 +342,17 @@ class BaseIdeSetup {
if (entry.isDirectory()) {
// Recursively search subdirectories
const subWorkflows = await this.findWorkflowYamlFiles(fullPath);
const subWorkflows = await this.findWorkflowFiles(fullPath);
workflows.push(...subWorkflows);
} else if (entry.isFile() && entry.name === 'workflow.yaml') {
// Read workflow.yaml to get name and standalone property
} else if (entry.isFile() && entry.name === 'workflow.md') {
// Read workflow.md frontmatter to get name and standalone property
try {
const yaml = require('yaml');
const content = await fs.readFile(fullPath, 'utf8');
const workflowData = yaml.parse(content);
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!frontmatterMatch) continue;
const workflowData = yaml.parse(frontmatterMatch[1]);
if (workflowData && workflowData.name) {
// Workflows are standalone by default unless explicitly false

View File

@ -232,16 +232,8 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
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';
// workflowTemplateType already contains full name (e.g., 'gemini-workflow-yaml'), so pass empty artifactType
const { content: template, extension } = await this.loadTemplate(workflowTemplateType, '', config, finalTemplateType);
const workflowTemplateType = config.md_workflow_template || `${templateType}-workflow`;
const { content: template, extension } = await this.loadTemplate(workflowTemplateType, '', config, 'default-workflow');
const content = this.renderTemplate(template, artifact);
const filename = this.generateFilename(artifact, 'workflow', extension);

View File

@ -63,7 +63,7 @@ function toDashPath(relativePath) {
}
// Strip common file extensions to avoid double extensions in generated filenames
// e.g., 'create-story.xml' → 'create-story', 'workflow.yaml' → 'workflow'
// e.g., 'create-story.xml' → 'create-story', 'workflow.md' → 'workflow'
const withoutExt = relativePath.replace(/\.(md|yaml|yml|json|xml|toml)$/i, '');
const parts = withoutExt.split(/[/\\]/);

View File

@ -9,7 +9,6 @@ const { toColonPath, toDashPath, customAgentColonName, customAgentDashName, BMAD
*/
class WorkflowCommandGenerator {
constructor(bmadFolderName = BMAD_FOLDER_NAME) {
this.templatePath = path.join(__dirname, '../templates/workflow-command-template.md');
this.bmadFolderName = bmadFolderName;
}
@ -67,7 +66,7 @@ class WorkflowCommandGenerator {
for (const workflow of allWorkflows) {
const commandContent = await this.generateCommandContent(workflow, bmadDir);
// Calculate the relative workflow path (e.g., bmm/workflows/4-implementation/sprint-planning/workflow.yaml)
// Calculate the relative workflow path (e.g., bmm/workflows/4-implementation/sprint-planning/workflow.md)
let workflowRelPath = workflow.path || '';
// Normalize path separators for cross-platform compatibility
workflowRelPath = workflowRelPath.replaceAll('\\', '/');
@ -85,11 +84,8 @@ class WorkflowCommandGenerator {
workflowRelPath = `${match[1]}/${match[2]}`;
}
}
// 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,
@ -125,17 +121,14 @@ class WorkflowCommandGenerator {
* Generate command content for a workflow
*/
async generateCommandContent(workflow, bmadDir) {
// Determine template based on workflow file type
const isMarkdownWorkflow = workflow.path.endsWith('workflow.md');
const templateName = isMarkdownWorkflow ? 'workflow-commander.md' : 'workflow-command-template.md';
const templatePath = path.join(path.dirname(this.templatePath), templateName);
const templatePath = path.join(__dirname, '../templates/workflow-commander.md');
// Load the appropriate template
// Load the template
const template = await fs.readFile(templatePath, 'utf8');
// Convert source path to installed path
// From: /Users/.../src/bmm/workflows/.../workflow.yaml
// To: {project-root}/_bmad/bmm/workflows/.../workflow.yaml
// From: /Users/.../src/bmm/workflows/.../workflow.md
// To: {project-root}/_bmad/bmm/workflows/.../workflow.md
let workflowPath = workflow.path;
// Extract the relative path from source
@ -218,10 +211,9 @@ class WorkflowCommandGenerator {
## 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
1. LOAD the workflow.md file at the path shown above
2. READ its entire contents and follow its directions exactly
3. Save outputs after EACH section
## Modes
- Normal: Full interaction

View File

@ -762,14 +762,8 @@ class ModuleManager {
}
}
// Check if this is a workflow.yaml file
if (file.endsWith('workflow.yaml')) {
await fs.ensureDir(path.dirname(targetFile));
await this.copyWorkflowYamlStripped(sourceFile, targetFile);
} else {
// Copy the file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
}
// Copy the file with placeholder replacement
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
// Track the file if callback provided
if (fileTrackingCallback) {
@ -778,91 +772,6 @@ class ModuleManager {
}
}
/**
* Copy workflow.yaml file with web_bundle section stripped
* Preserves comments, formatting, and line breaks
* @param {string} sourceFile - Source workflow.yaml file path
* @param {string} targetFile - Target workflow.yaml file path
*/
async copyWorkflowYamlStripped(sourceFile, targetFile) {
// Read the source YAML file
let yamlContent = await fs.readFile(sourceFile, 'utf8');
// IMPORTANT: Replace escape sequence and placeholder BEFORE parsing YAML
// Otherwise parsing will fail on the placeholder
yamlContent = yamlContent.replaceAll('_bmad', this.bmadFolderName);
try {
// First check if web_bundle exists by parsing
const workflowConfig = yaml.parse(yamlContent);
if (workflowConfig.web_bundle === undefined) {
// No web_bundle section, just write (placeholders already replaced above)
await fs.writeFile(targetFile, yamlContent, 'utf8');
return;
}
// Find the line that starts web_bundle
const lines = yamlContent.split('\n');
let startIdx = -1;
let endIdx = -1;
let baseIndent = 0;
// Find the start of web_bundle section
for (const [i, line] of lines.entries()) {
const match = line.match(/^(\s*)web_bundle:/);
if (match) {
startIdx = i;
baseIndent = match[1].length;
break;
}
}
if (startIdx === -1) {
// web_bundle not found in text (shouldn't happen), copy as-is
await fs.writeFile(targetFile, yamlContent, 'utf8');
return;
}
// Find the end of web_bundle section
// It ends when we find a line with same or less indentation that's not empty/comment
endIdx = startIdx;
for (let i = startIdx + 1; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines and comments
if (line.trim() === '' || line.trim().startsWith('#')) {
continue;
}
// Check indentation
const indent = line.match(/^(\s*)/)[1].length;
if (indent <= baseIndent) {
// Found next section at same or lower indentation
endIdx = i - 1;
break;
}
}
// If we didn't find an end, it goes to end of file
if (endIdx === startIdx) {
endIdx = lines.length - 1;
}
// Remove the web_bundle section (including the line before if it's just a blank line)
const newLines = [...lines.slice(0, startIdx), ...lines.slice(endIdx + 1)];
// Clean up any double blank lines that might result
const strippedYaml = newLines.join('\n').replaceAll(/\n\n\n+/g, '\n\n');
// Placeholders already replaced at the beginning of this function
await fs.writeFile(targetFile, strippedYaml, 'utf8');
} catch {
// If anything fails, just copy the file as-is
await prompts.log.warn(` Could not process ${path.basename(sourceFile)}, copying as-is`);
await fs.copy(sourceFile, targetFile, { overwrite: true });
}
}
/**
* Compile .agent.yaml files to .md format in modules
@ -1169,9 +1078,7 @@ class ModuleManager {
const installWorkflowPath = item['workflow-install']; // Where to copy TO
// Parse SOURCE workflow path
// Handle both _bmad placeholder and hardcoded 'bmad'
// Example: {project-root}/_bmad/bmm/workflows/4-implementation/create-story/workflow.yaml
// Or: {project-root}/bmad/bmm/workflows/4-implementation/create-story/workflow.yaml
// Example: {project-root}/_bmad/bmm/workflows/4-implementation/create-story/workflow.md
const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/);
if (!sourceMatch) {
await prompts.log.warn(` Could not parse workflow path: ${sourceWorkflowPath}`);
@ -1181,8 +1088,7 @@ class ModuleManager {
const [, sourceModule, sourceWorkflowSubPath] = sourceMatch;
// Parse INSTALL workflow path
// Handle_bmad
// Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.yaml
// Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.md
const installMatch = installWorkflowPath.match(/\{project-root\}\/(_bmad)\/([^/]+)\/workflows\/(.+)/);
if (!installMatch) {
await prompts.log.warn(` Could not parse workflow-install path: ${installWorkflowPath}`);
@ -1192,9 +1098,9 @@ class ModuleManager {
const installWorkflowSubPath = installMatch[2];
const sourceModulePath = getModulePath(sourceModule);
const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.yaml$/, ''));
const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.md$/, ''));
const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.yaml$/, ''));
const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.md$/, ''));
// Check if source workflow exists
if (!(await fs.pathExists(actualSourceWorkflowPath))) {
@ -1204,18 +1110,12 @@ class ModuleManager {
// Copy the entire workflow folder
await prompts.log.message(
` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.yaml$/, '')}${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.yaml$/, '')}`,
` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.md$/, '')}${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.md$/, '')}`,
);
await fs.ensureDir(path.dirname(actualDestWorkflowPath));
// Copy the workflow directory recursively with placeholder replacement
await this.copyDirectoryWithPlaceholderReplacement(actualSourceWorkflowPath, actualDestWorkflowPath);
// Update the workflow.yaml config_source reference
const workflowYamlPath = path.join(actualDestWorkflowPath, 'workflow.yaml');
if (await fs.pathExists(workflowYamlPath)) {
await this.updateWorkflowConfigSource(workflowYamlPath, moduleName);
}
}
}
@ -1224,27 +1124,6 @@ class ModuleManager {
}
}
/**
* Update workflow.yaml config_source to point to new module
* @param {string} workflowYamlPath - Path to workflow.yaml file
* @param {string} newModuleName - New module name to reference
*/
async updateWorkflowConfigSource(workflowYamlPath, newModuleName) {
let yamlContent = await fs.readFile(workflowYamlPath, 'utf8');
// Replace config_source: "{project-root}/_bmad/OLD_MODULE/config.yaml"
// with config_source: "{project-root}/_bmad/NEW_MODULE/config.yaml"
// Note: At this point _bmad has already been replaced with actual folder name
const configSourcePattern = /config_source:\s*["']?\{project-root\}\/[^/]+\/[^/]+\/config\.yaml["']?/g;
const newConfigSource = `config_source: "{project-root}/${this.bmadFolderName}/${newModuleName}/config.yaml"`;
const updatedYaml = yamlContent.replaceAll(configSourcePattern, newConfigSource);
if (updatedYaml !== yamlContent) {
await fs.writeFile(workflowYamlPath, updatedYaml, 'utf8');
await prompts.log.message(` Updated config_source to: ${this.bmadFolderName}/${newModuleName}/config.yaml`);
}
}
/**
* Create directories declared in module.yaml's `directories` key

View File

@ -39,12 +39,7 @@ class AgentAnalyzer {
if (Array.isArray(execArray)) {
for (const exec of execArray) {
if (exec.route) {
// Check if route is a workflow or exec
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
profile.usedAttributes.add('workflow');
} else {
profile.usedAttributes.add('exec');
}
profile.usedAttributes.add('exec');
}
if (exec.workflow) profile.usedAttributes.add('workflow');
if (exec.action) profile.usedAttributes.add('action');

View File

@ -229,12 +229,7 @@ function processExecArray(execArray) {
}
if (exec.route) {
// Determine if it's a workflow or exec based on file extension or context
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
result.workflow = exec.route;
} else {
result.route = exec.route;
}
result.route = exec.route;
}
if (exec.data !== null && exec.data !== undefined) {