Drop YAML workflow support from CLI tooling

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

View File

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

View File

@ -66,13 +66,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/ */
async installToTarget(projectDir, bmadDir, config, options) { async installToTarget(projectDir, bmadDir, config, options) {
const { target_dir, template_type, artifact_types } = config; 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); const targetPath = path.join(projectDir, target_dir);
await this.ensureDir(targetPath); await this.ensureDir(targetPath);
@ -93,11 +86,10 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
results.workflows = await this.writeWorkflowArtifacts(targetPath, artifacts, template_type, config); 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')) { if (!artifact_types || artifact_types.includes('tasks') || artifact_types.includes('tools')) {
const taskToolGen = new TaskToolCommandGenerator(this.bmadFolderName); const taskToolGen = new TaskToolCommandGenerator();
const { artifacts } = await taskToolGen.collectTaskToolArtifacts(bmadDir); const taskToolResult = await taskToolGen.generateDashTaskToolCommands(projectDir, bmadDir, targetPath);
const taskToolResult = await this.writeTaskToolArtifacts(targetPath, artifacts, template_type, config);
results.tasks = taskToolResult.tasks || 0; results.tasks = taskToolResult.tasks || 0;
results.tools = taskToolResult.tools || 0; results.tools = taskToolResult.tools || 0;
} }
@ -140,12 +132,12 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
*/ */
async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) { async writeAgentArtifacts(targetPath, artifacts, templateType, config = {}) {
// Try to load platform-specific template, fall back to default-agent // 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; let count = 0;
for (const artifact of artifacts) { for (const artifact of artifacts) {
const content = this.renderTemplate(template, artifact); 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); const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content); await this.writeFile(filePath, content);
count++; count++;
@ -167,18 +159,14 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
for (const artifact of artifacts) { for (const artifact of artifacts) {
if (artifact.type === 'workflow-command') { 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 // Default to 'default' template type, but allow override via config
const workflowTemplateType = artifact.isYamlWorkflow const workflowTemplateType = config.md_workflow_template || `${templateType}-workflow`;
? config.yaml_workflow_template || `${templateType}-workflow-yaml`
: config.md_workflow_template || `${templateType}-workflow`;
// Fall back to default templates if specific ones don't exist // Fall back to default template if the requested one doesn't exist
const finalTemplateType = artifact.isYamlWorkflow ? 'default-workflow-yaml' : 'default-workflow'; const finalTemplateType = 'default-workflow';
// workflowTemplateType already contains full name (e.g., 'gemini-workflow-yaml'), so pass empty artifactType const template = await this.loadTemplate(workflowTemplateType, 'workflow', config, finalTemplateType);
const { content: template, extension } = await this.loadTemplate(workflowTemplateType, '', config, finalTemplateType);
const content = this.renderTemplate(template, artifact); 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); const filePath = path.join(targetPath, filename);
await this.writeFile(filePath, content); await this.writeFile(filePath, content);
count++; count++;
@ -188,100 +176,40 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return count; 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 * Load template based on type and configuration
* @param {string} templateType - Template type (claude, windsurf, etc.) * @param {string} templateType - Template type (claude, windsurf, etc.)
* @param {string} artifactType - Artifact type (agent, workflow, task, tool) * @param {string} artifactType - Artifact type (agent, workflow, task, tool)
* @param {Object} config - Installation configuration * @param {Object} config - Installation configuration
* @param {string} fallbackTemplateType - Fallback template type if requested template not found * @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) { async loadTemplate(templateType, artifactType, config = {}, fallbackTemplateType = null) {
const { header_template, body_template } = config; const { header_template, body_template } = config;
// Check for separate header/body templates // Check for separate header/body templates
if (header_template || body_template) { if (header_template || body_template) {
const content = await this.loadSplitTemplates(templateType, artifactType, header_template, body_template); return 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 };
} }
// Load combined template - try multiple extensions // Load combined template
// If artifactType is empty, templateType already contains full name (e.g., 'gemini-workflow-yaml') const templateName = `${templateType}-${artifactType}.md`;
const templateBaseName = artifactType ? `${templateType}-${artifactType}` : templateType; const templatePath = path.join(__dirname, 'templates', 'combined', templateName);
const templateDir = path.join(__dirname, 'templates', 'combined');
const extensions = ['.md', '.toml', '.yaml', '.yml'];
for (const ext of extensions) { if (await fs.pathExists(templatePath)) {
const templatePath = path.join(templateDir, templateBaseName + ext); return await fs.readFile(templatePath, 'utf8');
if (await fs.pathExists(templatePath)) {
const content = await fs.readFile(templatePath, 'utf8');
return { content, extension: ext };
}
} }
// Fall back to default template (if provided) // Fall back to default template (if provided)
if (fallbackTemplateType) { if (fallbackTemplateType) {
for (const ext of extensions) { const fallbackPath = path.join(__dirname, 'templates', 'combined', `${fallbackTemplateType}.md`);
const fallbackPath = path.join(templateDir, `${fallbackTemplateType}${ext}`); if (await fs.pathExists(fallbackPath)) {
if (await fs.pathExists(fallbackPath)) { return await fs.readFile(fallbackPath, 'utf8');
const content = await fs.readFile(fallbackPath, 'utf8');
return { content, extension: ext };
}
} }
} }
// Ultimate fallback - minimal template // Ultimate fallback - minimal template
return { content: this.getDefaultTemplate(artifactType), extension: '.md' }; return this.getDefaultTemplate(artifactType);
} }
/** /**
@ -338,7 +266,6 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup {
return `--- return `---
name: '{{name}}' name: '{{name}}'
description: '{{description}}' description: '{{description}}'
disable-model-invocation: true
--- ---
You must fully embody this agent's persona and follow all activation instructions exactly as specified. 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 `--- return `---
name: '{{name}}' name: '{{name}}'
description: '{{description}}' description: '{{description}}'
disable-model-invocation: true
--- ---
# {{name}} # {{name}}
@ -371,24 +297,10 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
renderTemplate(template, artifact) { renderTemplate(template, artifact) {
// Use the appropriate path property based on artifact type // Use the appropriate path property based on artifact type
let pathToUse = artifact.relativePath || ''; let pathToUse = artifact.relativePath || '';
switch (artifact.type) { if (artifact.type === 'agent-launcher') {
case 'agent-launcher': { pathToUse = artifact.agentPath || artifact.relativePath || '';
pathToUse = artifact.agentPath || artifact.relativePath || ''; } else if (artifact.type === 'workflow-command') {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
break;
}
case 'workflow-command': {
pathToUse = artifact.workflowPath || artifact.relativePath || '';
break;
}
case 'task':
case 'tool': {
pathToUse = artifact.path || artifact.relativePath || '';
break;
}
// No default
} }
let rendered = template let rendered = template
@ -411,27 +323,13 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
* Generate filename for artifact * Generate filename for artifact
* @param {Object} artifact - Artifact data * @param {Object} artifact - Artifact data
* @param {string} artifactType - Artifact type (agent, workflow, task, tool) * @param {string} artifactType - Artifact type (agent, workflow, task, tool)
* @param {string} extension - File extension to use (e.g., '.md', '.toml')
* @returns {string} Generated filename * @returns {string} Generated filename
*/ */
generateFilename(artifact, artifactType, extension = '.md') { generateFilename(artifact, artifactType) {
const { toDashPath } = require('./shared/path-utils'); const { toDashPath } = require('./shared/path-utils');
// toDashPath already handles the .agent.md suffix for agents correctly
// Reuse central logic to ensure consistent naming conventions // No need to add it again here
const standardName = toDashPath(artifact.relativePath); return 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);
} }
/** /**

View File

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

View File

@ -6,7 +6,6 @@ const { XmlHandler } = require('../../../lib/xml-handler');
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
const { filterCustomizationData } = require('../../../lib/agent/compiler'); const { filterCustomizationData } = require('../../../lib/agent/compiler');
const { ExternalModuleManager } = require('./external-manager'); const { ExternalModuleManager } = require('./external-manager');
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
/** /**
* Manages the installation, updating, and removal of BMAD modules. * Manages the installation, updating, and removal of BMAD modules.
@ -27,7 +26,7 @@ const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
class ModuleManager { class ModuleManager {
constructor(options = {}) { constructor(options = {}) {
this.xmlHandler = new XmlHandler(); 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.customModulePaths = new Map(); // Initialize custom module paths
this.externalModuleManager = new ExternalModuleManager(); // For external official modules this.externalModuleManager = new ExternalModuleManager(); // For external official modules
} }
@ -450,7 +449,7 @@ class ModuleManager {
const installSpinner = await createSpinner(); const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try { 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, cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout timeout: 120_000, // 2 minute timeout
@ -476,7 +475,7 @@ class ModuleManager {
const installSpinner = await createSpinner(); const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`); installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
try { 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, cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000, // 2 minute timeout timeout: 120_000, // 2 minute timeout
@ -774,8 +773,8 @@ class ModuleManager {
} }
} }
// Check if this is a workflow file (YAML or MD) // Check if this is a workflow file (MD)
if (file.endsWith('workflow.yaml') || file.endsWith('workflow.md')) { if (file.endsWith('workflow.md')) {
await fs.ensureDir(path.dirname(targetFile)); await fs.ensureDir(path.dirname(targetFile));
await this.copyWorkflowFileStripped(sourceFile, targetFile); await this.copyWorkflowFileStripped(sourceFile, targetFile);
} else { } else {
@ -791,100 +790,18 @@ 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 * Preserves comments, formatting, and line breaks
* @param {string} sourceFile - Source workflow file path * @param {string} sourceFile - Source workflow file path
* @param {string} targetFile - Target workflow file path * @param {string} targetFile - Target workflow file path
*/ */
async copyWorkflowFileStripped(sourceFile, targetFile) { 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', '_bmad');
mdContent = mdContent.replaceAll('_bmad', this.bmadFolderName); mdContent = mdContent.replaceAll('_bmad', this.bmadFolderName);
mdContent = this.stripWebBundleFromFrontmatter(mdContent); mdContent = this.stripWebBundleFromFrontmatter(mdContent);
await fs.writeFile(targetFile, mdContent, 'utf8');
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', 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(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`);
await fs.copy(sourceFile, targetFile, { overwrite: true });
}
} }
stripWebBundleFromFrontmatter(content) { stripWebBundleFromFrontmatter(content) {
@ -925,7 +842,7 @@ class ModuleManager {
for (const agentFile of agentFiles) { for (const agentFile of agentFiles) {
if (!agentFile.endsWith('.agent.yaml')) continue; 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)); const targetDir = path.join(targetAgentsPath, path.dirname(relativePath));
await fs.ensureDir(targetDir); await fs.ensureDir(targetDir);
@ -1229,13 +1146,9 @@ class ModuleManager {
const installWorkflowSubPath = installMatch[2]; const installWorkflowSubPath = installMatch[2];
const sourceModulePath = getModulePath(sourceModule); const sourceModulePath = getModulePath(sourceModule);
const actualSourceWorkflowPath = path.join( const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.md$/, ''));
sourceModulePath,
'workflows',
sourceWorkflowSubPath.replace(/\/workflow\.(yaml|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 // Check if source workflow exists
if (!(await fs.pathExists(actualSourceWorkflowPath))) { if (!(await fs.pathExists(actualSourceWorkflowPath))) {
@ -1245,7 +1158,7 @@ class ModuleManager {
// Copy the entire workflow folder // Copy the entire workflow folder
await prompts.log.message( await prompts.log.message(
` 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$/, '')}`,
); );
await fs.ensureDir(path.dirname(actualDestWorkflowPath)); await fs.ensureDir(path.dirname(actualDestWorkflowPath));
@ -1254,12 +1167,8 @@ class ModuleManager {
// Update workflow config_source references // Update workflow config_source references
const workflowMdPath = path.join(actualDestWorkflowPath, 'workflow.md'); const workflowMdPath = path.join(actualDestWorkflowPath, 'workflow.md');
const workflowYamlPath = path.join(actualDestWorkflowPath, 'workflow.yaml');
if (await fs.pathExists(workflowMdPath)) { if (await fs.pathExists(workflowMdPath)) {
await this.updateWorkflowConfigSource(workflowMdPath, moduleName); await this.updateWorkflowConfigSource(workflowMdPath, moduleName);
} else if (await fs.pathExists(workflowYamlPath)) {
await this.updateWorkflowConfigSource(workflowYamlPath, moduleName);
} }
} }
} }