feat: v6.0.0-alpha.0 - the future is now

This commit is contained in:
Brian Madison
2025-09-28 23:17:07 -05:00
parent 52f6889089
commit 0a6a3f3015
747 changed files with 52759 additions and 235199 deletions

View File

@@ -0,0 +1,383 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const chalk = require('chalk');
const inquirer = require('inquirer');
const { getProjectRoot, getModulePath } = require('../../../lib/project-root');
const { CLIUtils } = require('../../../lib/cli-utils');
class ConfigCollector {
constructor() {
this.collectedConfig = {};
this.existingConfig = null;
}
/**
* Load existing config if it exists from module config files
* @param {string} projectDir - Target project directory
*/
async loadExistingConfig(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
this.existingConfig = {};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return false;
}
// Try to load existing module configs
const modules = ['core', 'bmm', 'cis'];
let foundAny = false;
for (const moduleName of modules) {
const moduleConfigPath = path.join(bmadDir, moduleName, 'config.yaml');
if (await fs.pathExists(moduleConfigPath)) {
try {
const content = await fs.readFile(moduleConfigPath, 'utf8');
const moduleConfig = yaml.load(content);
if (moduleConfig) {
this.existingConfig[moduleName] = moduleConfig;
foundAny = true;
}
} catch {
// Ignore parse errors for individual modules
}
}
}
if (foundAny) {
console.log(chalk.cyan('\n📋 Found existing BMAD module configurations'));
}
return foundAny;
}
/**
* Collect configuration for all modules
* @param {Array} modules - List of modules to configure (including 'core')
* @param {string} projectDir - Target project directory
*/
async collectAllConfigurations(modules, projectDir) {
await this.loadExistingConfig(projectDir);
// Check if core was already collected (e.g., in early collection phase)
const coreAlreadyCollected = this.collectedConfig.core && Object.keys(this.collectedConfig.core).length > 0;
// If core wasn't already collected, include it
const allModules = coreAlreadyCollected ? modules.filter((m) => m !== 'core') : ['core', ...modules.filter((m) => m !== 'core')];
// Store all answers across modules for cross-referencing
if (!this.allAnswers) {
this.allAnswers = {};
}
for (const moduleName of allModules) {
await this.collectModuleConfig(moduleName, projectDir);
}
// Add metadata
this.collectedConfig._meta = {
version: require(path.join(getProjectRoot(), 'package.json')).version,
installDate: new Date().toISOString(),
lastModified: new Date().toISOString(),
};
return this.collectedConfig;
}
/**
* Collect configuration for a single module
* @param {string} moduleName - Module name
* @param {string} projectDir - Target project directory
* @param {boolean} skipLoadExisting - Skip loading existing config (for early core collection)
* @param {boolean} skipCompletion - Skip showing completion message (for early core collection)
*/
async collectModuleConfig(moduleName, projectDir, skipLoadExisting = false, skipCompletion = false) {
// Load existing config if needed and not already loaded
if (!skipLoadExisting && !this.existingConfig) {
await this.loadExistingConfig(projectDir);
}
// Initialize allAnswers if not already initialized
if (!this.allAnswers) {
this.allAnswers = {};
}
// Load module's config.yaml (check new location first, then fallback)
const installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-menu-config.yaml');
const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(legacyConfigPath)) {
configPath = legacyConfigPath;
} else {
// No config for this module
return;
}
const configContent = await fs.readFile(configPath, 'utf8');
const moduleConfig = yaml.load(configContent);
if (!moduleConfig) {
return;
}
// Display module prompts using better formatting
if (moduleConfig.prompt) {
const prompts = Array.isArray(moduleConfig.prompt) ? moduleConfig.prompt : [moduleConfig.prompt];
CLIUtils.displayPromptSection(prompts);
}
// Process each config item
const questions = [];
const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt');
for (const key of configKeys) {
const item = moduleConfig[key];
// Skip if not a config object
if (!item || typeof item !== 'object' || !item.prompt) {
continue;
}
const question = await this.buildQuestion(moduleName, key, item);
if (question) {
questions.push(question);
}
}
if (questions.length > 0) {
console.log(); // Line break before questions
const answers = await inquirer.prompt(questions);
// Store answers for cross-referencing
Object.assign(this.allAnswers, answers);
// Process answers and build result values
for (const key of Object.keys(answers)) {
const originalKey = key.replace(`${moduleName}_`, '');
const item = moduleConfig[originalKey];
const value = answers[key];
// Build the result using the template
let result;
// For arrays (multi-select), handle differently
if (Array.isArray(value)) {
// If there's a result template and it's a string, don't use it for arrays
// Just use the array value directly
result = value;
} else if (item.result) {
result = item.result;
// Replace placeholders only for strings
if (typeof result === 'string' && value !== undefined) {
// Replace {value} with the actual value
if (typeof value === 'string') {
result = result.replace('{value}', value);
} else if (typeof value === 'boolean' || typeof value === 'number') {
// For boolean and number values, if result is just "{value}", use the raw value
if (result === '{value}') {
result = value;
} else {
// Otherwise replace in the string
result = result.replace('{value}', value);
}
} else {
// For non-string values, use directly
result = value;
}
// Only do further replacements if result is still a string
if (typeof result === 'string') {
// Replace references to other config values
result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => {
// Check if it's a special placeholder
if (configKey === 'project-root') {
return '{project-root}';
}
// Skip if it's the 'value' placeholder we already handled
if (configKey === 'value') {
return match;
}
// Look for the config value across all modules
// First check if it's in the current module's answers
let configValue = answers[`${moduleName}_${configKey}`];
// Then check all answers (for cross-module references like outputFolder)
if (!configValue) {
// Try with various module prefixes
for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) {
if (answerKey.endsWith(`_${configKey}`)) {
configValue = answerValue;
break;
}
}
}
// Check in already collected config
if (!configValue) {
for (const mod of Object.keys(this.collectedConfig)) {
if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) {
configValue = this.collectedConfig[mod][configKey];
// Extract just the value part if it's a result template
if (typeof configValue === 'string' && configValue.includes('{project-root}/')) {
configValue = configValue.replace('{project-root}/', '');
}
break;
}
}
}
return configValue || match;
});
}
}
} else {
// No result template, use value directly
result = value;
}
// Store only the result value (no prompts, defaults, examples, etc.)
if (!this.collectedConfig[moduleName]) {
this.collectedConfig[moduleName] = {};
}
this.collectedConfig[moduleName][originalKey] = result;
}
// Display module completion message after collecting all answers (unless skipped)
if (!skipCompletion) {
CLIUtils.displayModuleComplete(moduleName);
}
}
}
/**
* Build an inquirer question from a config item
* @param {string} moduleName - Module name
* @param {string} key - Config key
* @param {Object} item - Config item definition
*/
async buildQuestion(moduleName, key, item) {
const questionName = `${moduleName}_${key}`;
// Check for existing value
let existingValue = null;
if (this.existingConfig && this.existingConfig[moduleName]) {
existingValue = this.existingConfig[moduleName][key];
// Clean up existing value - remove {project-root}/ prefix if present
// This prevents duplication when the result template adds it back
if (typeof existingValue === 'string' && existingValue.startsWith('{project-root}/')) {
existingValue = existingValue.replace('{project-root}/', '');
}
}
// Determine question type and default value
let questionType = 'input';
let defaultValue = item.default;
let choices = null;
// Handle different question types
if (item['single-select']) {
questionType = 'list';
choices = item['single-select'];
if (existingValue && choices.includes(existingValue)) {
defaultValue = existingValue;
}
} else if (item['multi-select']) {
questionType = 'checkbox';
choices = item['multi-select'].map((choice) => ({
name: choice,
value: choice,
checked: existingValue
? existingValue.includes(choice)
: item.default && Array.isArray(item.default)
? item.default.includes(choice)
: false,
}));
} else if (typeof defaultValue === 'boolean') {
questionType = 'confirm';
}
// Build the prompt message
let message = '';
// Handle array prompts for multi-line messages
if (Array.isArray(item.prompt)) {
message = item.prompt.join('\n');
} else {
message = item.prompt;
}
// Add current value indicator for existing configs
if (existingValue !== null && existingValue !== undefined) {
if (typeof existingValue === 'boolean') {
message += chalk.dim(` (current: ${existingValue ? 'true' : 'false'})`);
defaultValue = existingValue;
} else if (Array.isArray(existingValue)) {
message += chalk.dim(` (current: ${existingValue.join(', ')})`);
} else if (questionType !== 'list') {
// Show the cleaned value (without {project-root}/) for display
message += chalk.dim(` (current: ${existingValue})`);
defaultValue = existingValue;
}
} else if (item.example && questionType === 'input') {
// Show example for input fields
const exampleText = typeof item.example === 'string' ? item.example.replace('{project-root}/', '') : JSON.stringify(item.example);
message += chalk.dim(` (e.g., ${exampleText})`);
}
const question = {
type: questionType,
name: questionName,
message: message,
default: defaultValue,
};
// Add choices for select types
if (choices) {
question.choices = choices;
}
// Add validation for input fields
if (questionType === 'input') {
question.validate = (input) => {
if (!input && item.required) {
return 'This field is required';
}
return true;
};
}
return question;
}
/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
*/
deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
result[key] = this.deepMerge(result[key], source[key]);
} else {
result[key] = source[key];
}
} else {
result[key] = source[key];
}
}
return result;
}
}
module.exports = { ConfigCollector };

View File

@@ -0,0 +1,721 @@
const fs = require('fs-extra');
const path = require('node:path');
const glob = require('glob');
const chalk = require('chalk');
const yaml = require('js-yaml');
/**
* Dependency Resolver for BMAD modules
* Handles cross-module dependencies and ensures all required files are included
*/
class DependencyResolver {
constructor() {
this.dependencies = new Map();
this.resolvedFiles = new Set();
this.missingDependencies = new Set();
}
/**
* Resolve all dependencies for selected modules
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Modules explicitly selected by user
* @param {Object} options - Resolution options
* @returns {Object} Resolution results with all required files
*/
async resolve(bmadDir, selectedModules = [], options = {}) {
if (options.verbose) {
console.log(chalk.cyan('Resolving module dependencies...'));
}
// Always include core as base
const modulesToProcess = new Set(['core', ...selectedModules]);
// First pass: collect all explicitly selected files
const primaryFiles = await this.collectPrimaryFiles(bmadDir, modulesToProcess);
// Second pass: parse and resolve dependencies
const allDependencies = await this.parseDependencies(primaryFiles);
// Third pass: resolve dependency paths and collect files
const resolvedDeps = await this.resolveDependencyPaths(bmadDir, allDependencies);
// Fourth pass: check for transitive dependencies
const transitiveDeps = await this.resolveTransitiveDependencies(bmadDir, resolvedDeps);
// Combine all files
const allFiles = new Set([...primaryFiles.map((f) => f.path), ...resolvedDeps, ...transitiveDeps]);
// Organize by module
const organizedFiles = this.organizeByModule(bmadDir, allFiles);
// Report results (only in verbose mode)
if (options.verbose) {
this.reportResults(organizedFiles, selectedModules);
}
return {
primaryFiles,
dependencies: resolvedDeps,
transitiveDependencies: transitiveDeps,
allFiles: [...allFiles],
byModule: organizedFiles,
missing: [...this.missingDependencies],
};
}
/**
* Collect primary files from selected modules
*/
async collectPrimaryFiles(bmadDir, modules) {
const files = [];
for (const module of modules) {
// Handle both source (src/) and installed (bmad/) directory structures
let moduleDir;
// Check if this is a source directory (has 'src' subdirectory)
const srcDir = path.join(bmadDir, 'src');
if (await fs.pathExists(srcDir)) {
// Source directory structure: src/core or src/modules/xxx
moduleDir = module === 'core' ? path.join(srcDir, 'core') : path.join(srcDir, 'modules', module);
} else {
// Installed directory structure: bmad/core or bmad/modules/xxx
moduleDir = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
}
if (!(await fs.pathExists(moduleDir))) {
console.warn(chalk.yellow(`Module directory not found: ${moduleDir}`));
continue;
}
// Collect agents
const agentsDir = path.join(moduleDir, 'agents');
if (await fs.pathExists(agentsDir)) {
const agentFiles = await glob.glob('*.md', { cwd: agentsDir });
for (const file of agentFiles) {
const agentPath = path.join(agentsDir, file);
// Check for localskip attribute
const content = await fs.readFile(agentPath, 'utf8');
const hasLocalSkip = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
if (hasLocalSkip) {
continue; // Skip agents marked for web-only
}
files.push({
path: agentPath,
type: 'agent',
module,
name: path.basename(file, '.md'),
});
}
}
// Collect tasks
const tasksDir = path.join(moduleDir, 'tasks');
if (await fs.pathExists(tasksDir)) {
const taskFiles = await glob.glob('*.md', { cwd: tasksDir });
for (const file of taskFiles) {
files.push({
path: path.join(tasksDir, file),
type: 'task',
module,
name: path.basename(file, '.md'),
});
}
}
}
return files;
}
/**
* Parse dependencies from file content
*/
async parseDependencies(files) {
const allDeps = new Set();
for (const file of files) {
const content = await fs.readFile(file.path, 'utf8');
// Parse YAML frontmatter for explicit dependencies
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
try {
// Pre-process to handle backticks in YAML values
let yamlContent = frontmatterMatch[1];
// Quote values with backticks to make them valid YAML
yamlContent = yamlContent.replaceAll(/: `([^`]+)`/g, ': "$1"');
const frontmatter = yaml.load(yamlContent);
if (frontmatter.dependencies) {
const deps = Array.isArray(frontmatter.dependencies) ? frontmatter.dependencies : [frontmatter.dependencies];
for (const dep of deps) {
allDeps.add({
from: file.path,
dependency: dep,
type: 'explicit',
});
}
}
// Check for template dependencies
if (frontmatter.template) {
const templates = Array.isArray(frontmatter.template) ? frontmatter.template : [frontmatter.template];
for (const template of templates) {
allDeps.add({
from: file.path,
dependency: template,
type: 'template',
});
}
}
} catch (error) {
console.warn(chalk.yellow(`Failed to parse frontmatter in ${file.name}: ${error.message}`));
}
}
// Parse content for command references (cross-module dependencies)
const commandRefs = this.parseCommandReferences(content);
for (const ref of commandRefs) {
allDeps.add({
from: file.path,
dependency: ref,
type: 'command',
});
}
// Parse for file path references
const fileRefs = this.parseFileReferences(content);
for (const ref of fileRefs) {
// Determine type based on path format
// Paths starting with bmad/ are absolute references to the bmad installation
const depType = ref.startsWith('bmad/') ? 'bmad-path' : 'file';
allDeps.add({
from: file.path,
dependency: ref,
type: depType,
});
}
}
return allDeps;
}
/**
* Parse command references from content
*/
parseCommandReferences(content) {
const refs = new Set();
// Match @task-{name} or @agent-{name} or @{module}-{type}-{name}
const commandPattern = /@(task-|agent-|bmad-)([a-z0-9-]+)/g;
let match;
while ((match = commandPattern.exec(content)) !== null) {
refs.add(match[0]);
}
// Match file paths like bmad/core/agents/analyst
const pathPattern = /bmad\/(core|bmm|cis)\/(agents|tasks)\/([a-z0-9-]+)/g;
while ((match = pathPattern.exec(content)) !== null) {
refs.add(match[0]);
}
return [...refs];
}
/**
* Parse file path references from content
*/
parseFileReferences(content) {
const refs = new Set();
// Match relative paths like ../templates/file.yaml or ./data/file.md
const relativePattern = /['"](\.\.?\/[^'"]+\.(md|yaml|yml|xml|json|txt|csv))['"]/g;
let match;
while ((match = relativePattern.exec(content)) !== null) {
refs.add(match[1]);
}
// Parse exec attributes in command tags
const execPattern = /exec="([^"]+)"/g;
while ((match = execPattern.exec(content)) !== null) {
let execPath = match[1];
if (execPath && execPath !== '*') {
// Remove {project-root} prefix to get the actual path
// Usage is like {project-root}/bmad/core/tasks/foo.md
if (execPath.includes('{project-root}')) {
execPath = execPath.replace('{project-root}', '');
}
refs.add(execPath);
}
}
// Parse tmpl attributes in command tags
const tmplPattern = /tmpl="([^"]+)"/g;
while ((match = tmplPattern.exec(content)) !== null) {
let tmplPath = match[1];
if (tmplPath && tmplPath !== '*') {
// Remove {project-root} prefix to get the actual path
// Usage is like {project-root}/bmad/core/tasks/foo.md
if (tmplPath.includes('{project-root}')) {
tmplPath = tmplPath.replace('{project-root}', '');
}
refs.add(tmplPath);
}
}
return [...refs];
}
/**
* Resolve dependency paths to actual files
*/
async resolveDependencyPaths(bmadDir, dependencies) {
const resolved = new Set();
for (const dep of dependencies) {
const resolvedPaths = await this.resolveSingleDependency(bmadDir, dep);
for (const path of resolvedPaths) {
resolved.add(path);
}
}
return resolved;
}
/**
* Resolve a single dependency to file paths
*/
async resolveSingleDependency(bmadDir, dep) {
const paths = [];
switch (dep.type) {
case 'explicit':
case 'file': {
let depPath = dep.dependency;
// Handle {project-root} prefix if present
if (depPath.includes('{project-root}')) {
// Remove {project-root} and resolve as bmad path
depPath = depPath.replace('{project-root}', '');
if (depPath.startsWith('bmad/')) {
const bmadPath = depPath.replace(/^bmad\//, '');
// Handle glob patterns
if (depPath.includes('*')) {
// Extract the base path and pattern
const pathParts = bmadPath.split('/');
const module = pathParts[0];
const filePattern = pathParts.at(-1);
const middlePath = pathParts.slice(1, -1).join('/');
let basePath;
if (module === 'core') {
basePath = path.join(bmadDir, 'core', middlePath);
} else {
basePath = path.join(bmadDir, 'modules', module, middlePath);
}
if (await fs.pathExists(basePath)) {
const files = await glob.glob(filePattern, { cwd: basePath });
for (const file of files) {
paths.push(path.join(basePath, file));
}
}
} else {
// Direct path
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
}
} else {
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
const modulePath = path.join(bmadDir, 'modules', module, rest);
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
}
}
}
}
} else {
// Regular relative path handling
const sourceDir = path.dirname(dep.from);
// Handle glob patterns
if (depPath.includes('*')) {
const basePath = path.resolve(sourceDir, path.dirname(depPath));
const pattern = path.basename(depPath);
if (await fs.pathExists(basePath)) {
const files = await glob.glob(pattern, { cwd: basePath });
for (const file of files) {
paths.push(path.join(basePath, file));
}
}
} else {
// Direct file reference
const fullPath = path.resolve(sourceDir, depPath);
if (await fs.pathExists(fullPath)) {
paths.push(fullPath);
} else {
this.missingDependencies.add(`${depPath} (referenced by ${path.basename(dep.from)})`);
}
}
}
break;
}
case 'command': {
// Resolve command references to actual files
const commandPath = await this.resolveCommandToPath(bmadDir, dep.dependency);
if (commandPath) {
paths.push(commandPath);
}
break;
}
case 'bmad-path': {
// Resolve bmad/ paths (from {project-root}/bmad/... references)
// These are paths relative to the src directory structure
const bmadPath = dep.dependency.replace(/^bmad\//, '');
// Try to resolve as if it's in src structure
// bmad/core/tasks/foo.md -> src/core/tasks/foo.md
// bmad/bmm/tasks/bar.md -> src/modules/bmm/tasks/bar.md
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
} else {
// Not found, but don't report as missing since it might be installed later
}
} else {
// It's a module path like bmm/tasks/foo.md or cis/agents/bar.md
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
const modulePath = path.join(bmadDir, 'modules', module, rest);
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
} else {
// Not found, but don't report as missing since it might be installed later
}
}
break;
}
case 'template': {
// Resolve template references
let templateDep = dep.dependency;
// Handle {project-root} prefix if present
if (templateDep.includes('{project-root}')) {
// Remove {project-root} and treat as bmad-path
templateDep = templateDep.replace('{project-root}', '');
// Now resolve as a bmad path
if (templateDep.startsWith('bmad/')) {
const bmadPath = templateDep.replace(/^bmad\//, '');
if (bmadPath.startsWith('core/')) {
const corePath = path.join(bmadDir, bmadPath);
if (await fs.pathExists(corePath)) {
paths.push(corePath);
}
} else {
// Module path like cis/templates/brainstorm.md
const parts = bmadPath.split('/');
const module = parts[0];
const rest = parts.slice(1).join('/');
const modulePath = path.join(bmadDir, 'modules', module, rest);
if (await fs.pathExists(modulePath)) {
paths.push(modulePath);
}
}
}
} else {
// Regular relative template path
const sourceDir = path.dirname(dep.from);
const templatePath = path.resolve(sourceDir, templateDep);
if (await fs.pathExists(templatePath)) {
paths.push(templatePath);
} else {
this.missingDependencies.add(`Template: ${dep.dependency}`);
}
}
break;
}
// No default
}
return paths;
}
/**
* Resolve command reference to file path
*/
async resolveCommandToPath(bmadDir, command) {
// Parse command format: @task-name or @agent-name or bmad/module/type/name
if (command.startsWith('@task-')) {
const taskName = command.slice(6);
// Search all modules for this task
for (const module of ['core', 'bmm', 'cis']) {
const taskPath =
module === 'core'
? path.join(bmadDir, 'core', 'tasks', `${taskName}.md`)
: path.join(bmadDir, 'modules', module, 'tasks', `${taskName}.md`);
if (await fs.pathExists(taskPath)) {
return taskPath;
}
}
} else if (command.startsWith('@agent-')) {
const agentName = command.slice(7);
// Search all modules for this agent
for (const module of ['core', 'bmm', 'cis']) {
const agentPath =
module === 'core'
? path.join(bmadDir, 'core', 'agents', `${agentName}.md`)
: path.join(bmadDir, 'modules', module, 'agents', `${agentName}.md`);
if (await fs.pathExists(agentPath)) {
return agentPath;
}
}
} else if (command.startsWith('bmad/')) {
// Direct path reference
const parts = command.split('/');
if (parts.length >= 4) {
const [, module, type, ...nameParts] = parts;
const name = nameParts.join('/'); // Handle nested paths
// Check if name already has extension
const fileName = name.endsWith('.md') ? name : `${name}.md`;
const filePath =
module === 'core' ? path.join(bmadDir, 'core', type, fileName) : path.join(bmadDir, 'modules', module, type, fileName);
if (await fs.pathExists(filePath)) {
return filePath;
}
}
}
// Don't report as missing if it's a self-reference within the module being installed
if (!command.includes('cis') || command.includes('brain')) {
// Only report missing if it's a true external dependency
// this.missingDependencies.add(`Command: ${command}`);
}
return null;
}
/**
* Resolve transitive dependencies (dependencies of dependencies)
*/
async resolveTransitiveDependencies(bmadDir, directDeps) {
const transitive = new Set();
const processed = new Set();
// Process each direct dependency
for (const depPath of directDeps) {
if (processed.has(depPath)) continue;
processed.add(depPath);
// Only process markdown and YAML files for transitive deps
if ((depPath.endsWith('.md') || depPath.endsWith('.yaml') || depPath.endsWith('.yml')) && (await fs.pathExists(depPath))) {
const content = await fs.readFile(depPath, 'utf8');
const subDeps = await this.parseDependencies([
{
path: depPath,
type: 'dependency',
module: this.getModuleFromPath(bmadDir, depPath),
name: path.basename(depPath),
},
]);
const resolvedSubDeps = await this.resolveDependencyPaths(bmadDir, subDeps);
for (const subDep of resolvedSubDeps) {
if (!directDeps.has(subDep)) {
transitive.add(subDep);
}
}
}
}
return transitive;
}
/**
* Get module name from file path
*/
getModuleFromPath(bmadDir, filePath) {
const relative = path.relative(bmadDir, filePath);
const parts = relative.split(path.sep);
// Handle source directory structure (src/core or src/modules/xxx)
if (parts[0] === 'src') {
if (parts[1] === 'core') {
return 'core';
} else if (parts[1] === 'modules' && parts.length > 2) {
return parts[2];
}
}
// Check if it's in modules directory (installed structure)
if (parts[0] === 'modules' && parts.length > 1) {
return parts[1];
}
// Otherwise return the first part (core, etc.)
// But don't return 'src' as a module name
if (parts[0] === 'src') {
return 'unknown';
}
return parts[0] || 'unknown';
}
/**
* Organize files by module
*/
organizeByModule(bmadDir, files) {
const organized = {};
for (const file of files) {
const module = this.getModuleFromPath(bmadDir, file);
if (!organized[module]) {
organized[module] = {
agents: [],
tasks: [],
templates: [],
data: [],
other: [],
};
}
// Get relative path correctly based on module structure
let moduleBase;
// Check if file is in source directory structure
if (file.includes('/src/core/') || file.includes('/src/modules/')) {
moduleBase = module === 'core' ? path.join(bmadDir, 'src', 'core') : path.join(bmadDir, 'src', 'modules', module);
} else {
// Installed structure
moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
}
const relative = path.relative(moduleBase, file);
// Check file path for categorization
// Brain-tech files are data, not tasks (even though they're in tasks/brain-tech/)
if (file.includes('/brain-tech/')) {
organized[module].data.push(file);
} else if (relative.startsWith('agents/') || file.includes('/agents/')) {
organized[module].agents.push(file);
} else if (relative.startsWith('tasks/') || file.includes('/tasks/')) {
organized[module].tasks.push(file);
} else if (relative.includes('template') || file.includes('/templates/')) {
organized[module].templates.push(file);
} else if (relative.includes('data/')) {
organized[module].data.push(file);
} else {
organized[module].other.push(file);
}
}
return organized;
}
/**
* Report resolution results
*/
reportResults(organized, selectedModules) {
console.log(chalk.green('\n✓ Dependency resolution complete'));
for (const [module, files] of Object.entries(organized)) {
const isSelected = selectedModules.includes(module) || module === 'core';
const totalFiles = files.agents.length + files.tasks.length + files.templates.length + files.data.length + files.other.length;
if (totalFiles > 0) {
console.log(chalk.cyan(`\n ${module.toUpperCase()} module:`));
console.log(chalk.dim(` Status: ${isSelected ? 'Selected' : 'Dependencies only'}`));
if (files.agents.length > 0) {
console.log(chalk.dim(` Agents: ${files.agents.length}`));
}
if (files.tasks.length > 0) {
console.log(chalk.dim(` Tasks: ${files.tasks.length}`));
}
if (files.templates.length > 0) {
console.log(chalk.dim(` Templates: ${files.templates.length}`));
}
if (files.data.length > 0) {
console.log(chalk.dim(` Data files: ${files.data.length}`));
}
if (files.other.length > 0) {
console.log(chalk.dim(` Other files: ${files.other.length}`));
}
}
}
if (this.missingDependencies.size > 0) {
console.log(chalk.yellow('\n ⚠ Missing dependencies:'));
for (const missing of this.missingDependencies) {
console.log(chalk.yellow(` - ${missing}`));
}
}
}
/**
* Create a bundle for web deployment
* @param {Object} resolution - Resolution results from resolve()
* @returns {Object} Bundle data ready for web
*/
async createWebBundle(resolution) {
const bundle = {
metadata: {
created: new Date().toISOString(),
modules: Object.keys(resolution.byModule),
totalFiles: resolution.allFiles.length,
},
agents: {},
tasks: {},
templates: {},
data: {},
};
// Bundle all files by type
for (const filePath of resolution.allFiles) {
if (!(await fs.pathExists(filePath))) continue;
const content = await fs.readFile(filePath, 'utf8');
const relative = path.relative(path.dirname(resolution.primaryFiles[0]?.path || '.'), filePath);
if (filePath.includes('/agents/')) {
bundle.agents[relative] = content;
} else if (filePath.includes('/tasks/')) {
bundle.tasks[relative] = content;
} else if (filePath.includes('template')) {
bundle.templates[relative] = content;
} else {
bundle.data[relative] = content;
}
}
return bundle;
}
}
module.exports = { DependencyResolver };

View File

@@ -0,0 +1,208 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const { Manifest } = require('./manifest');
class Detector {
/**
* Detect existing BMAD installation
* @param {string} bmadDir - Path to bmad directory
* @returns {Object} Installation status and details
*/
async detect(bmadDir) {
const result = {
installed: false,
path: bmadDir,
version: null,
hasCore: false,
modules: [],
ides: [],
manifest: null,
};
// Check if bmad directory exists
if (!(await fs.pathExists(bmadDir))) {
return result;
}
// Check for manifest using the Manifest class
const manifest = new Manifest();
const manifestData = await manifest.read(bmadDir);
if (manifestData) {
result.manifest = manifestData;
result.version = manifestData.version;
result.installed = true;
}
// Check for core
const corePath = path.join(bmadDir, 'core');
if (await fs.pathExists(corePath)) {
result.hasCore = true;
// Try to get core version from config
const coreConfigPath = path.join(corePath, 'config.yaml');
if (await fs.pathExists(coreConfigPath)) {
try {
const configContent = await fs.readFile(coreConfigPath, 'utf8');
const config = yaml.load(configContent);
if (!result.version && config.version) {
result.version = config.version;
}
} catch {
// Ignore config read errors
}
}
}
// Check for modules
const entries = await fs.readdir(bmadDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') {
const modulePath = path.join(bmadDir, entry.name);
const moduleConfigPath = path.join(modulePath, 'config.yaml');
const moduleInfo = {
id: entry.name,
path: modulePath,
version: 'unknown',
};
if (await fs.pathExists(moduleConfigPath)) {
try {
const configContent = await fs.readFile(moduleConfigPath, 'utf8');
const config = yaml.load(configContent);
moduleInfo.version = config.version || 'unknown';
moduleInfo.name = config.name || entry.name;
moduleInfo.description = config.description;
} catch {
// Ignore config read errors
}
}
result.modules.push(moduleInfo);
}
}
// Check for IDE configurations from manifest
if (result.manifest && result.manifest.ides) {
result.ides = result.manifest.ides;
}
// Mark as installed if we found core or modules
if (result.hasCore || result.modules.length > 0) {
result.installed = true;
}
return result;
}
/**
* Detect legacy installation (.bmad-method, .bmm, .cis)
* @param {string} projectDir - Project directory to check
* @returns {Object} Legacy installation details
*/
async detectLegacy(projectDir) {
const result = {
hasLegacy: false,
legacyCore: false,
legacyModules: [],
paths: [],
};
// Check for legacy core (.bmad-method)
const legacyCorePath = path.join(projectDir, '.bmad-method');
if (await fs.pathExists(legacyCorePath)) {
result.hasLegacy = true;
result.legacyCore = true;
result.paths.push(legacyCorePath);
}
// Check for legacy modules (directories starting with .)
const entries = await fs.readdir(projectDir, { withFileTypes: true });
for (const entry of entries) {
if (
entry.isDirectory() &&
entry.name.startsWith('.') &&
entry.name !== '.bmad-method' &&
!entry.name.startsWith('.git') &&
!entry.name.startsWith('.vscode') &&
!entry.name.startsWith('.idea')
) {
const modulePath = path.join(projectDir, entry.name);
const moduleManifestPath = path.join(modulePath, 'install-manifest.yaml');
// Check if it's likely a BMAD module
if ((await fs.pathExists(moduleManifestPath)) || (await fs.pathExists(path.join(modulePath, 'config.yaml')))) {
result.hasLegacy = true;
result.legacyModules.push({
name: entry.name.slice(1), // Remove leading dot
path: modulePath,
});
result.paths.push(modulePath);
}
}
}
return result;
}
/**
* Check if migration from legacy is needed
* @param {string} projectDir - Project directory
* @returns {Object} Migration requirements
*/
async checkMigrationNeeded(projectDir) {
const bmadDir = path.join(projectDir, 'bmad');
const current = await this.detect(bmadDir);
const legacy = await this.detectLegacy(projectDir);
return {
needed: legacy.hasLegacy && !current.installed,
canMigrate: legacy.hasLegacy,
legacy: legacy,
current: current,
};
}
/**
* Detect legacy BMAD v4 footprints (case-sensitive path checks)
* @param {string} projectDir - Project directory to check
* @returns {{ hasLegacyV4: boolean, offenders: string[] }}
*/
async detectLegacyV4(projectDir) {
// Helper: check existence of a nested path with case-sensitive segment matching
const existsCaseSensitive = async (baseDir, segments) => {
let dir = baseDir;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return false;
}
const hit = entries.find((e) => e.name === seg);
if (!hit) return false;
// Parents must be directories; the last segment may be a file or directory
if (i < segments.length - 1 && !hit.isDirectory()) return false;
dir = path.join(dir, hit.name);
}
return true;
};
const offenders = [];
if (await existsCaseSensitive(projectDir, ['.bmad-core'])) {
offenders.push(path.join(projectDir, '.bmad-core'));
}
if (await existsCaseSensitive(projectDir, ['.claude', 'commands', 'BMad'])) {
offenders.push(path.join(projectDir, '.claude', 'commands', 'BMad'));
}
if (await existsCaseSensitive(projectDir, ['.crush', 'commands', 'BMad'])) {
offenders.push(path.join(projectDir, '.crush', 'commands', 'BMad'));
}
return { hasLegacyV4: offenders.length > 0, offenders };
}
}
module.exports = { Detector };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
const path = require('node:path');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const { getSourcePath, getModulePath } = require('../../../lib/project-root');
/**
* Generates manifest files for installed workflows, agents, and tasks
*/
class ManifestGenerator {
constructor() {
this.workflows = [];
this.agents = [];
this.tasks = [];
this.modules = [];
this.files = [];
}
/**
* Generate all manifests for the installation
* @param {string} bmadDir - BMAD installation directory
* @param {Array} selectedModules - Selected modules for installation
*/
async generateManifests(bmadDir, selectedModules) {
// Create _cfg directory if it doesn't exist
const cfgDir = path.join(bmadDir, '_cfg');
await fs.ensureDir(cfgDir);
// Store modules list
this.modules = ['core', ...selectedModules];
// Collect workflow data
await this.collectWorkflows(selectedModules);
// Collect agent data
await this.collectAgents(selectedModules);
// Collect task data
await this.collectTasks(selectedModules);
// Write manifest files
await this.writeMainManifest(cfgDir);
await this.writeWorkflowManifest(cfgDir);
await this.writeAgentManifest(cfgDir);
await this.writeTaskManifest(cfgDir);
await this.writeFilesManifest(cfgDir);
return {
workflows: this.workflows.length,
agents: this.agents.length,
tasks: this.tasks.length,
files: this.files.length,
};
}
/**
* Collect all workflows from core and selected modules
*/
async collectWorkflows(selectedModules) {
this.workflows = [];
// Get core workflows
const corePath = getModulePath('core');
const coreWorkflows = await this.getWorkflowsFromPath(corePath, 'core');
this.workflows.push(...coreWorkflows);
// Get module workflows
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const moduleWorkflows = await this.getWorkflowsFromPath(modulePath, moduleName);
this.workflows.push(...moduleWorkflows);
}
}
/**
* Recursively find and parse workflow.yaml files
*/
async getWorkflowsFromPath(basePath, moduleName) {
const workflows = [];
const workflowsPath = path.join(basePath, 'workflows');
if (!(await fs.pathExists(workflowsPath))) {
return workflows;
}
// Recursively find workflow.yaml files
const findWorkflows = async (dir, relativePath = '') => {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recurse into subdirectories
const newRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
await findWorkflows(fullPath, newRelativePath);
} else if (entry.name === 'workflow.yaml') {
// Parse workflow file
try {
const content = await fs.readFile(fullPath, 'utf8');
const workflow = yaml.load(content);
// Skip template workflows (those with placeholder values)
if (workflow.name && workflow.name.includes('{') && workflow.name.includes('}')) {
continue;
}
if (workflow.name && workflow.description) {
// Build relative path for installation
const installPath =
moduleName === 'core'
? `bmad/core/workflows/${relativePath}/workflow.yaml`
: `bmad/${moduleName}/workflows/${relativePath}/workflow.yaml`;
workflows.push({
name: workflow.name,
description: workflow.description.replaceAll('"', '""'), // Escape quotes for CSV
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'workflow',
name: workflow.name,
module: moduleName,
path: installPath,
});
}
} catch (error) {
console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`);
}
}
}
};
await findWorkflows(workflowsPath);
return workflows;
}
/**
* Collect all agents from core and selected modules
*/
async collectAgents(selectedModules) {
this.agents = [];
// Get core agents
const corePath = getModulePath('core');
const coreAgentsPath = path.join(corePath, 'agents');
if (await fs.pathExists(coreAgentsPath)) {
const coreAgents = await this.getAgentsFromDir(coreAgentsPath, 'core');
this.agents.push(...coreAgents);
}
// Get module agents
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const agentsPath = path.join(modulePath, 'agents');
if (await fs.pathExists(agentsPath)) {
const moduleAgents = await this.getAgentsFromDir(agentsPath, moduleName);
this.agents.push(...moduleAgents);
}
}
}
/**
* Get agents from a directory
*/
async getAgentsFromDir(dirPath, moduleName) {
const agents = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Skip web-only agents
if (content.includes('localskip="true"')) {
continue;
}
// Extract agent metadata from content if possible
const nameMatch = content.match(/name="([^"]+)"/);
const descMatch = content.match(/<objective>([^<]+)<\/objective>/);
// Build relative path for installation
const installPath = moduleName === 'core' ? `bmad/core/agents/${file}` : `bmad/${moduleName}/agents/${file}`;
const agentName = file.replace('.md', '');
agents.push({
name: agentName,
displayName: nameMatch ? nameMatch[1] : agentName,
description: descMatch ? descMatch[1].trim().replaceAll('"', '""') : '',
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'agent',
name: agentName,
module: moduleName,
path: installPath,
});
}
}
return agents;
}
/**
* Collect all tasks from core and selected modules
*/
async collectTasks(selectedModules) {
this.tasks = [];
// Get core tasks
const corePath = getModulePath('core');
const coreTasksPath = path.join(corePath, 'tasks');
if (await fs.pathExists(coreTasksPath)) {
const coreTasks = await this.getTasksFromDir(coreTasksPath, 'core');
this.tasks.push(...coreTasks);
}
// Get module tasks
for (const moduleName of selectedModules) {
const modulePath = getSourcePath(`modules/${moduleName}`);
const tasksPath = path.join(modulePath, 'tasks');
if (await fs.pathExists(tasksPath)) {
const moduleTasks = await this.getTasksFromDir(tasksPath, moduleName);
this.tasks.push(...moduleTasks);
}
}
}
/**
* Get tasks from a directory
*/
async getTasksFromDir(dirPath, moduleName) {
const tasks = [];
const files = await fs.readdir(dirPath);
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(dirPath, file);
const content = await fs.readFile(filePath, 'utf8');
// Extract task metadata from content if possible
const nameMatch = content.match(/name="([^"]+)"/);
const objMatch = content.match(/<objective>([^<]+)<\/objective>/);
// Build relative path for installation
const installPath = moduleName === 'core' ? `bmad/core/tasks/${file}` : `bmad/${moduleName}/tasks/${file}`;
const taskName = file.replace('.md', '');
tasks.push({
name: taskName,
displayName: nameMatch ? nameMatch[1] : taskName,
description: objMatch ? objMatch[1].trim().replaceAll('"', '""') : '',
module: moduleName,
path: installPath,
});
// Add to files list
this.files.push({
type: 'task',
name: taskName,
module: moduleName,
path: installPath,
});
}
}
return tasks;
}
/**
* Write main manifest as YAML with installation info only
*/
async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml');
const manifest = {
installation: {
version: '6.0.0-alpha.0',
installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
},
modules: this.modules.map((name) => ({
name,
version: '',
shortTitle: '',
})),
ides: ['claude-code'],
};
const yamlStr = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(manifestPath, yamlStr);
}
/**
* Write workflow manifest CSV
*/
async writeWorkflowManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'workflow-manifest.csv');
// Create CSV header
let csv = 'name,description,module,path\n';
// Add rows
for (const workflow of this.workflows) {
csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write agent manifest CSV
*/
async writeAgentManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'agent-manifest.csv');
// Create CSV header
let csv = 'name,displayName,description,module,path\n';
// Add rows
for (const agent of this.agents) {
csv += `"${agent.name}","${agent.displayName}","${agent.description}","${agent.module}","${agent.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write task manifest CSV
*/
async writeTaskManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'task-manifest.csv');
// Create CSV header
let csv = 'name,displayName,description,module,path\n';
// Add rows
for (const task of this.tasks) {
csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
/**
* Write files manifest CSV
*/
async writeFilesManifest(cfgDir) {
const csvPath = path.join(cfgDir, 'files-manifest.csv');
// Create CSV header
let csv = 'type,name,module,path\n';
// Sort files by type, then module, then name
this.files.sort((a, b) => {
if (a.type !== b.type) return a.type.localeCompare(b.type);
if (a.module !== b.module) return a.module.localeCompare(b.module);
return a.name.localeCompare(b.name);
});
// Add rows
for (const file of this.files) {
csv += `"${file.type}","${file.name}","${file.module}","${file.path}"\n`;
}
await fs.writeFile(csvPath, csv);
}
}
module.exports = { ManifestGenerator };

View File

@@ -0,0 +1,484 @@
const path = require('node:path');
const fs = require('fs-extra');
class Manifest {
/**
* Create a new manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} data - Manifest data
* @param {Array} installedFiles - List of installed files to track
*/
async create(bmadDir, data, installedFiles = []) {
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
// Ensure _cfg directory exists
await fs.ensureDir(path.dirname(manifestPath));
// Load module configs to get module metadata
// If core is installed, add it to modules list
const allModules = [...(data.modules || [])];
if (data.core) {
allModules.unshift('core'); // Add core at the beginning
}
const moduleConfigs = await this.loadModuleConfigs(allModules);
// Parse installed files to extract metadata - pass bmadDir for relative paths
const fileMetadata = await this.parseInstalledFiles(installedFiles, bmadDir);
// Don't store installation path in manifest
// Generate CSV content
const csvContent = this.generateManifestCsv({ ...data, modules: allModules }, fileMetadata, moduleConfigs);
await fs.writeFile(manifestPath, csvContent, 'utf8');
return { success: true, path: manifestPath, filesTracked: fileMetadata.length };
}
/**
* Read existing manifest
* @param {string} bmadDir - Path to bmad directory
* @returns {Object|null} Manifest data or null if not found
*/
async read(bmadDir) {
const csvPath = path.join(bmadDir, '_cfg', 'manifest.csv');
if (await fs.pathExists(csvPath)) {
try {
const content = await fs.readFile(csvPath, 'utf8');
return this.parseManifestCsv(content);
} catch (error) {
console.error('Failed to read CSV manifest:', error.message);
}
}
return null;
}
/**
* Update existing manifest
* @param {string} bmadDir - Path to bmad directory
* @param {Object} updates - Fields to update
* @param {Array} installedFiles - Updated list of installed files
*/
async update(bmadDir, updates, installedFiles = null) {
const manifest = (await this.read(bmadDir)) || {};
// Merge updates
Object.assign(manifest, updates);
manifest.lastUpdated = new Date().toISOString();
// If new file list provided, reparse metadata
let fileMetadata = manifest.files || [];
if (installedFiles) {
fileMetadata = await this.parseInstalledFiles(installedFiles);
}
const manifestPath = path.join(bmadDir, '_cfg', 'manifest.csv');
await fs.ensureDir(path.dirname(manifestPath));
const csvContent = this.generateManifestCsv({ ...manifest, ...updates }, fileMetadata);
await fs.writeFile(manifestPath, csvContent, 'utf8');
return { ...manifest, ...updates, files: fileMetadata };
}
/**
* Add a module to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to add
*/
async addModule(bmadDir, moduleName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.modules) {
manifest.modules = [];
}
if (!manifest.modules.includes(moduleName)) {
manifest.modules.push(moduleName);
await this.update(bmadDir, { modules: manifest.modules });
}
}
/**
* Remove a module from the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} moduleName - Module name to remove
*/
async removeModule(bmadDir, moduleName) {
const manifest = await this.read(bmadDir);
if (!manifest || !manifest.modules) {
return;
}
const index = manifest.modules.indexOf(moduleName);
if (index !== -1) {
manifest.modules.splice(index, 1);
await this.update(bmadDir, { modules: manifest.modules });
}
}
/**
* Add an IDE configuration to the manifest
* @param {string} bmadDir - Path to bmad directory
* @param {string} ideName - IDE name to add
*/
async addIde(bmadDir, ideName) {
const manifest = await this.read(bmadDir);
if (!manifest) {
throw new Error('No manifest found');
}
if (!manifest.ides) {
manifest.ides = [];
}
if (!manifest.ides.includes(ideName)) {
manifest.ides.push(ideName);
await this.update(bmadDir, { ides: manifest.ides });
}
}
/**
* Parse installed files to extract metadata
* @param {Array} installedFiles - List of installed file paths
* @param {string} bmadDir - Path to bmad directory for relative paths
* @returns {Array} Array of file metadata objects
*/
async parseInstalledFiles(installedFiles, bmadDir) {
const fileMetadata = [];
for (const filePath of installedFiles) {
const fileExt = path.extname(filePath).toLowerCase();
// Make path relative to parent of bmad directory, starting with 'bmad/'
const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
// Handle markdown files - extract XML metadata
if (fileExt === '.md') {
try {
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
if (metadata) {
fileMetadata.push(metadata);
}
}
} catch (error) {
console.warn(`Warning: Could not parse ${filePath}:`, error.message);
}
}
// Handle other file types (CSV, JSON, etc.)
else {
fileMetadata.push({
file: relativePath,
type: fileExt.slice(1), // Remove the dot
name: path.basename(filePath, fileExt),
title: null,
});
}
}
return fileMetadata;
}
/**
* Extract XML node attributes from MD file content
* @param {string} content - File content
* @param {string} filePath - File path for context
* @param {string} relativePath - Relative path starting with 'bmad/'
* @returns {Object|null} Extracted metadata or null
*/
extractXmlNodeAttributes(content, filePath, relativePath) {
// Look for XML blocks in code fences
const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
if (!xmlBlockMatch) {
return null;
}
const xmlContent = xmlBlockMatch[1];
// Extract root XML node (agent, task, template, etc.)
const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
if (!rootNodeMatch) {
return null;
}
const nodeType = rootNodeMatch[1];
const attributes = rootNodeMatch[2];
// Extract name and title attributes (id not needed since we have path)
const nameMatch = attributes.match(/name="([^"]*)"/);
const titleMatch = attributes.match(/title="([^"]*)"/);
return {
file: relativePath,
type: nodeType,
name: nameMatch ? nameMatch[1] : null,
title: titleMatch ? titleMatch[1] : null,
};
}
/**
* Generate CSV manifest content
* @param {Object} data - Manifest data
* @param {Array} fileMetadata - File metadata array
* @param {Object} moduleConfigs - Module configuration data
* @returns {string} CSV content
*/
generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
const timestamp = new Date().toISOString();
let csv = [];
// Header section
csv.push(
'# BMAD Manifest',
`# Generated: ${timestamp}`,
'',
'## Installation Info',
'Property,Value',
`Version,${data.version}`,
`InstallDate,${data.installDate || timestamp}`,
`LastUpdated,${data.lastUpdated || timestamp}`,
);
if (data.language) {
csv.push(`Language,${data.language}`);
}
csv.push('');
// Modules section
if (data.modules && data.modules.length > 0) {
csv.push('## Modules', 'Name,Version,ShortTitle');
for (const moduleName of data.modules) {
const config = moduleConfigs[moduleName] || {};
csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
}
csv.push('');
}
// IDEs section
if (data.ides && data.ides.length > 0) {
csv.push('## IDEs', 'IDE');
for (const ide of data.ides) {
csv.push(this.escapeCsv(ide));
}
csv.push('');
}
// Files section
if (fileMetadata.length > 0) {
csv.push('## Files', 'Type,Path,Name,Title');
for (const file of fileMetadata) {
csv.push([file.type || '', file.file || '', file.name || '', file.title || ''].map((v) => this.escapeCsv(v)).join(','));
}
}
return csv.join('\n');
}
/**
* Parse CSV manifest content back to object
* @param {string} csvContent - CSV content to parse
* @returns {Object} Parsed manifest data
*/
parseManifestCsv(csvContent) {
const result = {
modules: [],
ides: [],
files: [],
};
const lines = csvContent.split('\n');
let section = '';
for (const line_ of lines) {
const line = line_.trim();
// Skip empty lines and comments
if (!line || line.startsWith('#')) {
// Check for section headers
if (line.startsWith('## ')) {
section = line.slice(3).toLowerCase();
}
continue;
}
// Parse based on current section
switch (section) {
case 'installation info': {
// Skip header row
if (line === 'Property,Value') continue;
const [property, ...valueParts] = line.split(',');
const value = this.unescapeCsv(valueParts.join(','));
switch (property) {
// Path no longer stored in manifest
case 'Version': {
result.version = value;
break;
}
case 'InstallDate': {
result.installDate = value;
break;
}
case 'LastUpdated': {
result.lastUpdated = value;
break;
}
case 'Language': {
result.language = value;
break;
}
}
break;
}
case 'modules': {
// Skip header row
if (line === 'Name,Version,ShortTitle') continue;
const parts = this.parseCsvLine(line);
if (parts[0]) {
result.modules.push(parts[0]);
}
break;
}
case 'ides': {
// Skip header row
if (line === 'IDE') continue;
result.ides.push(this.unescapeCsv(line));
break;
}
case 'files': {
// Skip header row
if (line === 'Type,Path,Name,Title') continue;
const parts = this.parseCsvLine(line);
if (parts.length >= 2) {
result.files.push({
type: parts[0] || '',
file: parts[1] || '',
name: parts[2] || null,
title: parts[3] || null,
});
}
break;
}
// No default
}
}
return result;
}
/**
* Parse a CSV line handling quotes and commas
* @param {string} line - CSV line to parse
* @returns {Array} Array of values
*/
parseCsvLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
current += '"';
i++;
} else {
// Toggle quote state
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
// Field separator
result.push(this.unescapeCsv(current));
current = '';
} else {
current += char;
}
}
// Add the last field
result.push(this.unescapeCsv(current));
return result;
}
/**
* Escape CSV special characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeCsv(text) {
if (!text) return '';
const str = String(text);
// If contains comma, newline, or quote, wrap in quotes and escape quotes
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return '"' + str.replaceAll('"', '""') + '"';
}
return str;
}
/**
* Unescape CSV field
* @param {string} text - Text to unescape
* @returns {string} Unescaped text
*/
unescapeCsv(text) {
if (!text) return '';
// Remove surrounding quotes if present
if (text.startsWith('"') && text.endsWith('"')) {
text = text.slice(1, -1);
// Unescape doubled quotes
text = text.replaceAll('""', '"');
}
return text;
}
/**
* Load module configuration files
* @param {Array} modules - List of module names
* @returns {Object} Module configurations indexed by name
*/
async loadModuleConfigs(modules) {
const configs = {};
for (const moduleName of modules) {
// Handle core module differently - it's in src/core not src/modules/core
const configPath =
moduleName === 'core'
? path.join(process.cwd(), 'src', 'core', 'config.yaml')
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
try {
if (await fs.pathExists(configPath)) {
const yaml = require('js-yaml');
const content = await fs.readFile(configPath, 'utf8');
configs[moduleName] = yaml.load(content);
}
} catch (error) {
console.warn(`Could not load config for module ${moduleName}:`, error.message);
}
}
return configs;
}
}
module.exports = { Manifest };