const chalk = require('chalk'); const inquirer = require('inquirer'); const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('../installers/lib/custom/handler'); /** * UI utilities for the installer */ class UI { constructor() {} /** * Prompt for installation configuration * @returns {Object} Installation configuration */ async promptInstall() { CLIUtils.displayLogo(); const version = CLIUtils.getVersion(); CLIUtils.displaySection('BMAD™ Setup', `Build More, Architect Dreams v${version}`); const confirmedDirectory = await this.getConfirmedDirectory(); // Preflight: Check for legacy BMAD v4 footprints immediately after getting directory const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); const detector = new Detector(); const installer = new Installer(); const legacyV4 = await detector.detectLegacyV4(confirmedDirectory); if (legacyV4.hasLegacyV4) { await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); } // Check for legacy folders and prompt for rename before showing any menus let hasLegacyCfg = false; let hasLegacyBmadFolder = false; let bmadDir = null; let legacyBmadPath = null; // First check for legacy .bmad folder (instead of _bmad) // Only check if directory exists if (await fs.pathExists(confirmedDirectory)) { const entries = await fs.readdir(confirmedDirectory, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name === '.bmad') { hasLegacyBmadFolder = true; legacyBmadPath = path.join(confirmedDirectory, '.bmad'); bmadDir = legacyBmadPath; // Check if it has _cfg folder const cfgPath = path.join(legacyBmadPath, '_cfg'); if (await fs.pathExists(cfgPath)) { hasLegacyCfg = true; } break; } } } // If no .bmad found, check for current installations if (!hasLegacyBmadFolder) { const bmadResult = await installer.findBmadDir(confirmedDirectory); bmadDir = bmadResult.bmadDir; hasLegacyCfg = bmadResult.hasLegacyCfg; } if (hasLegacyBmadFolder || hasLegacyCfg) { console.log(chalk.yellow('\n⚠️ Legacy folder structure detected')); let message = 'The following folders need to be renamed:\n'; if (hasLegacyBmadFolder) { message += chalk.dim(` • ".bmad" → "_bmad"\n`); } if (hasLegacyCfg) { message += chalk.dim(` • "_cfg" → "_config"\n`); } console.log(message); const { shouldRename } = await inquirer.prompt([ { type: 'confirm', name: 'shouldRename', message: 'Would you like the installer to rename these folders for you?', default: true, }, ]); if (!shouldRename) { console.log(chalk.red('\n❌ Installation cancelled')); console.log(chalk.dim('You must manually rename the folders before proceeding:')); if (hasLegacyBmadFolder) { console.log(chalk.dim(` • Rename ".bmad" to "_bmad"`)); } if (hasLegacyCfg) { console.log(chalk.dim(` • Rename "_cfg" to "_config"`)); } process.exit(0); return; } // Perform the renames const ora = require('ora'); const spinner = ora('Updating folder structure...').start(); try { // First rename .bmad to _bmad if needed if (hasLegacyBmadFolder) { const newBmadPath = path.join(confirmedDirectory, '_bmad'); await fs.move(legacyBmadPath, newBmadPath); bmadDir = newBmadPath; spinner.succeed('Renamed ".bmad" to "_bmad"'); } // Then rename _cfg to _config if needed if (hasLegacyCfg) { spinner.start('Renaming configuration folder...'); const oldCfgPath = path.join(bmadDir, '_cfg'); const newCfgPath = path.join(bmadDir, '_config'); await fs.move(oldCfgPath, newCfgPath); spinner.succeed('Renamed "_cfg" to "_config"'); } spinner.succeed('Folder structure updated successfully'); } catch (error) { spinner.fail('Failed to update folder structure'); console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } } // Check if there's an existing BMAD installation (after any folder renames) const hasExistingInstall = await fs.pathExists(bmadDir); // Collect IDE tool selection early - we need this to know if we should ask about TTS let toolSelection; let agentVibesConfig = { enabled: false, alreadyInstalled: false }; let claudeCodeSelected = false; if (!hasExistingInstall) { // For new installations, collect IDE selection first // We don't have modules yet, so pass empty array toolSelection = await this.promptToolSelection(confirmedDirectory, []); // Check if Claude Code was selected claudeCodeSelected = toolSelection.ides && toolSelection.ides.includes('claude-code'); // If Claude Code was selected, ask about TTS if (claudeCodeSelected) { const { enableTts } = await inquirer.prompt([ { type: 'confirm', name: 'enableTts', message: 'Claude Code supports TTS (Text-to-Speech). Would you like to enable it?', default: false, }, ]); if (enableTts) { agentVibesConfig = { enabled: true, alreadyInstalled: false }; } } } // Always ask for custom content, but we'll handle it differently for new installs let customContentConfig = { hasCustomContent: false }; if (hasExistingInstall) { // Existing installation - prompt to add/update custom content customContentConfig = await this.promptCustomContentForExisting(); } else { // New installation - we'll prompt after creating the directory structure // For now, set a flag to indicate we should ask later customContentConfig._shouldAsk = true; } // Track action type (only set if there's an existing installation) let actionType; // Only show action menu if there's an existing installation if (hasExistingInstall) { const promptResult = await inquirer.prompt([ { type: 'list', name: 'actionType', message: 'What would you like to do?', choices: [ { name: 'Quick Update (Settings Preserved)', value: 'quick-update' }, { name: 'Modify BMAD Installation (Confirm or change each setting)', value: 'update' }, { name: 'Add Custom Content', value: 'add-custom' }, { name: 'Remove BMad Folder and Reinstall (Full clean install - BMad Customization Will Be Lost)', value: 'reinstall' }, { name: 'Compile Agents (Quick rebuild of all agent .md files)', value: 'compile' }, { name: 'Cancel', value: 'cancel' }, ], default: 'quick-update', }, ]); // Extract actionType from prompt result actionType = promptResult.actionType; // Handle quick update separately if (actionType === 'quick-update') { // Quick update doesn't install custom content - just updates existing modules return { actionType: 'quick-update', directory: confirmedDirectory, customContent: { hasCustomContent: false }, }; } // Handle add custom content separately if (actionType === 'add-custom') { customContentConfig = await this.promptCustomContentSource(); // After adding custom content, continue to select additional modules const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); // Ask if user wants to add additional modules const { wantsMoreModules } = await inquirer.prompt([ { type: 'confirm', name: 'wantsMoreModules', message: 'Do you want to add any additional modules?', default: false, }, ]); let selectedModules = []; if (wantsMoreModules) { const moduleChoices = await this.getModuleChoices(installedModuleIds, customContentConfig); selectedModules = await this.selectModules(moduleChoices); // Process custom content selection const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__')); if (selectedCustomContent.length > 0) { customContentConfig.selected = true; customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); // Convert to module IDs const customContentModuleIds = []; const customHandler = new CustomHandler(); for (const customFile of customContentConfig.selectedFiles) { const customInfo = await customHandler.getCustomInfo(customFile); if (customInfo) { customContentModuleIds.push(customInfo.id); } } selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds]; } } return { actionType: 'update', directory: confirmedDirectory, installCore: false, // Don't reinstall core modules: selectedModules, customContent: customContentConfig, }; } // Handle agent compilation separately if (actionType === 'compile') { return { actionType: 'compile', directory: confirmedDirectory, }; } // Handle cancel if (actionType === 'cancel') { return { actionType: 'cancel', directory: confirmedDirectory, }; } // Handle reinstall - DON'T return early, let it flow through configuration collection // The installer will handle deletion when it sees actionType === 'reinstall' // For now, just note that we're in reinstall mode and continue below // If actionType === 'update' or 'reinstall', continue with normal flow below } // For new installations, ask about content types first if (!hasExistingInstall) { // Ask about official modules first const { wantsOfficialModules } = await inquirer.prompt([ { type: 'confirm', name: 'wantsOfficialModules', message: 'Will you be installing any official modules (BMad Method, BMad Builder, Creative Innovation Suite)?', default: true, }, ]); let selectedOfficialModules = []; if (wantsOfficialModules) { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); const moduleChoices = await this.getModuleChoices(installedModuleIds, { hasCustomContent: false }); selectedOfficialModules = await this.selectModules(moduleChoices); } // Then ask about custom content const { wantsCustomContent } = await inquirer.prompt([ { type: 'confirm', name: 'wantsCustomContent', message: 'Will you be installing any locally stored custom content?', default: false, }, ]); if (wantsCustomContent) { customContentConfig = await this.promptCustomContentSource(); } // Store the selected modules for later customContentConfig._selectedOfficialModules = selectedOfficialModules; } const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); // Collect core configuration first const coreConfig = await this.collectCoreConfig(confirmedDirectory); // Custom content will be handled during installation phase // Store the custom content config for later use if (customContentConfig._shouldAsk) { delete customContentConfig._shouldAsk; } // Handle module selection let selectedModules = []; if (actionType === 'update' || actionType === 'reinstall') { // Keep all existing installed modules during update/reinstall selectedModules = [...installedModuleIds]; console.log(chalk.cyan('\n📦 Keeping existing modules: ') + selectedModules.join(', ')); } else if (!hasExistingInstall) { // For new installs, we've already selected official modules selectedModules = customContentConfig._selectedOfficialModules || []; // Add custom content modules if any were selected if (customContentConfig && customContentConfig.selectedModuleIds) { selectedModules = [...selectedModules, ...customContentConfig.selectedModuleIds]; } // Custom modules are already added via selectedModuleIds from customContentConfig // No need for additional processing here } // AgentVibes TTS configuration already collected earlier for new installations // For existing installations, keep the old behavior if (hasExistingInstall && !agentVibesConfig.enabled) { agentVibesConfig = await this.promptAgentVibes(confirmedDirectory); } // Tool selection already collected for new installations // For existing installations, we need to collect it now if (hasExistingInstall && !toolSelection) { const modulesForToolSelection = selectedModules; toolSelection = await this.promptToolSelection(confirmedDirectory, modulesForToolSelection); } // No more screen clearing - keep output flowing return { actionType: actionType || 'update', // Preserve reinstall or update action directory: confirmedDirectory, installCore: true, // Always install core modules: selectedModules, // IDE selection collected after config, will be configured later ides: toolSelection.ides, skipIde: toolSelection.skipIde, coreConfig: coreConfig, // Pass collected core config to installer // Custom content configuration customContent: customContentConfig, enableAgentVibes: agentVibesConfig.enabled, agentVibesInstalled: agentVibesConfig.alreadyInstalled, }; } /** * Prompt for tool/IDE selection (called after module configuration) * @param {string} projectDir - Project directory to check for existing IDEs * @param {Array} selectedModules - Selected modules from configuration * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, selectedModules) { // Check for existing configured IDEs - use findBmadDir to detect custom folder names const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); const detector = new Detector(); const installer = new Installer(); const bmadResult = await installer.findBmadDir(projectDir || process.cwd()); const bmadDir = bmadResult.bmadDir; const existingInstall = await detector.detect(bmadDir); const configuredIdes = existingInstall.ides || []; // Get IDE manager to fetch available IDEs dynamically const { IdeManager } = require('../installers/lib/ide/manager'); const ideManager = new IdeManager(); const preferredIdes = ideManager.getPreferredIdes(); const otherIdes = ideManager.getOtherIdes(); // Build IDE choices array with separators const ideChoices = []; const processedIdes = new Set(); // First, add previously configured IDEs at the top, marked with ✅ if (configuredIdes.length > 0) { ideChoices.push(new inquirer.Separator('── Previously Configured ──')); for (const ideValue of configuredIdes) { // Skip empty or invalid IDE values if (!ideValue || typeof ideValue !== 'string') { continue; } // Find the IDE in either preferred or other lists const preferredIde = preferredIdes.find((ide) => ide.value === ideValue); const otherIde = otherIdes.find((ide) => ide.value === ideValue); const ide = preferredIde || otherIde; if (ide) { ideChoices.push({ name: `${ide.name} ✅`, value: ide.value, checked: true, // Previously configured IDEs are checked by default }); processedIdes.add(ide.value); } else { // Warn about unrecognized IDE (but don't fail) console.log(chalk.yellow(`⚠️ Previously configured IDE '${ideValue}' is no longer available`)); } } } // Add preferred tools (excluding already processed) const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingPreferred.length > 0) { ideChoices.push(new inquirer.Separator('── Recommended Tools ──')); for (const ide of remainingPreferred) { ideChoices.push({ name: `${ide.name} ⭐`, value: ide.value, checked: false, }); processedIdes.add(ide.value); } } // Add other tools (excluding already processed) const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value)); if (remainingOther.length > 0) { ideChoices.push(new inquirer.Separator('── Additional Tools ──')); for (const ide of remainingOther) { ideChoices.push({ name: ide.name, value: ide.value, checked: false, }); } } let answers; let userConfirmedNoTools = false; // Loop until user selects at least one tool OR explicitly confirms no tools while (!userConfirmedNoTools) { answers = await inquirer.prompt([ { type: 'checkbox', name: 'ides', message: 'Select tools to configure:', choices: ideChoices, pageSize: 30, }, ]); // If tools were selected, we're done if (answers.ides && answers.ides.length > 0) { break; } // Warn that no tools were selected - users often miss the spacebar requirement console.log(); console.log(chalk.red.bold('⚠️ WARNING: No tools were selected!')); console.log(chalk.red(' You must press SPACEBAR to select items, then ENTER to confirm.')); console.log(chalk.red(' Simply highlighting an item does NOT select it.')); console.log(); const { goBack } = await inquirer.prompt([ { type: 'confirm', name: 'goBack', message: chalk.yellow('Would you like to go back and select at least one tool?'), default: true, }, ]); if (goBack) { // Re-display a message before looping back console.log(); } else { // User explicitly chose to proceed without tools userConfirmedNoTools = true; } } return { ides: answers.ides || [], skipIde: !answers.ides || answers.ides.length === 0, }; } /** * Prompt for update configuration * @returns {Object} Update configuration */ async promptUpdate() { const answers = await inquirer.prompt([ { type: 'confirm', name: 'backupFirst', message: 'Create backup before updating?', default: true, }, { type: 'confirm', name: 'preserveCustomizations', message: 'Preserve local customizations?', default: true, }, ]); return answers; } /** * Prompt for module selection * @param {Array} modules - Available modules * @returns {Array} Selected modules */ async promptModules(modules) { const choices = modules.map((mod) => ({ name: `${mod.name} - ${mod.description}`, value: mod.id, checked: false, })); const { selectedModules } = await inquirer.prompt([ { type: 'checkbox', name: 'selectedModules', message: 'Select modules to add:', choices, validate: (answer) => { if (answer.length === 0) { return 'You must choose at least one module.'; } return true; }, }, ]); return selectedModules; } /** * Confirm action * @param {string} message - Confirmation message * @param {boolean} defaultValue - Default value * @returns {boolean} User confirmation */ async confirm(message, defaultValue = false) { const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message, default: defaultValue, }, ]); return confirmed; } /** * Display installation summary * @param {Object} result - Installation result */ showInstallSummary(result) { CLIUtils.displaySection('Installation Complete', 'BMAD™ has been successfully installed'); const summary = [ `📁 Installation Path: ${result.path}`, `📦 Modules Installed: ${result.modules?.length > 0 ? result.modules.join(', ') : 'core only'}`, `🔧 Tools Configured: ${result.ides?.length > 0 ? result.ides.join(', ') : 'none'}`, ]; // Add AgentVibes TTS info if enabled if (result.agentVibesEnabled) { summary.push(`🎤 AgentVibes TTS: Enabled`); } CLIUtils.displayBox(summary.join('\n\n'), { borderColor: 'green', borderStyle: 'round', }); // Display TTS injection details if present if (result.ttsInjectedFiles && result.ttsInjectedFiles.length > 0) { console.log('\n' + chalk.cyan.bold('═══════════════════════════════════════════════════')); console.log(chalk.cyan.bold(' AgentVibes TTS Injection Summary')); console.log(chalk.cyan.bold('═══════════════════════════════════════════════════\n')); // Explain what TTS injection is console.log(chalk.white.bold('What is TTS Injection?\n')); console.log(chalk.dim(' TTS (Text-to-Speech) injection adds voice instructions to BMAD agents,')); console.log(chalk.dim(' enabling them to speak their responses aloud using AgentVibes.\n')); console.log(chalk.dim(' Example: When you activate the PM agent, it will greet you with')); console.log(chalk.dim(' spoken audio like "Hey! I\'m your Project Manager. How can I help?"\n')); console.log(chalk.green(`✅ TTS injection applied to ${result.ttsInjectedFiles.length} file(s):\n`)); // Group by type const partyModeFiles = result.ttsInjectedFiles.filter((f) => f.type === 'party-mode'); const agentTTSFiles = result.ttsInjectedFiles.filter((f) => f.type === 'agent-tts'); if (partyModeFiles.length > 0) { console.log(chalk.yellow(' Party Mode (multi-agent conversations):')); for (const file of partyModeFiles) { console.log(chalk.dim(` • ${file.path}`)); } } if (agentTTSFiles.length > 0) { console.log(chalk.yellow(' Agent TTS (individual agent voices):')); for (const file of agentTTSFiles) { console.log(chalk.dim(` • ${file.path}`)); } } // Show backup info and restore command console.log('\n' + chalk.white.bold('Backups & Recovery:\n')); console.log(chalk.dim(' Pre-injection backups are stored in:')); console.log(chalk.cyan(' ~/_bmad-tts-backups/\n')); console.log(chalk.dim(' To restore original files (removes TTS instructions):')); console.log(chalk.cyan(` bmad-tts-injector.sh --restore ${result.path}\n`)); console.log(chalk.cyan('💡 BMAD agents will now speak when activated!')); console.log(chalk.dim(' Ensure AgentVibes is installed: https://agentvibes.org')); } console.log('\n' + chalk.green.bold('✨ BMAD is ready to use!')); } /** * Get confirmed directory from user * @returns {string} Confirmed directory path */ async getConfirmedDirectory() { let confirmedDirectory = null; while (!confirmedDirectory) { const directoryAnswer = await this.promptForDirectory(); await this.displayDirectoryInfo(directoryAnswer.directory); if (await this.confirmDirectory(directoryAnswer.directory)) { confirmedDirectory = directoryAnswer.directory; } } return confirmedDirectory; } /** * Get existing installation info and installed modules * @param {string} directory - Installation directory * @returns {Object} Object with existingInstall and installedModuleIds */ async getExistingInstallation(directory) { const { Detector } = require('../installers/lib/core/detector'); const { Installer } = require('../installers/lib/core/installer'); const detector = new Detector(); const installer = new Installer(); const bmadDir = await installer.findBmadDir(directory); const existingInstall = await detector.detect(bmadDir); const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id)); return { existingInstall, installedModuleIds }; } /** * Collect core configuration * @param {string} directory - Installation directory * @returns {Object} Core configuration */ async collectCoreConfig(directory) { const { ConfigCollector } = require('../installers/lib/core/config-collector'); const configCollector = new ConfigCollector(); // Load existing configs first if they exist await configCollector.loadExistingConfig(directory); // Now collect with existing values as defaults (false = don't skip loading, true = skip completion message) await configCollector.collectModuleConfig('core', directory, false, true); return configCollector.collectedConfig.core; } /** * Get module choices for selection * @param {Set} installedModuleIds - Currently installed module IDs * @param {Object} customContentConfig - Custom content configuration * @returns {Array} Module choices for inquirer */ async getModuleChoices(installedModuleIds, customContentConfig = null) { const moduleChoices = []; const isNewInstallation = installedModuleIds.size === 0; const customContentItems = []; const hasCustomContentItems = false; // Add custom content items if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) { // Existing installation - show from directory const customHandler = new CustomHandler(); const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); for (const customFile of customFiles) { const customInfo = await customHandler.getCustomInfo(customFile); if (customInfo) { customContentItems.push({ name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content checked: true, // Default to selected since user chose to provide custom content path: customInfo.path, // Track path to avoid duplicates }); } } } // Add official modules const { ModuleManager } = require('../installers/lib/modules/manager'); // For new installations, don't scan project yet (will do after custom content is discovered) // For existing installations, scan if user selected custom content const shouldScanProject = !isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected; const moduleManager = new ModuleManager({ scanProjectForModules: shouldScanProject, }); const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable(); // First, add all items to appropriate sections const allCustomModules = []; // Add custom content items from directory allCustomModules.push(...customContentItems); // Add custom modules from project scan (if scanning is enabled) for (const mod of customModulesFromProject) { // Skip if this module is already in customContentItems (by path) const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path)); if (!isDuplicate) { allCustomModules.push({ name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(${mod.source})`)}`, value: mod.id, checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), }); } } // Add separators and modules in correct order if (allCustomModules.length > 0) { // Add separator for custom content, all custom modules, and official content separator moduleChoices.push( new inquirer.Separator('── Custom Content ──'), ...allCustomModules, new inquirer.Separator('── Official Content ──'), ); } // Add official modules (only non-custom ones) for (const mod of availableModules) { if (!mod.isCustom) { moduleChoices.push({ name: mod.name, value: mod.id, checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), }); } } return moduleChoices; } /** * Prompt for module selection * @param {Array} moduleChoices - Available module choices * @returns {Array} Selected module IDs */ async selectModules(moduleChoices) { const moduleAnswer = await inquirer.prompt([ { type: 'checkbox', name: 'modules', message: 'Select modules to install:', choices: moduleChoices, }, ]); return moduleAnswer.modules || []; } /** * Prompt for directory selection * @returns {Object} Directory answer from inquirer */ async promptForDirectory() { return await inquirer.prompt([ { type: 'input', name: 'directory', message: `Installation directory:`, default: process.cwd(), validate: async (input) => this.validateDirectory(input), filter: (input) => { // If empty, use the default if (!input || input.trim() === '') { return process.cwd(); } return this.expandUserPath(input); }, }, ]); } /** * Display directory information * @param {string} directory - The directory path */ async displayDirectoryInfo(directory) { console.log(chalk.cyan('\nResolved installation path:'), chalk.bold(directory)); const dirExists = await fs.pathExists(directory); if (dirExists) { // Show helpful context about the existing path const stats = await fs.stat(directory); if (stats.isDirectory()) { const files = await fs.readdir(directory); if (files.length > 0) { // Check for any bmad installation (any folder with _config/manifest.yaml) const { Installer } = require('../installers/lib/core/installer'); const installer = new Installer(); const bmadDir = await installer.findBmadDir(directory); const hasBmadInstall = (await fs.pathExists(bmadDir)) && (await fs.pathExists(path.join(bmadDir, '_config', 'manifest.yaml'))); console.log( chalk.gray(`Directory exists and contains ${files.length} item(s)`) + (hasBmadInstall ? chalk.yellow(` including existing BMAD installation (${path.basename(bmadDir)})`) : ''), ); } else { console.log(chalk.gray('Directory exists and is empty')); } } } } /** * Confirm directory selection * @param {string} directory - The directory path * @returns {boolean} Whether user confirmed */ async confirmDirectory(directory) { const dirExists = await fs.pathExists(directory); if (dirExists) { const confirmAnswer = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: `Install to this directory?`, default: true, }, ]); if (!confirmAnswer.proceed) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } return confirmAnswer.proceed; } else { // Ask for confirmation to create the directory const createConfirm = await inquirer.prompt([ { type: 'confirm', name: 'create', message: `The directory '${directory}' doesn't exist. Would you like to create it?`, default: false, }, ]); if (!createConfirm.create) { console.log(chalk.yellow("\nLet's try again with a different path.\n")); } return createConfirm.create; } } /** * Validate directory path for installation * @param {string} input - User input path * @returns {string|true} Error message or true if valid */ async validateDirectory(input) { // Allow empty input to use the default if (!input || input.trim() === '') { return true; // Empty means use default } let expandedPath; try { expandedPath = this.expandUserPath(input.trim()); } catch (error) { return error.message; } // Check if the path exists const pathExists = await fs.pathExists(expandedPath); if (!pathExists) { // Find the first existing parent directory const existingParent = await this.findExistingParent(expandedPath); if (!existingParent) { return 'Cannot create directory: no existing parent directory found'; } // Check if the existing parent is writable try { await fs.access(existingParent, fs.constants.W_OK); // Path doesn't exist but can be created - will prompt for confirmation later return true; } catch { // Provide a detailed error message explaining both issues return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`; } } // If it exists, validate it's a directory and writable const stat = await fs.stat(expandedPath); if (!stat.isDirectory()) { return `Path exists but is not a directory: ${expandedPath}`; } // Check write permissions try { await fs.access(expandedPath, fs.constants.W_OK); } catch { return `Directory is not writable: ${expandedPath}`; } return true; } /** * Find the first existing parent directory * @param {string} targetPath - The path to check * @returns {string|null} The first existing parent directory, or null if none found */ async findExistingParent(targetPath) { let currentPath = path.resolve(targetPath); // Walk up the directory tree until we find an existing directory while (currentPath !== path.dirname(currentPath)) { // Stop at root const parent = path.dirname(currentPath); if (await fs.pathExists(parent)) { return parent; } currentPath = parent; } return null; // No existing parent found (shouldn't happen in practice) } /** * Expands the user-provided path: handles ~ and resolves to absolute. * @param {string} inputPath - User input path. * @returns {string} Absolute expanded path. */ expandUserPath(inputPath) { if (typeof inputPath !== 'string') { throw new TypeError('Path must be a string.'); } let expanded = inputPath.trim(); // Handle tilde expansion if (expanded.startsWith('~')) { if (expanded === '~') { expanded = os.homedir(); } else if (expanded.startsWith('~' + path.sep)) { const pathAfterHome = expanded.slice(2); // Remove ~/ or ~\ expanded = path.join(os.homedir(), pathAfterHome); } else { const restOfPath = expanded.slice(1); const separatorIndex = restOfPath.indexOf(path.sep); const username = separatorIndex === -1 ? restOfPath : restOfPath.slice(0, separatorIndex); if (username) { throw new Error(`Path expansion for ~${username} is not supported. Please use an absolute path or ~${path.sep}`); } } } // Resolve to the absolute path relative to the current working directory return path.resolve(expanded); } /** * @function promptAgentVibes * @intent Ask user if they want AgentVibes TTS integration during BMAD installation * @why Enables optional voice features without forcing TTS on users who don't want it * @param {string} projectDir - Absolute path to user's project directory * @returns {Promise} Configuration object: { enabled: boolean, alreadyInstalled: boolean } * @sideeffects None - pure user input collection, no files written * @edgecases Shows warning if user enables TTS but AgentVibes not detected * @calledby promptInstall() during installation flow, after core config, before IDE selection * @calls checkAgentVibesInstalled(), inquirer.prompt(), chalk.green/yellow/dim() * * AI NOTE: This prompt is strategically positioned in installation flow: * - AFTER core config (user_name, etc) * - BEFORE IDE selection (which can hang on Windows/PowerShell) * * Flow Logic: * 1. Auto-detect if AgentVibes already installed (checks for hook files) * 2. Show detection status to user (green checkmark or gray "not detected") * 3. Prompt: "Enable AgentVibes TTS?" (defaults to true if detected) * 4. If user says YES but AgentVibes NOT installed: * → Show warning with installation link (graceful degradation) * 5. Return config to promptInstall(), which passes to installer.install() * * State Flow: * promptAgentVibes() → { enabled, alreadyInstalled } * ↓ * promptInstall() → config.enableAgentVibes * ↓ * installer.install() → this.enableAgentVibes * ↓ * processTTSInjectionPoints() → injects OR strips markers * * RELATED: * ======== * - Detection: checkAgentVibesInstalled() - looks for bmad-speak.sh and play-tts.sh * - Processing: installer.js::processTTSInjectionPoints() * - Markers: src/core/workflows/party-mode/instructions.md:101, src/modules/bmm/agents/*.md * - GitHub Issue: paulpreibisch/AgentVibes#36 */ async promptAgentVibes(projectDir) { CLIUtils.displaySection('🎤 Voice Features', 'Enable TTS for multi-agent conversations'); // Check if AgentVibes is already installed const agentVibesInstalled = await this.checkAgentVibesInstalled(projectDir); if (agentVibesInstalled) { console.log(chalk.green(' ✓ AgentVibes detected')); } else { console.log(chalk.dim(' AgentVibes not detected')); } const answers = await inquirer.prompt([ { type: 'confirm', name: 'enableTts', message: 'Enable Agents to Speak Out loud (powered by Agent Vibes? Claude Code only currently)', default: false, // Default to yes - recommended for best experience }, ]); if (answers.enableTts && !agentVibesInstalled) { console.log(chalk.yellow('\n ⚠️ AgentVibes not installed')); console.log(chalk.dim(' Install AgentVibes separately to enable TTS:')); console.log(chalk.dim(' https://github.com/paulpreibisch/AgentVibes\n')); } return { enabled: answers.enableTts, alreadyInstalled: agentVibesInstalled, }; } /** * @function checkAgentVibesInstalled * @intent Detect if AgentVibes TTS hooks are present in user's project * @why Allows auto-enabling TTS and showing helpful installation guidance * @param {string} projectDir - Absolute path to user's project directory * @returns {Promise} true if both required AgentVibes hooks exist, false otherwise * @sideeffects None - read-only file existence checks * @edgecases Returns false if either hook missing (both required for functional TTS) * @calledby promptAgentVibes() to determine default value and show detection status * @calls fs.pathExists() twice (bmad-speak.sh, play-tts.sh) * * AI NOTE: This checks for the MINIMUM viable AgentVibes installation. * * Required Files: * =============== * 1. .claude/hooks/bmad-speak.sh * - Maps agent display names → agent IDs → voice profiles * - Calls play-tts.sh with agent's assigned voice * - Created by AgentVibes installer * * 2. .claude/hooks/play-tts.sh * - Core TTS router (ElevenLabs or Piper) * - Provider-agnostic interface * - Required by bmad-speak.sh * * Why Both Required: * ================== * - bmad-speak.sh alone: No TTS backend * - play-tts.sh alone: No BMAD agent voice mapping * - Both together: Full party mode TTS integration * * Detection Strategy: * =================== * We use simple file existence (not version checks) because: * - Fast and reliable * - Works across all AgentVibes versions * - User will discover version issues when TTS runs (fail-fast) * * PATTERN: Adding New Detection Criteria * ======================================= * If future AgentVibes features require additional files: * 1. Add new pathExists check to this function * 2. Update documentation in promptAgentVibes() * 3. Consider: should missing file prevent detection or just log warning? * * RELATED: * ======== * - AgentVibes Installer: creates these hooks * - bmad-speak.sh: calls play-tts.sh with agent voices * - Party Mode: uses bmad-speak.sh for agent dialogue */ async checkAgentVibesInstalled(projectDir) { const fs = require('fs-extra'); const path = require('node:path'); // Check for AgentVibes hook files const hookPath = path.join(projectDir, '.claude', 'hooks', 'bmad-speak.sh'); const playTtsPath = path.join(projectDir, '.claude', 'hooks', 'play-tts.sh'); return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath)); } /** * Prompt for custom content for existing installations * @returns {Object} Custom content configuration */ async promptCustomContentForExisting() { try { // Skip custom content installation - always return false return { hasCustomContent: false }; // TODO: Custom content installation temporarily disabled // CLIUtils.displaySection('Custom Content', 'Add new custom agents, workflows, or modules to your installation'); // const { hasCustomContent } = await inquirer.prompt([ // { // type: 'list', // name: 'hasCustomContent', // message: 'Do you want to add or update custom content?', // choices: [ // { // name: 'No, continue with current installation only', // value: false, // }, // { // name: 'Yes, I have custom content to add or update', // value: true, // }, // ], // default: false, // }, // ]); // if (!hasCustomContent) { // return { hasCustomContent: false }; // } // TODO: Custom content installation temporarily disabled // // Get directory path // const { customPath } = await inquirer.prompt([ // { // type: 'input', // name: 'customPath', // message: 'Enter directory to search for custom content (will scan subfolders):', // default: process.cwd(), // validate: async (input) => { // if (!input || input.trim() === '') { // return 'Please enter a directory path'; // } // // Normalize and check if path exists // const expandedPath = CLIUtils.expandPath(input.trim()); // const pathExists = await fs.pathExists(expandedPath); // if (!pathExists) { // return 'Directory does not exist'; // } // // Check if it's actually a directory // const stats = await fs.stat(expandedPath); // if (!stats.isDirectory()) { // return 'Path must be a directory'; // } // return true; // }, // transformer: (input) => { // return CLIUtils.expandPath(input); // }, // }, // ]); // const resolvedPath = CLIUtils.expandPath(customPath); // // Find custom content // const customHandler = new CustomHandler(); // const customFiles = await customHandler.findCustomContent(resolvedPath); // if (customFiles.length === 0) { // console.log(chalk.yellow(`\nNo custom content found in ${resolvedPath}`)); // const { tryDifferent } = await inquirer.prompt([ // { // type: 'confirm', // name: 'tryDifferent', // message: 'Try a different directory?', // default: true, // }, // ]); // if (tryDifferent) { // return await this.promptCustomContentForExisting(); // } // return { hasCustomContent: false }; // } // // Display found items // console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`)); // const customContentItems = []; // for (const customFile of customFiles) { // const customInfo = await customHandler.getCustomInfo(customFile); // if (customInfo) { // customContentItems.push({ // name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, // value: `__CUSTOM_CONTENT__${customFile}`, // checked: true, // }); // } // } // // Add option to keep existing custom content // console.log(chalk.yellow('\nExisting custom modules will be preserved unless you remove them')); // const { selectedFiles } = await inquirer.prompt([ // { // type: 'checkbox', // name: 'selectedFiles', // message: 'Select custom content to add:', // choices: customContentItems, // pageSize: 15, // validate: (answer) => { // if (answer.length === 0) { // return 'You must select at least one item'; // } // return true; // }, // }, // ]); // return { // hasCustomContent: true, // customPath: resolvedPath, // selected: true, // selectedFiles: selectedFiles, // }; } catch (error) { console.error(chalk.red('Error configuring custom content:'), error); return { hasCustomContent: false }; } } /** * Prompt user for custom content source location * @returns {Object} Custom content configuration */ async promptCustomContentSource() { const customContentConfig = { hasCustomContent: true, sources: [] }; // Keep asking for more sources until user is done while (true) { // First ask if user wants to add another module or continue if (customContentConfig.sources.length > 0) { const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'Would you like to:', choices: [ { name: 'Add another custom module', value: 'add' }, { name: 'Continue with installation', value: 'continue' }, ], default: 'continue', }, ]); if (action === 'continue') { break; } } let sourcePath; let isValid = false; while (!isValid) { const { path: inputPath } = await inquirer.prompt([ { type: 'input', name: 'path', message: 'Enter the path to your custom content folder (or press Enter to cancel):', validate: async (input) => { // Allow empty input to cancel if (!input || input.trim() === '') { return true; // Allow empty to exit } try { // Expand the path const expandedPath = this.expandUserPath(input.trim()); // Check if path exists if (!(await fs.pathExists(expandedPath))) { return 'Path does not exist'; } // Check if it's a directory const stat = await fs.stat(expandedPath); if (!stat.isDirectory()) { return 'Path must be a directory'; } // Check for module.yaml in the root const moduleYamlPath = path.join(expandedPath, 'module.yaml'); if (!(await fs.pathExists(moduleYamlPath))) { return 'Directory must contain a module.yaml file in the root'; } // Try to parse the module.yaml to get the module ID try { const yaml = require('yaml'); const content = await fs.readFile(moduleYamlPath, 'utf8'); const moduleData = yaml.parse(content); if (!moduleData.code) { return 'module.yaml must contain a "code" field for the module ID'; } } catch (error) { return 'Invalid module.yaml file: ' + error.message; } return true; } catch (error) { return 'Error validating path: ' + error.message; } }, }, ]); // If user pressed Enter without typing anything, exit the loop if (!inputPath || inputPath.trim() === '') { // If we have no modules yet, return false for no custom content if (customContentConfig.sources.length === 0) { return { hasCustomContent: false }; } return customContentConfig; } sourcePath = this.expandUserPath(inputPath); isValid = true; } // Read module.yaml to get module info const yaml = require('yaml'); const moduleYamlPath = path.join(sourcePath, 'module.yaml'); const moduleContent = await fs.readFile(moduleYamlPath, 'utf8'); const moduleData = yaml.parse(moduleContent); // Add to sources customContentConfig.sources.push({ path: sourcePath, id: moduleData.code, name: moduleData.name || moduleData.code, }); console.log(chalk.green(`✓ Confirmed local custom module: ${moduleData.name || moduleData.code}`)); } // Ask if user wants to add these to the installation const { shouldInstall } = await inquirer.prompt([ { type: 'confirm', name: 'shouldInstall', message: `Install ${customContentConfig.sources.length} custom module(s) now?`, default: true, }, ]); if (shouldInstall) { customContentConfig.selected = true; // Store paths to module.yaml files, not directories customContentConfig.selectedFiles = customContentConfig.sources.map((s) => path.join(s.path, 'module.yaml')); // Also include module IDs for installation customContentConfig.selectedModuleIds = customContentConfig.sources.map((s) => s.id); } return customContentConfig; } } module.exports = { UI };