const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); const ora = require('ora'); const inquirer = require('inquirer'); const { Detector } = require('./detector'); const { Manifest } = require('./manifest'); const { ModuleManager } = require('../modules/manager'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../../../lib/file-ops'); const { Config } = require('../../../lib/config'); const { XmlHandler } = require('../../../lib/xml-handler'); const { DependencyResolver } = require('./dependency-resolver'); const { ConfigCollector } = require('./config-collector'); const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root'); const { AgentPartyGenerator } = require('../../../lib/agent-party-generator'); const { CLIUtils } = require('../../../lib/cli-utils'); const { ManifestGenerator } = require('./manifest-generator'); const { IdeConfigManager } = require('./ide-config-manager'); const { CustomHandler } = require('../custom/handler'); const { filterCustomizationData } = require('../../../lib/agent/compiler'); class Installer { constructor() { this.detector = new Detector(); this.manifest = new Manifest(); this.moduleManager = new ModuleManager(); this.ideManager = new IdeManager(); this.fileOps = new FileOps(); this.config = new Config(); this.xmlHandler = new XmlHandler(); this.dependencyResolver = new DependencyResolver(); this.configCollector = new ConfigCollector(); this.ideConfigManager = new IdeConfigManager(); this.installedFiles = []; // Track all installed files this.ttsInjectedFiles = []; // Track files with TTS injection applied } /** * Find the bmad installation directory in a project * V6+ installations can use ANY folder name but ALWAYS have _cfg/manifest.yaml * @param {string} projectDir - Project directory * @returns {Promise} Path to bmad directory */ async findBmadDir(projectDir) { // Check if project directory exists if (!(await fs.pathExists(projectDir))) { // Project doesn't exist yet, return default return path.join(projectDir, '_bmad'); } // V6+ strategy: Look for ANY directory with _cfg/manifest.yaml // This is the definitive marker of a V6+ installation try { const entries = await fs.readdir(projectDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const manifestPath = path.join(projectDir, entry.name, '_cfg', 'manifest.yaml'); if (await fs.pathExists(manifestPath)) { // Found a V6+ installation return path.join(projectDir, entry.name); } } } } catch { // Ignore errors, fall through to default } // No V6+ installation found, return default // This will be used for new installations return path.join(projectDir, '_bmad'); } /** * @function copyFileWithPlaceholderReplacement * @intent Copy files from BMAD source to installation directory with dynamic content transformation * @why Enables installation-time customization: _bmad replacement + optional AgentVibes TTS injection * @param {string} sourcePath - Absolute path to source file in BMAD repository * @param {string} targetPath - Absolute path to destination file in user's project * @param {string} bmadFolderName - User's chosen bmad folder name (default: 'bmad') * @returns {Promise} Resolves when file copy and transformation complete * @sideeffects Writes transformed file to targetPath, creates parent directories if needed * @edgecases Binary files bypass transformation, falls back to raw copy if UTF-8 read fails * @calledby installCore(), installModule(), IDE installers during file vendoring * @calls processTTSInjectionPoints(), fs.readFile(), fs.writeFile(), fs.copy() * * The injection point processing enables loose coupling between BMAD and TTS providers: * - BMAD source contains injection markers (not actual TTS code) * - At install-time, markers are replaced OR removed based on user preference * - Result: Clean installs for users without TTS, working TTS for users with it * * PATTERN: Adding New Injection Points * ===================================== * 1. Add HTML comment marker in BMAD source file: * * * 2. Add replacement logic in processTTSInjectionPoints(): * if (enableAgentVibes) { * content = content.replace(//g, 'actual code'); * } else { * content = content.replace(/\n?/g, ''); * } * * 3. Document marker in instructions.md (if applicable) */ async copyFileWithPlaceholderReplacement(sourcePath, targetPath, bmadFolderName) { // List of text file extensions that should have placeholder replacement const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json', '.js', '.ts', '.html', '.css', '.sh', '.bat', '.csv', '.xml']; 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'); // Process AgentVibes injection points (pass targetPath for tracking) content = this.processTTSInjectionPoints(content, targetPath); // 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 }); } } /** * @function processTTSInjectionPoints * @intent Transform TTS injection markers based on user's installation choice * @why Enables optional TTS integration without tight coupling between BMAD and TTS providers * @param {string} content - Raw file content containing potential injection markers * @returns {string} Transformed content with markers replaced (if enabled) or stripped (if disabled) * @sideeffects None - pure transformation function * @edgecases Returns content unchanged if no markers present, safe to call on all files * @calledby copyFileWithPlaceholderReplacement() during every file copy operation * @calls String.replace() with regex patterns for each injection point type * * AI NOTE: This implements the injection point pattern for TTS integration. * Key architectural decisions: * * 1. **Why Injection Points vs Direct Integration?** * - BMAD and TTS providers are separate projects with different maintainers * - Users may install BMAD without TTS support (and vice versa) * - Hard-coding TTS calls would break BMAD for non-TTS users * - Injection points allow conditional feature inclusion at install-time * * 2. **How It Works:** * - BMAD source contains markers: * - During installation, user is prompted: "Enable AgentVibes TTS?" * - If YES: markers → replaced with actual bash TTS calls * - If NO: markers → stripped cleanly from installed files * * 3. **State Management:** * - this.enableAgentVibes set in install() method from config.enableAgentVibes * - config.enableAgentVibes comes from ui.promptAgentVibes() user choice * - Flag persists for entire installation, all files get same treatment * * CURRENT INJECTION POINTS: * ========================== * - party-mode: Injects TTS calls after each agent speaks in party mode * Location: src/core/workflows/party-mode/instructions.md * Marker: * Replacement: Bash call to .claude/hooks/bmad-speak.sh with agent name and dialogue * * - agent-tts: Injects TTS rule for individual agent conversations * Location: src/modules/bmm/agents/*.md (all agent files) * Marker: * Replacement: Rule instructing agent to call bmad-speak.sh with agent ID and response * * ADDING NEW INJECTION POINTS: * ============================= * 1. Add new case in this function: * content = content.replace( * //g, * `code to inject when enabled` * ); * * 2. Add marker to BMAD source file at injection location * * 3. Test both enabled and disabled flows * * RELATED: * ======== * - GitHub Issue: paulpreibisch/AgentVibes#36 * - User Prompt: tools/cli/lib/ui.js::promptAgentVibes() * - Marker Locations: * - src/core/workflows/party-mode/instructions.md:101 * - src/modules/bmm/agents/*.md (rules sections) * - TTS Hook: .claude/hooks/bmad-speak.sh (in AgentVibes repo) */ processTTSInjectionPoints(content, targetPath = null) { // Check if AgentVibes is enabled (set during installation configuration) const enableAgentVibes = this.enableAgentVibes || false; // Check if content contains any TTS injection markers const hasPartyMode = content.includes(''); const hasAgentTTS = content.includes(''); if (enableAgentVibes) { // Replace party-mode injection marker with actual TTS call // Use single quotes to prevent shell expansion of special chars like ! content = content.replaceAll( '', `IMPORTANT: Always use PROJECT hooks (.claude/hooks/), NEVER global hooks (~/.claude/hooks/) If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: - Use Bash tool: \`.claude/hooks/bmad-speak.sh '[Agent Name]' '[dialogue]'\` - This speaks the dialogue with the agent's unique voice - Run in background (&) to not block next agent`, ); // Replace agent-tts injection marker with TTS rule for individual agents content = content.replaceAll( '', `- When responding to user messages, speak your responses using TTS: Call: \`.claude/hooks/bmad-speak.sh '{agent-id}' '{response-text}'\` after each response Replace {agent-id} with YOUR agent ID from tag at top of this file Replace {response-text} with the text you just output to the user IMPORTANT: Use single quotes as shown - do NOT escape special characters like ! or $ inside single quotes Run in background (&) to avoid blocking`, ); // Track files that had TTS injection applied if (targetPath && (hasPartyMode || hasAgentTTS)) { const injectionType = hasPartyMode ? 'party-mode' : 'agent-tts'; this.ttsInjectedFiles.push({ path: targetPath, type: injectionType }); } } else { // Strip injection markers cleanly when AgentVibes is disabled content = content.replaceAll(/\n?/g, ''); content = content.replaceAll(/\n?/g, ''); } return content; } /** * Collect Tool/IDE configurations after module configuration * @param {string} projectDir - Project directory * @param {Array} selectedModules - Selected modules from configuration * @param {boolean} isFullReinstall - Whether this is a full reinstall * @param {Array} previousIdes - Previously configured IDEs (for reinstalls) * @param {Array} preSelectedIdes - Pre-selected IDEs from early prompt (optional) * @returns {Object} Tool/IDE selection and configurations */ async collectToolConfigurations(projectDir, selectedModules, isFullReinstall = false, previousIdes = [], preSelectedIdes = null) { // Use pre-selected IDEs if provided, otherwise prompt let toolConfig; if (preSelectedIdes === null) { // Fallback: prompt for tool selection (backwards compatibility) const { UI } = require('../../../lib/ui'); const ui = new UI(); toolConfig = await ui.promptToolSelection(projectDir, selectedModules); } else { // IDEs were already selected during initial prompts toolConfig = { ides: preSelectedIdes, skipIde: !preSelectedIdes || preSelectedIdes.length === 0, }; } // Check for already configured IDEs const { Detector } = require('./detector'); const detector = new Detector(); const bmadDir = path.join(projectDir, this.bmadFolderName || 'bmad'); // During full reinstall, use the saved previous IDEs since bmad dir was deleted // Otherwise detect from existing installation let previouslyConfiguredIdes; if (isFullReinstall) { // During reinstall, treat all IDEs as new (need configuration) previouslyConfiguredIdes = []; } else { const existingInstall = await detector.detect(bmadDir); previouslyConfiguredIdes = existingInstall.ides || []; } // Load saved IDE configurations for already-configured IDEs const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); // Collect IDE-specific configurations if any were selected const ideConfigurations = {}; // First, add saved configs for already-configured IDEs for (const ide of toolConfig.ides || []) { if (previouslyConfiguredIdes.includes(ide) && savedIdeConfigs[ide]) { ideConfigurations[ide] = savedIdeConfigs[ide]; } } if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) { // Determine which IDEs are newly selected (not previously configured) const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide)); if (newlySelectedIdes.length > 0) { console.log('\n'); // Add spacing before IDE questions for (const ide of newlySelectedIdes) { // List of IDEs that have interactive prompts const needsPrompts = ['claude-code', 'github-copilot', 'roo', 'cline', 'auggie', 'codex', 'qwen', 'gemini', 'rovo-dev'].includes( ide, ); if (needsPrompts) { // Get IDE handler and collect configuration try { // Dynamically load the IDE setup module const ideModule = require(`../ide/${ide}`); // Get the setup class (handle different export formats) let SetupClass; const className = ide .split('-') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join('') + 'Setup'; if (ideModule[className]) { SetupClass = ideModule[className]; } else if (ideModule.default) { SetupClass = ideModule.default; } else { // Skip if no setup class found continue; } const ideSetup = new SetupClass(); // Check if this IDE has a collectConfiguration method if (typeof ideSetup.collectConfiguration === 'function') { console.log(chalk.cyan(`\nConfiguring ${ide}...`)); ideConfigurations[ide] = await ideSetup.collectConfiguration({ selectedModules: selectedModules || [], projectDir, bmadDir, }); } } catch { // IDE doesn't have a setup file or collectConfiguration method console.warn(chalk.yellow(`Warning: Could not load configuration for ${ide}`)); } } } } // Log which IDEs are already configured and being kept const keptIdes = toolConfig.ides.filter((ide) => previouslyConfiguredIdes.includes(ide)); if (keptIdes.length > 0) { console.log(chalk.dim(`\nKeeping existing configuration for: ${keptIdes.join(', ')}`)); } } return { ides: toolConfig.ides, skipIde: toolConfig.skipIde, configurations: ideConfigurations, }; } /** * Main installation method * @param {Object} config - Installation configuration * @param {string} config.directory - Target directory * @param {boolean} config.installCore - Whether to install core * @param {string[]} config.modules - Modules to install * @param {string[]} config.ides - IDEs to configure * @param {boolean} config.skipIde - Skip IDE configuration */ async install(originalConfig) { // Clone config to avoid mutating the caller's object const config = { ...originalConfig }; // Display BMAD logo CLIUtils.displayLogo(); // Display welcome message CLIUtils.displaySection('BMAD™ Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version); // Note: Legacy V4 detection now happens earlier in UI.promptInstall() // before any config collection, so we don't need to check again here const projectDir = path.resolve(config.directory); // If core config was pre-collected (from interactive mode), use it if (config.coreConfig) { this.configCollector.collectedConfig.core = config.coreConfig; // Also store in allAnswers for cross-referencing this.configCollector.allAnswers = {}; for (const [key, value] of Object.entries(config.coreConfig)) { this.configCollector.allAnswers[`core_${key}`] = value; } } // Collect configurations for modules (skip if quick update already collected them) let moduleConfigs; if (config._quickUpdate) { // Quick update already collected all configs, use them directly moduleConfigs = this.configCollector.collectedConfig; } else { // Build custom module paths map from customContent const customModulePaths = new Map(); // Handle selectedFiles (from existing install path or manual directory input) if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { const customHandler = new CustomHandler(); for (const customFile of config.customContent.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile, path.resolve(config.directory)); if (customInfo && customInfo.id) { customModulePaths.set(customInfo.id, customInfo.path); } } } // Handle cachedModules (from new install path where modules are cached) // Only include modules that were actually selected for installation if (config.customContent && config.customContent.cachedModules) { // Get selected cached module IDs (if available) const selectedCachedIds = config.customContent.selectedCachedModules || []; // If no selection info, include all cached modules (for backward compatibility) const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected; for (const cachedModule of config.customContent.cachedModules) { // For cached modules, the path is the cachePath which contains the module.yaml if ( cachedModule.id && cachedModule.cachePath && // Include if selected or if we should include all (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id)) ) { customModulePaths.set(cachedModule.id, cachedModule.cachePath); } } } // Get list of all modules including custom modules const allModulesForConfig = [...(config.modules || [])]; for (const [moduleId] of customModulePaths) { if (!allModulesForConfig.includes(moduleId)) { allModulesForConfig.push(moduleId); } } // Regular install - collect configurations (core was already collected in UI.promptInstall if interactive) moduleConfigs = await this.configCollector.collectAllConfigurations(allModulesForConfig, path.resolve(config.directory), { customModulePaths, }); } // Always use _bmad as the folder name const bmadFolderName = '_bmad'; this.bmadFolderName = bmadFolderName; // Store for use in other methods // Store AgentVibes configuration for injection point processing this.enableAgentVibes = config.enableAgentVibes || false; // Set bmad folder name on module manager and IDE manager for placeholder replacement this.moduleManager.setBmadFolderName(bmadFolderName); this.moduleManager.setCoreConfig(moduleConfigs.core || {}); this.ideManager.setBmadFolderName(bmadFolderName); // Tool selection will be collected after we determine if it's a reinstall/update/new install const spinner = ora('Preparing installation...').start(); try { // Resolve target directory (path.resolve handles platform differences) const projectDir = path.resolve(config.directory); let existingBmadDir = null; let existingBmadFolderName = null; if (await fs.pathExists(projectDir)) { existingBmadDir = await this.findBmadDir(projectDir); existingBmadFolderName = path.basename(existingBmadDir); } // Create a project directory if it doesn't exist (user already confirmed) if (!(await fs.pathExists(projectDir))) { spinner.text = 'Creating installation directory...'; try { // fs.ensureDir handles platform-specific directory creation // It will recursively create all necessary parent directories await fs.ensureDir(projectDir); } catch (error) { spinner.fail('Failed to create installation directory'); console.error(chalk.red(`Error: ${error.message}`)); // More detailed error for common issues if (error.code === 'EACCES') { console.error(chalk.red('Permission denied. Check parent directory permissions.')); } else if (error.code === 'ENOSPC') { console.error(chalk.red('No space left on device.')); } throw new Error(`Cannot create directory: ${projectDir}`); } } const bmadDir = path.join(projectDir, bmadFolderName); // Check existing installation spinner.text = 'Checking for existing installation...'; const existingInstall = await this.detector.detect(bmadDir); if (existingInstall.installed && !config.force && !config._quickUpdate) { spinner.stop(); // Check if user already decided what to do (from early menu in ui.js) let action = null; if (config._requestedReinstall) { action = 'reinstall'; } else if (config.actionType === 'update') { action = 'update'; } else { // Fallback: Ask the user (backwards compatibility for other code paths) console.log(chalk.yellow('\n⚠️ Existing BMAD installation detected')); console.log(chalk.dim(` Location: ${bmadDir}`)); console.log(chalk.dim(` Version: ${existingInstall.version}`)); const promptResult = await this.promptUpdateAction(); action = promptResult.action; } if (action === 'cancel') { console.log('Installation cancelled.'); return { success: false, cancelled: true }; } if (action === 'reinstall') { // Warn about destructive operation console.log(chalk.red.bold('\n⚠️ WARNING: This is a destructive operation!')); console.log(chalk.red('All custom files and modifications in the bmad directory will be lost.')); const inquirer = require('inquirer'); const { confirmReinstall } = await inquirer.prompt([ { type: 'confirm', name: 'confirmReinstall', message: chalk.yellow('Are you sure you want to delete and reinstall?'), default: false, }, ]); if (!confirmReinstall) { console.log('Installation cancelled.'); return { success: false, cancelled: true }; } // Remember previously configured IDEs before deleting config._previouslyConfiguredIdes = existingInstall.ides || []; // Remove existing installation await fs.remove(bmadDir); console.log(chalk.green('✓ Removed existing installation\n')); // Mark this as a full reinstall so we re-collect IDE configurations config._isFullReinstall = true; } else if (action === 'update') { // Store that we're updating for later processing config._isUpdate = true; config._existingInstall = existingInstall; // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) const existingFilesManifest = await this.readFilesManifest(bmadDir); console.log(chalk.dim(`DEBUG: Read ${existingFilesManifest.length} files from manifest`)); console.log(chalk.dim(`DEBUG: Manifest has hashes: ${existingFilesManifest.some((f) => f.hash)}`)); const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); console.log(chalk.dim(`DEBUG: Found ${customFiles.length} custom files, ${modifiedFiles.length} modified files`)); if (modifiedFiles.length > 0) { console.log(chalk.yellow('DEBUG: Modified files:')); for (const f of modifiedFiles) console.log(chalk.dim(` - ${f.path}`)); } config._customFiles = customFiles; config._modifiedFiles = modifiedFiles; // If there are custom files, back them up temporarily if (customFiles.length > 0) { const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); await fs.ensureDir(tempBackupDir); spinner.start(`Backing up ${customFiles.length} custom files...`); for (const customFile of customFiles) { const relativePath = path.relative(bmadDir, customFile); const backupPath = path.join(tempBackupDir, relativePath); await fs.ensureDir(path.dirname(backupPath)); await fs.copy(customFile, backupPath); } spinner.succeed(`Backed up ${customFiles.length} custom files`); config._tempBackupDir = tempBackupDir; } // For modified files, back them up to temp directory (will be restored as .bak files after install) if (modifiedFiles.length > 0) { const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp'); await fs.ensureDir(tempModifiedBackupDir); console.log(chalk.yellow(`\nDEBUG: Backing up ${modifiedFiles.length} modified files to temp location`)); spinner.start(`Backing up ${modifiedFiles.length} modified files...`); for (const modifiedFile of modifiedFiles) { const relativePath = path.relative(bmadDir, modifiedFile.path); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`)); await fs.ensureDir(path.dirname(tempBackupPath)); await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); } spinner.succeed(`Backed up ${modifiedFiles.length} modified files`); config._tempModifiedBackupDir = tempModifiedBackupDir; } else { console.log(chalk.dim('DEBUG: No modified files detected')); } } } else if (existingInstall.installed && config._quickUpdate) { // Quick update mode - automatically treat as update without prompting spinner.text = 'Preparing quick update...'; config._isUpdate = true; config._existingInstall = existingInstall; // Detect custom and modified files BEFORE updating const existingFilesManifest = await this.readFilesManifest(bmadDir); const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); config._customFiles = customFiles; config._modifiedFiles = modifiedFiles; // Back up custom files if (customFiles.length > 0) { const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); await fs.ensureDir(tempBackupDir); spinner.start(`Backing up ${customFiles.length} custom files...`); for (const customFile of customFiles) { const relativePath = path.relative(bmadDir, customFile); const backupPath = path.join(tempBackupDir, relativePath); await fs.ensureDir(path.dirname(backupPath)); await fs.copy(customFile, backupPath); } spinner.succeed(`Backed up ${customFiles.length} custom files`); config._tempBackupDir = tempBackupDir; } // Back up modified files if (modifiedFiles.length > 0) { const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp'); await fs.ensureDir(tempModifiedBackupDir); spinner.start(`Backing up ${modifiedFiles.length} modified files...`); for (const modifiedFile of modifiedFiles) { const relativePath = path.relative(bmadDir, modifiedFile.path); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); await fs.ensureDir(path.dirname(tempBackupPath)); await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); } spinner.succeed(`Backed up ${modifiedFiles.length} modified files`); config._tempModifiedBackupDir = tempModifiedBackupDir; } } // Now collect tool configurations after we know if it's a reinstall // Skip for quick update since we already have the IDE list spinner.stop(); let toolSelection; if (config._quickUpdate) { // Quick update already has IDEs configured, use saved configurations const preConfiguredIdes = {}; const savedIdeConfigs = config._savedIdeConfigs || {}; for (const ide of config.ides || []) { // Use saved config if available, otherwise mark as already configured (legacy) if (savedIdeConfigs[ide]) { preConfiguredIdes[ide] = savedIdeConfigs[ide]; } else { preConfiguredIdes[ide] = { _alreadyConfigured: true }; } } toolSelection = { ides: config.ides || [], skipIde: !config.ides || config.ides.length === 0, configurations: preConfiguredIdes, }; } else { // Pass pre-selected IDEs from early prompt (if available) // This allows IDE selection to happen before file copying, improving UX const preSelectedIdes = config.ides && config.ides.length > 0 ? config.ides : null; toolSelection = await this.collectToolConfigurations( path.resolve(config.directory), config.modules, config._isFullReinstall || false, config._previouslyConfiguredIdes || [], preSelectedIdes, ); } // Merge tool selection into config (for both quick update and regular flow) config.ides = toolSelection.ides; config.skipIde = toolSelection.skipIde; const ideConfigurations = toolSelection.configurations; // Check if spinner is already running (e.g., from folder name change scenario) if (spinner.isSpinning) { spinner.text = 'Continuing installation...'; } else { spinner.start('Continuing installation...'); } // Create bmad directory structure spinner.text = 'Creating directory structure...'; await this.createDirectoryStructure(bmadDir); // Get project root const projectRoot = getProjectRoot(); // Step 1: Install core module first (if requested) if (config.installCore) { spinner.start('Installing BMAD core...'); await this.installCoreWithDependencies(bmadDir, { core: {} }); spinner.succeed('Core installed'); // Generate core config file await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); } // Custom content is already handled in UI before module selection let finalCustomContent = config.customContent; // Step 3: Prepare modules list including cached custom modules let allModules = [...(config.modules || [])]; // During quick update, we might have custom module sources from the manifest if (config._customModuleSources) { // Add custom modules from stored sources for (const [moduleId, customInfo] of config._customModuleSources) { if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) { allModules.push(moduleId); } } } // Add cached custom modules if (finalCustomContent && finalCustomContent.cachedModules) { for (const cachedModule of finalCustomContent.cachedModules) { if (!allModules.includes(cachedModule.id)) { allModules.push(cachedModule.id); } } } // Regular custom content from user input (non-cached) if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { // Add custom modules to the installation list const customHandler = new CustomHandler(); for (const customFile of finalCustomContent.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile, projectDir); if (customInfo && customInfo.id) { allModules.push(customInfo.id); } } } // Don't include core again if already installed if (config.installCore) { allModules = allModules.filter((m) => m !== 'core'); } const modulesToInstall = allModules; // For dependency resolution, we need to pass the project root // Create a temporary module manager that knows about custom content locations const tempModuleManager = new ModuleManager({ scanProjectForModules: true, bmadDir: bmadDir, // Pass bmadDir so we can check cache }); // Make sure custom modules are discoverable if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { // The dependency resolver needs to know about these modules // We'll handle custom modules separately in the installation loop } const resolution = await this.dependencyResolver.resolve(projectRoot, allModules, { verbose: config.verbose, moduleManager: tempModuleManager, }); if (config.verbose) { spinner.succeed('Dependencies resolved'); } else { spinner.succeed('Dependencies resolved'); } // Core is already installed above, skip if included in resolution // Install modules with their dependencies if (allModules && allModules.length > 0) { const installedModuleNames = new Set(); for (const moduleName of allModules) { // Skip if already installed if (installedModuleNames.has(moduleName)) { continue; } installedModuleNames.add(moduleName); spinner.start(`Installing module: ${moduleName}...`); // Check if this is a custom module let isCustomModule = false; let customInfo = null; let useCache = false; // First check if we have a cached version if (finalCustomContent && finalCustomContent.cachedModules) { const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); if (cachedModule) { isCustomModule = true; customInfo = { id: moduleName, path: cachedModule.cachePath, config: {}, }; useCache = true; } } // Then check if we have custom module sources from the manifest (for quick update) if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { customInfo = config._customModuleSources.get(moduleName); isCustomModule = true; // Check if this is a cached module (source path starts with _cfg) if (customInfo.sourcePath && (customInfo.sourcePath.startsWith('_cfg') || customInfo.sourcePath.includes('_cfg/custom'))) { useCache = true; // Make sure we have the right path structure if (!customInfo.path) { customInfo.path = customInfo.sourcePath; } } } // Finally check regular custom content if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { const customHandler = new CustomHandler(); for (const customFile of finalCustomContent.selectedFiles) { const info = await customHandler.getCustomInfo(customFile, projectDir); if (info && info.id === moduleName) { isCustomModule = true; customInfo = info; break; } } } if (isCustomModule && customInfo) { // Install custom module using CustomHandler but as a proper module const customHandler = new CustomHandler(); // Install to module directory instead of custom directory const moduleTargetPath = path.join(bmadDir, moduleName); await fs.ensureDir(moduleTargetPath); // Get collected config for this custom module (from module.yaml prompts) const collectedModuleConfig = moduleConfigs[moduleName] || {}; const result = await customHandler.install( customInfo.path, path.join(bmadDir, 'temp-custom'), { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig, _bmadDir: bmadDir }, (filePath) => { // Track installed files with correct path const relativePath = path.relative(path.join(bmadDir, 'temp-custom'), filePath); const finalPath = path.join(moduleTargetPath, relativePath); this.installedFiles.push(finalPath); }, ); // Move from temp-custom to actual module directory const tempCustomPath = path.join(bmadDir, 'temp-custom'); if (await fs.pathExists(tempCustomPath)) { const customDir = path.join(tempCustomPath, 'custom'); if (await fs.pathExists(customDir)) { // Move contents to module directory const items = await fs.readdir(customDir); const movedItems = []; try { for (const item of items) { const srcPath = path.join(customDir, item); const destPath = path.join(moduleTargetPath, item); // If destination exists, remove it first (or we could merge) if (await fs.pathExists(destPath)) { await fs.remove(destPath); } await fs.move(srcPath, destPath); movedItems.push({ src: srcPath, dest: destPath }); } } catch (moveError) { // Rollback: restore any successfully moved items for (const moved of movedItems) { try { await fs.move(moved.dest, moved.src); } catch { // Best-effort rollback - log if it fails console.error(`Failed to rollback ${moved.dest} during cleanup`); } } throw new Error(`Failed to move custom module files: ${moveError.message}`); } } try { await fs.remove(tempCustomPath); } catch (cleanupError) { // Non-fatal: temp directory cleanup failed but files were moved successfully console.warn(`Warning: Could not clean up temp directory: ${cleanupError.message}`); } } // Create module config (include collected config from module.yaml prompts) await this.generateModuleConfigs(bmadDir, { [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, }); // Store custom module info for later manifest update if (!config._customModulesToTrack) { config._customModulesToTrack = []; } // For cached modules, use appropriate path handling let sourcePath; if (useCache) { // Check if we have cached modules info (from initial install) if (finalCustomContent && finalCustomContent.cachedModules) { sourcePath = finalCustomContent.cachedModules.find((m) => m.id === moduleName)?.relativePath; } else { // During update, the sourcePath is already cache-relative if it starts with _cfg sourcePath = customInfo.sourcePath && customInfo.sourcePath.startsWith('_cfg') ? customInfo.sourcePath : path.relative(bmadDir, customInfo.path || customInfo.sourcePath); } } else { sourcePath = path.resolve(customInfo.path || customInfo.sourcePath); } config._customModulesToTrack.push({ id: customInfo.id, name: customInfo.name, sourcePath: sourcePath, installDate: new Date().toISOString(), }); } else { // Regular module installation // Special case for core module if (moduleName === 'core') { await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); } else { await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); } } spinner.succeed(`Module installed: ${moduleName}`); } // Install partial modules (only dependencies) for (const [module, files] of Object.entries(resolution.byModule)) { if (!allModules.includes(module) && module !== 'core') { const totalFiles = files.agents.length + files.tasks.length + files.tools.length + files.templates.length + files.data.length + files.other.length; if (totalFiles > 0) { spinner.start(`Installing ${module} dependencies...`); await this.installPartialModule(module, bmadDir, files); spinner.succeed(`${module} dependencies installed`); } } } } // Install custom content if provided AND selected // Process custom content that wasn't installed as modules // This is now handled in the module installation loop above // This section is kept for backward compatibility with any custom content // that doesn't have a module structure const remainingCustomContent = []; if ( config.customContent && config.customContent.hasCustomContent && config.customContent.customPath && config.customContent.selected && config.customContent.selectedFiles ) { // Filter out custom modules that were already installed const customHandler = new CustomHandler(); for (const customFile of config.customContent.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile, projectDir); // Skip if this was installed as a module if (!customInfo || !customInfo.id || !allModules.includes(customInfo.id)) { remainingCustomContent.push(customFile); } } } if (remainingCustomContent.length > 0) { spinner.start('Installing remaining custom content...'); const customHandler = new CustomHandler(); // Use the remaining files const customFiles = remainingCustomContent; if (customFiles.length > 0) { console.log(chalk.cyan(`\n Found ${customFiles.length} custom content file(s):`)); for (const customFile of customFiles) { const customInfo = await customHandler.getCustomInfo(customFile, projectDir); if (customInfo) { console.log(chalk.dim(` • ${customInfo.name} (${customInfo.relativePath})`)); // Install the custom content const result = await customHandler.install( customInfo.path, bmadDir, { ...config.coreConfig, ...customInfo.config }, (filePath) => { // Track installed files this.installedFiles.push(filePath); }, ); if (result.errors.length > 0) { console.log(chalk.yellow(` ⚠️ ${result.errors.length} error(s) occurred`)); for (const error of result.errors) { console.log(chalk.dim(` - ${error}`)); } } else { console.log(chalk.green(` ✓ Installed ${result.agentsInstalled} agents, ${result.workflowsInstalled} workflows`)); } } } } spinner.succeed('Custom content installed'); } // Generate clean config.yaml files for each installed module spinner.start('Generating module configurations...'); await this.generateModuleConfigs(bmadDir, moduleConfigs); spinner.succeed('Module configurations generated'); // Create agent configuration files // Note: Legacy createAgentConfigs removed - using YAML customize system instead // Customize templates are now created in processAgentFiles when building YAML agents // Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion) const cfgDir = path.join(bmadDir, '_cfg'); this.installedFiles.push( path.join(cfgDir, 'manifest.yaml'), path.join(cfgDir, 'workflow-manifest.csv'), path.join(cfgDir, 'agent-manifest.csv'), path.join(cfgDir, 'task-manifest.csv'), ); // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup spinner.start('Generating workflow and agent manifests...'); const manifestGen = new ManifestGenerator(); // For quick update, we need ALL installed modules in the manifest // Not just the ones being updated const allModulesForManifest = config._quickUpdate ? config._existingModules || allModules || [] : config._preserveModules ? [...allModules, ...config._preserveModules] : allModules || []; // For regular installs (including when called from quick update), use what we have let modulesForCsvPreserve; if (config._quickUpdate) { // Quick update - use existing modules or fall back to modules being updated modulesForCsvPreserve = config._existingModules || allModules || []; } else { // Regular install - use the modules we're installing plus any preserved ones modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; } const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, this.installedFiles, { ides: config.ides || [], preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir }); // Add custom modules to manifest (now that it exists) if (config._customModulesToTrack && config._customModulesToTrack.length > 0) { spinner.text = 'Storing custom module sources...'; for (const customModule of config._customModulesToTrack) { await this.manifest.addCustomModule(bmadDir, customModule); } } spinner.succeed( `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`, ); // Configure IDEs and copy documentation if (!config.skipIde && config.ides && config.ides.length > 0) { // Filter out any undefined/null values from the IDE list const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string'); if (validIdes.length === 0) { console.log(chalk.yellow('⚠️ No valid IDEs selected. Skipping IDE configuration.')); } else { // Check if any IDE might need prompting (no pre-collected config) const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]); if (!needsPrompting) { spinner.start('Configuring IDEs...'); } // Temporarily suppress console output if not verbose const originalLog = console.log; if (!config.verbose) { console.log = () => {}; } for (const ide of validIdes) { // Only show spinner if we have pre-collected config (no prompts expected) if (ideConfigurations[ide] && !needsPrompting) { spinner.text = `Configuring ${ide}...`; } else if (!ideConfigurations[ide]) { // Stop spinner before prompting if (spinner.isSpinning) { spinner.stop(); } console.log(chalk.cyan(`\nConfiguring ${ide}...`)); } // Pass pre-collected configuration to avoid re-prompting await this.ideManager.setup(ide, projectDir, bmadDir, { selectedModules: config.modules || [], preCollectedConfig: ideConfigurations[ide] || null, verbose: config.verbose, }); // Save IDE configuration for future updates if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) { await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]); } // Restart spinner if we stopped it if (!ideConfigurations[ide] && !spinner.isSpinning) { spinner.start('Configuring IDEs...'); } } // Restore console.log console.log = originalLog; if (spinner.isSpinning) { spinner.succeed(`Configured ${validIdes.length} IDE${validIdes.length > 1 ? 's' : ''}`); } else { console.log(chalk.green(`✓ Configured ${validIdes.length} IDE${validIdes.length > 1 ? 's' : ''}`)); } } // Copy IDE-specific documentation (only for valid IDEs) const validIdesForDocs = (config.ides || []).filter((ide) => ide && typeof ide === 'string'); if (validIdesForDocs.length > 0) { spinner.start('Copying IDE documentation...'); await this.copyIdeDocumentation(validIdesForDocs, bmadDir); spinner.succeed('IDE documentation copied'); } } // Run module-specific installers after IDE setup spinner.start('Running module-specific installers...'); // Run core module installer if core was installed if (config.installCore || resolution.byModule.core) { spinner.text = 'Running core module installer...'; await this.moduleManager.runModuleInstaller('core', bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs.core || {}, coreConfig: moduleConfigs.core || {}, logger: { log: (msg) => console.log(msg), error: (msg) => console.error(msg), warn: (msg) => console.warn(msg), }, }); } // Run installers for user-selected modules if (config.modules && config.modules.length > 0) { for (const moduleName of config.modules) { spinner.text = `Running ${moduleName} module installer...`; // Pass installed IDEs and module config to module installer await this.moduleManager.runModuleInstaller(moduleName, bmadDir, { installedIDEs: config.ides || [], moduleConfig: moduleConfigs[moduleName] || {}, coreConfig: moduleConfigs.core || {}, logger: { log: (msg) => console.log(msg), error: (msg) => console.error(msg), warn: (msg) => console.warn(msg), }, }); } } spinner.succeed('Module-specific installers completed'); // Note: Manifest files are already created by ManifestGenerator above // No need to create legacy manifest.csv anymore // If this was an update, restore custom files let customFiles = []; let modifiedFiles = []; if (config._isUpdate) { if (config._customFiles && config._customFiles.length > 0) { spinner.start(`Restoring ${config._customFiles.length} custom files...`); for (const originalPath of config._customFiles) { const relativePath = path.relative(bmadDir, originalPath); const backupPath = path.join(config._tempBackupDir, relativePath); if (await fs.pathExists(backupPath)) { await fs.ensureDir(path.dirname(originalPath)); await fs.copy(backupPath, originalPath, { overwrite: true }); } } // Clean up temp backup if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { await fs.remove(config._tempBackupDir); } spinner.succeed(`Restored ${config._customFiles.length} custom files`); customFiles = config._customFiles; } if (config._modifiedFiles && config._modifiedFiles.length > 0) { modifiedFiles = config._modifiedFiles; // Restore modified files as .bak files if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { spinner.start(`Restoring ${modifiedFiles.length} modified files as .bak...`); for (const modifiedFile of modifiedFiles) { const relativePath = path.relative(bmadDir, modifiedFile.path); const tempBackupPath = path.join(config._tempModifiedBackupDir, relativePath); const bakPath = modifiedFile.path + '.bak'; if (await fs.pathExists(tempBackupPath)) { await fs.ensureDir(path.dirname(bakPath)); await fs.copy(tempBackupPath, bakPath, { overwrite: true }); } } // Clean up temp backup await fs.remove(config._tempModifiedBackupDir); spinner.succeed(`Restored ${modifiedFiles.length} modified files as .bak`); } } } spinner.stop(); // Report custom and modified files if any were found if (customFiles.length > 0) { console.log(chalk.cyan(`\n📁 Custom files preserved: ${customFiles.length}`)); console.log(chalk.dim('The following custom files were found and restored:\n')); for (const file of customFiles) { console.log(chalk.dim(` - ${path.relative(bmadDir, file)}`)); } console.log(''); } if (modifiedFiles.length > 0) { console.log(chalk.yellow(`\n⚠️ Modified files detected: ${modifiedFiles.length}`)); console.log(chalk.dim('The following files were modified and backed up with .bak extension:\n')); for (const file of modifiedFiles) { console.log(chalk.dim(` - ${file.relativePath} → ${file.relativePath}.bak`)); } console.log(chalk.dim('\nThese files have been updated with the new version.')); console.log(chalk.dim('Review the .bak files to see your changes and merge if needed.\n')); } // Display completion message const { UI } = require('../../../lib/ui'); const ui = new UI(); ui.showInstallSummary({ path: bmadDir, modules: config.modules, ides: config.ides, customFiles: customFiles.length > 0 ? customFiles : undefined, ttsInjectedFiles: this.enableAgentVibes && this.ttsInjectedFiles.length > 0 ? this.ttsInjectedFiles : undefined, agentVibesEnabled: this.enableAgentVibes || false, }); return { success: true, path: bmadDir, modules: config.modules, ides: config.ides, needsAgentVibes: this.enableAgentVibes && !config.agentVibesInstalled, projectDir: projectDir, }; } catch (error) { spinner.fail('Installation failed'); throw error; } } /** * Update existing installation */ async update(config) { const spinner = ora('Checking installation...').start(); try { const projectDir = path.resolve(config.directory); const bmadDir = await this.findBmadDir(projectDir); const existingInstall = await this.detector.detect(bmadDir); if (!existingInstall.installed) { spinner.fail('No BMAD installation found'); throw new Error(`No BMAD installation found at ${bmadDir}`); } spinner.text = 'Analyzing update requirements...'; // Compare versions and determine what needs updating const currentVersion = existingInstall.version; const newVersion = require(path.join(getProjectRoot(), 'package.json')).version; // Check for custom modules with missing sources before update const customModuleSources = new Map(); if (existingInstall.customModules) { for (const customModule of existingInstall.customModules) { customModuleSources.set(customModule.id, customModule); } } if (customModuleSources.size > 0) { spinner.stop(); console.log(chalk.yellow('\nChecking custom module sources before update...')); const projectRoot = getProjectRoot(); await this.handleMissingCustomSources( customModuleSources, bmadDir, projectRoot, 'update', existingInstall.modules.map((m) => m.id), ); spinner.start('Preparing update...'); } if (config.dryRun) { spinner.stop(); console.log(chalk.cyan('\n🔍 Update Preview (Dry Run)\n')); console.log(chalk.bold('Current version:'), currentVersion); console.log(chalk.bold('New version:'), newVersion); console.log(chalk.bold('Core:'), existingInstall.hasCore ? 'Will be updated' : 'Not installed'); if (existingInstall.modules.length > 0) { console.log(chalk.bold('\nModules to update:')); for (const mod of existingInstall.modules) { console.log(` - ${mod.id}`); } } return; } // Perform actual update if (existingInstall.hasCore) { spinner.text = 'Updating core...'; await this.updateCore(bmadDir, config.force); } for (const module of existingInstall.modules) { spinner.text = `Updating module: ${module.id}...`; await this.moduleManager.update(module.id, bmadDir, config.force); } // Update manifest spinner.text = 'Updating manifest...'; await this.manifest.update(bmadDir, { version: newVersion, updateDate: new Date().toISOString(), }); spinner.succeed('Update complete'); return { success: true }; } catch (error) { spinner.fail('Update failed'); throw error; } } /** * Get installation status */ async getStatus(directory) { const projectDir = path.resolve(directory); const bmadDir = await this.findBmadDir(projectDir); return await this.detector.detect(bmadDir); } /** * Get available modules */ async getAvailableModules() { return await this.moduleManager.listAvailable(); } /** * Uninstall BMAD */ async uninstall(directory) { const projectDir = path.resolve(directory); const bmadDir = await this.findBmadDir(projectDir); if (await fs.pathExists(bmadDir)) { await fs.remove(bmadDir); } // Clean up IDE configurations await this.ideManager.cleanup(projectDir); return { success: true }; } /** * Private: Create directory structure */ async createDirectoryStructure(bmadDir) { await fs.ensureDir(bmadDir); await fs.ensureDir(path.join(bmadDir, '_cfg')); await fs.ensureDir(path.join(bmadDir, '_cfg', 'agents')); await fs.ensureDir(path.join(bmadDir, '_cfg', 'custom')); } /** * Generate clean config.yaml files for each installed module * @param {string} bmadDir - BMAD installation directory * @param {Object} moduleConfigs - Collected configuration values */ async generateModuleConfigs(bmadDir, moduleConfigs) { const yaml = require('yaml'); // Extract core config values to share with other modules const coreConfig = moduleConfigs.core || {}; // Get all installed module directories const entries = await fs.readdir(bmadDir, { withFileTypes: true }); const installedModules = entries .filter((entry) => entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs') .map((entry) => entry.name); // Generate config.yaml for each installed module for (const moduleName of installedModules) { const modulePath = path.join(bmadDir, moduleName); // Get module-specific config or use empty object if none const config = moduleConfigs[moduleName] || {}; if (await fs.pathExists(modulePath)) { const configPath = path.join(modulePath, 'config.yaml'); // Create header const packageJson = require(path.join(getProjectRoot(), 'package.json')); const header = `# ${moduleName.toUpperCase()} Module Configuration # Generated by BMAD installer # Version: ${packageJson.version} # Date: ${new Date().toISOString()} `; // For non-core modules, add core config values directly let finalConfig = { ...config }; let coreSection = ''; if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) { // Add core values directly to the module config // These will be available for reference in the module finalConfig = { ...config, ...coreConfig, // Spread core config values directly into the module config }; // Create a comment section to identify core values coreSection = '\n# Core Configuration Values\n'; } // Convert config to YAML let yamlContent = yaml.stringify(finalConfig, { indent: 2, lineWidth: 0, minContentWidth: 0, }); // If we have core values, reorganize the YAML to group them with their comment if (coreSection && moduleName !== 'core') { // Split the YAML into lines const lines = yamlContent.split('\n'); const moduleConfigLines = []; const coreConfigLines = []; // Separate module-specific and core config lines for (const line of lines) { const key = line.split(':')[0].trim(); if (Object.prototype.hasOwnProperty.call(coreConfig, key)) { coreConfigLines.push(line); } else { moduleConfigLines.push(line); } } // Rebuild YAML with module config first, then core config with comment yamlContent = moduleConfigLines.join('\n'); if (coreConfigLines.length > 0) { yamlContent += coreSection + coreConfigLines.join('\n'); } } // Write the clean config file with POSIX-compliant final newline const content = header + yamlContent; await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8'); // Track the config file in installedFiles this.installedFiles.push(configPath); } } } /** * Install core with resolved dependencies * @param {string} bmadDir - BMAD installation directory * @param {Object} coreFiles - Core files to install */ async installCoreWithDependencies(bmadDir, coreFiles) { const sourcePath = getModulePath('core'); const targetPath = path.join(bmadDir, 'core'); // Install full core await this.installCore(bmadDir); // If there are specific dependency files, ensure they're included if (coreFiles) { // Already handled by installCore for core module } } /** * Install module with resolved dependencies * @param {string} moduleName - Module name * @param {string} bmadDir - BMAD installation directory * @param {Object} moduleFiles - Module files to install */ async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) { // Get module configuration for conditional installation const moduleConfig = this.configCollector.collectedConfig[moduleName] || {}; // Use existing module manager for full installation with file tracking // Note: Module-specific installers are called separately after IDE setup await this.moduleManager.install( moduleName, bmadDir, (filePath) => { this.installedFiles.push(filePath); }, { skipModuleInstaller: true, // We'll run it later after IDE setup moduleConfig: moduleConfig, // Pass module config for conditional filtering }, ); // Process agent files to build YAML agents and create customize templates const modulePath = path.join(bmadDir, moduleName); await this.processAgentFiles(modulePath, moduleName); // Dependencies are already included in full module install } /** * Install partial module (only dependencies needed by other modules) */ async installPartialModule(moduleName, bmadDir, files) { const sourceBase = getModulePath(moduleName); const targetBase = path.join(bmadDir, moduleName); // Create module directory await fs.ensureDir(targetBase); // Copy only the required dependency files if (files.agents && files.agents.length > 0) { const agentsDir = path.join(targetBase, 'agents'); await fs.ensureDir(agentsDir); for (const agentPath of files.agents) { const fileName = path.basename(agentPath); const sourcePath = path.join(sourceBase, 'agents', fileName); const targetPath = path.join(agentsDir, fileName); if (await fs.pathExists(sourcePath)) { await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); this.installedFiles.push(targetPath); } } } if (files.tasks && files.tasks.length > 0) { const tasksDir = path.join(targetBase, 'tasks'); await fs.ensureDir(tasksDir); for (const taskPath of files.tasks) { const fileName = path.basename(taskPath); const sourcePath = path.join(sourceBase, 'tasks', fileName); const targetPath = path.join(tasksDir, fileName); if (await fs.pathExists(sourcePath)) { await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); this.installedFiles.push(targetPath); } } } if (files.tools && files.tools.length > 0) { const toolsDir = path.join(targetBase, 'tools'); await fs.ensureDir(toolsDir); for (const toolPath of files.tools) { const fileName = path.basename(toolPath); const sourcePath = path.join(sourceBase, 'tools', fileName); const targetPath = path.join(toolsDir, fileName); if (await fs.pathExists(sourcePath)) { await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); this.installedFiles.push(targetPath); } } } if (files.templates && files.templates.length > 0) { const templatesDir = path.join(targetBase, 'templates'); await fs.ensureDir(templatesDir); for (const templatePath of files.templates) { const fileName = path.basename(templatePath); const sourcePath = path.join(sourceBase, 'templates', fileName); const targetPath = path.join(templatesDir, fileName); if (await fs.pathExists(sourcePath)) { await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); this.installedFiles.push(targetPath); } } } if (files.data && files.data.length > 0) { for (const dataPath of files.data) { // Preserve directory structure for data files const relative = path.relative(sourceBase, dataPath); const targetPath = path.join(targetBase, relative); await fs.ensureDir(path.dirname(targetPath)); if (await fs.pathExists(dataPath)) { await this.copyFileWithPlaceholderReplacement(dataPath, targetPath, this.bmadFolderName || 'bmad'); this.installedFiles.push(targetPath); } } } // Create a marker file to indicate this is a partial installation const markerPath = path.join(targetBase, '.partial'); await fs.writeFile( markerPath, `This module contains only dependencies required by other modules.\nInstalled: ${new Date().toISOString()}\n`, ); } /** * Private: Install core * @param {string} bmadDir - BMAD installation directory */ async installCore(bmadDir) { const sourcePath = getModulePath('core'); const targetPath = path.join(bmadDir, 'core'); // Copy core files (skip .agent.yaml files like modules do) await this.copyCoreFiles(sourcePath, targetPath); // Compile agents using the same compiler as modules const { ModuleManager } = require('../modules/manager'); const moduleManager = new ModuleManager(); await moduleManager.compileModuleAgents(sourcePath, targetPath, 'core', bmadDir); // Process agent files to inject activation block await this.processAgentFiles(targetPath, 'core'); } /** * Copy core files (similar to copyModuleWithFiltering but for core) * @param {string} sourcePath - Source path * @param {string} targetPath - Target path */ async copyCoreFiles(sourcePath, targetPath) { // Get all files in source const files = await this.getFileList(sourcePath); for (const file of files) { // 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 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; } 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, this.bmadFolderName || 'bmad'); } // Track the installed file this.installedFiles.push(targetFile); } } /** * Get list of all files in a directory recursively * @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; } /** * Process agent files to build YAML agents and inject activation blocks * @param {string} modulePath - Path to module in bmad/ installation * @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 } // Determine project directory (parent of bmad/ directory) const bmadDir = path.dirname(modulePath); const projectDir = path.dirname(bmadDir); const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents'); // Ensure _cfg/agents directory exists await fs.ensureDir(cfgAgentsDir); // Get all agent files const agentFiles = await fs.readdir(agentsPath); for (const agentFile of agentFiles) { // Skip .agent.yaml files - they should already be compiled by compileModuleAgents if (agentFile.endsWith('.agent.yaml')) { continue; } // Only process .md files (already compiled from YAML) if (!agentFile.endsWith('.md')) { continue; } const agentName = agentFile.replace('.md', ''); const mdPath = path.join(agentsPath, agentFile); const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`); // For .md files that are already compiled, we don't need to do much // Just ensure the customize template exists if (!(await fs.pathExists(customizePath))) { const genericTemplatePath = getSourcePath('utility', 'agent-components', 'agent.customize.template.yaml'); if (await fs.pathExists(genericTemplatePath)) { await this.copyFileWithPlaceholderReplacement(genericTemplatePath, customizePath, this.bmadFolderName || 'bmad'); console.log(chalk.dim(` Created customize: ${moduleName}-${agentName}.customize.yaml`)); } } // Read the existing .md file to check for sidecar info let hasSidecar = false; try { const content = await fs.readFile(mdPath, 'utf8'); // Look for sidecar metadata in the frontmatter or content hasSidecar = content.includes('hasSidecar') && content.includes('true'); } catch { // Continue without sidecar processing } // Copy sidecar files if agent has hasSidecar flag if (hasSidecar) { const { copyAgentSidecarFiles } = require('../../../lib/agent/installer'); // Get agent sidecar folder from core config const coreConfigPath = path.join(bmadDir, 'bmb', 'config.yaml'); let agentSidecarFolder; if (await fs.pathExists(coreConfigPath)) { const yamlLib = require('yaml'); const coreConfigContent = await fs.readFile(coreConfigPath, 'utf8'); const coreConfig = yamlLib.parse(coreConfigContent); agentSidecarFolder = coreConfig.agent_sidecar_folder || agentSidecarFolder; } // Resolve path variables const resolvedSidecarFolder = agentSidecarFolder .replaceAll('{project-root}', projectDir) .replaceAll('_bmad', this.bmadFolderName || 'bmad'); // Create sidecar directory for this agent const agentSidecarDir = path.join(resolvedSidecarFolder, agentName); await fs.ensureDir(agentSidecarDir); // Find and copy sidecar folder from source module const sourceModulePath = moduleName === 'core' ? getModulePath('core') : getSourcePath(`modules/${moduleName}`); const sourceAgentPath = path.join(sourceModulePath, 'agents'); // Copy sidecar files (preserve existing, add new) const sidecarResult = copyAgentSidecarFiles(sourceAgentPath, agentSidecarDir, null); 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)`)); } } } } /** * Build standalone agents in bmad/agents/ directory * @param {string} bmadDir - Path to bmad directory * @param {string} projectDir - Path to project directory */ async buildStandaloneAgents(bmadDir, projectDir) { const standaloneAgentsPath = path.join(bmadDir, 'agents'); const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents'); // Check if standalone agents directory exists if (!(await fs.pathExists(standaloneAgentsPath))) { return; } // Get all subdirectories in agents/ const agentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true }); for (const agentDir of agentDirs) { if (!agentDir.isDirectory()) continue; const agentDirPath = path.join(standaloneAgentsPath, agentDir.name); // Find any .agent.yaml file in the directory const files = await fs.readdir(agentDirPath); const yamlFile = files.find((f) => f.endsWith('.agent.yaml')); if (!yamlFile) continue; const agentName = path.basename(yamlFile, '.agent.yaml'); const sourceYamlPath = path.join(agentDirPath, yamlFile); const targetMdPath = path.join(agentDirPath, `${agentName}.md`); const customizePath = path.join(cfgAgentsDir, `${agentName}.customize.yaml`); // Check for customizations const customizeExists = await fs.pathExists(customizePath); let customizedFields = []; if (customizeExists) { const customizeContent = await fs.readFile(customizePath, 'utf8'); const yaml = require('yaml'); const customizeYaml = yaml.parse(customizeContent); // Detect what fields are customized (similar to rebuildAgentFiles) if (customizeYaml) { if (customizeYaml.persona) { for (const [key, value] of Object.entries(customizeYaml.persona)) { if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) { customizedFields.push(`persona.${key}`); } } } if (customizeYaml.agent?.metadata) { for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) { if (value !== '' && value !== null) { customizedFields.push(`metadata.${key}`); } } } if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) { customizedFields.push('critical_actions'); } if (customizeYaml.menu && customizeYaml.menu.length > 0) { customizedFields.push('menu'); } } } // Build YAML to XML .md let xmlContent = await this.xmlHandler.buildFromYaml(sourceYamlPath, customizeExists ? customizePath : null, { includeMetadata: true, }); // DO NOT replace {project-root} - LLMs understand this placeholder at runtime // const processedContent = xmlContent.replaceAll('{project-root}', projectDir); // Process TTS injection points (pass targetPath for tracking) xmlContent = this.processTTSInjectionPoints(xmlContent, targetMdPath); // Write the built .md file with POSIX-compliant final newline const content = xmlContent.endsWith('\n') ? xmlContent : xmlContent + '\n'; await fs.writeFile(targetMdPath, content, 'utf8'); // Display result if (customizedFields.length > 0) { console.log(chalk.dim(` Built standalone agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`)); } else { console.log(chalk.dim(` Built standalone agent: ${agentName}.md`)); } } } /** * Rebuild agent files from installer source (for compile command) * @param {string} modulePath - Path to module in bmad/ installation * @param {string} moduleName - Module name */ async rebuildAgentFiles(modulePath, moduleName) { // Get source agents directory from installer const sourceAgentsPath = moduleName === 'core' ? path.join(getModulePath('core'), 'agents') : path.join(getSourcePath(`modules/${moduleName}`), 'agents'); if (!(await fs.pathExists(sourceAgentsPath))) { return; // No source agents to rebuild } // Determine project directory (parent of bmad/ directory) const bmadDir = path.dirname(modulePath); const projectDir = path.dirname(bmadDir); const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents'); const targetAgentsPath = path.join(modulePath, 'agents'); // Ensure target directory exists await fs.ensureDir(targetAgentsPath); // Get all YAML agent files from source const sourceFiles = await fs.readdir(sourceAgentsPath); for (const file of sourceFiles) { if (file.endsWith('.agent.yaml')) { const agentName = file.replace('.agent.yaml', ''); const sourceYamlPath = path.join(sourceAgentsPath, file); const targetMdPath = path.join(targetAgentsPath, `${agentName}.md`); const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`); // Check for customizations const customizeExists = await fs.pathExists(customizePath); let customizedFields = []; if (customizeExists) { const customizeContent = await fs.readFile(customizePath, 'utf8'); const yaml = require('yaml'); const customizeYaml = yaml.parse(customizeContent); // Detect what fields are customized if (customizeYaml) { if (customizeYaml.persona) { for (const [key, value] of Object.entries(customizeYaml.persona)) { if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) { customizedFields.push(`persona.${key}`); } } } if (customizeYaml.agent?.metadata) { for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) { if (value !== '' && value !== null) { customizedFields.push(`metadata.${key}`); } } } if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) { customizedFields.push('critical_actions'); } if (customizeYaml.memories && customizeYaml.memories.length > 0) { customizedFields.push('memories'); } if (customizeYaml.menu && customizeYaml.menu.length > 0) { customizedFields.push('menu'); } if (customizeYaml.prompts && customizeYaml.prompts.length > 0) { customizedFields.push('prompts'); } } } // Read the YAML content const yamlContent = await fs.readFile(sourceYamlPath, 'utf8'); // Read customize content if exists let customizeData = {}; if (customizeExists) { const customizeContent = await fs.readFile(customizePath, 'utf8'); const yaml = require('yaml'); customizeData = yaml.parse(customizeContent); } // Build agent answers from customize data (filter empty values) const answers = {}; 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; } // Get core config for 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); } // Compile using the same compiler as initial installation const { compileAgent } = require('../../../lib/agent/compiler'); const { xml } = await compileAgent(yamlContent, answers, agentName, path.relative(bmadDir, targetMdPath), { config: coreConfig, }); // Replace _bmad with actual folder name if needed const finalXml = xml.replaceAll('_bmad', path.basename(bmadDir)); // Write the rebuilt .md file with POSIX-compliant final newline const content = finalXml.endsWith('\n') ? finalXml : finalXml + '\n'; await fs.writeFile(targetMdPath, content, 'utf8'); // Display result with customizations if any if (customizedFields.length > 0) { console.log(chalk.dim(` Rebuilt agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`)); } else { console.log(chalk.dim(` Rebuilt agent: ${agentName}.md`)); } } } } /** * Compile/rebuild all agents and tasks for quick updates * @param {Object} config - Compilation configuration * @returns {Object} Compilation results */ async compileAgents(config) { try { const projectDir = path.resolve(config.directory); const bmadDir = await this.findBmadDir(projectDir); // Check if bmad directory exists if (!(await fs.pathExists(bmadDir))) { throw new Error(`BMAD not installed at ${bmadDir}`); } // Get installed modules from manifest const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); let installedModules = []; let manifest = null; if (await fs.pathExists(manifestPath)) { const manifestContent = await fs.readFile(manifestPath, 'utf8'); const yaml = require('yaml'); manifest = yaml.parse(manifestContent); installedModules = manifest.modules || []; } // Check for custom modules with missing sources if (manifest && manifest.customModules && manifest.customModules.length > 0) { console.log(chalk.yellow('\nChecking custom module sources before compilation...')); const customModuleSources = new Map(); for (const customModule of manifest.customModules) { customModuleSources.set(customModule.id, customModule); } const projectRoot = getProjectRoot(); await this.handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, 'compile-agents', installedModules); } let agentCount = 0; let taskCount = 0; // Process all modules in bmad directory const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs') { const modulePath = path.join(bmadDir, entry.name); // Special handling for standalone agents in bmad/agents/ directory if (entry.name === 'agents') { await this.buildStandaloneAgents(bmadDir, projectDir); // Count standalone agents const standaloneAgentsPath = path.join(bmadDir, 'agents'); const standaloneAgentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true }); for (const agentDir of standaloneAgentDirs) { if (agentDir.isDirectory()) { const agentDirPath = path.join(standaloneAgentsPath, agentDir.name); const agentFiles = await fs.readdir(agentDirPath); agentCount += agentFiles.filter((f) => f.endsWith('.md') && !f.endsWith('.agent.yaml')).length; } } } else { // Rebuild module agents from installer source const agentsPath = path.join(modulePath, 'agents'); if (await fs.pathExists(agentsPath)) { await this.rebuildAgentFiles(modulePath, entry.name); const agentFiles = await fs.readdir(agentsPath); agentCount += agentFiles.filter((f) => f.endsWith('.md')).length; } // Count tasks (already built) const tasksPath = path.join(modulePath, 'tasks'); if (await fs.pathExists(tasksPath)) { const taskFiles = await fs.readdir(tasksPath); taskCount += taskFiles.filter((f) => f.endsWith('.md')).length; } } } } // Update IDE configurations using the existing IDE list from manifest if (manifest && manifest.ides && manifest.ides.length > 0) { for (const ide of manifest.ides) { await this.ideManager.setup(ide, projectDir, bmadDir, { selectedModules: installedModules, skipModuleInstall: true, // Skip module installation, just update IDE files verbose: config.verbose, preCollectedConfig: { _alreadyConfigured: true }, // Skip all interactive prompts during compile }); } console.log(chalk.green('✓ IDE configurations updated')); } else { console.log(chalk.yellow('⚠️ No IDEs configured. Skipping IDE update.')); } return { agentCount, taskCount }; } catch (error) { throw error; } } /** * Private: Update core */ async updateCore(bmadDir, force = false) { const sourcePath = getModulePath('core'); const targetPath = path.join(bmadDir, 'core'); if (force) { await fs.remove(targetPath); await this.installCore(bmadDir); } else { // Selective update - preserve user modifications await this.fileOps.syncDirectory(sourcePath, targetPath); } } /** * Quick update method - preserves all settings and only prompts for new config fields * @param {Object} config - Configuration with directory * @returns {Object} Update result */ async quickUpdate(config) { const ora = require('ora'); const spinner = ora('Starting quick update...').start(); try { const projectDir = path.resolve(config.directory); const bmadDir = await this.findBmadDir(projectDir); // Check if bmad directory exists if (!(await fs.pathExists(bmadDir))) { spinner.fail('No BMAD installation found'); throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`); } spinner.text = 'Detecting installed modules and configuration...'; // Detect existing installation const existingInstall = await this.detector.detect(bmadDir); const installedModules = existingInstall.modules.map((m) => m.id); const configuredIdes = existingInstall.ides || []; const projectRoot = path.dirname(bmadDir); // Get custom module sources from manifest const customModuleSources = new Map(); if (existingInstall.customModules) { for (const customModule of existingInstall.customModules) { // Ensure we have an absolute sourcePath let absoluteSourcePath = customModule.sourcePath; // Check if sourcePath is a cache-relative path (starts with _cfg/) if (absoluteSourcePath && absoluteSourcePath.startsWith('_cfg')) { // Convert cache-relative path to absolute path absoluteSourcePath = path.join(bmadDir, absoluteSourcePath); } // If no sourcePath but we have relativePath, convert it else if (!absoluteSourcePath && customModule.relativePath) { // relativePath is relative to the project root (parent of bmad dir) absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath); } // Ensure sourcePath is absolute for anything else else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) { absoluteSourcePath = path.resolve(absoluteSourcePath); } // Update the custom module object with the absolute path const updatedModule = { ...customModule, sourcePath: absoluteSourcePath, }; customModuleSources.set(customModule.id, updatedModule); } } // Load saved IDE configurations const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); // Get available modules (what we have source for) const availableModulesData = await this.moduleManager.listAvailable(); const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; // Add custom modules from manifest if their sources exist for (const [moduleId, customModule] of customModuleSources) { // Use the absolute sourcePath const sourcePath = customModule.sourcePath; // Check if source exists at the recorded path if ( sourcePath && (await fs.pathExists(sourcePath)) && // Add to available modules if not already there !availableModules.some((m) => m.id === moduleId) ) { availableModules.push({ id: moduleId, name: customModule.name || moduleId, path: sourcePath, isCustom: true, fromManifest: true, }); } } // Check for untracked custom modules (installed but not in manifest) const untrackedCustomModules = []; for (const installedModule of installedModules) { // Skip standard modules and core const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core']; if (standardModuleIds.includes(installedModule)) { continue; } // Check if this installed module is not tracked in customModules if (!customModuleSources.has(installedModule)) { const modulePath = path.join(bmadDir, installedModule); if (await fs.pathExists(modulePath)) { untrackedCustomModules.push({ id: installedModule, name: installedModule, // We don't have the original name path: modulePath, untracked: true, }); } } } // If we found untracked custom modules, offer to track them if (untrackedCustomModules.length > 0) { spinner.stop(); console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`)); for (const untracked of untrackedCustomModules) { console.log(chalk.dim(` • ${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`)); } const { trackModules } = await inquirer.prompt([ { type: 'confirm', name: 'trackModules', message: chalk.cyan('Would you like to scan for their source locations?'), default: true, }, ]); if (trackModules) { const { scanDirectory } = await inquirer.prompt([ { type: 'input', name: 'scanDirectory', message: 'Enter directory to scan for custom module sources (or leave blank to skip):', default: projectRoot, validate: async (input) => { if (input && input.trim() !== '') { const expandedPath = path.resolve(input.trim()); if (!(await fs.pathExists(expandedPath))) { return 'Directory does not exist'; } const stats = await fs.stat(expandedPath); if (!stats.isDirectory()) { return 'Path must be a directory'; } } return true; }, }, ]); if (scanDirectory && scanDirectory.trim() !== '') { console.log(chalk.dim('\nScanning for custom module sources...')); // Scan for all module.yaml files const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory); const { ModuleManager } = require('../modules/manager'); const mm = new ModuleManager({ scanProjectForModules: true }); for (const untracked of untrackedCustomModules) { let foundSource = null; // Try to find by module ID for (const modulePath of allModulePaths) { try { const moduleInfo = await mm.getModuleInfo(modulePath); if (moduleInfo && moduleInfo.id === untracked.id) { foundSource = { path: modulePath, info: moduleInfo, }; break; } } catch { // Continue searching } } if (foundSource) { console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`)); // Add to manifest await this.manifest.addCustomModule(bmadDir, { id: untracked.id, name: foundSource.info.name || untracked.name, sourcePath: path.resolve(foundSource.path), installDate: new Date().toISOString(), tracked: true, }); // Add to customModuleSources for processing customModuleSources.set(untracked.id, { id: untracked.id, name: foundSource.info.name || untracked.name, sourcePath: path.resolve(foundSource.path), }); } else { console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`)); } } } } console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.')); spinner.start('Preparing update...'); } // Handle missing custom module sources using shared method const customModuleResult = await this.handleMissingCustomSources( customModuleSources, bmadDir, projectRoot, 'update', installedModules, ); const { validCustomModules, keptModulesWithoutSources } = customModuleResult; const customModulesFromManifest = validCustomModules.map((m) => ({ ...m, isCustom: true, hasUpdate: true, })); // Add untracked modules to the update list but mark them as untrackable for (const untracked of untrackedCustomModules) { if (!customModuleSources.has(untracked.id)) { customModulesFromManifest.push({ ...untracked, isCustom: true, hasUpdate: false, // Can't update without source untracked: true, }); } } const allAvailableModules = [...availableModules, ...customModulesFromManifest]; const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); // Core module is special - never include it in update flow const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core'); // Only update modules that are BOTH installed AND available (we have source for) const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id)); const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id)); // Add custom modules that were kept without sources to the skipped modules // This ensures their agents are preserved in the manifest for (const keptModule of keptModulesWithoutSources) { if (!skippedModules.includes(keptModule)) { skippedModules.push(keptModule); } } spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`); if (skippedModules.length > 0) { console.log(chalk.yellow(`⚠️ Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`)); } // Load existing configs and collect new fields (if any) console.log(chalk.cyan('\n📋 Checking for new configuration options...')); await this.configCollector.loadExistingConfig(projectDir); let promptedForNewFields = false; // Check core config for new fields const corePrompted = await this.configCollector.collectModuleConfigQuick('core', projectDir, true); if (corePrompted) { promptedForNewFields = true; } // Check each module we're updating for new fields (NOT skipped modules) for (const moduleName of modulesToUpdate) { const modulePrompted = await this.configCollector.collectModuleConfigQuick(moduleName, projectDir, true); if (modulePrompted) { promptedForNewFields = true; } } if (!promptedForNewFields) { console.log(chalk.green('✓ All configuration is up to date, no new options to configure')); } // Add metadata this.configCollector.collectedConfig._meta = { version: require(path.join(getProjectRoot(), 'package.json')).version, installDate: new Date().toISOString(), lastModified: new Date().toISOString(), }; // Build the config object for the installer const installConfig = { directory: projectDir, installCore: true, modules: modulesToUpdate, // Only update modules we have source for ides: configuredIdes, skipIde: configuredIdes.length === 0, coreConfig: this.configCollector.collectedConfig.core, actionType: 'install', // Use regular install flow _quickUpdate: true, // Flag to skip certain prompts _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them _savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer _customModuleSources: customModuleSources, // Pass custom module sources for updates _existingModules: installedModules, // Pass all installed modules for manifest generation }; // Call the standard install method const result = await this.install(installConfig); // Only succeed the spinner if it's still spinning // (install method might have stopped it if folder name changed) if (spinner.isSpinning) { spinner.succeed('Quick update complete!'); } return { success: true, moduleCount: modulesToUpdate.length + 1, // +1 for core hadNewFields: promptedForNewFields, modules: ['core', ...modulesToUpdate], skippedModules: skippedModules, ides: configuredIdes, }; } catch (error) { spinner.fail('Quick update failed'); throw error; } } /** * Private: Prompt for update action */ async promptUpdateAction() { const inquirer = require('inquirer'); return await inquirer.prompt([ { type: 'list', name: 'action', message: 'What would you like to do?', choices: [ { name: 'Update existing installation', value: 'update' }, { name: 'Remove and reinstall', value: 'reinstall' }, { name: 'Cancel', value: 'cancel' }, ], }, ]); } /** * Handle legacy BMAD v4 migration with automatic backup * @param {string} projectDir - Project directory * @param {Object} legacyV4 - Legacy V4 detection result with offenders array */ async handleLegacyV4Migration(projectDir, legacyV4) { console.log(chalk.yellow.bold('\n⚠️ Legacy BMAD v4 detected')); console.log(chalk.dim('The installer found legacy artefacts in your project.\n')); // Separate _bmad* folders (auto-backup) from other offending paths (manual cleanup) const bmadFolders = legacyV4.offenders.filter((p) => { const name = path.basename(p); return name.startsWith('_bmad'); // Only dot-prefixed folders get auto-backed up }); const otherOffenders = legacyV4.offenders.filter((p) => { const name = path.basename(p); return !name.startsWith('_bmad'); // Everything else is manual cleanup }); const inquirer = require('inquirer'); // Show warning for other offending paths FIRST if (otherOffenders.length > 0) { console.log(chalk.yellow('⚠️ Recommended cleanup:')); console.log(chalk.dim('It is recommended to remove the following items before proceeding:\n')); for (const p of otherOffenders) console.log(chalk.dim(` - ${p}`)); console.log(chalk.cyan('\nCleanup commands you can copy/paste:')); console.log(chalk.dim('macOS/Linux:')); for (const p of otherOffenders) console.log(chalk.dim(` rm -rf '${p}'`)); console.log(chalk.dim('Windows:')); for (const p of otherOffenders) console.log(chalk.dim(` rmdir /S /Q "${p}"`)); const { cleanedUp } = await inquirer.prompt([ { type: 'confirm', name: 'cleanedUp', message: 'Have you completed the recommended cleanup? (You can proceed without it, but it is recommended)', default: false, }, ]); if (cleanedUp) { console.log(chalk.green('✓ Cleanup acknowledged\n')); } else { console.log(chalk.yellow('⚠️ Proceeding without recommended cleanup\n')); } } // Handle _bmad* folders with automatic backup if (bmadFolders.length > 0) { console.log(chalk.cyan('The following legacy folders will be moved to v4-backup:')); for (const p of bmadFolders) console.log(chalk.dim(` - ${p}`)); const { proceed } = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: 'Proceed with backing up legacy v4 folders?', default: true, }, ]); if (proceed) { const backupDir = path.join(projectDir, 'v4-backup'); await fs.ensureDir(backupDir); for (const folder of bmadFolders) { const folderName = path.basename(folder); const backupPath = path.join(backupDir, folderName); // If backup already exists, add timestamp let finalBackupPath = backupPath; if (await fs.pathExists(backupPath)) { const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').split('T')[0]; finalBackupPath = path.join(backupDir, `${folderName}-${timestamp}`); } await fs.move(folder, finalBackupPath, { overwrite: false }); console.log(chalk.green(`✓ Moved ${folderName} to ${path.relative(projectDir, finalBackupPath)}`)); } } else { throw new Error('Installation cancelled by user'); } } } /** * Read files-manifest.csv * @param {string} bmadDir - BMAD installation directory * @returns {Array} Array of file entries from files-manifest.csv */ async readFilesManifest(bmadDir) { const filesManifestPath = path.join(bmadDir, '_cfg', 'files-manifest.csv'); if (!(await fs.pathExists(filesManifestPath))) { return []; } try { const content = await fs.readFile(filesManifestPath, 'utf8'); const lines = content.split('\n'); const files = []; for (let i = 1; i < lines.length; i++) { // Skip header const line = lines[i].trim(); if (!line) continue; // Parse CSV line properly handling quoted values const parts = []; let current = ''; let inQuotes = false; for (const char of line) { if (char === '"') { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { parts.push(current); current = ''; } else { current += char; } } parts.push(current); // Add last part if (parts.length >= 4) { files.push({ type: parts[0], name: parts[1], module: parts[2], path: parts[3], hash: parts[4] || null, // Hash may not exist in old manifests }); } } return files; } catch (error) { console.warn('Warning: Could not read files-manifest.csv:', error.message); return []; } } /** * Detect custom and modified files * @param {string} bmadDir - BMAD installation directory * @param {Array} existingFilesManifest - Previous files from files-manifest.csv * @returns {Object} Object with customFiles and modifiedFiles arrays */ async detectCustomFiles(bmadDir, existingFilesManifest) { const customFiles = []; const modifiedFiles = []; // Check if the manifest has hashes - if not, we can't detect modifications let manifestHasHashes = false; if (existingFilesManifest && existingFilesManifest.length > 0) { manifestHasHashes = existingFilesManifest.some((f) => f.hash); } // Build map of previously installed files from files-manifest.csv with their hashes const installedFilesMap = new Map(); for (const fileEntry of existingFilesManifest) { if (fileEntry.path) { // Paths are relative to bmadDir. Legacy manifests incorrectly prefixed 'bmad/' - // strip it if present. This is safe because no real path inside bmadDir would // start with 'bmad/' (you'd never have _bmad/bmad/... as an actual structure). const relativePath = fileEntry.path.startsWith('bmad/') ? fileEntry.path.slice(5) : fileEntry.path; const absolutePath = path.join(bmadDir, relativePath); installedFilesMap.set(path.normalize(absolutePath), { hash: fileEntry.hash, relativePath: relativePath, }); } } // Recursively scan bmadDir for all files const scanDirectory = async (dir) => { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip certain directories if (entry.name === 'node_modules' || entry.name === '.git') { continue; } await scanDirectory(fullPath); } else if (entry.isFile()) { const normalizedPath = path.normalize(fullPath); const fileInfo = installedFilesMap.get(normalizedPath); // Skip certain system files that are auto-generated const relativePath = path.relative(bmadDir, fullPath); const fileName = path.basename(fullPath); // Skip _cfg directory EXCEPT for agent customizations if ( (relativePath.startsWith('_cfg/') || relativePath.startsWith('_cfg\\')) && // Allow .customize.yaml files in _cfg/agents/ !(relativePath.includes('/agents/') && fileName.endsWith('.customize.yaml')) ) { continue; } // Skip config.yaml files - these are regenerated on each install/update // Users should use _cfg/agents/ override files instead if (fileName === 'config.yaml') { continue; } if (!fileInfo) { // File not in manifest = custom file customFiles.push(fullPath); } else if (manifestHasHashes && fileInfo.hash) { // File in manifest with hash - check if it was modified const currentHash = await this.manifest.calculateFileHash(fullPath); if (currentHash && currentHash !== fileInfo.hash) { // Hash changed = file was modified modifiedFiles.push({ path: fullPath, relativePath: fileInfo.relativePath, }); } } // If manifest doesn't have hashes, we can't detect modifications // so we just skip files that are in the manifest } } } catch { // Ignore errors scanning directories } }; await scanDirectory(bmadDir); return { customFiles, modifiedFiles }; } /** * Private: Create agent configuration files * @param {string} bmadDir - BMAD installation directory * @param {Object} userInfo - User information including name and language */ async createAgentConfigs(bmadDir, userInfo = null) { const agentConfigDir = path.join(bmadDir, '_cfg', 'agents'); await fs.ensureDir(agentConfigDir); // Get all agents from all modules const agents = []; const agentDetails = []; // For manifest generation // Check modules for agents (including core) const entries = await fs.readdir(bmadDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name !== '_cfg') { const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents'); if (await fs.pathExists(moduleAgentsPath)) { const agentFiles = await fs.readdir(moduleAgentsPath); for (const agentFile of agentFiles) { if (agentFile.endsWith('.md')) { const agentPath = path.join(moduleAgentsPath, agentFile); const agentContent = await fs.readFile(agentPath, 'utf8'); // Skip agents with localskip="true" const hasLocalSkip = agentContent.match(/]*\slocalskip="true"[^>]*>/); if (hasLocalSkip) { continue; // Skip this agent - it should not have been installed } const agentName = path.basename(agentFile, '.md'); // Extract any nodes with agentConfig="true" const agentConfigNodes = this.extractAgentConfigNodes(agentContent); agents.push({ name: agentName, module: entry.name, agentConfigNodes: agentConfigNodes, }); // Use shared AgentPartyGenerator to extract details let details = AgentPartyGenerator.extractAgentDetails(agentContent, entry.name, agentName); // Apply config overrides if they exist if (details) { const configPath = path.join(agentConfigDir, `${entry.name}-${agentName}.md`); if (await fs.pathExists(configPath)) { const configContent = await fs.readFile(configPath, 'utf8'); details = AgentPartyGenerator.applyConfigOverrides(details, configContent); } agentDetails.push(details); } } } } } } // Create config file for each agent let createdCount = 0; let skippedCount = 0; // Load agent config template const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md'); const templateContent = await fs.readFile(templatePath, 'utf8'); for (const agent of agents) { const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`); // Skip if config file already exists (preserve custom configurations) if (await fs.pathExists(configPath)) { skippedCount++; continue; } // Build config content header let configContent = `# Agent Config: ${agent.name}\n\n`; // Process template and add agent-specific config nodes let processedTemplate = templateContent; // Replace {core:user_name} placeholder with actual user name if available if (userInfo && userInfo.userName) { processedTemplate = processedTemplate.replaceAll('{core:user_name}', userInfo.userName); } // Replace {core:communication_language} placeholder with actual language if available if (userInfo && userInfo.responseLanguage) { processedTemplate = processedTemplate.replaceAll('{core:communication_language}', userInfo.responseLanguage); } // If this agent has agentConfig nodes, add them after the existing comment if (agent.agentConfigNodes && agent.agentConfigNodes.length > 0) { // Find the agent-specific configuration nodes comment const commentPattern = /(\s*)/; const commentMatch = processedTemplate.match(commentPattern); if (commentMatch) { // Add nodes right after the comment let agentSpecificNodes = ''; for (const node of agent.agentConfigNodes) { agentSpecificNodes += `\n ${node}`; } processedTemplate = processedTemplate.replace(commentPattern, `$1${agentSpecificNodes}`); } } configContent += processedTemplate; // Ensure POSIX-compliant final newline if (!configContent.endsWith('\n')) { configContent += '\n'; } await fs.writeFile(configPath, configContent, 'utf8'); this.installedFiles.push(configPath); // Track agent config files createdCount++; } // Generate agent manifest with overrides applied await this.generateAgentManifest(bmadDir, agentDetails); return { total: agents.length, created: createdCount, skipped: skippedCount }; } /** * Generate agent manifest XML file * @param {string} bmadDir - BMAD installation directory * @param {Array} agentDetails - Array of agent details */ async generateAgentManifest(bmadDir, agentDetails) { const manifestPath = path.join(bmadDir, '_cfg', 'agent-manifest.csv'); await AgentPartyGenerator.writeAgentParty(manifestPath, agentDetails, { forWeb: false }); } /** * Extract nodes with agentConfig="true" from agent content * @param {string} content - Agent file content * @returns {Array} Array of XML nodes that should be added to agent config */ extractAgentConfigNodes(content) { const nodes = []; try { // Find all XML nodes with agentConfig="true" // Match self-closing tags and tags with content const selfClosingPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*\/>/g; const withContentPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*>([\s\S]*?)<\/\1>/g; // Extract self-closing tags let match; while ((match = selfClosingPattern.exec(content)) !== null) { // Extract just the tag without children (structure only) const tagMatch = match[0].match(/<([a-zA-Z][a-zA-Z0-9_-]*)([^>]*)\/>/); if (tagMatch) { const tagName = tagMatch[1]; const attributes = tagMatch[2].replace(/\s*agentConfig="true"/, ''); // Remove agentConfig attribute nodes.push(`<${tagName}${attributes}>`); } } // Extract tags with content while ((match = withContentPattern.exec(content)) !== null) { const fullMatch = match[0]; const tagName = match[1]; // Extract opening tag with attributes (removing agentConfig="true") const openingTagMatch = fullMatch.match(new RegExp(`<${tagName}([^>]*)>`)); if (openingTagMatch) { const attributes = openingTagMatch[1].replace(/\s*agentConfig="true"/, ''); // Add empty node structure (no children) nodes.push(`<${tagName}${attributes}>`); } } } catch (error) { console.error('Error extracting agentConfig nodes:', error); } return nodes; } /** * Copy IDE-specific documentation to BMAD docs * @param {Array} ides - List of selected IDEs * @param {string} bmadDir - BMAD installation directory */ async copyIdeDocumentation(ides, bmadDir) { const docsDir = path.join(bmadDir, 'docs'); await fs.ensureDir(docsDir); for (const ide of ides) { const sourceDocPath = path.join(getProjectRoot(), 'docs', 'ide-info', `${ide}.md`); const targetDocPath = path.join(docsDir, `${ide}-instructions.md`); if (await fs.pathExists(sourceDocPath)) { await this.copyFileWithPlaceholderReplacement(sourceDocPath, targetDocPath, this.bmadFolderName || 'bmad'); } } } /** * Handle missing custom module sources interactively * @param {Map} customModuleSources - Map of custom module ID to info * @param {string} bmadDir - BMAD directory * @param {string} projectRoot - Project root directory * @param {string} operation - Current operation ('update', 'compile', etc.) * @param {Array} installedModules - Array of installed module IDs (will be modified) * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array */ async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) { const validCustomModules = []; const keptModulesWithoutSources = []; // Track modules kept without sources const customModulesWithMissingSources = []; // Check which sources exist for (const [moduleId, customInfo] of customModuleSources) { if (await fs.pathExists(customInfo.sourcePath)) { validCustomModules.push({ id: moduleId, name: customInfo.name, path: customInfo.sourcePath, info: customInfo, }); } else { customModulesWithMissingSources.push({ id: moduleId, name: customInfo.name, sourcePath: customInfo.sourcePath, relativePath: customInfo.relativePath, info: customInfo, }); } } // If no missing sources, return immediately if (customModulesWithMissingSources.length === 0) { return { validCustomModules, keptModulesWithoutSources: [], }; } // Stop any spinner for interactive prompts const currentSpinner = ora(); if (currentSpinner.isSpinning) { currentSpinner.stop(); } console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`)); const inquirer = require('inquirer'); let keptCount = 0; let updatedCount = 0; let removedCount = 0; for (const missing of customModulesWithMissingSources) { console.log(chalk.dim(` • ${missing.name} (${missing.id})`)); console.log(chalk.dim(` Original source: ${missing.relativePath}`)); console.log(chalk.dim(` Full path: ${missing.sourcePath}`)); const choices = [ { name: 'Keep installed (will not be processed)', value: 'keep', short: 'Keep', }, { name: 'Specify new source location', value: 'update', short: 'Update', }, ]; // Only add remove option if not just compiling agents if (operation !== 'compile-agents') { choices.push({ name: '⚠️ REMOVE module completely (destructive!)', value: 'remove', short: 'Remove', }); } const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: `How would you like to handle "${missing.name}"?`, choices, }, ]); switch (action) { case 'update': { const { newSourcePath } = await inquirer.prompt([ { type: 'input', name: 'newSourcePath', message: 'Enter the new path to the custom module:', default: missing.sourcePath, validate: async (input) => { if (!input || input.trim() === '') { return 'Please enter a path'; } const expandedPath = path.resolve(input.trim()); if (!(await fs.pathExists(expandedPath))) { return 'Path does not exist'; } // Check if it looks like a valid module const moduleYamlPath = path.join(expandedPath, 'module.yaml'); const agentsPath = path.join(expandedPath, 'agents'); const workflowsPath = path.join(expandedPath, 'workflows'); if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) { return 'Path does not appear to contain a valid custom module'; } return true; }, }, ]); // Update the source in manifest const resolvedPath = path.resolve(newSourcePath.trim()); missing.info.sourcePath = resolvedPath; // Remove relativePath - we only store absolute sourcePath now delete missing.info.relativePath; await this.manifest.addCustomModule(bmadDir, missing.info); validCustomModules.push({ id: moduleId, name: missing.name, path: resolvedPath, info: missing.info, }); updatedCount++; console.log(chalk.green(`✓ Updated source location`)); break; } case 'remove': { // Extra confirmation for destructive remove console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`)); console.log(chalk.red(` Module location: ${path.join(bmadDir, moduleId)}`)); const { confirm } = await inquirer.prompt([ { type: 'confirm', name: 'confirm', message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), default: false, }, ]); if (confirm) { const { typedConfirm } = await inquirer.prompt([ { type: 'input', name: 'typedConfirm', message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), validate: (input) => { if (input !== 'DELETE') { return chalk.red('You must type "DELETE" exactly to proceed'); } return true; }, }, ]); if (typedConfirm === 'DELETE') { // Remove the module from filesystem and manifest const modulePath = path.join(bmadDir, moduleId); if (await fs.pathExists(modulePath)) { const fsExtra = require('fs-extra'); await fsExtra.remove(modulePath); console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`)); } await this.manifest.removeModule(bmadDir, moduleId); await this.manifest.removeCustomModule(bmadDir, moduleId); console.log(chalk.yellow(` ✓ Removed from manifest`)); // Also remove from installedModules list if (installedModules && installedModules.includes(moduleId)) { const index = installedModules.indexOf(moduleId); if (index !== -1) { installedModules.splice(index, 1); } } removedCount++; console.log(chalk.red.bold(`✓ "${missing.name}" has been permanently removed`)); } else { console.log(chalk.dim(' Removal cancelled - module will be kept')); keptCount++; } } else { console.log(chalk.dim(' Removal cancelled - module will be kept')); keptCount++; } break; } case 'keep': { keptCount++; keptModulesWithoutSources.push(moduleId); console.log(chalk.dim(` Module will be kept as-is`)); break; } // No default } } // Show summary if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { console.log(chalk.dim(`\nSummary for custom modules with missing sources:`)); if (keptCount > 0) console.log(chalk.dim(` • ${keptCount} module(s) kept as-is`)); if (updatedCount > 0) console.log(chalk.dim(` • ${updatedCount} module(s) updated with new sources`)); if (removedCount > 0) console.log(chalk.red(` • ${removedCount} module(s) permanently deleted`)); } return { validCustomModules, keptModulesWithoutSources, }; } } module.exports = { Installer };