const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { ExternalModuleManager } = require('./modules/external-manager'); const { getProjectRoot } = require('./project-root'); const prompts = require('./prompts'); /** * Read module version from .claude-plugin/marketplace.json * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis') * @returns {string} Version string or empty string */ async function getMarketplaceVersion(moduleCode) { let marketplacePath; if (moduleCode === 'core' || moduleCode === 'bmm') { marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json'); } else { const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode); marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json'); } try { if (await fs.pathExists(marketplacePath)) { const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); return _extractMarketplaceVersion(data); } } catch { // ignore } return ''; } /** * Extract the highest version from marketplace.json plugins array. * Handles multiple plugins per file safely. * @param {Object} data - Parsed marketplace.json * @returns {string} Version string or empty string */ function _extractMarketplaceVersion(data) { const plugins = data?.plugins; if (!Array.isArray(plugins) || plugins.length === 0) return ''; // Use the highest version across all plugins in the file let best = ''; for (const p of plugins) { if (p.version && (!best || p.version > best)) best = p.version; } return best; } /** * UI utilities for the installer */ class UI { /** * Prompt for installation configuration * @param {Object} options - Command-line options from install command * @returns {Object} Installation configuration */ async promptInstall(options = {}) { await CLIUtils.displayLogo(); // Display version-specific start message from install-messages.yaml const { MessageLoader } = require('./message-loader'); const messageLoader = new MessageLoader(); await messageLoader.displayStartMessage(); // Get directory from options or prompt let confirmedDirectory; if (options.directory) { // Use provided directory from command-line const expandedDir = this.expandUserPath(options.directory); const validation = this.validateDirectorySync(expandedDir); if (validation) { throw new Error(`Invalid directory: ${validation}`); } confirmedDirectory = expandedDir; await prompts.log.info(`Using directory from command-line: ${confirmedDirectory}`); } else { confirmedDirectory = await this.getConfirmedDirectory(); } const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(confirmedDirectory); // Check if there's an existing BMAD installation const hasExistingInstall = await fs.pathExists(bmadDir); // 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) { // Get version information const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); // Build menu choices dynamically const choices = []; // Always show Quick Update first (allows refreshing installation even on same version) if (existingInstall.installed) { choices.push({ name: 'Quick Update', value: 'quick-update', }); } // Common actions choices.push({ name: 'Modify BMAD Installation', value: 'update' }); // Check if action is provided via command-line if (options.action) { const validActions = choices.map((c) => c.value); if (!validActions.includes(options.action)) { throw new Error(`Invalid action: ${options.action}. Valid actions: ${validActions.join(', ')}`); } actionType = options.action; await prompts.log.info(`Using action from command-line: ${actionType}`); } else if (options.yes) { // Default to quick-update if available, otherwise first available choice if (choices.length === 0) { throw new Error('No valid actions available for this installation'); } const hasQuickUpdate = choices.some((c) => c.value === 'quick-update'); actionType = hasQuickUpdate ? 'quick-update' : choices[0].value; await prompts.log.info(`Non-interactive mode (--yes): defaulting to ${actionType}`); } else { actionType = await prompts.select({ message: 'How would you like to proceed?', choices: choices, default: choices[0].value, }); } // Handle quick update separately if (actionType === 'quick-update') { return { actionType: 'quick-update', directory: confirmedDirectory, skipPrompts: options.yes || false, }; } // If actionType === 'update', handle it with the new flow // Return early with modify configuration if (actionType === 'update') { // Get existing installation info const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); await prompts.log.message(`Found existing modules: ${[...installedModuleIds].join(', ')}`); // Unified module selection - all modules in one grouped multiselect let selectedModules; if (options.modules) { // Use modules from command-line selectedModules = options.modules .split(',') .map((m) => m.trim()) .filter(Boolean); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); } else if (options.yes) { selectedModules = await this.getDefaultModules(installedModuleIds); await prompts.log.info( `Non-interactive mode (--yes): using default modules (installed + defaults): ${selectedModules.join(', ')}`, ); } else { selectedModules = await this.selectAllModules(installedModuleIds); } // Ensure core is in the modules list if (!selectedModules.includes('core')) { selectedModules.unshift('core'); } // Get tool selection const toolSelection = await this.promptToolSelection(confirmedDirectory, options); const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'update', directory: confirmedDirectory, modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, skipPrompts: options.yes || false, }; } } // This section is only for new installations (update returns early above) const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); // Unified module selection - all modules in one grouped multiselect let selectedModules; if (options.modules) { // Use modules from command-line selectedModules = options.modules .split(',') .map((m) => m.trim()) .filter(Boolean); await prompts.log.info(`Using modules from command-line: ${selectedModules.join(', ')}`); } else if (options.yes) { // Use default modules when --yes flag is set selectedModules = await this.getDefaultModules(installedModuleIds); await prompts.log.info(`Using default modules (--yes flag): ${selectedModules.join(', ')}`); } else { selectedModules = await this.selectAllModules(installedModuleIds); } // Ensure core is in the modules list if (!selectedModules.includes('core')) { selectedModules.unshift('core'); } let toolSelection = await this.promptToolSelection(confirmedDirectory, options); const moduleConfigs = await this.collectModuleConfigs(confirmedDirectory, selectedModules, options); return { actionType: 'install', directory: confirmedDirectory, modules: selectedModules, ides: toolSelection.ides, skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, skipPrompts: options.yes || false, }; } /** * Prompt for tool/IDE selection (called after module configuration) * Uses a split prompt approach: * 1. Recommended tools - standard multiselect for preferred tools * 2. Additional tools - autocompleteMultiselect with search capability * @param {string} projectDir - Project directory to check for existing IDEs * @param {Object} options - Command-line options * @returns {Object} Tool configuration */ async promptToolSelection(projectDir, options = {}) { const { ExistingInstall } = require('./core/existing-install'); const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(projectDir || process.cwd()); const existingInstall = await ExistingInstall.detect(bmadDir); const configuredIdes = existingInstall.ides; // Get IDE manager to fetch available IDEs dynamically const { IdeManager } = require('./ide/manager'); const ideManager = new IdeManager(); await ideManager.ensureInitialized(); // IMPORTANT: Must initialize before getting IDEs const preferredIdes = ideManager.getPreferredIdes(); const otherIdes = ideManager.getOtherIdes(); // Determine which configured IDEs are in "preferred" vs "other" categories const configuredPreferred = configuredIdes.filter((id) => preferredIdes.some((ide) => ide.value === id)); const configuredOther = configuredIdes.filter((id) => otherIdes.some((ide) => ide.value === id)); // Warn about previously configured tools that are no longer available const allKnownValues = new Set([...preferredIdes, ...otherIdes].map((ide) => ide.value)); const unknownTools = configuredIdes.filter((id) => id && typeof id === 'string' && !allKnownValues.has(id)); if (unknownTools.length > 0) { await prompts.log.warn(`Previously configured tools are no longer available: ${unknownTools.join(', ')}`); } // ───────────────────────────────────────────────────────────────────────────── // UPGRADE PATH: If tools already configured, show all tools with configured at top // ───────────────────────────────────────────────────────────────────────────── if (configuredIdes.length > 0) { const allTools = [...preferredIdes, ...otherIdes]; // Non-interactive: handle --tools and --yes flags before interactive prompt if (options.tools) { if (options.tools.toLowerCase() === 'none') { await prompts.log.info('Skipping tool configuration (--tools none)'); return { ides: [], skipIde: true }; } const selectedIdes = options.tools .split(',') .map((t) => t.trim()) .filter(Boolean); await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`); await this.displaySelectedTools(selectedIdes, preferredIdes, allTools); return { ides: selectedIdes, skipIde: false }; } if (options.yes) { await prompts.log.info(`Non-interactive mode (--yes): keeping configured tools: ${configuredIdes.join(', ')}`); await this.displaySelectedTools(configuredIdes, preferredIdes, allTools); return { ides: configuredIdes, skipIde: false }; } // Sort: configured tools first, then preferred, then others const sortedTools = [ ...allTools.filter((ide) => configuredIdes.includes(ide.value)), ...allTools.filter((ide) => !configuredIdes.includes(ide.value)), ]; const upgradeOptions = sortedTools.map((ide) => { const isConfigured = configuredIdes.includes(ide.value); const isPreferred = preferredIdes.some((p) => p.value === ide.value); let label = ide.name; if (isPreferred) label += ' ⭐'; if (isConfigured) label += ' ✅'; return { label, value: ide.value }; }); // Sort initialValues to match display order const sortedInitialValues = sortedTools.filter((ide) => configuredIdes.includes(ide.value)).map((ide) => ide.value); const upgradeSelected = await prompts.autocompleteMultiselect({ message: 'Integrate with', options: upgradeOptions, initialValues: sortedInitialValues, required: false, maxItems: 8, }); const selectedIdes = upgradeSelected || []; if (selectedIdes.length === 0) { const confirmNoTools = await prompts.confirm({ message: 'No tools selected. Continue without installing any tools?', default: false, }); if (!confirmNoTools) { return this.promptToolSelection(projectDir, options); } return { ides: [], skipIde: true }; } // Display selected tools await this.displaySelectedTools(selectedIdes, preferredIdes, allTools); return { ides: selectedIdes, skipIde: false }; } // ───────────────────────────────────────────────────────────────────────────── // NEW INSTALL: Show all tools with search // ───────────────────────────────────────────────────────────────────────────── const allTools = [...preferredIdes, ...otherIdes]; const allToolOptions = allTools.map((ide) => { const isPreferred = preferredIdes.some((p) => p.value === ide.value); let label = ide.name; if (isPreferred) label += ' ⭐'; return { label, value: ide.value, }; }); let selectedIdes = []; // Check if tools are provided via command-line if (options.tools) { // Check for explicit "none" value to skip tool installation if (options.tools.toLowerCase() === 'none') { await prompts.log.info('Skipping tool configuration (--tools none)'); return { ides: [], skipIde: true }; } else { selectedIdes = options.tools .split(',') .map((t) => t.trim()) .filter(Boolean); await prompts.log.info(`Using tools from command-line: ${selectedIdes.join(', ')}`); await this.displaySelectedTools(selectedIdes, preferredIdes, allTools); return { ides: selectedIdes, skipIde: false }; } } else if (options.yes) { // If --yes flag is set, skip tool prompt and use previously configured tools or empty if (configuredIdes.length > 0) { await prompts.log.info(`Using previously configured tools (--yes flag): ${configuredIdes.join(', ')}`); await this.displaySelectedTools(configuredIdes, preferredIdes, allTools); return { ides: configuredIdes, skipIde: false }; } else { await prompts.log.info('Skipping tool configuration (--yes flag, no previous tools)'); return { ides: [], skipIde: true }; } } // Interactive mode const interactiveSelectedIdes = await prompts.autocompleteMultiselect({ message: 'Integrate with:', options: allToolOptions, initialValues: configuredIdes.length > 0 ? configuredIdes : undefined, required: false, maxItems: 8, }); selectedIdes = interactiveSelectedIdes || []; // ───────────────────────────────────────────────────────────────────────────── // STEP 3: Confirm if no tools selected // ───────────────────────────────────────────────────────────────────────────── if (selectedIdes.length === 0) { const confirmNoTools = await prompts.confirm({ message: 'No tools selected. Continue without installing any tools?', default: false, }); if (!confirmNoTools) { // User wants to select tools - recurse return this.promptToolSelection(projectDir, options); } return { ides: [], skipIde: true, }; } // Display selected tools await this.displaySelectedTools(selectedIdes, preferredIdes, allTools); return { ides: selectedIdes, skipIde: selectedIdes.length === 0, }; } /** * Prompt for update configuration * @returns {Object} Update configuration */ async promptUpdate() { const backupFirst = await prompts.confirm({ message: 'Create backup before updating?', default: true, }); const preserveCustomizations = await prompts.confirm({ message: 'Preserve local customizations?', default: true, }); return { backupFirst, preserveCustomizations }; } /** * Confirm action * @param {string} message - Confirmation message * @param {boolean} defaultValue - Default value * @returns {boolean} User confirmation */ async confirm(message, defaultValue = false) { return await prompts.confirm({ message, default: defaultValue, }); } /** * 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, installedModuleIds, and bmadDir */ async getExistingInstallation(directory) { const { ExistingInstall } = require('./core/existing-install'); const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(directory); const existingInstall = await ExistingInstall.detect(bmadDir); const installedModuleIds = new Set(existingInstall.moduleIds); return { existingInstall, installedModuleIds, bmadDir }; } /** * Collect all module configurations (core + selected modules). * All interactive prompting happens here in the UI layer. * @param {string} directory - Installation directory * @param {string[]} modules - Modules to configure (including 'core') * @param {Object} options - Command-line options * @returns {Object} Collected module configurations keyed by module name */ async collectModuleConfigs(directory, modules, options = {}) { const { OfficialModules } = require('./modules/official-modules'); const configCollector = new OfficialModules(); // Seed core config from CLI options if provided if (options.userName || options.communicationLanguage || options.documentOutputLanguage || options.outputFolder) { const coreConfig = {}; if (options.userName) { coreConfig.user_name = options.userName; await prompts.log.info(`Using user name from command-line: ${options.userName}`); } if (options.communicationLanguage) { coreConfig.communication_language = options.communicationLanguage; await prompts.log.info(`Using communication language from command-line: ${options.communicationLanguage}`); } if (options.documentOutputLanguage) { coreConfig.document_output_language = options.documentOutputLanguage; await prompts.log.info(`Using document output language from command-line: ${options.documentOutputLanguage}`); } if (options.outputFolder) { coreConfig.output_folder = options.outputFolder; await prompts.log.info(`Using output folder from command-line: ${options.outputFolder}`); } // Load existing config to merge with provided options await configCollector.loadExistingConfig(directory); const existingConfig = configCollector.collectedConfig.core || {}; configCollector.collectedConfig.core = { ...existingConfig, ...coreConfig }; // If not all options are provided, collect the missing ones interactively (unless --yes flag) if ( !options.yes && (!options.userName || !options.communicationLanguage || !options.documentOutputLanguage || !options.outputFolder) ) { await configCollector.collectModuleConfig('core', directory, false, true); } } else if (options.yes) { // Use all defaults when --yes flag is set await configCollector.loadExistingConfig(directory); const existingConfig = configCollector.collectedConfig.core || {}; if (Object.keys(existingConfig).length === 0) { let safeUsername; try { safeUsername = os.userInfo().username; } catch { safeUsername = process.env.USER || process.env.USERNAME || 'User'; } const defaultUsername = safeUsername.charAt(0).toUpperCase() + safeUsername.slice(1); configCollector.collectedConfig.core = { user_name: defaultUsername, communication_language: 'English', document_output_language: 'English', output_folder: '_bmad-output', }; await prompts.log.info('Using default configuration (--yes flag)'); } } // Collect all module configs — core is skipped if already seeded above await configCollector.collectAllConfigurations(modules, directory, { skipPrompts: options.yes || false, }); return configCollector.collectedConfig; } /** * Select all modules across three tiers: official, community, and custom URL. * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Array} Selected module codes (excluding core) */ async selectAllModules(installedModuleIds = new Set()) { // Phase 1: Official modules const officialSelected = await this._selectOfficialModules(installedModuleIds); // Determine which installed modules are NOT official (community or custom). // These must be preserved even if the user declines to browse community/custom. const officialCodes = new Set(officialSelected); const externalManager = new ExternalModuleManager(); const registryModules = await externalManager.listAvailable(); const officialRegistryCodes = new Set(registryModules.map((m) => m.code)); const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id)); // Phase 2: Community modules (category drill-down) // Returns { codes, didBrowse } so we know if the user entered the flow const communityResult = await this._browseCommunityModules(installedModuleIds); // Phase 3: Custom URL modules const customSelected = await this._addCustomUrlModules(installedModuleIds); // Merge all selections const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]); // Auto-include installed non-official modules that the user didn't get // a chance to manage (they declined to browse). If they did browse, // trust their selections - they could have deselected intentionally. if (!communityResult.didBrowse) { for (const code of installedNonOfficial) { allSelected.add(code); } } return [...allSelected]; } /** * Select official modules using autocompleteMultiselect. * Extracted from the original selectAllModules - unchanged behavior. * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Array} Selected official module codes */ async _selectOfficialModules(installedModuleIds = new Set()) { const externalManager = new ExternalModuleManager(); const registryModules = await externalManager.listAvailable(); const allOptions = []; const initialValues = []; const lockedValues = ['core']; const buildModuleEntry = async (mod) => { const isInstalled = installedModuleIds.has(mod.code); const version = await getMarketplaceVersion(mod.code); const label = version ? `${mod.name} (v${version})` : mod.name; return { label, value: mod.code, hint: mod.description, selected: isInstalled, }; }; for (const mod of registryModules) { const entry = await buildModuleEntry(mod); allOptions.push({ label: entry.label, value: entry.value, hint: entry.hint }); if (entry.selected) { initialValues.push(mod.code); } } const selected = await prompts.autocompleteMultiselect({ message: 'Select official modules to install:', options: allOptions, initialValues: initialValues.length > 0 ? initialValues : undefined, lockedValues, required: true, maxItems: allOptions.length, }); const result = selected ? [...selected] : []; if (result.length > 0) { const moduleLines = result.map((moduleId) => { const opt = allOptions.find((o) => o.value === moduleId); return ` \u2022 ${opt?.label || moduleId}`; }); await prompts.log.message('Selected official modules:\n' + moduleLines.join('\n')); } return result; } /** * Browse and select community modules using category drill-down. * Featured/promoted modules appear at the top. * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Object} { codes: string[], didBrowse: boolean } */ async _browseCommunityModules(installedModuleIds = new Set()) { const browseCommunity = await prompts.confirm({ message: 'Would you like to browse community modules?', default: false, }); if (!browseCommunity) return { codes: [], didBrowse: false }; const { CommunityModuleManager } = require('./modules/community-manager'); const communityMgr = new CommunityModuleManager(); const s = await prompts.spinner(); s.start('Loading community module catalog...'); let categories, featured, allCommunity; try { [categories, featured, allCommunity] = await Promise.all([ communityMgr.getCategoryList(), communityMgr.listFeatured(), communityMgr.listAll(), ]); s.stop(`Community catalog loaded (${allCommunity.length} modules)`); } catch (error) { s.error('Failed to load community catalog'); await prompts.log.warn(` ${error.message}`); return { codes: [], didBrowse: false }; } if (allCommunity.length === 0) { await prompts.log.info('No community modules are currently available.'); return { codes: [], didBrowse: false }; } const selectedCodes = new Set(); let browsing = true; while (browsing) { const categoryChoices = []; // Featured section at top if (featured.length > 0) { categoryChoices.push({ value: '__featured__', label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`, }); } // Categories with module counts for (const cat of categories) { categoryChoices.push({ value: cat.slug, label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`, }); } // Special actions at bottom categoryChoices.push( { value: '__all__', label: '\u25CE View all community modules' }, { value: '__search__', label: '\u25CE Search by keyword' }, { value: '__done__', label: '\u2713 Done browsing' }, ); const selectedCount = selectedCodes.size; const categoryChoice = await prompts.select({ message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`, choices: categoryChoices, }); if (categoryChoice === '__done__') { browsing = false; continue; } let modulesToShow; switch (categoryChoice) { case '__featured__': { modulesToShow = featured; break; } case '__all__': { modulesToShow = allCommunity; break; } case '__search__': { const query = await prompts.text({ message: 'Search community modules:', placeholder: 'e.g., design, testing, game', }); if (!query || query.trim() === '') continue; modulesToShow = await communityMgr.searchByKeyword(query.trim()); if (modulesToShow.length === 0) { await prompts.log.warn('No matching modules found.'); continue; } break; } default: { modulesToShow = await communityMgr.listByCategory(categoryChoice); } } // Build options for autocompleteMultiselect const trustBadge = (tier) => { if (tier === 'bmad-certified') return '\u2713'; if (tier === 'community-reviewed') return '\u25CB'; return '\u26A0'; }; const options = modulesToShow.map((mod) => { const versionStr = mod.version ? ` (v${mod.version})` : ''; const badge = trustBadge(mod.trustTier); return { label: `${mod.displayName}${versionStr} [${badge}]`, value: mod.code, hint: mod.description, }; }); // Pre-check modules that are already selected or installed const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code); const selected = await prompts.autocompleteMultiselect({ message: 'Select community modules:', options, initialValues: initialValues.length > 0 ? initialValues : undefined, required: false, maxItems: Math.min(options.length, 10), }); // Update accumulated selections: sync with what user selected in this view const shownCodes = new Set(modulesToShow.map((m) => m.code)); for (const code of shownCodes) { if (selected && selected.includes(code)) { selectedCodes.add(code); } else { selectedCodes.delete(code); } } } if (selectedCodes.size > 0) { const moduleLines = []; for (const code of selectedCodes) { const mod = await communityMgr.getModuleByCode(code); moduleLines.push(` \u2022 ${mod?.displayName || code}`); } await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n')); } return { codes: [...selectedCodes], didBrowse: true }; } /** * Prompt user to install modules from custom GitHub URLs. * @param {Set} installedModuleIds - Currently installed module IDs * @returns {Array} Selected custom module code strings */ async _addCustomUrlModules(installedModuleIds = new Set()) { const addCustom = await prompts.confirm({ message: 'Would you like to install from a custom GitHub URL?', default: false, }); if (!addCustom) return []; const { CustomModuleManager } = require('./modules/custom-module-manager'); const customMgr = new CustomModuleManager(); const selectedModules = []; let addMore = true; while (addMore) { const url = await prompts.text({ message: 'GitHub repository URL:', placeholder: 'https://github.com/owner/repo', validate: (input) => { if (!input || input.trim() === '') return 'URL is required'; const result = customMgr.validateGitHubUrl(input.trim()); return result.isValid ? undefined : result.error; }, }); const s = await prompts.spinner(); s.start('Fetching module info...'); let plugins; try { plugins = await customMgr.discoverModules(url.trim()); s.stop('Module info loaded'); } catch (error) { s.error('Failed to load module info'); await prompts.log.error(` ${error.message}`); addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); continue; } await prompts.log.warn( 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', ); // Clone the repo so we can resolve plugin structures (skip npm install until user confirms) s.start('Cloning repository...'); let repoPath; try { repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true }); s.stop('Repository cloned'); } catch (cloneError) { s.error('Failed to clone repository'); await prompts.log.error(` ${cloneError.message}`); addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); continue; } // Resolve each plugin to determine installable modules s.start('Analyzing plugin structure...'); const allResolved = []; for (const plugin of plugins) { try { const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim()); if (resolved.length > 0) { allResolved.push(...resolved); } else { // No skills array or empty - use plugin metadata as-is (legacy) allResolved.push({ code: plugin.code, name: plugin.displayName || plugin.name, version: plugin.version, description: plugin.description, strategy: 0, pluginName: plugin.name, skillPaths: [], }); } } catch (resolveError) { await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`); } } s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`); if (allResolved.length === 0) { await prompts.log.warn('No installable modules found in this repository.'); addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); continue; } // Build multiselect choices // Already-installed modules are pre-checked (update). New modules are unchecked (opt-in). // Unchecking an installed module means "skip update" - removal is handled elsewhere. const choices = allResolved.map((mod) => { const versionStr = mod.version ? ` v${mod.version}` : ''; const skillCount = mod.skillPaths ? mod.skillPaths.length : 0; const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : ''; const alreadyInstalled = installedModuleIds.has(mod.code); const hint = alreadyInstalled ? 'update' : undefined; return { name: `${mod.name}${versionStr}${skillStr}`, value: mod.code, hint, checked: alreadyInstalled, }; }); // Show descriptions before the multiselect for (const mod of allResolved) { const versionStr = mod.version ? ` v${mod.version}` : ''; await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`); } const selected = await prompts.multiselect({ message: 'Select modules to install:', choices, required: false, }); if (selected && selected.length > 0) { for (const code of selected) { selectedModules.push(code); } } addMore = await prompts.confirm({ message: 'Add another custom module URL?', default: false, }); } if (selectedModules.length > 0) { await prompts.log.message('Selected custom modules:\n' + selectedModules.map((c) => ` \u2022 ${c}`).join('\n')); } return selectedModules; } /** * Get default modules for non-interactive mode * @param {Set} installedModuleIds - Already installed module IDs * @returns {Array} Default module codes */ async getDefaultModules(installedModuleIds = new Set()) { const externalManager = new ExternalModuleManager(); const registryModules = await externalManager.listAvailable(); const defaultModules = []; for (const mod of registryModules) { if (mod.defaultSelected || installedModuleIds.has(mod.code)) { defaultModules.push(mod.code); } } // If no defaults found, use 'bmm' as the fallback default if (defaultModules.length === 0) { defaultModules.push('bmm'); } return defaultModules; } /** * Prompt for directory selection * @returns {Object} Directory answer from prompt */ async promptForDirectory() { // Use sync validation because @clack/prompts doesn't support async validate const directory = await prompts.text({ message: 'Installation directory:', default: process.cwd(), placeholder: process.cwd(), validate: (input) => this.validateDirectorySync(input), }); // Apply filter logic let filteredDir = directory; if (!filteredDir || filteredDir.trim() === '') { filteredDir = process.cwd(); } else { filteredDir = this.expandUserPath(filteredDir); } return { directory: filteredDir }; } /** * Display directory information * @param {string} directory - The directory path */ async displayDirectoryInfo(directory) { await prompts.log.info(`Resolved installation path: ${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('./core/installer'); const installer = new Installer(); const bmadResult = await installer.findBmadDir(directory); const hasBmadInstall = (await fs.pathExists(bmadResult.bmadDir)) && (await fs.pathExists(path.join(bmadResult.bmadDir, '_config', 'manifest.yaml'))); const bmadNote = hasBmadInstall ? ` including existing BMAD installation (${path.basename(bmadResult.bmadDir)})` : ''; await prompts.log.message(`Directory exists and contains ${files.length} item(s)${bmadNote}`); } else { await prompts.log.message('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 proceed = await prompts.confirm({ message: 'Install to this directory?', default: true, }); if (!proceed) { await prompts.log.warn("Let's try again with a different path."); } return proceed; } else { // Ask for confirmation to create the directory const create = await prompts.confirm({ message: `Create directory: ${directory}?`, default: false, }); if (!create) { await prompts.log.warn("Let's try again with a different path."); } return create; } } /** * Validate directory path for installation (sync version for clack prompts) * @param {string} input - User input path * @returns {string|undefined} Error message or undefined if valid */ validateDirectorySync(input) { // Allow empty input to use the default if (!input || input.trim() === '') { return; // Empty means use default, undefined = valid for clack } let expandedPath; try { expandedPath = this.expandUserPath(input.trim()); } catch (error) { return error.message; } // Check if the path exists const pathExists = fs.pathExistsSync(expandedPath); if (!pathExists) { // Find the first existing parent directory const existingParent = this.findExistingParentSync(expandedPath); if (!existingParent) { return 'Cannot create directory: no existing parent directory found'; } // Check if the existing parent is writable try { fs.accessSync(existingParent, fs.constants.W_OK); // Path doesn't exist but can be created - will prompt for confirmation later return; } 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 = fs.statSync(expandedPath); if (!stat.isDirectory()) { return `Path exists but is not a directory: ${expandedPath}`; } // Check write permissions try { fs.accessSync(expandedPath, fs.constants.W_OK); } catch { return `Directory is not writable: ${expandedPath}`; } return; } /** * Validate directory path for installation (async version) * @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 (sync version) * @param {string} targetPath - The path to check * @returns {string|null} The first existing parent directory, or null if none found */ findExistingParentSync(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 (fs.pathExistsSync(parent)) { return parent; } currentPath = parent; } return null; // No existing parent found (shouldn't happen in practice) } /** * Find the first existing parent directory (async version) * @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); } /** * Get configured IDEs from existing installation * @param {string} directory - Installation directory * @returns {Array} List of configured IDEs */ async getConfiguredIdes(directory) { const { ExistingInstall } = require('./core/existing-install'); const { Installer } = require('./core/installer'); const installer = new Installer(); const { bmadDir } = await installer.findBmadDir(directory); const existingInstall = await ExistingInstall.detect(bmadDir); return existingInstall.ides; } /** * Display module versions with update availability * @param {Array} modules - Array of module info objects with version info * @param {Array} availableUpdates - Array of available updates */ async displayModuleVersions(modules, availableUpdates = []) { // Group modules by source const builtIn = modules.filter((m) => m.source === 'built-in'); const external = modules.filter((m) => m.source === 'external'); const community = modules.filter((m) => m.source === 'community'); const custom = modules.filter((m) => m.source === 'custom'); const unknown = modules.filter((m) => m.source === 'unknown'); const lines = []; const formatGroup = (group, title) => { if (group.length === 0) return; lines.push(title); for (const mod of group) { const updateInfo = availableUpdates.find((u) => u.name === mod.name); const versionDisplay = mod.version || 'unknown'; if (updateInfo) { lines.push(` ${mod.name.padEnd(20)} ${versionDisplay} \u2192 ${updateInfo.latestVersion} \u2191`); } else { lines.push(` ${mod.name.padEnd(20)} ${versionDisplay} \u2713`); } } }; formatGroup(builtIn, 'Built-in Modules'); formatGroup(external, 'External Modules (Official)'); formatGroup(community, 'Community Modules'); formatGroup(custom, 'Custom Modules'); formatGroup(unknown, 'Other Modules'); await prompts.note(lines.join('\n'), 'Module Versions'); } /** * Prompt user to select which modules to update * @param {Array} availableUpdates - Array of available updates * @returns {Array} Selected module names to update */ async promptUpdateSelection(availableUpdates) { if (availableUpdates.length === 0) { return []; } await prompts.log.info('Available Updates'); const choices = availableUpdates.map((update) => ({ name: `${update.name} (v${update.installedVersion} \u2192 v${update.latestVersion})`, value: update.name, checked: true, // Default to selecting all updates })); // Add "Update All" and "Cancel" options const action = await prompts.select({ message: 'How would you like to proceed?', choices: [ { name: 'Update all available modules', value: 'all' }, { name: 'Select specific modules to update', value: 'select' }, { name: 'Skip updates for now', value: 'skip' }, ], default: 'all', }); if (action === 'all') { return availableUpdates.map((u) => u.name); } if (action === 'skip') { return []; } // Allow specific selection const selected = await prompts.multiselect({ message: 'Select modules to update (use arrow keys, space to toggle):', choices: choices, required: true, }); return selected || []; } /** * Display status of all installed modules * @param {Object} statusData - Status data with modules, installation info, and available updates */ async displayStatus(statusData) { const { installation, modules, availableUpdates, bmadDir } = statusData; // Installation info const infoLines = [ `Version: ${installation.version || 'unknown'}`, `Location: ${bmadDir}`, `Installed: ${new Date(installation.installDate).toLocaleDateString()}`, `Last Updated: ${installation.lastUpdated ? new Date(installation.lastUpdated).toLocaleDateString() : 'unknown'}`, ]; await prompts.note(infoLines.join('\n'), 'BMAD Status'); // Module versions await this.displayModuleVersions(modules, availableUpdates); // Update summary if (availableUpdates.length > 0) { await prompts.log.warn(`${availableUpdates.length} update(s) available`); await prompts.log.message('Run \'bmad install\' and select "Quick Update" to update'); } else { await prompts.log.success('All modules are up to date'); } } /** * Display list of selected tools after IDE selection * @param {Array} selectedIdes - Array of selected IDE values * @param {Array} preferredIdes - Array of preferred IDE objects * @param {Array} allTools - Array of all tool objects */ async displaySelectedTools(selectedIdes, preferredIdes, allTools) { if (selectedIdes.length === 0) return; const preferredValues = new Set(preferredIdes.map((ide) => ide.value)); const toolLines = selectedIdes.map((ideValue) => { const tool = allTools.find((t) => t.value === ideValue); const name = tool?.name || ideValue; const marker = preferredValues.has(ideValue) ? ' \u2B50' : ''; return ` \u2022 ${name}${marker}`; }); await prompts.log.message('Selected tools:\n' + toolLines.join('\n')); } } module.exports = { UI };