const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); const chalk = require('chalk'); const { XmlHandler } = require('../../../lib/xml-handler'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { filterCustomizationData } = require('../../../lib/agent/compiler'); /** * Manages the installation, updating, and removal of BMAD modules. * Handles module discovery, dependency resolution, configuration processing, * and agent file management including XML activation block injection. * * @class ModuleManager * @requires fs-extra * @requires yaml * @requires chalk * @requires XmlHandler * * @example * const manager = new ModuleManager(); * const modules = await manager.listAvailable(); * await manager.install('core-module', '/path/to/bmad'); */ class ModuleManager { constructor(options = {}) { // Path to source modules directory this.modulesSourcePath = getSourcePath('modules'); this.xmlHandler = new XmlHandler(); this.bmadFolderName = 'bmad'; // Default, can be overridden this.scanProjectForModules = options.scanProjectForModules !== false; // Default to true for backward compatibility } /** * Set the bmad folder name for placeholder replacement * @param {string} bmadFolderName - The bmad folder name */ setBmadFolderName(bmadFolderName) { this.bmadFolderName = bmadFolderName; } /** * Set the core configuration for access during module installation * @param {Object} coreConfig - Core configuration object */ setCoreConfig(coreConfig) { this.coreConfig = coreConfig; } /** * Copy a file and replace _bmad placeholder with actual folder name * @param {string} sourcePath - Source file path * @param {string} targetPath - Target file path */ async copyFileWithPlaceholderReplacement(sourcePath, targetPath) { // List of text file extensions that should have placeholder replacement const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv']; const ext = path.extname(sourcePath).toLowerCase(); // Check if this is a text file that might contain placeholders if (textExtensions.includes(ext)) { try { // Read the file content let content = await fs.readFile(sourcePath, 'utf8'); // Replace escape sequence _bmad with literal _bmad if (content.includes('_bmad')) { content = content.replaceAll('_bmad', '_bmad'); } // Replace _bmad placeholder with actual folder name if (content.includes('_bmad')) { content = content.replaceAll('_bmad', this.bmadFolderName); } // Write to target with replaced content await fs.ensureDir(path.dirname(targetPath)); await fs.writeFile(targetPath, content, 'utf8'); } catch { // If reading as text fails (might be binary despite extension), fall back to regular copy await fs.copy(sourcePath, targetPath, { overwrite: true }); } } else { // Binary file or other file type - just copy directly await fs.copy(sourcePath, targetPath, { overwrite: true }); } } /** * Copy a directory recursively with placeholder replacement * @param {string} sourceDir - Source directory path * @param {string} targetDir - Target directory path */ async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir) { await fs.ensureDir(targetDir); const entries = await fs.readdir(sourceDir, { withFileTypes: true }); for (const entry of entries) { const sourcePath = path.join(sourceDir, entry.name); const targetPath = path.join(targetDir, entry.name); if (entry.isDirectory()) { await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath); } else { await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath); } } } /** * Find all modules in the project by searching for module.yaml files * @returns {Array} List of module paths */ async findModulesInProject() { const projectRoot = getProjectRoot(); const modulePaths = new Set(); // Helper function to recursively scan directories async function scanDirectory(dir, excludePaths = []) { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); // Skip hidden directories, node_modules, and literal placeholder directories if ( entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build' || entry.name === '{project-root}' ) { continue; } // Skip excluded paths if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) { continue; } if (entry.isDirectory()) { // Skip core module - it's always installed first and not selectable if (entry.name === 'core') { continue; } // Check if this directory contains a module (module.yaml OR custom.yaml) const moduleConfigPath = path.join(fullPath, 'module.yaml'); const installerConfigPath = path.join(fullPath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(fullPath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(fullPath, 'custom.yaml'); if ( (await fs.pathExists(moduleConfigPath)) || (await fs.pathExists(installerConfigPath)) || (await fs.pathExists(customConfigPath)) || (await fs.pathExists(rootCustomConfigPath)) ) { modulePaths.add(fullPath); // Don't scan inside modules - they might have their own nested structures continue; } // Recursively scan subdirectories await scanDirectory(fullPath, excludePaths); } } } catch { // Ignore errors (e.g., permission denied) } } // Scan the entire project, but exclude src/modules since we handle it separately await scanDirectory(projectRoot, [this.modulesSourcePath]); return [...modulePaths]; } /** * List all available modules (excluding core which is always installed) * @returns {Object} Object with modules array and customModules array */ async listAvailable() { const modules = []; const customModules = []; // First, scan src/modules (the standard location) if (await fs.pathExists(this.modulesSourcePath)) { const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const modulePath = path.join(this.modulesSourcePath, entry.name); // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); // Skip if this doesn't look like a module if ( !(await fs.pathExists(moduleConfigPath)) && !(await fs.pathExists(installerConfigPath)) && !(await fs.pathExists(customConfigPath)) ) { continue; } // Skip core module - it's always installed first and not selectable if (entry.name === 'core') { continue; } const moduleInfo = await this.getModuleInfo(modulePath, entry.name, 'src/modules'); if (moduleInfo) { modules.push(moduleInfo); } } } } // Then, find all other modules in the project (only if scanning is enabled) if (this.scanProjectForModules) { const otherModulePaths = await this.findModulesInProject(); for (const modulePath of otherModulePaths) { const moduleName = path.basename(modulePath); const relativePath = path.relative(getProjectRoot(), modulePath); // Skip core module - it's always installed first and not selectable if (moduleName === 'core') { continue; } const moduleInfo = await this.getModuleInfo(modulePath, moduleName, relativePath); if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { // Avoid duplicates - skip if we already have this module ID if (moduleInfo.isCustom) { customModules.push(moduleInfo); } else { modules.push(moduleInfo); } } } // Also check for cached custom modules in _cfg/custom/ if (this.bmadDir) { const customCacheDir = path.join(this.bmadDir, '_cfg', 'custom'); if (await fs.pathExists(customCacheDir)) { const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true }); for (const entry of cacheEntries) { if (entry.isDirectory()) { const cachePath = path.join(customCacheDir, entry.name); const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_cfg/custom'); if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { moduleInfo.isCustom = true; moduleInfo.fromCache = true; customModules.push(moduleInfo); } } } } } } return { modules, customModules }; } /** * Get module information from a module path * @param {string} modulePath - Path to the module directory * @param {string} defaultName - Default name for the module * @param {string} sourceDescription - Description of where the module was found * @returns {Object|null} Module info or null if not a valid module */ async getModuleInfo(modulePath, defaultName, sourceDescription) { // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; } else if (await fs.pathExists(installerConfigPath)) { configPath = installerConfigPath; } else if (await fs.pathExists(customConfigPath)) { configPath = customConfigPath; } else if (await fs.pathExists(rootCustomConfigPath)) { configPath = rootCustomConfigPath; } // Skip if this doesn't look like a module if (!configPath) { return null; } // Mark as custom if it's using custom.yaml OR if it's outside src/modules const isCustomSource = sourceDescription !== 'src/modules'; const moduleInfo = { id: defaultName, path: modulePath, name: defaultName .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '), description: 'BMAD Module', version: '5.0.0', source: sourceDescription, isCustom: configPath === customConfigPath || configPath === rootCustomConfigPath || isCustomSource, }; // Read module config for metadata try { const configContent = await fs.readFile(configPath, 'utf8'); const config = yaml.parse(configContent); // Use the code property as the id if available if (config.code) { moduleInfo.id = config.code; } moduleInfo.name = config.name || moduleInfo.name; moduleInfo.description = config.description || moduleInfo.description; moduleInfo.version = config.version || moduleInfo.version; moduleInfo.dependencies = config.dependencies || []; moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected; } catch (error) { console.warn(`Failed to read config for ${defaultName}:`, error.message); } return moduleInfo; } /** * Find the source path for a module by searching all possible locations * @param {string} moduleName - Name of the module to find * @returns {string|null} Path to the module source or null if not found */ async findModuleSource(moduleName) { const projectRoot = getProjectRoot(); // First, check src/modules const srcModulePath = path.join(this.modulesSourcePath, moduleName); if (await fs.pathExists(srcModulePath)) { // Check if this looks like a module (has module.yaml) const moduleConfigPath = path.join(srcModulePath, 'module.yaml'); const installerConfigPath = path.join(srcModulePath, '_module-installer', 'module.yaml'); if ((await fs.pathExists(moduleConfigPath)) || (await fs.pathExists(installerConfigPath))) { return srcModulePath; } // Also check for custom.yaml in src/modules/_module-installer const customConfigPath = path.join(srcModulePath, '_module-installer', 'custom.yaml'); if (await fs.pathExists(customConfigPath)) { return srcModulePath; } } // If not found in src/modules, search the entire project const allModulePaths = await this.findModulesInProject(); for (const modulePath of allModulePaths) { if (path.basename(modulePath) === moduleName) { return modulePath; } } // Also check by module ID (not just folder name) // Need to read configs to match by ID for (const modulePath of allModulePaths) { const moduleConfigPath = path.join(modulePath, 'module.yaml'); const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; } else if (await fs.pathExists(installerConfigPath)) { configPath = installerConfigPath; } else if (await fs.pathExists(customConfigPath)) { configPath = customConfigPath; } else if (await fs.pathExists(rootCustomConfigPath)) { configPath = rootCustomConfigPath; } if (configPath) { try { const configContent = await fs.readFile(configPath, 'utf8'); const config = yaml.parse(configContent); if (config.code === moduleName) { return modulePath; } } catch (error) { throw new Error(`Failed to parse module.yaml at ${configPath}: ${error.message}`); } } } return null; } /** * Install a module * @param {string} moduleName - Name of the module to install * @param {string} bmadDir - Target bmad directory * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} options - Additional installation options * @param {Array} options.installedIDEs - Array of IDE codes that were installed * @param {Object} options.moduleConfig - Module configuration from config collector * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { const sourcePath = await this.findModuleSource(moduleName); const targetPath = path.join(bmadDir, moduleName); // Check if source module exists if (!sourcePath) { throw new Error(`Module '${moduleName}' not found in any source location`); } // Check if this is a custom module and read its custom.yaml values let customConfig = null; const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); const moduleInstallerCustomPath = path.join(sourcePath, '_module-installer', 'custom.yaml'); if (await fs.pathExists(rootCustomConfigPath)) { try { const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); customConfig = yaml.parse(customContent); } catch (error) { console.warn(chalk.yellow(`Warning: Failed to read custom.yaml for ${moduleName}:`, error.message)); } } else if (await fs.pathExists(moduleInstallerCustomPath)) { try { const customContent = await fs.readFile(moduleInstallerCustomPath, 'utf8'); customConfig = yaml.parse(customContent); } catch (error) { console.warn(chalk.yellow(`Warning: Failed to read custom.yaml for ${moduleName}:`, error.message)); } } // If this is a custom module, merge its values into the module config if (customConfig) { options.moduleConfig = { ...options.moduleConfig, ...customConfig }; if (options.logger) { options.logger.log(chalk.cyan(` Merged custom configuration for ${moduleName}`)); } } // Check if already installed if (await fs.pathExists(targetPath)) { console.log(chalk.yellow(`Module '${moduleName}' already installed, updating...`)); await fs.remove(targetPath); } // Vendor cross-module workflows BEFORE copying // This reads source agent.yaml files and copies referenced workflows await this.vendorCrossModuleWorkflows(sourcePath, targetPath, moduleName); // Copy module files with filtering await this.copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback, options.moduleConfig); // Compile any .agent.yaml files to .md format await this.compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir); // Process agent files to inject activation block await this.processAgentFiles(targetPath, moduleName); // Call module-specific installer if it exists (unless explicitly skipped) if (!options.skipModuleInstaller) { await this.runModuleInstaller(moduleName, bmadDir, options); } return { success: true, module: moduleName, path: targetPath, }; } /** * Update an existing module * @param {string} moduleName - Name of the module to update * @param {string} bmadDir - Target bmad directory * @param {boolean} force - Force update (overwrite modifications) */ async update(moduleName, bmadDir, force = false) { const sourcePath = await this.findModuleSource(moduleName); const targetPath = path.join(bmadDir, moduleName); // Check if source module exists if (!sourcePath) { throw new Error(`Module '${moduleName}' not found in any source location`); } // Check if module is installed if (!(await fs.pathExists(targetPath))) { throw new Error(`Module '${moduleName}' is not installed`); } if (force) { // Force update - remove and reinstall await fs.remove(targetPath); return await this.install(moduleName, bmadDir); } else { // Selective update - preserve user modifications await this.syncModule(sourcePath, targetPath); } return { success: true, module: moduleName, path: targetPath, }; } /** * Remove a module * @param {string} moduleName - Name of the module to remove * @param {string} bmadDir - Target bmad directory */ async remove(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); if (!(await fs.pathExists(targetPath))) { throw new Error(`Module '${moduleName}' is not installed`); } await fs.remove(targetPath); return { success: true, module: moduleName, }; } /** * Check if a module is installed * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @returns {boolean} True if module is installed */ async isInstalled(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); return await fs.pathExists(targetPath); } /** * Get installed module info * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @returns {Object|null} Module info or null if not installed */ async getInstalledInfo(moduleName, bmadDir) { const targetPath = path.join(bmadDir, moduleName); if (!(await fs.pathExists(targetPath))) { return null; } const configPath = path.join(targetPath, 'config.yaml'); const moduleInfo = { id: moduleName, path: targetPath, installed: true, }; if (await fs.pathExists(configPath)) { try { const configContent = await fs.readFile(configPath, 'utf8'); const config = yaml.parse(configContent); Object.assign(moduleInfo, config); } catch (error) { console.warn(`Failed to read installed module config:`, error.message); } } return moduleInfo; } /** * Copy module with filtering for localskip agents and conditional content * @param {string} sourcePath - Source module path * @param {string} targetPath - Target module path * @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Object} moduleConfig - Module configuration with conditional flags */ async copyModuleWithFiltering(sourcePath, targetPath, fileTrackingCallback = null, moduleConfig = {}) { // Get all files in source const sourceFiles = await this.getFileList(sourcePath); // Game development files to conditionally exclude const gameDevFiles = [ 'agents/game-architect.agent.yaml', 'agents/game-designer.agent.yaml', 'agents/game-dev.agent.yaml', 'workflows/1-analysis/brainstorm-game', 'workflows/1-analysis/game-brief', 'workflows/2-plan-workflows/gdd', ]; for (const file of sourceFiles) { // Skip sub-modules directory - these are IDE-specific and handled separately if (file.startsWith('sub-modules/')) { continue; } // Skip sidecar directories - they are handled separately during agent compilation if ( path .dirname(file) .split('/') .some((dir) => dir.toLowerCase().includes('sidecar')) ) { continue; } // Skip _module-installer directory - it's only needed at install time if (file.startsWith('_module-installer/') || file === 'module.yaml') { continue; } // Skip config.yaml templates - we'll generate clean ones with actual values // Also skip custom.yaml files - their values will be merged into core config if (file === 'config.yaml' || file.endsWith('/config.yaml') || file === 'custom.yaml' || file.endsWith('/custom.yaml')) { continue; } // Skip .agent.yaml files - they will be compiled separately if (file.endsWith('.agent.yaml')) { continue; } // Skip user documentation if install_user_docs is false if (moduleConfig.install_user_docs === false && (file.startsWith('docs/') || file.startsWith('docs\\'))) { console.log(chalk.dim(` Skipping user documentation: ${file}`)); continue; } // Skip game development content if include_game_planning is false if (moduleConfig.include_game_planning === false) { const shouldSkipGameDev = gameDevFiles.some((gamePath) => { // Check if file path starts with or is within any game dev directory return file === gamePath || file.startsWith(gamePath + '/') || file.startsWith(gamePath + '\\'); }); if (shouldSkipGameDev) { console.log(chalk.dim(` Skipping game dev content: ${file}`)); continue; } } const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); // Check if this is an agent file if (file.startsWith('agents/') && file.endsWith('.md')) { // Read the file to check for localskip const content = await fs.readFile(sourceFile, 'utf8'); // Check for localskip="true" in the agent tag const agentMatch = content.match(/]*\slocalskip="true"[^>]*>/); if (agentMatch) { console.log(chalk.dim(` Skipping web-only agent: ${path.basename(file)}`)); continue; // Skip this agent } } // Check if this is a workflow.yaml file if (file.endsWith('workflow.yaml')) { await fs.ensureDir(path.dirname(targetFile)); await this.copyWorkflowYamlStripped(sourceFile, targetFile); } else { // Copy the file with placeholder replacement await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); } // Track the file if callback provided if (fileTrackingCallback) { fileTrackingCallback(targetFile); } } } /** * Copy workflow.yaml file with web_bundle section stripped * Preserves comments, formatting, and line breaks * @param {string} sourceFile - Source workflow.yaml file path * @param {string} targetFile - Target workflow.yaml file path */ async copyWorkflowYamlStripped(sourceFile, targetFile) { // Read the source YAML file let yamlContent = await fs.readFile(sourceFile, 'utf8'); // IMPORTANT: Replace escape sequence and placeholder BEFORE parsing YAML // Otherwise parsing will fail on the placeholder yamlContent = yamlContent.replaceAll('_bmad', '_bmad'); yamlContent = yamlContent.replaceAll('_bmad', this.bmadFolderName); try { // First check if web_bundle exists by parsing const workflowConfig = yaml.parse(yamlContent); if (workflowConfig.web_bundle === undefined) { // No web_bundle section, just write (placeholders already replaced above) await fs.writeFile(targetFile, yamlContent, 'utf8'); return; } // Remove web_bundle section using regex to preserve formatting // Match the web_bundle key and all its content (including nested items) // This handles both web_bundle: false and web_bundle: {...} // Find the line that starts web_bundle const lines = yamlContent.split('\n'); let startIdx = -1; let endIdx = -1; let baseIndent = 0; // Find the start of web_bundle section for (const [i, line] of lines.entries()) { const match = line.match(/^(\s*)web_bundle:/); if (match) { startIdx = i; baseIndent = match[1].length; break; } } if (startIdx === -1) { // web_bundle not found in text (shouldn't happen), copy as-is await fs.writeFile(targetFile, yamlContent, 'utf8'); return; } // Find the end of web_bundle section // It ends when we find a line with same or less indentation that's not empty/comment endIdx = startIdx; for (let i = startIdx + 1; i < lines.length; i++) { const line = lines[i]; // Skip empty lines and comments if (line.trim() === '' || line.trim().startsWith('#')) { continue; } // Check indentation const indent = line.match(/^(\s*)/)[1].length; if (indent <= baseIndent) { // Found next section at same or lower indentation endIdx = i - 1; break; } } // If we didn't find an end, it goes to end of file if (endIdx === startIdx) { endIdx = lines.length - 1; } // Remove the web_bundle section (including the line before if it's just a blank line) const newLines = [...lines.slice(0, startIdx), ...lines.slice(endIdx + 1)]; // Clean up any double blank lines that might result const strippedYaml = newLines.join('\n').replaceAll(/\n\n\n+/g, '\n\n'); // Placeholders already replaced at the beginning of this function await fs.writeFile(targetFile, strippedYaml, 'utf8'); } catch { // If anything fails, just copy the file as-is console.warn(chalk.yellow(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`)); await fs.copy(sourceFile, targetFile, { overwrite: true }); } } /** * Compile .agent.yaml files to .md format in modules * @param {string} sourcePath - Source module path * @param {string} targetPath - Target module path * @param {string} moduleName - Module name * @param {string} bmadDir - BMAD installation directory */ async compileModuleAgents(sourcePath, targetPath, moduleName, bmadDir) { const sourceAgentsPath = path.join(sourcePath, 'agents'); const targetAgentsPath = path.join(targetPath, 'agents'); const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents'); // Check if agents directory exists in source if (!(await fs.pathExists(sourceAgentsPath))) { return; // No agents to compile } // Get all agent YAML files recursively const agentFiles = await this.findAgentFiles(sourceAgentsPath); for (const agentFile of agentFiles) { if (!agentFile.endsWith('.agent.yaml')) continue; const relativePath = path.relative(sourceAgentsPath, agentFile); const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); await fs.ensureDir(targetDir); const agentName = path.basename(agentFile, '.agent.yaml'); const sourceYamlPath = agentFile; const targetMdPath = path.join(targetDir, `${agentName}.md`); const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`); // Read and compile the YAML try { const yamlContent = await fs.readFile(sourceYamlPath, 'utf8'); const { compileAgent } = require('../../../lib/agent/compiler'); // Create customize template if it doesn't exist if (!(await fs.pathExists(customizePath))) { const { getSourcePath } = require('../../../lib/project-root'); const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml'); if (await fs.pathExists(genericTemplatePath)) { await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath); console.log(chalk.dim(` Created customize: ${moduleName}-${agentName}.customize.yaml`)); } } // Check for customizations and build answers object let customizedFields = []; let answers = {}; if (await fs.pathExists(customizePath)) { const customizeContent = await fs.readFile(customizePath, 'utf8'); const customizeData = yaml.parse(customizeContent); customizedFields = customizeData.customized_fields || []; // Build answers object from customizations (filter empty values) if (customizeData.persona) { Object.assign(answers, filterCustomizationData(customizeData.persona)); } if (customizeData.agent?.metadata) { const filteredMetadata = filterCustomizationData(customizeData.agent.metadata); if (Object.keys(filteredMetadata).length > 0) { Object.assign(answers, { metadata: filteredMetadata }); } } if (customizeData.critical_actions && customizeData.critical_actions.length > 0) { answers.critical_actions = customizeData.critical_actions; } if (customizeData.memories && customizeData.memories.length > 0) { answers.memories = customizeData.memories; } } // Load core config to get agent_sidecar_folder const coreConfigPath = path.join(bmadDir, 'bmb', 'config.yaml'); let coreConfig = {}; if (await fs.pathExists(coreConfigPath)) { const yaml = require('yaml'); const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8'); coreConfig = yaml.parse(coreConfigContent); } // Check if agent has sidecar let hasSidecar = false; try { const agentYaml = yaml.parse(yamlContent); hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true; } catch { // Continue without sidecar processing } // Compile with customizations if any const { xml } = await compileAgent(yamlContent, answers, agentName, relativePath, { config: coreConfig }); // Replace _bmad placeholder if needed if (xml.includes('_bmad') && this.bmadFolderName) { const processedXml = xml.replaceAll('_bmad', this.bmadFolderName); await fs.writeFile(targetMdPath, processedXml, 'utf8'); } else { await fs.writeFile(targetMdPath, xml, 'utf8'); } // Copy sidecar files if agent has hasSidecar flag if (hasSidecar) { const { copyAgentSidecarFiles } = require('../../../lib/agent/installer'); // Get agent sidecar folder from core config (should always be set) const agentSidecarFolder = this.coreConfig?.agent_sidecar_folder; // Resolve path variables const projectDir = path.dirname(bmadDir); const resolvedSidecarFolder = agentSidecarFolder .replaceAll('{project-root}', projectDir) .replaceAll('_bmad', path.basename(bmadDir)); // Create sidecar directory for this agent const agentSidecarDir = path.join(resolvedSidecarFolder, agentName); await fs.ensureDir(agentSidecarDir); // Copy sidecar files (preserve existing, add new) const sidecarResult = copyAgentSidecarFiles(path.dirname(sourceYamlPath), agentSidecarDir, sourceYamlPath); const totalFiles = sidecarResult.copied.length + sidecarResult.preserved.length; if (sidecarResult.copied.length > 0) { console.log(chalk.dim(` Copied ${sidecarResult.copied.length} new sidecar file(s) to: ${agentSidecarDir}`)); } if (sidecarResult.preserved.length > 0) { console.log(chalk.dim(` Preserved ${sidecarResult.preserved.length} existing sidecar file(s)`)); } } console.log( chalk.dim(` Compiled agent: ${agentName} -> ${path.relative(targetPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`), ); } catch (error) { console.warn(chalk.yellow(` Failed to compile agent ${agentName}:`, error.message)); } } } /** * Find all .agent.yaml files recursively in a directory * @param {string} dir - Directory to search * @returns {Array} List of .agent.yaml file paths */ async findAgentFiles(dir) { const agentFiles = []; async function searchDirectory(searchDir) { const entries = await fs.readdir(searchDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(searchDir, entry.name); if (entry.isFile() && entry.name.endsWith('.agent.yaml')) { agentFiles.push(fullPath); } else if (entry.isDirectory()) { await searchDirectory(fullPath); } } } await searchDirectory(dir); return agentFiles; } /** * Process agent files to inject activation block * @param {string} modulePath - Path to installed module * @param {string} moduleName - Module name */ async processAgentFiles(modulePath, moduleName) { // const agentsPath = path.join(modulePath, 'agents'); // // Check if agents directory exists // if (!(await fs.pathExists(agentsPath))) { // return; // No agents to process // } // // Get all agent MD files recursively // const agentFiles = await this.findAgentMdFiles(agentsPath); // for (const agentFile of agentFiles) { // if (!agentFile.endsWith('.md')) continue; // let content = await fs.readFile(agentFile, 'utf8'); // // Check if content has agent XML and no activation block // if (content.includes(' f.endsWith('.agent.yaml') || f.endsWith('.yaml')); if (yamlFiles.length === 0) { return; // No YAML agent files } let workflowsVendored = false; for (const agentFile of yamlFiles) { const agentPath = path.join(sourceAgentsPath, agentFile); const agentYaml = yaml.parse(await fs.readFile(agentPath, 'utf8')); // Check if agent has menu items with workflow-install const menuItems = agentYaml?.agent?.menu || []; const workflowInstallItems = menuItems.filter((item) => item['workflow-install']); if (workflowInstallItems.length === 0) { continue; // No workflow-install in this agent } if (!workflowsVendored) { console.log(chalk.cyan(`\n Vendoring cross-module workflows for ${moduleName}...`)); workflowsVendored = true; } console.log(chalk.dim(` Processing: ${agentFile}`)); for (const item of workflowInstallItems) { const sourceWorkflowPath = item.workflow; // Where to copy FROM const installWorkflowPath = item['workflow-install']; // Where to copy TO // Parse SOURCE workflow path // Handle both _bmad placeholder and hardcoded 'bmad' // Example: {project-root}/_bmad/bmm/workflows/4-implementation/create-story/workflow.yaml // Or: {project-root}/bmad/bmm/workflows/4-implementation/create-story/workflow.yaml const sourceMatch = sourceWorkflowPath.match(/\{project-root\}\/(?:_bmad)\/([^/]+)\/workflows\/(.+)/); if (!sourceMatch) { console.warn(chalk.yellow(` Could not parse workflow path: ${sourceWorkflowPath}`)); continue; } const [, sourceModule, sourceWorkflowSubPath] = sourceMatch; // Parse INSTALL workflow path // Handle_bmad // Example: {project-root}/_bmad/bmgd/workflows/4-production/create-story/workflow.yaml const installMatch = installWorkflowPath.match(/\{project-root\}\/(_bmad)\/([^/]+)\/workflows\/(.+)/); if (!installMatch) { console.warn(chalk.yellow(` Could not parse workflow-install path: ${installWorkflowPath}`)); continue; } const installWorkflowSubPath = installMatch[2]; // Determine actual filesystem paths const sourceModulePath = path.join(this.modulesSourcePath, sourceModule); const actualSourceWorkflowPath = path.join(sourceModulePath, 'workflows', sourceWorkflowSubPath.replace(/\/workflow\.yaml$/, '')); const actualDestWorkflowPath = path.join(targetPath, 'workflows', installWorkflowSubPath.replace(/\/workflow\.yaml$/, '')); // Check if source workflow exists if (!(await fs.pathExists(actualSourceWorkflowPath))) { console.warn(chalk.yellow(` Source workflow not found: ${actualSourceWorkflowPath}`)); continue; } // Copy the entire workflow folder console.log( chalk.dim( ` Vendoring: ${sourceModule}/workflows/${sourceWorkflowSubPath.replace(/\/workflow\.yaml$/, '')} → ${moduleName}/workflows/${installWorkflowSubPath.replace(/\/workflow\.yaml$/, '')}`, ), ); await fs.ensureDir(path.dirname(actualDestWorkflowPath)); // Copy the workflow directory recursively with placeholder replacement await this.copyDirectoryWithPlaceholderReplacement(actualSourceWorkflowPath, actualDestWorkflowPath); // Update the workflow.yaml config_source reference const workflowYamlPath = path.join(actualDestWorkflowPath, 'workflow.yaml'); if (await fs.pathExists(workflowYamlPath)) { await this.updateWorkflowConfigSource(workflowYamlPath, moduleName); } } } if (workflowsVendored) { console.log(chalk.green(` ✓ Workflow vendoring complete\n`)); } } /** * Update workflow.yaml config_source to point to new module * @param {string} workflowYamlPath - Path to workflow.yaml file * @param {string} newModuleName - New module name to reference */ async updateWorkflowConfigSource(workflowYamlPath, newModuleName) { let yamlContent = await fs.readFile(workflowYamlPath, 'utf8'); // Replace config_source: "{project-root}/_bmad/OLD_MODULE/config.yaml" // with config_source: "{project-root}/_bmad/NEW_MODULE/config.yaml" // Note: At this point _bmad has already been replaced with actual folder name const configSourcePattern = /config_source:\s*["']?\{project-root\}\/[^/]+\/[^/]+\/config\.yaml["']?/g; const newConfigSource = `config_source: "{project-root}/${this.bmadFolderName}/${newModuleName}/config.yaml"`; const updatedYaml = yamlContent.replaceAll(configSourcePattern, newConfigSource); if (updatedYaml !== yamlContent) { await fs.writeFile(workflowYamlPath, updatedYaml, 'utf8'); console.log(chalk.dim(` Updated config_source to: ${this.bmadFolderName}/${newModuleName}/config.yaml`)); } } /** * Run module-specific installer if it exists * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @param {Object} options - Installation options */ async runModuleInstaller(moduleName, bmadDir, options = {}) { // Special handling for core module - it's in src/core not src/modules let sourcePath; if (moduleName === 'core') { sourcePath = getSourcePath('core'); } else { sourcePath = await this.findModuleSource(moduleName); if (!sourcePath) { // No source found, skip module installer return; } } const installerPath = path.join(sourcePath, '_module-installer', 'installer.js'); // Check if module has a custom installer if (!(await fs.pathExists(installerPath))) { return; // No custom installer } try { // Load the module installer const moduleInstaller = require(installerPath); if (typeof moduleInstaller.install === 'function') { // Get project root (parent of bmad directory) const projectRoot = path.dirname(bmadDir); // Prepare logger (use console if not provided) const logger = options.logger || { log: console.log, error: console.error, warn: console.warn, }; // Call the module installer const result = await moduleInstaller.install({ projectRoot, config: options.moduleConfig || {}, coreConfig: options.coreConfig || {}, installedIDEs: options.installedIDEs || [], logger, }); if (!result) { console.warn(chalk.yellow(`Module installer for ${moduleName} returned false`)); } } } catch (error) { console.error(chalk.red(`Error running module installer for ${moduleName}: ${error.message}`)); } } /** * Private: Process module configuration * @param {string} modulePath - Path to installed module * @param {string} moduleName - Module name */ async processModuleConfig(modulePath, moduleName) { const configPath = path.join(modulePath, 'config.yaml'); if (await fs.pathExists(configPath)) { try { let configContent = await fs.readFile(configPath, 'utf8'); // Replace path placeholders configContent = configContent.replaceAll('{project-root}', `bmad/${moduleName}`); configContent = configContent.replaceAll('{module}', moduleName); await fs.writeFile(configPath, configContent, 'utf8'); } catch (error) { console.warn(`Failed to process module config:`, error.message); } } } /** * Private: Sync module files (preserving user modifications) * @param {string} sourcePath - Source module path * @param {string} targetPath - Target module path */ async syncModule(sourcePath, targetPath) { // Get list of all source files const sourceFiles = await this.getFileList(sourcePath); for (const file of sourceFiles) { const sourceFile = path.join(sourcePath, file); const targetFile = path.join(targetPath, file); // Check if target file exists and has been modified if (await fs.pathExists(targetFile)) { const sourceStats = await fs.stat(sourceFile); const targetStats = await fs.stat(targetFile); // Skip if target is newer (user modified) if (targetStats.mtime > sourceStats.mtime) { continue; } } // Copy file with placeholder replacement await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile); } } /** * Private: Get list of all files in a directory * @param {string} dir - Directory path * @param {string} baseDir - Base directory for relative paths * @returns {Array} List of relative file paths */ async getFileList(dir, baseDir = dir) { const files = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip _module-installer directories if (entry.name === '_module-installer') { continue; } const subFiles = await this.getFileList(fullPath, baseDir); files.push(...subFiles); } else { files.push(path.relative(baseDir, fullPath)); } } return files; } } module.exports = { ModuleManager };