Drop YAML workflow support from CLI tooling

This commit is contained in:
Dicky Moore 2026-02-05 16:46:38 +00:00
parent de63874520
commit 2224edaa84
5 changed files with 183 additions and 415 deletions

View File

@ -2,7 +2,6 @@ const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('yaml');
const crypto = require('node:crypto');
const csv = require('csv-parse/sync');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
// Load package.json for version info
@ -22,19 +21,6 @@ class ManifestGenerator {
this.selectedIdes = [];
}
/**
* Clean text for CSV output by normalizing whitespace and escaping quotes
* @param {string} text - Text to clean
* @returns {string} Cleaned text safe for CSV
*/
cleanForCSV(text) {
if (!text) return '';
return text
.trim()
.replaceAll(/\s+/g, ' ') // Normalize all whitespace (including newlines) to single space
.replaceAll('"', '""'); // Escape quotes for CSV
}
/**
* Generate all manifests for the installation
* @param {string} bmadDir - _bmad
@ -159,12 +145,8 @@ class ManifestGenerator {
// Recurse into subdirectories
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'))
) {
// Parse workflow file (both YAML and MD formats)
} else if (entry.name === 'workflow.md') {
// Parse workflow file (MD with YAML frontmatter)
if (debug) {
console.log(`[DEBUG] Found workflow file: ${fullPath}`);
}
@ -173,21 +155,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(/^---\r?\n([\s\S]*?)\r?\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'}`);
@ -219,7 +195,7 @@ class ManifestGenerator {
// Workflows with standalone: false are filtered out above
workflows.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV
module: moduleName,
path: installPath,
});
@ -337,15 +313,24 @@ class ManifestGenerator {
const agentName = entry.name.replace('.md', '');
// Helper function to clean and escape CSV content
const cleanForCSV = (text) => {
if (!text) return '';
return text
.trim()
.replaceAll(/\s+/g, ' ') // Normalize whitespace
.replaceAll('"', '""'); // Escape quotes for CSV
};
agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
title: titleMatch ? titleMatch[1] : '',
icon: iconMatch ? iconMatch[1] : '',
role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? this.cleanForCSV(principlesMatch[1]) : '',
role: roleMatch ? cleanForCSV(roleMatch[1]) : '',
identity: identityMatch ? cleanForCSV(identityMatch[1]) : '',
communicationStyle: styleMatch ? cleanForCSV(styleMatch[1]) : '',
principles: principlesMatch ? cleanForCSV(principlesMatch[1]) : '',
module: moduleName,
path: installPath,
});
@ -394,11 +379,6 @@ class ManifestGenerator {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal/engine files (not user-facing tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
@ -406,21 +386,17 @@ class ManifestGenerator {
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tasks
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
// Tasks are standalone by default unless explicitly false (internal=true is already filtered above)
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
description = frontmatter.description || '';
standalone = frontmatter.standalone === true || frontmatter.standalone === 'true';
} catch {
// If YAML parsing fails, use defaults
standalone = true; // Default to standalone
}
} else {
standalone = true; // No frontmatter means standalone
}
} else {
// For .xml tasks, extract from tag attributes
@ -429,10 +405,10 @@ class ManifestGenerator {
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '';
const standaloneFalseMatch = content.match(/<task[^>]+standalone="false"/);
standalone = !standaloneFalseMatch;
const standaloneMatch = content.match(/<task[^>]+standalone="true"/);
standalone = !!standaloneMatch;
}
// Build relative path for installation
@ -442,7 +418,7 @@ class ManifestGenerator {
tasks.push({
name: name,
displayName: displayName,
description: description,
description: description.replaceAll('"', '""'),
module: moduleName,
path: installPath,
standalone: standalone,
@ -492,11 +468,6 @@ class ManifestGenerator {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip internal tools (same as tasks)
if (content.includes('internal="true"')) {
continue;
}
let name = file.replace(/\.(xml|md)$/, '');
let displayName = name;
let description = '';
@ -504,21 +475,17 @@ class ManifestGenerator {
if (file.endsWith('.md')) {
// Parse YAML frontmatter for .md tools
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
const frontmatter = yaml.parse(frontmatterMatch[1]);
name = frontmatter.name || name;
displayName = frontmatter.displayName || frontmatter.name || name;
description = this.cleanForCSV(frontmatter.description || '');
// Tools are standalone by default unless explicitly false (internal=true is already filtered above)
standalone = frontmatter.standalone !== false && frontmatter.standalone !== 'false';
description = frontmatter.description || '';
standalone = frontmatter.standalone === true || frontmatter.standalone === 'true';
} catch {
// If YAML parsing fails, use defaults
standalone = true; // Default to standalone
}
} else {
standalone = true; // No frontmatter means standalone
}
} else {
// For .xml tools, extract from tag attributes
@ -527,10 +494,10 @@ class ManifestGenerator {
const descMatch = content.match(/description="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
description = this.cleanForCSV(descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '');
description = descMatch ? descMatch[1] : objMatch ? objMatch[1].trim() : '';
const standaloneFalseMatch = content.match(/<tool[^>]+standalone="false"/);
standalone = !standaloneFalseMatch;
const standaloneMatch = content.match(/<tool[^>]+standalone="true"/);
standalone = !!standaloneMatch;
}
// Build relative path for installation
@ -540,7 +507,7 @@ class ManifestGenerator {
tools.push({
name: name,
displayName: displayName,
description: description,
description: description.replaceAll('"', '""'),
module: moduleName,
path: installPath,
standalone: standalone,
@ -733,15 +700,47 @@ class ManifestGenerator {
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const parseCsvLine = (line) => {
const columns = line.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || [];
return columns.map((c) => c.replaceAll(/^"|"$/g, ''));
};
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
const parts = parseCsvLine(line);
if (parts.length >= 4) {
const [name, description, module, workflowPath] = parts;
existingEntries.set(`${module}:${name}`, {
name,
description,
module,
path: workflowPath,
});
}
}
}
}
// Create CSV header - standalone column removed, everything is canonicalized to 4 columns
let csv = 'name,description,module,path\n';
// Build workflows map from discovered workflows only
// Old entries are NOT preserved - the manifest reflects what actually exists on disk
// Combine existing and new workflows
const allWorkflows = new Map();
// Only add workflows that were actually discovered in this scan
// Add existing entries
for (const [key, value] of existingEntries) {
allWorkflows.set(key, value);
}
// Add/update new workflows
for (const workflow of this.workflows) {
const key = `${workflow.module}:${workflow.name}`;
allWorkflows.set(key, {
@ -768,23 +767,30 @@ class ManifestGenerator {
*/
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 11) {
const name = parts[0].replace(/^"/, '');
const module = parts[8];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with persona fields
let csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n';
// Combine existing and new agents, preferring new data for duplicates
const allAgents = new Map();
@ -797,38 +803,18 @@ class ManifestGenerator {
// Add/update new agents
for (const agent of this.agents) {
const key = `${agent.module}:${agent.name}`;
allAgents.set(key, {
name: agent.name,
displayName: agent.displayName,
title: agent.title,
icon: agent.icon,
role: agent.role,
identity: agent.identity,
communicationStyle: agent.communicationStyle,
principles: agent.principles,
module: agent.module,
path: agent.path,
});
allAgents.set(
key,
`"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"`,
);
}
// Write all agents
for (const [, record] of allAgents) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.title),
escapeCsv(record.icon),
escapeCsv(record.role),
escapeCsv(record.identity),
escapeCsv(record.communicationStyle),
escapeCsv(record.principles),
escapeCsv(record.module),
escapeCsv(record.path),
].join(',');
csvContent += row + '\n';
for (const [, value] of allAgents) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}
@ -838,23 +824,30 @@ class ManifestGenerator {
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 6) {
const name = parts[0].replace(/^"/, '');
const module = parts[3];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with standalone column
let csvContent = 'name,displayName,description,module,path,standalone\n';
let csv = 'name,displayName,description,module,path,standalone\n';
// Combine existing and new tasks
const allTasks = new Map();
@ -867,30 +860,15 @@ class ManifestGenerator {
// Add/update new tasks
for (const task of this.tasks) {
const key = `${task.module}:${task.name}`;
allTasks.set(key, {
name: task.name,
displayName: task.displayName,
description: task.description,
module: task.module,
path: task.path,
standalone: task.standalone,
});
allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`);
}
// Write all tasks
for (const [, record] of allTasks) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
].join(',');
csvContent += row + '\n';
for (const [, value] of allTasks) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}
@ -900,23 +878,30 @@ class ManifestGenerator {
*/
async writeToolManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'tool-manifest.csv');
const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`;
// Read existing manifest to preserve entries
const existingEntries = new Map();
if (await fs.pathExists(csvPath)) {
const content = await fs.readFile(csvPath, 'utf8');
const records = csv.parse(content, {
columns: true,
skip_empty_lines: true,
});
for (const record of records) {
existingEntries.set(`${record.module}:${record.name}`, record);
const lines = content.split('\n').filter((line) => line.trim());
// Skip header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line) {
// Parse CSV (simple parsing assuming no commas in quoted fields)
const parts = line.split('","');
if (parts.length >= 6) {
const name = parts[0].replace(/^"/, '');
const module = parts[3];
existingEntries.set(`${module}:${name}`, line);
}
}
}
}
// Create CSV header with standalone column
let csvContent = 'name,displayName,description,module,path,standalone\n';
let csv = 'name,displayName,description,module,path,standalone\n';
// Combine existing and new tools
const allTools = new Map();
@ -929,30 +914,15 @@ class ManifestGenerator {
// Add/update new tools
for (const tool of this.tools) {
const key = `${tool.module}:${tool.name}`;
allTools.set(key, {
name: tool.name,
displayName: tool.displayName,
description: tool.description,
module: tool.module,
path: tool.path,
standalone: tool.standalone,
});
allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`);
}
// Write all tools
for (const [, record] of allTools) {
const row = [
escapeCsv(record.name),
escapeCsv(record.displayName),
escapeCsv(record.description),
escapeCsv(record.module),
escapeCsv(record.path),
escapeCsv(record.standalone),
].join(',');
csvContent += row + '\n';
for (const [, value] of allTools) {
csv += value + '\n';
}
await fs.writeFile(csvPath, csvContent);
await fs.writeFile(csvPath, csv);
return csvPath;
}

View File

@ -344,22 +344,18 @@ class BaseIdeSetup {
// Recursively search subdirectories
const subWorkflows = await this.findWorkflowFiles(fullPath);
workflows.push(...subWorkflows);
} else if (entry.isFile() && (entry.name === 'workflow.yaml' || entry.name === 'workflow.md')) {
} else if (entry.isFile() && entry.name === 'workflow.md') {
// Read workflow file to get name and standalone property
try {
const yaml = require('yaml');
const content = await fs.readFile(fullPath, 'utf8');
let workflowData = null;
if (entry.name === 'workflow.yaml') {
workflowData = yaml.parse(content);
} else {
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
continue;
}
workflowData = yaml.parse(frontmatterMatch[1]);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!frontmatterMatch) {
continue;
}
workflowData = yaml.parse(frontmatterMatch[1]);
if (workflowData && workflowData.name) {
// Workflows are standalone by default unless explicitly false

View File

@ -66,13 +66,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/
async installToTarget(projectDir, bmadDir, config, options) {
const { target_dir, template_type, artifact_types } = config;
// Skip targets with explicitly empty artifact_types array
// This prevents creating empty directories when no artifacts will be written
if (Array.isArray(artifact_types) && artifact_types.length === 0) {
return { success: true, results: { agents: 0, workflows: 0, tasks: 0, tools: 0 } };
}
const targetPath = path.join(projectDir, target_dir);
await this.ensureDir(targetPath);
@ -93,11 +86,10 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config);
}
// Install tasks and tools using template system (supports TOML for Gemini, MD for others)
// Install tasks and tools
if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) {
const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName);
const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir);
const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config);
const taskToolGen = new TaskToolCommandGenerator();
const taskToolResult = await taskToolGen.generateDashTaskToolCommands(projectDir, bmadDir, targetPath);
results.tasks = taskToolResult.tasks || 0;
results.tools = taskToolResult.tools || 0;
}
@ -140,12 +132,12 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/
async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) {
// Try to load platform-specific template, fall back to default-agent
const { content: template, extension } = await this.loadTemplate(templateType, 'agent', config, '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', extension);
const filename = this.generateFilename(artifact, 'agent');
const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content);
count++;
@ -167,18 +159,14 @@ 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`;
const workflowTemplateType = 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);
// Fall back to default template if the requested one doesn't exist
const finalTemplateType = 'default-workflow';
const template = await this.loadTemplate(workflowTemplateType, 'workflow', config, finalTemplateType);
const content = this.renderTemplate(template, artifact);
const filename = this.generateFilename(artifact, 'workflow', extension);
const filename = this.generateFilename(artifact, 'workflow');
const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content);
count++;
@ -188,100 +176,40 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return count;
}
/**
* Write task/tool artifacts to target directory using templates
* @param {string} targetPath - Target directory path
* @param {Array} artifacts - Task/tool artifacts
* @param {string} templateType - Template type to use
* @param {Object} config - Installation configuration
* @returns {Promise<Object>} Counts of tasks and tools written
*/
async writeTaskToolArtifacts(targetPath, artifacts, templateType, config = {}) {
let taskCount = 0;
let toolCount = 0;
// Pre-load templates to avoid repeated file I/O in the loop
const taskTemplate = await this.loadTemplate(templateType, 'task', config, 'default-task');
const toolTemplate = await this.loadTemplate(templateType, 'tool', config, 'default-tool');
const { artifact_types } = config;
for (const artifact of artifacts) {
if (artifact.type !== 'task' && artifact.type !== 'tool') {
continue;
}
// Skip if the specific artifact type is not requested in config
if (artifact_types) {
if (artifact.type === 'task' && !artifact_types.includes('tasks')) continue;
if (artifact.type === 'tool' && !artifact_types.includes('tools')) continue;
}
// Use pre-loaded template based on artifact type
const { content: template, extension } = artifact.type === 'task' ? taskTemplate : toolTemplate;
const content = this.renderTemplate(template, artifact);
const filename = this.generateFilename(artifact, artifact.type, extension);
const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content);
if (artifact.type === 'task') {
taskCount++;
} else {
toolCount++;
}
}
return { tasks: taskCount, tools: toolCount };
}
/**
* 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<{content: string, extension: string}>} Template content and extension
* @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) {
const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template);
// Allow config to override extension, default to .md
const ext = config.extension || '.md';
const normalizedExt = ext.startsWith('.') ? ext : `.${ext}`;
return { content, extension: normalizedExt };
return await this.loadSplitTemplates(templateType, artifactType, header_template, body_template);
}
// Load combined template - try multiple extensions
// If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml')
const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType;
const templateDir = path.join(__dirname, 'templates', 'combined');
const extensions = ['.md', '.toml', '.yaml', '.yml'];
// Load combined template
const templateName = `${templateType}-${artifactType}.md`;
const templatePath = path.join(__dirname, 'templates', 'combined', templateName);
for (const ext of extensions) {
const templatePath = path.join(templateDir, templateBaseName + ext);
if (await fs.pathExists(templatePath)) {
const content = await fs.readFile(templatePath, 'utf8');
return { content, extension: ext };
}
if (await fs.pathExists(templatePath)) {
return await fs.readFile(templatePath, 'utf8');
}
// Fall back to default template (if provided)
if (fallbackTemplateType) {
for (const ext of extensions) {
const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`);
if (await fs.pathExists(fallbackPath)) {
const content = await fs.readFile(fallbackPath, 'utf8');
return { content, extension: ext };
}
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 { content: this.getDefaultTemplate(artifactType), extension: '.md' };
return this.getDefaultTemplate(artifactType);
}
/**
@ -338,7 +266,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return `---
name: '{{name}}'
description: '{{description}}'
disable-model-invocation: true
---
You must fully embody this agent's persona and follow all activation instructions exactly as specified.
@ -353,7 +280,6 @@ You must fully embody this agent's persona and follow all activation instruction
return `---
name: '{{name}}'
description: '{{description}}'
disable-model-invocation: true
---
# {{name}}
@ -371,24 +297,10 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
renderTemplate(template, artifact) {
// Use the appropriate path property based on artifact type
let pathToUse = artifact.relativePath || '';
switch (artifact.type) {
case 'agent-launcher': {
pathToUse = artifact.agentPath || artifact.relativePath || '';
break;
}
case 'workflow-command': {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
break;
}
case 'task':
case 'tool': {
pathToUse = artifact.path || artifact.relativePath || '';
break;
}
// No default
if (artifact.type === 'agent-launcher') {
pathToUse = artifact.agentPath || artifact.relativePath || '';
} else if (artifact.type === 'workflow-command') {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
}
let rendered = template
@ -411,27 +323,13 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
* Generate filename for artifact
* @param {Object} artifact - Artifact data
* @param {string} artifactType - Artifact type (agent, workflow, task, tool)
* @param {string} extension - File extension to use (e.g., '.md', '.toml')
* @returns {string} Generated filename
*/
generateFilename(artifact, artifactType, extension = '.md') {
generateFilename(artifact, artifactType) {
const { toDashPath } = require('./shared/path-utils');
// Reuse central logic to ensure consistent naming conventions
const standardName = toDashPath(artifact.relativePath);
// Clean up potential double extensions from source files (e.g. .yaml.md, .xml.md -> .md)
// This handles any extensions that might slip through toDashPath()
const baseName = standardName.replace(/\.(md|yaml|yml|json|xml|toml)\.md$/i, '.md');
// If using default markdown, preserve the bmad-agent- prefix for agents
if (extension === '.md') {
return baseName;
}
// For other extensions (e.g., .toml), replace .md extension
// Note: agent prefix is preserved even with non-markdown extensions
return baseName.replace(/\.md$/, extension);
// toDashPath already handles the .agent.md suffix for agents correctly
// No need to add it again here
return toDashPath(artifact.relativePath);
}
/**

View File

@ -9,7 +9,7 @@ const { toColonPath, toDashPath, customAgentColonName, customAgentDashName } = r
*/
class WorkflowCommandGenerator {
constructor(bmadFolderName = 'bmad') {
this.templatePath = path.join(__dirname, '../templates/workflow-command-template.md');
this.templatePath = path.join(__dirname, '../templates/workflow-commander.md');
this.bmadFolderName = bmadFolderName;
}
@ -77,11 +77,8 @@ class WorkflowCommandGenerator {
workflowRelPath = parts.slice(1).join('/');
}
}
// Determine if this is a YAML workflow
const isYamlWorkflow = workflow.path.endsWith('.yaml') || workflow.path.endsWith('.yml');
artifacts.push({
type: 'workflow-command',
isYamlWorkflow: isYamlWorkflow, // For template selection
name: workflow.name,
description: workflow.description || `${workflow.name} workflow`,
module: workflow.module,
@ -117,9 +114,7 @@ class WorkflowCommandGenerator {
*/
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(path.dirname(this.templatePath), 'workflow-commander.md');
// Load the appropriate template
const template = await fs.readFile(templatePath, 'utf8');

View File

@ -7,7 +7,6 @@ const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/compiler');
const { ExternalModuleManager } = require('./external-manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
/**
* Manages the installation, updating, and removal of BMAD modules.
@ -28,7 +27,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class ModuleManager {
constructor(options = {}) {
this.xmlHandler = new XmlHandler();
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
this.bmadFolderName = 'bmad'; // Default, can be overridden
this.customModulePaths = new Map(); // Initialize custom module paths
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
}
@ -417,7 +416,7 @@ class ModuleManager {
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout
@ -442,7 +441,7 @@ class ModuleManager {
if (packageJsonNewer) {
const installSpinner = ora(`Installing dependencies for ${moduleInfo.name}...`).start();
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
execSync('npm install --production --no-audit --no-fund --prefer-offline --no-progress', {
cwd: moduleCacheDir,
stdio: 'pipe',
timeout: 120_000, // 2 minute timeout
@ -740,8 +739,8 @@ class ModuleManager {
}
}
// Check if this is a workflow file (YAML or MD)
if (file.endsWith('workflow.yaml') || file.endsWith('workflow.md')) {
// Check if this is a workflow file (MD)
if (file.endsWith('workflow.md')) {
await fs.ensureDir(path.dirname(targetFile));
await this.copyWorkflowFileStripped(sourceFile, targetFile);
} else {
@ -757,101 +756,19 @@ class ModuleManager {
}
/**
* Copy workflow file with web_bundle section stripped (YAML or MD)
* Copy workflow file with web_bundle section stripped (MD)
* Preserves comments, formatting, and line breaks
* @param {string} sourceFile - Source workflow file path
* @param {string} targetFile - Target workflow file path
*/
async copyWorkflowFileStripped(sourceFile, targetFile) {
if (sourceFile.endsWith('.md')) {
let mdContent = await fs.readFile(sourceFile, 'utf8');
let mdContent = await fs.readFile(sourceFile, 'utf8');
mdContent = mdContent.replaceAll('_bmad', '_bmad');
mdContent = mdContent.replaceAll('_bmad', this.bmadFolderName);
mdContent = this.stripWebBundleFromFrontmatter(mdContent);
mdContent = mdContent.replaceAll('_bmad', '_bmad');
mdContent = mdContent.replaceAll('_bmad', this.bmadFolderName);
mdContent = this.stripWebBundleFromFrontmatter(mdContent);
await fs.writeFile(targetFile, mdContent, 'utf8');
return;
}
// 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', '_bmad');
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
console.warn(chalk.yellow(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`));
await fs.copy(sourceFile, targetFile, { overwrite: true });
}
await fs.writeFile(targetFile, mdContent, 'utf8');
}
stripWebBundleFromFrontmatter(content) {
@ -892,7 +809,7 @@ class ModuleManager {
for (const agentFile of agentFiles) {
if (!agentFile.endsWith('.agent.yaml')) continue;
const relativePath = path.relative(sourceAgentsPath, agentFile).split(path.sep).join('/');
const relativePath = path.relative(sourceAgentsPath, agentFile);
const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
await fs.ensureDir(targetDir);
@ -1198,13 +1115,9 @@ class ModuleManager {
const installWorkflowSubPath = installMatch[2];
const sourceModulePath = getModulePath(sourceModule);
const actualSourceWorkflowPath = path.join(
sourceModulePath,
'workflows',
sourceWorkflowSubPath.replace(/\/workflow\.(yaml|md)$/, ''),
);
const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.md$/, ''));
const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.(yaml|md)$/, ''));
const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.md$/, ''));
// Check if source workflow exists
if (!(await fs.pathExists(actualSourceWorkflowPath))) {
@ -1215,7 +1128,7 @@ class ModuleManager {
// Copy the entire workflow folder
console.log(
chalk.dim(
` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.(yaml|md)$/, '')}${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.(yaml|md)$/, '')}`,
` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.md$/, '')}${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.md$/, '')}`,
),
);
@ -1225,12 +1138,8 @@ class ModuleManager {
// Update workflow config_source references
const workflowMdPath = path.join(actualDestWorkflowPath, 'workflow.md');
const workflowYamlPath = path.join(actualDestWorkflowPath, 'workflow.yaml');
if (await fs.pathExists(workflowMdPath)) {
await this.updateWorkflowConfigSource(workflowMdPath, moduleName);
} else if (await fs.pathExists(workflowYamlPath)) {
await this.updateWorkflowConfigSource(workflowYamlPath, moduleName);
}
}
}