diff --git a/.claude/skills/changelog-social.skill b/.claude/skills/changelog-social.skill deleted file mode 100644 index 8ef04097d..000000000 Binary files a/.claude/skills/changelog-social.skill and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7911a7d9e..0574f9363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [6.0.0-Beta.8] + +**Release: February 8, 2026** + +### ๐ŸŒŸ Key Highlights + +1. **Non-Interactive Installation** โ€” Full CI/CD support with 10 new CLI flags for automated deployments +2. **Complete @clack/prompts Migration** โ€” Unified CLI experience with consolidated installer output +3. **CSV File Reference Validation** โ€” Extended Layer 1 validator to catch broken workflow references in CSV files +4. **Kiro IDE Support** โ€” Standardized config-driven installation, replacing custom installer + +### ๐ŸŽ Features + +* **Non-Interactive Installation** โ€” Added `--directory`, `--modules`, `--tools`, `--custom-content`, `--user-name`, `--communication-language`, `--document-output-language`, `--output-folder`, and `-y/--yes` flags for CI/CD automation (#1520) +* **CSV File Reference Validation** โ€” Extended validator to scan `.csv` files for broken workflow references, checking 501 references across 212 files (#1573) +* **Kiro IDE Support** โ€” Replaced broken custom installer with config-driven templates using `#[[file:...]]` syntax and `inclusion: manual` frontmatter (#1589) +* **OpenCode Template Consolidation** โ€” Combined split templates with `mode: primary` frontmatter for Tab-switching support, fixing agent discovery (#1556) +* **Modules Reference Page** โ€” Added official external modules reference documentation (#1540) + +### ๐Ÿ› Bug Fixes + +* **Installer Streamlining** โ€” Removed "None - Skip module installation" option, eliminated ~100 lines of dead code, and added ESM/.cjs support for module installers (#1590) +* **CodeRabbit Workflow** โ€” Changed `pull_request` to `pull_request_target` to fix 403 errors and enable reviews on fork PRs (#1583) +* **Party Mode Return Protocol** โ€” Added RETURN PROTOCOL to prevent lost-in-the-middle failures after Party Mode completes (#1569) +* **Spacebar Toggle** โ€” Fixed SPACE key not working in autocomplete multiselect prompts for tool/IDE selection (#1557) +* **OpenCode Agent Routing** โ€” Fixed agents installing to wrong directory by adding `targets` array for routing `.opencode/agent/` vs `.opencode/command/` (#1549) +* **Technical Research Workflow** โ€” Fixed step-05 routing to step-06 and corrected `stepsCompleted` values (#1547) +* **Forbidden Variable Removal** โ€” Removed `workflow_path` variable from 16 workflow step files (#1546) +* **Kilo Installer** โ€” Fixed YAML formatting issues by trimming activation header and converting to yaml.parse/stringify (#1537) +* **bmad-help** โ€” Now reads project-specific docs and respects `communication_language` setting (#1535) +* **Cache Errors** โ€” Removed `--prefer-offline` npm flag to prevent stale cache errors during installation (#1531) + +### โ™ป๏ธ Refactoring + +* **Complete @clack/prompts Migration** โ€” Migrated 24 files from legacy libraries (ora, chalk, boxen, figlet, etc.), replaced ~100 console.log+chalk calls, consolidated installer output to single spinner, and removed 5 dependencies (#1586) +* **Downloads Page Removal** โ€” Removed downloads page, bundle generation, and archiver dependency in favor of GitHub's native archives (#1577) +* **Workflow Verb Standardization** โ€” Replaced "invoke/run" with "load and follow/load" in review workflow prompts (#1570) +* **Documentation Language** โ€” Renamed "brownfield" to "established projects" and flattened directory structure for accessibility (#1539) + +### ๐Ÿ“š Documentation + +* **Comprehensive Site Review** โ€” Fixed broken directory tree diagram, corrected grammar/capitalization, added SEO descriptions, and reordered how-to guides (#1578) +* **SEO Metadata** โ€” Added description front matter to 9 documentation pages for search engine optimization (#1566) +* **PR Template** โ€” Added pull request template for consistent PR descriptions (#1554) +* **Manual Release Cleanup** โ€” Removed broken manual-release workflow and related scripts (#1576) + +### ๐Ÿ”ง Maintenance + +* **Dual-Mode AI Code Review** โ€” Configured Augment Code (audit mode) and CodeRabbit (adversarial mode) for improved code quality (#1511) +* **Package-Lock Sync** โ€” Cleaned up 471 lines of orphaned dependencies after archiver removal (#1580) + +--- + ## [6.0.0-Beta.7] **Release: February 4, 2026** diff --git a/eslint.config.mjs b/eslint.config.mjs index d6c20f329..23bf73aa5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -114,17 +114,6 @@ export default [ }, }, - // Module installer scripts use CommonJS for compatibility - { - files: ['**/_module-installer/**/*.js'], - rules: { - // Allow CommonJS patterns for installer scripts - 'unicorn/prefer-module': 'off', - 'n/no-missing-require': 'off', - 'n/no-unpublished-require': 'off', - }, - }, - // ESLint config file should not be checked for publish-related Node rules { files: ['eslint.config.mjs'], diff --git a/package-lock.json b/package-lock.json index 9f0ce7e21..da039ecc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bmad-method", - "version": "6.0.0-Beta.7", + "version": "6.0.0-Beta.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bmad-method", - "version": "6.0.0-Beta.7", + "version": "6.0.0-Beta.8", "license": "MIT", "dependencies": { "@clack/core": "^1.0.0", diff --git a/package.json b/package.json index 404548897..9cd7e90ad 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "bmad-method", - "version": "6.0.0-Beta.7", + "version": "6.0.0-Beta.8", "description": "Breakthrough Method of Agile AI-driven Development", "keywords": [ "agile", diff --git a/src/bmm/_module-installer/installer.js b/src/bmm/_module-installer/installer.js deleted file mode 100644 index 7b844a15d..000000000 --- a/src/bmm/_module-installer/installer.js +++ /dev/null @@ -1,48 +0,0 @@ -const fs = require('fs-extra'); -const path = require('node:path'); -const chalk = require('chalk'); - -// Directories to create from config -const DIRECTORIES = ['output_folder', 'planning_artifacts', 'implementation_artifacts']; - -/** - * BMM Module Installer - * Creates output directories configured in module config - * - * @param {Object} options - Installation options - * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from module.yaml - * @param {Array} options.installedIDEs - Array of IDE codes that were installed - * @param {Object} options.logger - Logger instance for output - * @returns {Promise} - Success status - */ -async function install(options) { - const { projectRoot, config, logger } = options; - - try { - logger.log(chalk.blue('๐Ÿš€ Installing BMM Module...')); - - // Create configured directories - for (const configKey of DIRECTORIES) { - const configValue = config[configKey]; - if (!configValue) continue; - - const dirPath = configValue.replace('{project-root}/', ''); - const fullPath = path.join(projectRoot, dirPath); - - if (!(await fs.pathExists(fullPath))) { - const dirName = configKey.replace('_', ' '); - logger.log(chalk.yellow(`Creating ${dirName} directory: ${dirPath}`)); - await fs.ensureDir(fullPath); - } - } - - logger.log(chalk.green('โœ“ BMM Module installation complete')); - return true; - } catch (error) { - logger.error(chalk.red(`Error installing BMM module: ${error.message}`)); - return false; - } -} - -module.exports = { install }; diff --git a/src/bmm/agents/analyst.agent.yaml b/src/bmm/agents/analyst.agent.yaml index c340f69c1..28120d098 100644 --- a/src/bmm/agents/analyst.agent.yaml +++ b/src/bmm/agents/analyst.agent.yaml @@ -5,6 +5,7 @@ agent: title: Business Analyst icon: ๐Ÿ“Š module: bmm + capabilities: "market research, competitive analysis, requirements elicitation, domain expertise" hasSidecar: false persona: diff --git a/src/bmm/agents/architect.agent.yaml b/src/bmm/agents/architect.agent.yaml index 5ebfd90fb..d9fc48b9b 100644 --- a/src/bmm/agents/architect.agent.yaml +++ b/src/bmm/agents/architect.agent.yaml @@ -7,6 +7,7 @@ agent: title: Architect icon: ๐Ÿ—๏ธ module: bmm + capabilities: "distributed systems, cloud infrastructure, API design, scalable patterns" hasSidecar: false persona: diff --git a/src/bmm/agents/dev.agent.yaml b/src/bmm/agents/dev.agent.yaml index d88166eda..c707124d0 100644 --- a/src/bmm/agents/dev.agent.yaml +++ b/src/bmm/agents/dev.agent.yaml @@ -7,6 +7,7 @@ agent: title: Developer Agent icon: ๐Ÿ’ป module: bmm + capabilities: "story execution, test-driven development, code implementation" hasSidecar: false persona: diff --git a/src/bmm/agents/pm.agent.yaml b/src/bmm/agents/pm.agent.yaml index 9ce0bf32f..30377a682 100644 --- a/src/bmm/agents/pm.agent.yaml +++ b/src/bmm/agents/pm.agent.yaml @@ -5,6 +5,7 @@ agent: title: Product Manager icon: ๐Ÿ“‹ module: bmm + capabilities: "PRD creation, requirements discovery, stakeholder alignment, user interviews" hasSidecar: false persona: diff --git a/src/bmm/agents/qa.agent.yaml b/src/bmm/agents/qa.agent.yaml index 07ca4022f..9265f5a7b 100644 --- a/src/bmm/agents/qa.agent.yaml +++ b/src/bmm/agents/qa.agent.yaml @@ -5,6 +5,7 @@ agent: title: QA Engineer icon: ๐Ÿงช module: bmm + capabilities: "test automation, API testing, E2E testing, coverage analysis" hasSidecar: false persona: diff --git a/src/bmm/agents/quick-flow-solo-dev.agent.yaml b/src/bmm/agents/quick-flow-solo-dev.agent.yaml index 6d207a399..fff3052d4 100644 --- a/src/bmm/agents/quick-flow-solo-dev.agent.yaml +++ b/src/bmm/agents/quick-flow-solo-dev.agent.yaml @@ -7,6 +7,7 @@ agent: title: Quick Flow Solo Dev icon: ๐Ÿš€ module: bmm + capabilities: "rapid spec creation, lean implementation, minimum ceremony" hasSidecar: false persona: diff --git a/src/bmm/agents/sm.agent.yaml b/src/bmm/agents/sm.agent.yaml index 9e51f4259..d79f644e5 100644 --- a/src/bmm/agents/sm.agent.yaml +++ b/src/bmm/agents/sm.agent.yaml @@ -7,6 +7,7 @@ agent: title: Scrum Master icon: ๐Ÿƒ module: bmm + capabilities: "sprint planning, story preparation, agile ceremonies, backlog management" hasSidecar: false persona: diff --git a/src/bmm/agents/tech-writer/tech-writer.agent.yaml b/src/bmm/agents/tech-writer/tech-writer.agent.yaml index a742a6c9f..a129aca34 100644 --- a/src/bmm/agents/tech-writer/tech-writer.agent.yaml +++ b/src/bmm/agents/tech-writer/tech-writer.agent.yaml @@ -7,6 +7,7 @@ agent: title: Technical Writer icon: ๐Ÿ“š module: bmm + capabilities: "documentation, Mermaid diagrams, standards compliance, concept explanation" hasSidecar: true persona: diff --git a/src/bmm/agents/ux-designer.agent.yaml b/src/bmm/agents/ux-designer.agent.yaml index 639a8263f..cbff28576 100644 --- a/src/bmm/agents/ux-designer.agent.yaml +++ b/src/bmm/agents/ux-designer.agent.yaml @@ -7,6 +7,7 @@ agent: title: UX Designer icon: ๐ŸŽจ module: bmm + capabilities: "user research, interaction design, UI patterns, experience strategy" hasSidecar: false persona: diff --git a/src/bmm/module.yaml b/src/bmm/module.yaml index a9884e586..76f6b7433 100644 --- a/src/bmm/module.yaml +++ b/src/bmm/module.yaml @@ -42,3 +42,9 @@ project_knowledge: # Artifacts from research, document-project output, other lon prompt: "Where should long-term project knowledge be stored? (docs, research, references)" default: "docs" result: "{project-root}/{value}" + +# Directories to create during installation (declarative, no code execution) +directories: + - "{planning_artifacts}" + - "{implementation_artifacts}" + - "{project_knowledge}" diff --git a/src/core/_module-installer/installer.js b/src/core/_module-installer/installer.js deleted file mode 100644 index d77bc62fa..000000000 --- a/src/core/_module-installer/installer.js +++ /dev/null @@ -1,60 +0,0 @@ -const chalk = require('chalk'); - -/** - * Core Module Installer - * Standard module installer function that executes after IDE installations - * - * @param {Object} options - Installation options - * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from module.yaml - * @param {Array} options.installedIDEs - Array of IDE codes that were installed - * @param {Object} options.logger - Logger instance for output - * @returns {Promise} - Success status - */ -async function install(options) { - const { projectRoot, config, installedIDEs, logger } = options; - - try { - logger.log(chalk.blue('๐Ÿ—๏ธ Installing Core Module...')); - - // Core agent configs are created by the main installer's createAgentConfigs method - // No need to create them here - they'll be handled along with all other agents - - // Handle IDE-specific configurations if needed - if (installedIDEs && installedIDEs.length > 0) { - logger.log(chalk.cyan(`Configuring Core for IDEs: ${installedIDEs.join(', ')}`)); - - // Add any IDE-specific Core configurations here - for (const ide of installedIDEs) { - await configureForIDE(ide, projectRoot, config, logger); - } - } - - logger.log(chalk.green('โœ“ Core Module installation complete')); - return true; - } catch (error) { - logger.error(chalk.red(`Error installing Core module: ${error.message}`)); - return false; - } -} - -/** - * Configure Core module for specific IDE - * @private - */ -async function configureForIDE(ide) { - // Add IDE-specific configurations here - switch (ide) { - case 'claude-code': { - // Claude Code specific Core configurations - break; - } - // Add more IDEs as needed - default: { - // No specific configuration needed - break; - } - } -} - -module.exports = { install }; diff --git a/src/core/agents/bmad-master.agent.yaml b/src/core/agents/bmad-master.agent.yaml index 66e9d37fc..a7dbc7105 100644 --- a/src/core/agents/bmad-master.agent.yaml +++ b/src/core/agents/bmad-master.agent.yaml @@ -7,6 +7,7 @@ agent: name: "BMad Master" title: "BMad Master Executor, Knowledge Custodian, and Workflow Orchestrator" icon: "๐Ÿง™" + capabilities: "runtime resource management, workflow orchestration, task execution, knowledge custodian" hasSidecar: false persona: diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index 961a1a9fa..d9d8332be 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -39,7 +39,6 @@ module.exports = { if (config.actionType === 'cancel') { await prompts.log.warn('Installation cancelled.'); process.exit(0); - return; } // Handle quick update separately @@ -47,23 +46,14 @@ module.exports = { const result = await installer.quickUpdate(config); await prompts.log.success('Quick update complete!'); await prompts.log.info(`Updated ${result.moduleCount} modules with preserved settings (${result.modules.join(', ')})`); - - // Display version-specific end message - const { MessageLoader } = require('../installers/lib/message-loader'); - const messageLoader = new MessageLoader(); - await messageLoader.displayEndMessage(); - process.exit(0); - return; } // Handle compile agents separately if (config.actionType === 'compile-agents') { const result = await installer.compileAgents(config); - await prompts.log.success('Agent recompilation complete!'); await prompts.log.info(`Recompiled ${result.agentCount} agents with customizations applied`); process.exit(0); - return; } // Regular install/update flow @@ -72,16 +62,10 @@ module.exports = { // Check if installation was cancelled if (result && result.cancelled) { process.exit(0); - return; } // Check if installation succeeded if (result && result.success) { - // Display version-specific end message from install-messages.yaml - const { MessageLoader } = require('../installers/lib/message-loader'); - const messageLoader = new MessageLoader(); - await messageLoader.displayEndMessage(); - process.exit(0); } } catch (error) { diff --git a/tools/cli/external-official-modules.yaml b/tools/cli/external-official-modules.yaml index 431ded4a3..d6ae06ee6 100644 --- a/tools/cli/external-official-modules.yaml +++ b/tools/cli/external-official-modules.yaml @@ -42,13 +42,12 @@ modules: type: bmad-org npmPackage: bmad-method-test-architecture-enterprise -# TODO: Enable once fixes applied: - -# whiteport-design-system: -# url: https://github.com/bmad-code-org/bmad-method-wds-expansion -# module-definition: src/module.yaml -# code: WDS -# name: "Whiteport UX Design System" -# description: "UX design framework with Figma integration" -# defaultSelected: false -# type: community + # whiteport-design-system: + # url: https://github.com/bmad-code-org/bmad-method-wds-expansion + # module-definition: src/module.yaml + # code: wds + # name: "Whiteport UX Design System" + # description: "UX design framework with Figma integration" + # defaultSelected: false + # type: community + # npmPackage: bmad-method-wds-expansion diff --git a/tools/cli/installers/install-messages.yaml b/tools/cli/installers/install-messages.yaml index d9f40b0d7..66e683a27 100644 --- a/tools/cli/installers/install-messages.yaml +++ b/tools/cli/installers/install-messages.yaml @@ -10,32 +10,14 @@ startMessage: | We've officially graduated from Alpha! This milestone represents: - 50+ workflows covering the full development lifecycle - - Stability - we will still be adding and evolving and optimizing, + - Stability - we will still be adding and evolving and optimizing, but anticipate no massive breaking changes - Groundwork in place for customization and community modules - ๐Ÿ“š New Docs Site: http://docs.bmad-method.org/ - - High quality tutorials, guided walkthrough, and articles coming soon! - - Everything is free. No paywalls. No gated content. - - Knowledge should be shared, not sold. - - ๐Ÿ’ก Love BMad? Please star us on GitHub & subscribe on YouTube! - - GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - - YouTube: https://www.youtube.com/@BMadCode - - Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md - - โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” - -# Display at the END of installation (after all setup completes) -endMessage: | - โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - - โœจ BMAD V6 BETA IS INSTALLED! Thank you for being part of this journey! - ๐ŸŒŸ BMad is 100% free and open source. - - No gated Discord. No paywalls. + - No gated Discord. No paywalls. No gated content. - We believe in empowering everyone, not just those who can pay. + - Knowledge should be shared, not sold. ๐Ÿ™ SUPPORT BMAD DEVELOPMENT: - During the Beta, please give us feedback and raise issues on GitHub! @@ -47,13 +29,14 @@ endMessage: | - Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method - For speaking inquiries or interviews, reach out to BMad on Discord! - ๐Ÿ“š RESOURCES: - - Docs: http://docs.bmad-method.org/ (bookmark it!) - - Changelog: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md - - โญโญโญ HELP US GROW: + โญ HELP US GROW: - Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - Subscribe on YouTube: https://www.youtube.com/@BMadCode - Every star & sub helps us reach more developers! - โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/CHANGELOG.md + + โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +# No end message - install summary and next steps are rendered by the installer +endMessage: "" diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index 1a0f50d29..44d3805d7 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -10,6 +10,19 @@ class ConfigCollector { this.collectedConfig = {}; this.existingConfig = null; this.currentProjectDir = null; + this._moduleManagerInstance = null; + } + + /** + * Get or create a cached ModuleManager instance (lazy initialization) + * @returns {Object} ModuleManager instance + */ + _getModuleManager() { + if (!this._moduleManagerInstance) { + const { ModuleManager } = require('../modules/manager'); + this._moduleManagerInstance = new ModuleManager(); + } + return this._moduleManagerInstance; } /** @@ -129,6 +142,70 @@ class ConfigCollector { return foundAny; } + /** + * Pre-scan module schemas to gather metadata for the configuration gateway prompt. + * Returns info about which modules have configurable options. + * @param {Array} modules - List of non-core module names + * @returns {Promise} Array of {moduleName, displayName, questionCount, hasFieldsWithoutDefaults} + */ + async scanModuleSchemas(modules) { + const metadataFields = new Set(['code', 'name', 'header', 'subheader', 'default_selected']); + const results = []; + + for (const moduleName of modules) { + // Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search + let moduleConfigPath = null; + const customPath = this.customModulePaths?.get(moduleName); + if (customPath) { + moduleConfigPath = path.join(customPath, 'module.yaml'); + } else { + const standardPath = path.join(getModulePath(moduleName), 'module.yaml'); + if (await fs.pathExists(standardPath)) { + moduleConfigPath = standardPath; + } else { + const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); + if (moduleSourcePath) { + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); + } + } + } + + if (!moduleConfigPath || !(await fs.pathExists(moduleConfigPath))) { + continue; + } + + try { + const content = await fs.readFile(moduleConfigPath, 'utf8'); + const moduleConfig = yaml.parse(content); + if (!moduleConfig) continue; + + const displayName = moduleConfig.header || `${moduleName.toUpperCase()} Module`; + const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); + const questionKeys = configKeys.filter((key) => { + if (metadataFields.has(key)) return false; + const item = moduleConfig[key]; + return item && typeof item === 'object' && item.prompt; + }); + + const hasFieldsWithoutDefaults = questionKeys.some((key) => { + const item = moduleConfig[key]; + return item.default === undefined || item.default === null || item.default === ''; + }); + + results.push({ + moduleName, + displayName, + questionCount: questionKeys.length, + hasFieldsWithoutDefaults, + }); + } catch (error) { + await prompts.log.warn(`Could not read schema for module "${moduleName}": ${error.message}`); + } + } + + return results; + } + /** * Collect configuration for all modules * @param {Array} modules - List of modules to configure (including 'core') @@ -141,6 +218,7 @@ class ConfigCollector { // Store custom module paths for use in collectModuleConfig this.customModulePaths = options.customModulePaths || new Map(); this.skipPrompts = options.skipPrompts || false; + this.modulesToCustomize = undefined; await this.loadExistingConfig(projectDir); // Check if core was already collected (e.g., in early collection phase) @@ -154,10 +232,95 @@ class ConfigCollector { this.allAnswers = {}; } - for (const moduleName of allModules) { + // Split processing: core first, then gateway, then remaining modules + const coreModules = allModules.filter((m) => m === 'core'); + const nonCoreModules = allModules.filter((m) => m !== 'core'); + + // Collect core config first (always fully prompted) + for (const moduleName of coreModules) { await this.collectModuleConfig(moduleName, projectDir); } + // Show batch configuration gateway for non-core modules + // Scan all non-core module schemas for display names and config metadata + let scannedModules = []; + if (!this.skipPrompts && nonCoreModules.length > 0) { + scannedModules = await this.scanModuleSchemas(nonCoreModules); + const customizableModules = scannedModules.filter((m) => m.questionCount > 0); + + if (customizableModules.length > 0) { + const configMode = await prompts.select({ + message: 'Module configuration', + choices: [ + { name: 'Express Setup', value: 'express', hint: 'accept all defaults (recommended)' }, + { name: 'Customize', value: 'customize', hint: 'choose modules to configure' }, + ], + default: 'express', + }); + + if (configMode === 'customize') { + const choices = customizableModules.map((m) => ({ + name: `${m.displayName} (${m.questionCount} option${m.questionCount === 1 ? '' : 's'})`, + value: m.moduleName, + hint: m.hasFieldsWithoutDefaults ? 'has fields without defaults' : undefined, + checked: m.hasFieldsWithoutDefaults, + })); + const selected = await prompts.multiselect({ + message: 'Select modules to customize:', + choices, + required: false, + }); + this.modulesToCustomize = new Set(selected); + } else { + // Express mode: no modules to customize + this.modulesToCustomize = new Set(); + } + } else { + // All non-core modules have zero config - no gateway needed + this.modulesToCustomize = new Set(); + } + } + + // Collect remaining non-core modules + if (this.modulesToCustomize === undefined) { + // No gateway was shown (skipPrompts, no non-core modules, or direct call) - process all normally + for (const moduleName of nonCoreModules) { + await this.collectModuleConfig(moduleName, projectDir); + } + } else { + // Split into default modules (tasks progress) and customized modules (interactive) + const defaultModules = nonCoreModules.filter((m) => !this.modulesToCustomize.has(m)); + const customizeModules = nonCoreModules.filter((m) => this.modulesToCustomize.has(m)); + + // Run default modules with a single spinner + if (defaultModules.length > 0) { + // Build display name map from all scanned modules for pre-call spinner messages + const displayNameMap = new Map(); + for (const m of scannedModules) { + displayNameMap.set(m.moduleName, m.displayName); + } + + const configSpinner = await prompts.spinner(); + configSpinner.start('Configuring modules...'); + for (const moduleName of defaultModules) { + const displayName = displayNameMap.get(moduleName) || moduleName.toUpperCase(); + configSpinner.message(`Configuring ${displayName}...`); + try { + this._silentConfig = true; + await this.collectModuleConfig(moduleName, projectDir); + } finally { + this._silentConfig = false; + } + } + configSpinner.stop('Module configuration complete'); + } + + // Run customized modules individually (may show interactive prompts) + for (const moduleName of customizeModules) { + await this.collectModuleConfig(moduleName, projectDir); + } + } + // Add metadata this.collectedConfig._meta = { version: require(path.join(getProjectRoot(), 'package.json')).version, @@ -188,20 +351,15 @@ class ConfigCollector { this.allAnswers = {}; } - // Load module's install config schema + // Load module's config schema from module.yaml // First, try the standard src/modules location - let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml'); let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); // If not found in src/modules, we need to find it by searching the project - if (!(await fs.pathExists(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { - // Use the module manager to find the module source - const { ModuleManager } = require('../modules/manager'); - const moduleManager = new ModuleManager(); - const moduleSourcePath = await moduleManager.findModuleSource(moduleName); + if (!(await fs.pathExists(moduleConfigPath))) { + const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } @@ -211,19 +369,14 @@ class ConfigCollector { if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; } else { // Check if this is a custom module with custom.yaml - const { ModuleManager } = require('../modules/manager'); - const moduleManager = new ModuleManager(); - const moduleSourcePath = await moduleManager.findModuleSource(moduleName); + const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); - const moduleInstallerCustomPath = path.join(moduleSourcePath, '_module-installer', 'custom.yaml'); - if ((await fs.pathExists(rootCustomConfigPath)) || (await fs.pathExists(moduleInstallerCustomPath))) { + if (await fs.pathExists(rootCustomConfigPath)) { isCustomModule = true; // For custom modules, we don't have an install-config schema, so just use existing values // The custom.yaml values will be loaded and merged during installation @@ -500,28 +653,21 @@ class ConfigCollector { } // Load module's config // First, check if we have a custom module path for this module - let installerConfigPath = null; let moduleConfigPath = null; if (this.customModulePaths && this.customModulePaths.has(moduleName)) { const customPath = this.customModulePaths.get(moduleName); - installerConfigPath = path.join(customPath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(customPath, 'module.yaml'); } else { // Try the standard src/modules location - installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'module.yaml'); moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); } // If not found in src/modules or custom paths, search the project - if (!(await fs.pathExists(installerConfigPath)) && !(await fs.pathExists(moduleConfigPath))) { - // Use the module manager to find the module source - const { ModuleManager } = require('../modules/manager'); - const moduleManager = new ModuleManager(); - const moduleSourcePath = await moduleManager.findModuleSource(moduleName); + if (!(await fs.pathExists(moduleConfigPath))) { + const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true }); if (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } @@ -529,8 +675,6 @@ class ConfigCollector { let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; } else { // No config for this module return; @@ -590,12 +734,12 @@ class ConfigCollector { } } } else { - await prompts.log.step(moduleDisplayName); - let customize = true; + if (!this._silentConfig) await prompts.log.step(`Configuring ${moduleDisplayName}`); + let useDefaults = true; if (moduleName === 'core') { - // Core module: no confirm prompt, continues directly - } else { - // Non-core modules: show "Accept Defaults?" confirm prompt (clack adds spacing) + useDefaults = false; // Core: always show all questions + } else if (this.modulesToCustomize === undefined) { + // Fallback: original per-module confirm (backward compat for direct calls) const customizeAnswer = await prompts.prompt([ { type: 'confirm', @@ -604,10 +748,13 @@ class ConfigCollector { default: true, }, ]); - customize = customizeAnswer.customize; + useDefaults = customizeAnswer.customize; + } else { + // Batch mode: use defaults unless module was selected for customization + useDefaults = !this.modulesToCustomize.has(moduleName); } - if (customize && moduleName !== 'core') { + if (useDefaults && moduleName !== 'core') { // Accept defaults - only ask questions that have NO default value const questionsWithoutDefaults = questions.filter((q) => q.default === undefined || q.default === null || q.default === ''); @@ -737,16 +884,18 @@ class ConfigCollector { const actualConfigKeys = configKeys.filter((key) => !metadataFields.has(key)); const hasNoConfig = actualConfigKeys.length === 0; - if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) { - await prompts.log.step(moduleDisplayName); - if (moduleConfig.subheader) { - await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); + if (!this._silentConfig) { + if (hasNoConfig && (moduleConfig.subheader || moduleConfig.header)) { + await prompts.log.step(moduleDisplayName); + if (moduleConfig.subheader) { + await prompts.log.message(` \u2713 ${moduleConfig.subheader}`); + } else { + await prompts.log.message(` \u2713 No custom configuration required`); + } } else { - await prompts.log.message(` \u2713 No custom configuration required`); + // Module has config but just no questions to ask + await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`); } - } else { - // Module has config but just no questions to ask - await prompts.log.message(` \u2713 ${moduleName.toUpperCase()} module configured`); } } diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/cli/installers/lib/core/custom-module-cache.js index 4486e5fe5..b1cc3d0f7 100644 --- a/tools/cli/installers/lib/core/custom-module-cache.js +++ b/tools/cli/installers/lib/core/custom-module-cache.js @@ -7,6 +7,7 @@ const fs = require('fs-extra'); const path = require('node:path'); const crypto = require('node:crypto'); +const prompts = require('../../../lib/prompts'); class CustomModuleCache { constructor(bmadDir) { @@ -195,7 +196,7 @@ class CustomModuleCache { // Verify cache integrity const currentCacheHash = await this.calculateHash(cacheDir); if (currentCacheHash !== cached.cacheHash) { - console.warn(`Warning: Cache integrity check failed for ${moduleId}`); + await prompts.log.warn(`Cache integrity check failed for ${moduleId}`); } return { diff --git a/tools/cli/installers/lib/core/ide-config-manager.js b/tools/cli/installers/lib/core/ide-config-manager.js index f871e4b5d..c00c00d48 100644 --- a/tools/cli/installers/lib/core/ide-config-manager.js +++ b/tools/cli/installers/lib/core/ide-config-manager.js @@ -1,6 +1,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const yaml = require('yaml'); +const prompts = require('../../../lib/prompts'); /** * Manages IDE configuration persistence @@ -93,7 +94,7 @@ class IdeConfigManager { const config = yaml.parse(content); return config; } catch (error) { - console.warn(`Warning: Failed to load IDE config for ${ideName}:`, error.message); + await prompts.log.warn(`Failed to load IDE config for ${ideName}: ${error.message}`); return null; } } @@ -123,7 +124,7 @@ class IdeConfigManager { } } } catch (error) { - console.warn('Warning: Failed to load IDE configs:', error.message); + await prompts.log.warn(`Failed to load IDE configs: ${error.message}`); } return configs; diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 659d55193..3acb36465 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -109,9 +109,17 @@ class Installer { * @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) + * @param {boolean} skipPrompts - Skip prompts and use defaults (for --yes flag) * @returns {Object} Tool/IDE selection and configurations */ - async collectToolConfigurations(projectDir, selectedModules, isFullReinstall = false, previousIdes = [], preSelectedIdes = null) { + async collectToolConfigurations( + projectDir, + selectedModules, + isFullReinstall = false, + previousIdes = [], + preSelectedIdes = null, + skipPrompts = false, + ) { // Use pre-selected IDEs if provided, otherwise prompt let toolConfig; if (preSelectedIdes === null) { @@ -182,6 +190,7 @@ class Installer { selectedModules: selectedModules || [], projectDir, bmadDir, + skipPrompts, }); } else { // Config-driven IDEs don't need configuration - mark as ready @@ -406,6 +415,9 @@ class Installer { let action = null; if (config.actionType === 'update') { action = 'update'; + } else if (config.skipPrompts) { + // Non-interactive mode: default to update + action = 'update'; } else { // Fallback: Ask the user (backwards compatibility for other code paths) await prompts.log.warn('Existing BMAD installation detected'); @@ -431,48 +443,56 @@ class Installer { // If there are modules to remove, ask for confirmation if (modulesToRemove.length > 0) { - const prompts = require('../../../lib/prompts'); - if (spinner.isSpinning) { - spinner.stop('Reviewing module changes'); - } - - await prompts.log.warn('Modules to be removed:'); - for (const moduleId of modulesToRemove) { - const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId); - const displayName = moduleInfo?.name || moduleId; - const modulePath = path.join(bmadDir, moduleId); - await prompts.log.error(` - ${displayName} (${modulePath})`); - } - - const confirmRemoval = await prompts.confirm({ - message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`, - default: false, - }); - - if (confirmRemoval) { - // Remove module folders - for (const moduleId of modulesToRemove) { - const modulePath = path.join(bmadDir, moduleId); - try { - if (await fs.pathExists(modulePath)) { - await fs.remove(modulePath); - await prompts.log.message(` Removed: ${moduleId}`); - } - } catch (error) { - await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`); - } - } - await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`); - } else { - await prompts.log.message(' Module removal cancelled'); - // Add the modules back to the selection since user cancelled removal + if (config.skipPrompts) { + // Non-interactive mode: preserve modules (matches prompt default: false) for (const moduleId of modulesToRemove) { if (!config.modules) config.modules = []; config.modules.push(moduleId); } - } + spinner.start('Preparing update...'); + } else { + if (spinner.isSpinning) { + spinner.stop('Module changes reviewed'); + } - spinner.start('Preparing update...'); + await prompts.log.warn('Modules to be removed:'); + for (const moduleId of modulesToRemove) { + const moduleInfo = existingInstall.modules.find((m) => m.id === moduleId); + const displayName = moduleInfo?.name || moduleId; + const modulePath = path.join(bmadDir, moduleId); + await prompts.log.error(` - ${displayName} (${modulePath})`); + } + + const confirmRemoval = await prompts.confirm({ + message: `Remove ${modulesToRemove.length} module(s) from BMAD installation?`, + default: false, + }); + + if (confirmRemoval) { + // Remove module folders + for (const moduleId of modulesToRemove) { + const modulePath = path.join(bmadDir, moduleId); + try { + if (await fs.pathExists(modulePath)) { + await fs.remove(modulePath); + await prompts.log.message(` Removed: ${moduleId}`); + } + } catch (error) { + await prompts.log.warn(` Warning: Failed to remove ${moduleId}: ${error.message}`); + } + } + await prompts.log.success(` Removed ${modulesToRemove.length} module(s)`); + } else { + await prompts.log.message(' Module removal cancelled'); + // Add the modules back to the selection since user cancelled removal + for (const moduleId of modulesToRemove) { + if (!config.modules) config.modules = []; + config.modules.push(moduleId); + } + } + + spinner.start('Preparing update...'); + } } // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) @@ -684,6 +704,7 @@ class Installer { config._isFullReinstall || false, config._previouslyConfiguredIdes || [], preSelectedIdes, + config.skipPrompts || false, ); } @@ -692,14 +713,80 @@ class Installer { config.skipIde = toolSelection.skipIde; const ideConfigurations = toolSelection.configurations; + // Detect IDEs that were previously installed but are NOT in the new selection (to be removed) + if (config._isUpdate && config._existingInstall) { + const previouslyInstalledIdes = new Set(config._existingInstall.ides || []); + const newlySelectedIdes = new Set(config.ides || []); + + const idesToRemove = [...previouslyInstalledIdes].filter((ide) => !newlySelectedIdes.has(ide)); + + if (idesToRemove.length > 0) { + if (config.skipPrompts) { + // Non-interactive mode: silently preserve existing IDE configs + if (!config.ides) config.ides = []; + const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); + for (const ide of idesToRemove) { + config.ides.push(ide); + if (savedIdeConfigs[ide] && !ideConfigurations[ide]) { + ideConfigurations[ide] = savedIdeConfigs[ide]; + } + } + } else { + if (spinner.isSpinning) { + spinner.stop('IDE changes reviewed'); + } + + await prompts.log.warn('IDEs to be removed:'); + for (const ide of idesToRemove) { + await prompts.log.error(` - ${ide}`); + } + + const confirmRemoval = await prompts.confirm({ + message: `Remove BMAD configuration for ${idesToRemove.length} IDE(s)?`, + default: false, + }); + + if (confirmRemoval) { + await this.ideManager.ensureInitialized(); + for (const ide of idesToRemove) { + try { + const handler = this.ideManager.handlers.get(ide); + if (handler) { + await handler.cleanup(projectDir); + } + await this.ideConfigManager.deleteIdeConfig(bmadDir, ide); + await prompts.log.message(` Removed: ${ide}`); + } catch (error) { + await prompts.log.warn(` Warning: Failed to remove ${ide}: ${error.message}`); + } + } + await prompts.log.success(` Removed ${idesToRemove.length} IDE(s)`); + } else { + await prompts.log.message(' IDE removal cancelled'); + // Add IDEs back to selection and restore their saved configurations + if (!config.ides) config.ides = []; + const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); + for (const ide of idesToRemove) { + config.ides.push(ide); + if (savedIdeConfigs[ide] && !ideConfigurations[ide]) { + ideConfigurations[ide] = savedIdeConfigs[ide]; + } + } + } + + spinner.start('Preparing installation...'); + } + } + } + // Results collector for consolidated summary const results = []; const addResult = (step, status, detail = '') => results.push({ step, status, detail }); if (spinner.isSpinning) { - spinner.message('Installing...'); + spinner.message('Preparing installation...'); } else { - spinner.start('Installing...'); + spinner.start('Preparing installation...'); } // Create bmad directory structure @@ -728,20 +815,10 @@ class Installer { const projectRoot = getProjectRoot(); - // Step 1: Install core module first (if requested) - if (config.installCore) { - spinner.message('Installing BMAD core...'); - await this.installCoreWithDependencies(bmadDir, { core: {} }); - addResult('Core', 'ok', '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; + const finalCustomContent = config.customContent; - // Step 3: Prepare modules list including cached custom modules + // Prepare modules list including cached custom modules let allModules = [...(config.modules || [])]; // During quick update, we might have custom module sources from the manifest @@ -780,8 +857,6 @@ class Installer { allModules = allModules.filter((m) => m !== 'core'); } - const modulesToInstall = allModules; - // For dependency resolution, we only need regular modules (not custom modules) // Custom modules are already installed in _bmad and don't need dependency resolution from source const regularModulesForResolution = allModules.filter((module) => { @@ -796,365 +871,440 @@ class Installer { return !isCustom; }); - // 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({ - bmadDir: bmadDir, // Pass bmadDir so we can check cache + // Stop spinner before tasks() takes over progress display + spinner.stop('Preparation complete'); + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // FIRST TASKS BLOCK: Core installation through manifests (non-interactive) + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const isQuickUpdate = config._quickUpdate || false; + + // Shared resolution result across task callbacks (closure-scoped, not on `this`) + let taskResolution; + + // Collect directory creation results for output after tasks() completes + const dirResults = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + + // Build task list conditionally + const installTasks = []; + + // Core installation task + if (config.installCore) { + installTasks.push({ + title: isQuickUpdate ? 'Updating BMAD core' : 'Installing BMAD core', + task: async (message) => { + await this.installCoreWithDependencies(bmadDir, { core: {} }); + addResult('Core', 'ok', isQuickUpdate ? 'updated' : 'installed'); + await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); + return isQuickUpdate ? 'Core updated' : 'Core installed'; + }, + }); + } + + // Dependency resolution task + installTasks.push({ + title: 'Resolving dependencies', + task: async (message) => { + // Create a temporary module manager that knows about custom content locations + const tempModuleManager = new ModuleManager({ + bmadDir: bmadDir, + }); + + taskResolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, { + verbose: config.verbose, + moduleManager: tempModuleManager, + }); + return 'Dependencies resolved'; + }, }); - spinner.message('Resolving dependencies...'); - - const resolution = await this.dependencyResolver.resolve(projectRoot, regularModulesForResolution, { - verbose: config.verbose, - moduleManager: tempModuleManager, - }); - - // Install modules with their dependencies + // Module installation task if (allModules && allModules.length > 0) { - const installedModuleNames = new Set(); + installTasks.push({ + title: isQuickUpdate ? `Updating ${allModules.length} module(s)` : `Installing ${allModules.length} module(s)`, + task: async (message) => { + const resolution = taskResolution; + const installedModuleNames = new Set(); - for (const moduleName of allModules) { - // Skip if already installed - if (installedModuleNames.has(moduleName)) { - continue; - } - installedModuleNames.add(moduleName); + for (const moduleName of allModules) { + if (installedModuleNames.has(moduleName)) continue; + installedModuleNames.add(moduleName); - // Show appropriate message based on whether this is a quick update - const isQuickUpdate = config._quickUpdate || false; - spinner.message(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`); + message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - // Check if this is a custom module - let isCustomModule = false; - let customInfo = null; - let useCache = false; + // Check if this is a custom module + let isCustomModule = false; + let customInfo = null; - // 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 _config) - if ( - customInfo.sourcePath && - (customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom')) - ) { - useCache = true; - // Make sure we have the right path structure - if (!customInfo.path) { - customInfo.path = customInfo.sourcePath; + // 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: {} }; + } } - } - } - // 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) { + // Then check custom module sources from manifest (for quick update) + if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { + customInfo = config._customModuleSources.get(moduleName); isCustomModule = true; - customInfo = info; - break; + if ( + customInfo.sourcePath && + (customInfo.sourcePath.startsWith('_config') || customInfo.sourcePath.includes('_config/custom')) && + !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) { + if (!customModulePaths.has(moduleName) && customInfo.path) { + customModulePaths.set(moduleName, customInfo.path); + this.moduleManager.setCustomModulePaths(customModulePaths); + } + + const collectedModuleConfig = moduleConfigs[moduleName] || {}; + await this.moduleManager.install( + moduleName, + bmadDir, + (filePath) => { + this.installedFiles.add(filePath); + }, + { + isCustom: true, + moduleConfig: collectedModuleConfig, + isQuickUpdate: isQuickUpdate, + installer: this, + silent: true, + }, + ); + await this.generateModuleConfigs(bmadDir, { + [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, + }); + } else { + if (!resolution || !resolution.byModule) { + addResult(`Module: ${moduleName}`, 'warn', 'skipped (no resolution data)'); + continue; + } + if (moduleName === 'core') { + await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); + } else { + await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); + } + } + + addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + } + + // Install partial modules (only dependencies) + if (!resolution || !resolution.byModule) { + return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; + } + 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) { + message(`Installing ${module} dependencies...`); + await this.installPartialModule(module, bmadDir, files); + } + } + } + + return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; + }, + }); + } + + // Module directory creation task + installTasks.push({ + title: 'Creating module directories', + task: async (message) => { + const resolution = taskResolution; + if (!resolution || !resolution.byModule) { + addResult('Module directories', 'warn', 'no resolution data'); + return 'Module directories skipped (no resolution data)'; + } + const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose; + const moduleLogger = { + log: async (msg) => (verboseMode ? await prompts.log.message(msg) : undefined), + error: async (msg) => await prompts.log.error(msg), + warn: async (msg) => await prompts.log.warn(msg), + }; + + // Core module directories + if (config.installCore || resolution.byModule.core) { + const result = await this.moduleManager.createModuleDirectories('core', bmadDir, { + installedIDEs: config.ides || [], + moduleConfig: moduleConfigs.core || {}, + existingModuleConfig: this.configCollector.existingConfig?.core || {}, + coreConfig: moduleConfigs.core || {}, + logger: moduleLogger, + silent: true, + }); + if (result) { + dirResults.createdDirs.push(...result.createdDirs); + dirResults.movedDirs.push(...(result.movedDirs || [])); + dirResults.createdWdsFolders.push(...result.createdWdsFolders); + } + } + + // User-selected module directories + if (config.modules && config.modules.length > 0) { + for (const moduleName of config.modules) { + message(`Setting up ${moduleName}...`); + const result = await this.moduleManager.createModuleDirectories(moduleName, bmadDir, { + installedIDEs: config.ides || [], + moduleConfig: moduleConfigs[moduleName] || {}, + existingModuleConfig: this.configCollector.existingConfig?.[moduleName] || {}, + coreConfig: moduleConfigs.core || {}, + logger: moduleLogger, + silent: true, + }); + if (result) { + dirResults.createdDirs.push(...result.createdDirs); + dirResults.movedDirs.push(...(result.movedDirs || [])); + dirResults.createdWdsFolders.push(...result.createdWdsFolders); } } } - if (isCustomModule && customInfo) { - // Custom modules are now installed via ModuleManager just like standard modules - // The custom module path should already be in customModulePaths from earlier setup - if (!customModulePaths.has(moduleName) && customInfo.path) { - customModulePaths.set(moduleName, customInfo.path); - this.moduleManager.setCustomModulePaths(customModulePaths); - } - - const collectedModuleConfig = moduleConfigs[moduleName] || {}; - - // Use ModuleManager to install the custom module - await this.moduleManager.install( - moduleName, - bmadDir, - (filePath) => { - this.installedFiles.add(filePath); - }, - { - isCustom: true, - moduleConfig: collectedModuleConfig, - isQuickUpdate: config._quickUpdate || false, - installer: this, - silent: true, - }, - ); - - // Create module config (include collected config from module.yaml prompts) - await this.generateModuleConfigs(bmadDir, { - [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, - }); - } 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]); - } - } - - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); - } - - // 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.message(`Installing ${module} dependencies...`); - await this.installPartialModule(module, bmadDir, files); - } - } - } - } - - // All content is now installed as modules - no separate custom content handling needed - - // Generate clean config.yaml files for each installed module - spinner.message('Generating module configurations...'); - await this.generateModuleConfigs(bmadDir, moduleConfigs); - addResult('Configurations', 'ok', '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, '_config'); - this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); - this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv')); - this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv')); - this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv')); - - // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup - // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv - spinner.message('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 + addResult('Module directories', 'ok'); + return 'Module directories created'; + }, }); - // Custom modules are now included in the main modules list - no separate tracking needed + // Configuration generation task (stored as named reference for deferred execution) + const configTask = { + title: 'Generating configurations', + task: async (message) => { + // Generate clean config.yaml files for each installed module + await this.generateModuleConfigs(bmadDir, moduleConfigs); + addResult('Configurations', 'ok', 'generated'); - addResult( - 'Manifests', - 'ok', - `${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`, - ); + // Pre-register manifest files + const cfgDir = path.join(bmadDir, '_config'); + this.installedFiles.add(path.join(cfgDir, 'manifest.yaml')); + this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv')); + this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv')); + this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv')); - // Merge all module-help.csv files into bmad-help.csv - // This must happen AFTER generateManifests because it depends on agent-manifest.csv - spinner.message('Generating workflow help catalog...'); - await this.mergeModuleHelpCatalogs(bmadDir); - addResult('Help catalog', 'ok'); + // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes + // This must happen BEFORE mergeModuleHelpCatalogs because it depends on agent-manifest.csv + message('Generating manifests...'); + const manifestGen = new ManifestGenerator(); - // Configure IDEs and copy documentation + const allModulesForManifest = config._quickUpdate + ? config._existingModules || allModules || [] + : config._preserveModules + ? [...allModules, ...config._preserveModules] + : allModules || []; + + let modulesForCsvPreserve; + if (config._quickUpdate) { + modulesForCsvPreserve = config._existingModules || allModules || []; + } else { + modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; + } + + const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], { + ides: config.ides || [], + preservedModules: modulesForCsvPreserve, + }); + + addResult( + 'Manifests', + 'ok', + `${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools`, + ); + + // Merge help catalogs + message('Generating help catalog...'); + await this.mergeModuleHelpCatalogs(bmadDir); + addResult('Help catalog', 'ok'); + + return 'Configurations generated'; + }, + }; + installTasks.push(configTask); + + // Run all tasks except config (which runs after directory output) + const mainTasks = installTasks.filter((t) => t !== configTask); + await prompts.tasks(mainTasks); + + // Render directory creation output right after directory task + const color = await prompts.getColor(); + if (dirResults.movedDirs.length > 0) { + const lines = dirResults.movedDirs.map((d) => ` ${d}`).join('\n'); + await prompts.log.message(color.cyan(`Moved directories:\n${lines}`)); + } + if (dirResults.createdDirs.length > 0) { + const lines = dirResults.createdDirs.map((d) => ` ${d}`).join('\n'); + await prompts.log.message(color.yellow(`Created directories:\n${lines}`)); + } + if (dirResults.createdWdsFolders.length > 0) { + const lines = dirResults.createdWdsFolders.map((f) => color.dim(` \u2713 ${f}/`)).join('\n'); + await prompts.log.message(color.cyan(`Created WDS folder structure:\n${lines}`)); + } + + // Now run configuration generation + await prompts.tasks([configTask]); + + // Resolution is now available via closure-scoped taskResolution + const resolution = taskResolution; + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // IDE SETUP: Keep as spinner since it may prompt for user input + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (!config.skipIde && config.ides && config.ides.length > 0) { - // Ensure IDE manager is initialized (handlers may not be loaded in quick update flow) await this.ideManager.ensureInitialized(); - - // Filter out any undefined/null values from the IDE list const validIdes = config.ides.filter((ide) => ide && typeof ide === 'string'); if (validIdes.length === 0) { addResult('IDE configuration', 'warn', 'no valid IDEs selected'); } else { - // Check if any IDE might need prompting (no pre-collected config) const needsPrompting = validIdes.some((ide) => !ideConfigurations[ide]); - - // Temporarily suppress console output if not verbose - const originalLog = console.log; - if (!config.verbose) { - console.log = () => {}; - } + const ideSpinner = await prompts.spinner(); + ideSpinner.start('Configuring tools...'); try { for (const ide of validIdes) { if (!needsPrompting || ideConfigurations[ide]) { - // All IDEs pre-configured, or this specific IDE has config: keep spinner running - spinner.message(`Configuring ${ide}...`); + ideSpinner.message(`Configuring ${ide}...`); } else { - // This IDE needs prompting: stop spinner to allow user interaction - if (spinner.isSpinning) { - spinner.stop('Ready for IDE configuration'); + if (ideSpinner.isSpinning) { + ideSpinner.stop('Ready for IDE configuration'); } } - // Silent when this IDE has pre-collected config (no prompts for THIS IDE) + // Suppress stray console output for pre-configured IDEs (no user interaction) const ideHasConfig = Boolean(ideConfigurations[ide]); - const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, { - selectedModules: allModules || [], - preCollectedConfig: ideConfigurations[ide] || null, - verbose: config.verbose, - silent: ideHasConfig, - }); + const originalLog = console.log; + if (!config.verbose && ideHasConfig) { + console.log = () => {}; + } + try { + const setupResult = await this.ideManager.setup(ide, projectDir, bmadDir, { + selectedModules: allModules || [], + preCollectedConfig: ideConfigurations[ide] || null, + verbose: config.verbose, + silent: ideHasConfig, + }); - // Save IDE configuration for future updates - if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) { - await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]); + if (ideConfigurations[ide] && !ideConfigurations[ide]._alreadyConfigured) { + await this.ideConfigManager.saveIdeConfig(bmadDir, ide, ideConfigurations[ide]); + } + + if (setupResult.success) { + addResult(ide, 'ok', setupResult.detail || ''); + } else { + addResult(ide, 'error', setupResult.error || 'failed'); + } + } finally { + console.log = originalLog; } - // Collect result for summary - if (setupResult.success) { - addResult(ide, 'ok', setupResult.detail || ''); - } else { - addResult(ide, 'error', setupResult.error || 'failed'); - } - - // Restart spinner if we stopped it for prompting - if (needsPrompting && !spinner.isSpinning) { - spinner.start('Configuring IDEs...'); + if (needsPrompting && !ideSpinner.isSpinning) { + ideSpinner.start('Configuring tools...'); } } } finally { - console.log = originalLog; - } - } - } - - // Run module-specific installers after IDE setup - spinner.message('Running module-specific installers...'); - - // Create a conditional logger based on verbose mode - const verboseMode = process.env.BMAD_VERBOSE_INSTALL === 'true' || config.verbose; - const moduleLogger = { - log: (msg) => (verboseMode ? console.log(msg) : {}), // Only log in verbose mode - error: (msg) => console.error(msg), // Always show errors - warn: (msg) => console.warn(msg), // Always show warnings - }; - - // Run core module installer if core was installed - if (config.installCore || resolution.byModule.core) { - spinner.message('Running core module installer...'); - - await this.moduleManager.runModuleInstaller('core', bmadDir, { - installedIDEs: config.ides || [], - moduleConfig: moduleConfigs.core || {}, - coreConfig: moduleConfigs.core || {}, - logger: moduleLogger, - silent: true, - }); - } - - // Run installers for user-selected modules - if (config.modules && config.modules.length > 0) { - for (const moduleName of config.modules) { - spinner.message(`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: moduleLogger, - silent: true, - }); - } - } - - addResult('Module installers', 'ok'); - - // 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.message(`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 }); + if (ideSpinner.isSpinning) { + ideSpinner.stop('Tool configuration complete'); } } - - // Clean up temp backup - if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { - await fs.remove(config._tempBackupDir); - } - - customFiles = config._customFiles; } + } - if (config._modifiedFiles && config._modifiedFiles.length > 0) { - modifiedFiles = config._modifiedFiles; + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // SECOND TASKS BLOCK: Post-IDE operations (non-interactive) + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const postIdeTasks = []; - // Restore modified files as .bak files - if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { - spinner.message(`Restoring ${modifiedFiles.length} modified files as .bak...`); + // File restoration task (only for updates) + if ( + config._isUpdate && + ((config._customFiles && config._customFiles.length > 0) || (config._modifiedFiles && config._modifiedFiles.length > 0)) + ) { + postIdeTasks.push({ + title: 'Finalizing installation', + task: async (message) => { + let customFiles = []; + let modifiedFiles = []; - 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 (config._customFiles && config._customFiles.length > 0) { + message(`Restoring ${config._customFiles.length} custom files...`); - if (await fs.pathExists(tempBackupPath)) { - await fs.ensureDir(path.dirname(bakPath)); - await fs.copy(tempBackupPath, bakPath, { overwrite: true }); + 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 }); + } + } + + if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) { + await fs.remove(config._tempBackupDir); + } + + customFiles = config._customFiles; + } + + if (config._modifiedFiles && config._modifiedFiles.length > 0) { + modifiedFiles = config._modifiedFiles; + + if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) { + message(`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 }); + } + } + + await fs.remove(config._tempModifiedBackupDir); } } - // Clean up temp backup - await fs.remove(config._tempModifiedBackupDir); - } - } + // Store for summary access + config._restoredCustomFiles = customFiles; + config._restoredModifiedFiles = modifiedFiles; + + return 'Installation finalized'; + }, + }); } - // Stop the single installation spinner - spinner.stop('Installation complete'); + await prompts.tasks(postIdeTasks); + + // Retrieve restored file info for summary + const customFiles = config._restoredCustomFiles || []; + const modifiedFiles = config._restoredModifiedFiles || []; // Render consolidated summary await this.renderInstallSummary(results, { @@ -1173,7 +1323,15 @@ class Installer { projectDir: projectDir, }; } catch (error) { - spinner.error('Installation failed'); + try { + if (spinner.isSpinning) { + spinner.error('Installation failed'); + } else { + await prompts.log.error('Installation failed'); + } + } catch { + // Ensure the original error is never swallowed by a logging failure + } throw error; } } @@ -1201,19 +1359,11 @@ class Installer { lines.push(` ${icon} ${r.step}${detail}`); } - // Add context info + // Context and warnings lines.push(''); if (context.bmadDir) { lines.push(` Installed to: ${color.dim(context.bmadDir)}`); } - if (context.modules && context.modules.length > 0) { - lines.push(` Modules: ${color.dim(context.modules.join(', '))}`); - } - if (context.ides && context.ides.length > 0) { - lines.push(` Tools: ${color.dim(context.ides.join(', '))}`); - } - - // Custom/modified file warnings if (context.customFiles && context.customFiles.length > 0) { lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); } @@ -1221,6 +1371,14 @@ class Installer { lines.push(` ${color.yellow(`Modified files backed up (.bak): ${context.modifiedFiles.length}`)}`); } + // Next steps + lines.push( + '', + ' Next steps:', + ` Docs: ${color.dim('https://docs.bmad-method.org/')}`, + ` Run ${color.cyan('/bmad-help')} in your IDE to get started`, + ); + await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); } @@ -1305,6 +1463,7 @@ class Installer { projectRoot, 'update', existingInstall.modules.map((m) => m.id), + config.skipPrompts || false, ); spinner.start('Preparing update...'); @@ -1912,8 +2071,8 @@ class Installer { continue; } - // Skip _module-installer directory - it's only needed at install time - if (file.startsWith('_module-installer/') || file === 'module.yaml') { + // Skip module.yaml at root - it's only needed at install time + if (file === 'module.yaml') { continue; } @@ -1966,10 +2125,6 @@ class Installer { 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 { @@ -2172,6 +2327,7 @@ class Installer { projectRoot, 'update', installedModules, + config.skipPrompts || false, ); const { validCustomModules, keptModulesWithoutSources } = customModuleResult; @@ -2429,7 +2585,9 @@ class Installer { if (proceed === 'exit') { await prompts.log.info('Please remove the .bmad-method folder and any v4 rules/commands, then run the installer again.'); - process.exit(0); + // Allow event loop to flush pending I/O before exit + setImmediate(() => process.exit(0)); + return; } await prompts.log.warn('Proceeding with installation despite legacy v4 folder'); @@ -2613,9 +2771,10 @@ class Installer { * @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) + * @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array */ - async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) { + async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) { const validCustomModules = []; const keptModulesWithoutSources = []; // Track modules kept without sources const customModulesWithMissingSources = []; @@ -2658,6 +2817,14 @@ class Installer { }; } + // Non-interactive mode: keep all modules with missing sources + if (skipPrompts) { + for (const missing of customModulesWithMissingSources) { + keptModulesWithoutSources.push(missing.id); + } + return { validCustomModules, keptModulesWithoutSources }; + } + await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`); let keptCount = 0; @@ -2722,6 +2889,13 @@ class Installer { }, }); + // Defensive: handleCancel should have exited, but guard against symbol propagation + if (typeof newSourcePath !== 'string') { + keptCount++; + keptModulesWithoutSources.push(missing.id); + continue; + } + // Update the source in manifest const resolvedPath = path.resolve(newSourcePath.trim()); missing.info.sourcePath = resolvedPath; diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index caea790eb..bc4694a6d 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -4,6 +4,7 @@ const yaml = require('yaml'); const crypto = require('node:crypto'); const csv = require('csv-parse/sync'); const { getSourcePath, getModulePath } = require('../../../lib/project-root'); +const prompts = require('../../../lib/prompts'); // Load package.json for version info const packageJson = require('../../../../../package.json'); @@ -241,7 +242,7 @@ class ManifestGenerator { } } } catch (error) { - console.warn(`Warning: Failed to parse workflow at ${fullPath}: ${error.message}`); + await prompts.log.warn(`Failed to parse workflow at ${fullPath}: ${error.message}`); } } } @@ -321,6 +322,7 @@ class ManifestGenerator { const nameMatch = content.match(/name="([^"]+)"/); const titleMatch = content.match(/title="([^"]+)"/); const iconMatch = content.match(/icon="([^"]+)"/); + const capabilitiesMatch = content.match(/capabilities="([^"]+)"/); // Extract persona fields const roleMatch = content.match(/([^<]+)<\/role>/); @@ -342,6 +344,7 @@ class ManifestGenerator { displayName: nameMatch ? nameMatch[1] : agentName, title: titleMatch ? titleMatch[1] : '', icon: iconMatch ? iconMatch[1] : '', + capabilities: capabilitiesMatch ? this.cleanForCSV(capabilitiesMatch[1]) : '', role: roleMatch ? this.cleanForCSV(roleMatch[1]) : '', identity: identityMatch ? this.cleanForCSV(identityMatch[1]) : '', communicationStyle: styleMatch ? this.cleanForCSV(styleMatch[1]) : '', @@ -691,7 +694,7 @@ class ManifestGenerator { return preservedRows; } catch (error) { - console.warn(`Warning: Failed to read existing CSV ${csvPath}:`, error.message); + await prompts.log.warn(`Failed to read existing CSV ${csvPath}: ${error.message}`); return []; } } @@ -784,7 +787,7 @@ class ManifestGenerator { } // Create CSV header with persona fields - let csvContent = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; + let csvContent = 'name,displayName,title,icon,capabilities,role,identity,communicationStyle,principles,module,path\n'; // Combine existing and new agents, preferring new data for duplicates const allAgents = new Map(); @@ -802,6 +805,7 @@ class ManifestGenerator { displayName: agent.displayName, title: agent.title, icon: agent.icon, + capabilities: agent.capabilities, role: agent.role, identity: agent.identity, communicationStyle: agent.communicationStyle, @@ -818,6 +822,7 @@ class ManifestGenerator { escapeCsv(record.displayName), escapeCsv(record.title), escapeCsv(record.icon), + escapeCsv(record.capabilities), escapeCsv(record.role), escapeCsv(record.identity), escapeCsv(record.communicationStyle), @@ -1068,7 +1073,7 @@ class ManifestGenerator { } } } catch (error) { - console.warn(`Warning: Could not scan for installed modules: ${error.message}`); + await prompts.log.warn(`Could not scan for installed modules: ${error.message}`); } return modules; diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js index 865b75f96..5fa1229e1 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/cli/installers/lib/core/manifest.js @@ -2,6 +2,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const crypto = require('node:crypto'); const { getProjectRoot } = require('../../../lib/project-root'); +const prompts = require('../../../lib/prompts'); class Manifest { /** @@ -100,7 +101,7 @@ class Manifest { ides: manifestData.ides || [], }; } catch (error) { - console.error('Failed to read YAML manifest:', error.message); + await prompts.log.error(`Failed to read YAML manifest: ${error.message}`); } } @@ -230,7 +231,7 @@ class Manifest { const content = await fs.readFile(yamlPath, 'utf8'); return yaml.parse(content); } catch (error) { - console.error('Failed to read YAML manifest:', error.message); + await prompts.log.error(`Failed to read YAML manifest: ${error.message}`); } } @@ -472,7 +473,7 @@ class Manifest { } } } catch (error) { - console.warn(`Warning: Could not parse ${filePath}:`, error.message); + await prompts.log.warn(`Could not parse ${filePath}: ${error.message}`); } } // Handle other file types (CSV, JSON, YAML, etc.) @@ -774,7 +775,7 @@ class Manifest { configs[moduleName] = yaml.parse(content); } } catch (error) { - console.warn(`Could not load config for module ${moduleName}:`, error.message); + await prompts.log.warn(`Could not load config for module ${moduleName}: ${error.message}`); } } @@ -876,7 +877,7 @@ class Manifest { const pkg = require(packageJsonPath); version = pkg.version; } catch (error) { - console.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); + await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); } } } @@ -904,7 +905,7 @@ class Manifest { repoUrl: moduleConfig.repoUrl || null, }; } catch (error) { - console.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); + await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); } } diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index 6256e3cd2..52595e4ff 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -55,7 +55,7 @@ class CustomHandler { // Found a custom.yaml file customPaths.push(fullPath); } else if ( - entry.name === 'module.yaml' && // Check if this is a custom module (either in _module-installer or in root directory) + entry.name === 'module.yaml' && // Check if this is a custom module (in root directory) // Skip if it's in src/modules (those are standard modules) !fullPath.includes(path.join('src', 'modules')) ) { diff --git a/tools/cli/installers/lib/ide/codex.js b/tools/cli/installers/lib/ide/codex.js index 8e91e003b..143402282 100644 --- a/tools/cli/installers/lib/ide/codex.js +++ b/tools/cli/installers/lib/ide/codex.js @@ -23,6 +23,11 @@ class CodexSetup extends BaseIdeSetup { * @returns {Object} Collected configuration */ async collectConfiguration(options = {}) { + // Non-interactive mode: use default (global) + if (options.skipPrompts) { + return { installLocation: 'global' }; + } + let confirmed = false; let installLocation = 'global'; diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js new file mode 100644 index 000000000..4d852fcb0 --- /dev/null +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -0,0 +1,655 @@ +const path = require('node:path'); +const { BaseIdeSetup } = require('./_base-ide'); +const chalk = require('chalk'); +const { AgentCommandGenerator } = require('./shared/agent-command-generator'); +const { BMAD_FOLDER_NAME, toDashPath } = require('./shared/path-utils'); +const fs = require('fs-extra'); +const csv = require('csv-parse/sync'); +const yaml = require('yaml'); + +/** + * GitHub Copilot setup handler + * Creates agents in .github/agents/, prompts in .github/prompts/, + * copilot-instructions.md, and configures VS Code settings + */ +class GitHubCopilotSetup extends BaseIdeSetup { + constructor() { + super('github-copilot', 'GitHub Copilot', false); + // Don't set configDir to '.github' โ€” nearly every GitHub repo has that directory, + // which would cause the base detect() to false-positive. Use detectionPaths instead. + this.configDir = null; + this.githubDir = '.github'; + this.agentsDir = 'agents'; + this.promptsDir = 'prompts'; + this.detectionPaths = ['.github/copilot-instructions.md', '.github/agents']; + } + + /** + * Setup GitHub Copilot configuration + * @param {string} projectDir - Project directory + * @param {string} bmadDir - BMAD installation directory + * @param {Object} options - Setup options + */ + async setup(projectDir, bmadDir, options = {}) { + console.log(chalk.cyan(`Setting up ${this.name}...`)); + + // Create .github/agents and .github/prompts directories + const githubDir = path.join(projectDir, this.githubDir); + const agentsDir = path.join(githubDir, this.agentsDir); + const promptsDir = path.join(githubDir, this.promptsDir); + await this.ensureDir(agentsDir); + await this.ensureDir(promptsDir); + + // Preserve any customised tool permissions from existing files before cleanup + this.existingToolPermissions = await this.collectExistingToolPermissions(projectDir); + + // Clean up any existing BMAD files before reinstalling + await this.cleanup(projectDir); + + // Load agent manifest for enriched descriptions + const agentManifest = await this.loadAgentManifest(bmadDir); + + // Generate agent launchers + const agentGen = new AgentCommandGenerator(this.bmadFolderName); + const { artifacts: agentArtifacts } = await agentGen.collectAgentArtifacts(bmadDir, options.selectedModules || []); + + // Create agent .agent.md files + let agentCount = 0; + for (const artifact of agentArtifacts) { + const agentMeta = agentManifest.get(artifact.name); + + // Compute fileName first so we can look up any existing tool permissions + const dashName = toDashPath(artifact.relativePath); + const fileName = dashName.replace(/\.md$/, '.agent.md'); + const toolsStr = this.getToolsForFile(fileName); + const agentContent = this.createAgentContent(artifact, agentMeta, toolsStr); + const targetPath = path.join(agentsDir, fileName); + await this.writeFile(targetPath, agentContent); + agentCount++; + + console.log(chalk.green(` โœ“ Created agent: ${fileName}`)); + } + + // Generate prompt files from bmad-help.csv + const promptCount = await this.generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest); + + // Generate copilot-instructions.md + await this.generateCopilotInstructions(projectDir, bmadDir, agentManifest); + + console.log(chalk.green(`\nโœ“ ${this.name} configured:`)); + console.log(chalk.dim(` - ${agentCount} agents created in .github/agents/`)); + console.log(chalk.dim(` - ${promptCount} prompts created in .github/prompts/`)); + console.log(chalk.dim(` - copilot-instructions.md generated`)); + console.log(chalk.dim(` - Destination: .github/`)); + + return { + success: true, + results: { + agents: agentCount, + workflows: promptCount, + tasks: 0, + tools: 0, + }, + }; + } + + /** + * Load agent manifest CSV into a Map keyed by agent name + * @param {string} bmadDir - BMAD installation directory + * @returns {Map} Agent metadata keyed by name + */ + async loadAgentManifest(bmadDir) { + const manifestPath = path.join(bmadDir, '_config', 'agent-manifest.csv'); + const agents = new Map(); + + if (!(await fs.pathExists(manifestPath))) { + return agents; + } + + try { + const csvContent = await fs.readFile(manifestPath, 'utf8'); + const records = csv.parse(csvContent, { + columns: true, + skip_empty_lines: true, + }); + + for (const record of records) { + agents.set(record.name, record); + } + } catch { + // Gracefully degrade if manifest is unreadable/malformed + } + + return agents; + } + + /** + * Load bmad-help.csv to drive prompt generation + * @param {string} bmadDir - BMAD installation directory + * @returns {Array|null} Parsed CSV rows + */ + async loadBmadHelp(bmadDir) { + const helpPath = path.join(bmadDir, '_config', 'bmad-help.csv'); + + if (!(await fs.pathExists(helpPath))) { + return null; + } + + try { + const csvContent = await fs.readFile(helpPath, 'utf8'); + return csv.parse(csvContent, { + columns: true, + skip_empty_lines: true, + }); + } catch { + // Gracefully degrade if help CSV is unreadable/malformed + return null; + } + } + + /** + * Create agent .agent.md content with enriched description + * @param {Object} artifact - Agent artifact from AgentCommandGenerator + * @param {Object|undefined} manifestEntry - Agent manifest entry with metadata + * @returns {string} Agent file content + */ + createAgentContent(artifact, manifestEntry, toolsStr) { + // Build enriched description from manifest metadata + let description; + if (manifestEntry) { + const persona = manifestEntry.displayName || artifact.name; + const title = manifestEntry.title || this.formatTitle(artifact.name); + const capabilities = manifestEntry.capabilities || 'agent capabilities'; + description = `${persona} โ€” ${title}: ${capabilities}`; + } else { + description = `Activates the ${this.formatTitle(artifact.name)} agent persona.`; + } + + // Build the agent file path for the activation block + const agentPath = artifact.agentPath || artifact.relativePath; + const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; + + return `--- +description: '${description.replaceAll("'", "''")}' +tools: ${toolsStr} +disable-model-invocation: true +--- + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. + + +1. LOAD the FULL agent file from ${agentFilePath} +2. READ its entire contents - this contains the complete agent persona, menu, and instructions +3. FOLLOW every step in the section precisely +4. DISPLAY the welcome/greeting as instructed +5. PRESENT the numbered menu +6. WAIT for user input before proceeding + +`; + } + + /** + * Generate .prompt.md files for workflows, tasks, tech-writer commands, and agent activators + * @param {string} projectDir - Project directory + * @param {string} bmadDir - BMAD installation directory + * @param {Array} agentArtifacts - Agent artifacts for activator generation + * @param {Map} agentManifest - Agent manifest data + * @returns {number} Count of prompts generated + */ + async generatePromptFiles(projectDir, bmadDir, agentArtifacts, agentManifest) { + const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir); + let promptCount = 0; + + // Load bmad-help.csv to drive workflow/task prompt generation + const helpEntries = await this.loadBmadHelp(bmadDir); + + if (helpEntries) { + for (const entry of helpEntries) { + const command = entry.command; + if (!command) continue; // Skip entries without a command (tech-writer commands have no command column) + + const workflowFile = entry['workflow-file']; + if (!workflowFile) continue; // Skip entries with no workflow file path + const promptFileName = `${command}.prompt.md`; + const toolsStr = this.getToolsForFile(promptFileName); + const promptContent = this.createWorkflowPromptContent(entry, workflowFile, toolsStr); + const promptPath = path.join(promptsDir, promptFileName); + await this.writeFile(promptPath, promptContent); + promptCount++; + } + + // Generate tech-writer command prompts (entries with no command column) + for (const entry of helpEntries) { + if (entry.command) continue; // Already handled above + const techWriterPrompt = this.createTechWriterPromptContent(entry); + if (techWriterPrompt) { + const promptFileName = `${techWriterPrompt.fileName}.prompt.md`; + const promptPath = path.join(promptsDir, promptFileName); + await this.writeFile(promptPath, techWriterPrompt.content); + promptCount++; + } + } + } + + // Generate agent activator prompts (Pattern D) + for (const artifact of agentArtifacts) { + const agentMeta = agentManifest.get(artifact.name); + const fileName = `bmad-${artifact.name}.prompt.md`; + const toolsStr = this.getToolsForFile(fileName); + const promptContent = this.createAgentActivatorPromptContent(artifact, agentMeta, toolsStr); + const promptPath = path.join(promptsDir, fileName); + await this.writeFile(promptPath, promptContent); + promptCount++; + } + + return promptCount; + } + + /** + * Create prompt content for a workflow/task entry from bmad-help.csv + * Determines the pattern (A, B, or A for .xml tasks) based on file extension + * @param {Object} entry - bmad-help.csv row + * @param {string} workflowFile - Workflow file path + * @returns {string} Prompt file content + */ + createWorkflowPromptContent(entry, workflowFile, toolsStr) { + const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name)); + // bmm/config.yaml is safe to hardcode here: these prompts are only generated when + // bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed. + const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`; + + let body; + if (workflowFile.endsWith('.yaml')) { + // Pattern B: YAML-based workflows โ€” use workflow engine + body = `${configLine} +2. Load the workflow engine at {project-root}/${this.bmadFolderName}/core/tasks/workflow.xml +3. Load and execute the workflow configuration at {project-root}/${workflowFile} using the engine from step 2`; + } else if (workflowFile.endsWith('.xml')) { + // Pattern A variant: XML tasks โ€” load and execute directly + body = `${configLine} +2. Load and execute the task at {project-root}/${workflowFile}`; + } else { + // Pattern A: MD workflows โ€” load and follow directly + body = `${configLine} +2. Load and follow the workflow at {project-root}/${workflowFile}`; + } + + return `--- +description: '${description}' +agent: 'agent' +tools: ${toolsStr} +--- + +${body} +`; + } + + /** + * Create a short 2-5 word description for a prompt from the entry name + * @param {string} name - Entry name from bmad-help.csv + * @returns {string} Short description + */ + createPromptDescription(name) { + const descriptionMap = { + 'Brainstorm Project': 'Brainstorm ideas', + 'Market Research': 'Market research', + 'Domain Research': 'Domain research', + 'Technical Research': 'Technical research', + 'Create Brief': 'Create product brief', + 'Create PRD': 'Create PRD', + 'Validate PRD': 'Validate PRD', + 'Edit PRD': 'Edit PRD', + 'Create UX': 'Create UX design', + 'Create Architecture': 'Create architecture', + 'Create Epics and Stories': 'Create epics and stories', + 'Check Implementation Readiness': 'Check implementation readiness', + 'Sprint Planning': 'Sprint planning', + 'Sprint Status': 'Sprint status', + 'Create Story': 'Create story', + 'Validate Story': 'Validate story', + 'Dev Story': 'Dev story', + 'QA Automation Test': 'QA automation', + 'Code Review': 'Code review', + Retrospective: 'Retrospective', + 'Document Project': 'Document project', + 'Generate Project Context': 'Generate project context', + 'Quick Spec': 'Quick spec', + 'Quick Dev': 'Quick dev', + 'Correct Course': 'Correct course', + Brainstorming: 'Brainstorm ideas', + 'Party Mode': 'Party mode', + 'bmad-help': 'BMAD help', + 'Index Docs': 'Index documents', + 'Shard Document': 'Shard document', + 'Editorial Review - Prose': 'Editorial review prose', + 'Editorial Review - Structure': 'Editorial review structure', + 'Adversarial Review (General)': 'Adversarial review', + }; + + return descriptionMap[name] || name; + } + + /** + * Create prompt content for tech-writer agent-only commands (Pattern C) + * @param {Object} entry - bmad-help.csv row + * @returns {Object|null} { fileName, content } or null if not a tech-writer command + */ + createTechWriterPromptContent(entry) { + if (entry['agent-name'] !== 'tech-writer') return null; + + const techWriterCommands = { + 'Write Document': { code: 'WD', file: 'bmad-bmm-write-document', description: 'Write document' }, + 'Update Standards': { code: 'US', file: 'bmad-bmm-update-standards', description: 'Update standards' }, + 'Mermaid Generate': { code: 'MG', file: 'bmad-bmm-mermaid-generate', description: 'Mermaid generate' }, + 'Validate Document': { code: 'VD', file: 'bmad-bmm-validate-document', description: 'Validate document' }, + 'Explain Concept': { code: 'EC', file: 'bmad-bmm-explain-concept', description: 'Explain concept' }, + }; + + const cmd = techWriterCommands[entry.name]; + if (!cmd) return null; + + const safeDescription = this.escapeYamlSingleQuote(cmd.description); + const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`); + + const content = `--- +description: '${safeDescription}' +agent: 'agent' +tools: ${toolsStr} +--- + +1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables +2. Load the full agent file from {project-root}/${this.bmadFolderName}/bmm/agents/tech-writer/tech-writer.md and activate the Paige (Technical Writer) persona +3. Execute the ${entry.name} menu command (${cmd.code}) +`; + + return { fileName: cmd.file, content }; + } + + /** + * Create agent activator prompt content (Pattern D) + * @param {Object} artifact - Agent artifact + * @param {Object|undefined} manifestEntry - Agent manifest entry + * @returns {string} Prompt file content + */ + createAgentActivatorPromptContent(artifact, manifestEntry, toolsStr) { + let description; + if (manifestEntry) { + description = manifestEntry.title || this.formatTitle(artifact.name); + } else { + description = this.formatTitle(artifact.name); + } + + const safeDescription = this.escapeYamlSingleQuote(description); + const agentPath = artifact.agentPath || artifact.relativePath; + const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; + + // bmm/config.yaml is safe to hardcode: agent activators are only generated from + // bmm agent artifacts, so bmm is guaranteed to be installed. + return `--- +description: '${safeDescription}' +agent: 'agent' +tools: ${toolsStr} +--- + +1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables +2. Load the full agent file from ${agentFilePath} +3. Follow ALL activation instructions in the agent file +4. Display the welcome/greeting as instructed +5. Present the numbered menu +6. Wait for user input before proceeding +`; + } + + /** + * Generate copilot-instructions.md from module config + * @param {string} projectDir - Project directory + * @param {string} bmadDir - BMAD installation directory + * @param {Map} agentManifest - Agent manifest data + */ + async generateCopilotInstructions(projectDir, bmadDir, agentManifest) { + const configVars = await this.loadModuleConfig(bmadDir); + + // Build the agents table from the manifest + let agentsTable = '| Agent | Persona | Title | Capabilities |\n|---|---|---|---|\n'; + const agentOrder = [ + 'bmad-master', + 'analyst', + 'architect', + 'dev', + 'pm', + 'qa', + 'quick-flow-solo-dev', + 'sm', + 'tech-writer', + 'ux-designer', + ]; + + for (const agentName of agentOrder) { + const meta = agentManifest.get(agentName); + if (meta) { + const capabilities = meta.capabilities || 'agent capabilities'; + const cleanTitle = (meta.title || '').replaceAll('""', '"'); + agentsTable += `| ${agentName} | ${meta.displayName} | ${cleanTitle} | ${capabilities} |\n`; + } + } + + const bmad = this.bmadFolderName; + const bmadSection = `# BMAD Method โ€” Project Instructions + +## Project Configuration + +- **Project**: ${configVars.project_name || '{{project_name}}'} +- **User**: ${configVars.user_name || '{{user_name}}'} +- **Communication Language**: ${configVars.communication_language || '{{communication_language}}'} +- **Document Output Language**: ${configVars.document_output_language || '{{document_output_language}}'} +- **User Skill Level**: ${configVars.user_skill_level || '{{user_skill_level}}'} +- **Output Folder**: ${configVars.output_folder || '{{output_folder}}'} +- **Planning Artifacts**: ${configVars.planning_artifacts || '{{planning_artifacts}}'} +- **Implementation Artifacts**: ${configVars.implementation_artifacts || '{{implementation_artifacts}}'} +- **Project Knowledge**: ${configVars.project_knowledge || '{{project_knowledge}}'} + +## BMAD Runtime Structure + +- **Agent definitions**: \`${bmad}/bmm/agents/\` (BMM module) and \`${bmad}/core/agents/\` (core) +- **Workflow definitions**: \`${bmad}/bmm/workflows/\` (organized by phase) +- **Core tasks**: \`${bmad}/core/tasks/\` (help, editorial review, indexing, sharding, adversarial review) +- **Core workflows**: \`${bmad}/core/workflows/\` (brainstorming, party-mode, advanced-elicitation) +- **Workflow engine**: \`${bmad}/core/tasks/workflow.xml\` (executes YAML-based workflows) +- **Module configuration**: \`${bmad}/bmm/config.yaml\` +- **Core configuration**: \`${bmad}/core/config.yaml\` +- **Agent manifest**: \`${bmad}/_config/agent-manifest.csv\` +- **Workflow manifest**: \`${bmad}/_config/workflow-manifest.csv\` +- **Help manifest**: \`${bmad}/_config/bmad-help.csv\` +- **Agent memory**: \`${bmad}/_memory/\` + +## Key Conventions + +- Always load \`${bmad}/bmm/config.yaml\` before any agent activation or workflow execution +- Store all config fields as session variables: \`{user_name}\`, \`{communication_language}\`, \`{output_folder}\`, \`{planning_artifacts}\`, \`{implementation_artifacts}\`, \`{project_knowledge}\` +- MD-based workflows execute directly โ€” load and follow the \`.md\` file +- YAML-based workflows require the workflow engine โ€” load \`workflow.xml\` first, then pass the \`.yaml\` config +- Follow step-based workflow execution: load steps JIT, never multiple at once +- Save outputs after EACH step when using the workflow engine +- The \`{project-root}\` variable resolves to the workspace root at runtime + +## Available Agents + +${agentsTable} +## Slash Commands + +Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent activators. Agents are also available in the agents dropdown.`; + + const instructionsPath = path.join(projectDir, this.githubDir, 'copilot-instructions.md'); + const markerStart = ''; + const markerEnd = ''; + const markedContent = `${markerStart}\n${bmadSection}\n${markerEnd}`; + + if (await fs.pathExists(instructionsPath)) { + const existing = await fs.readFile(instructionsPath, 'utf8'); + const startIdx = existing.indexOf(markerStart); + const endIdx = existing.indexOf(markerEnd); + + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + // Replace only the BMAD section between markers + const before = existing.slice(0, startIdx); + const after = existing.slice(endIdx + markerEnd.length); + const merged = `${before}${markedContent}${after}`; + await this.writeFile(instructionsPath, merged); + console.log(chalk.green(' โœ“ Updated BMAD section in copilot-instructions.md')); + } else { + // Existing file without markers โ€” back it up before overwriting + const backupPath = `${instructionsPath}.bak`; + await fs.copy(instructionsPath, backupPath); + console.log(chalk.yellow(` โš  Backed up existing copilot-instructions.md โ†’ copilot-instructions.md.bak`)); + await this.writeFile(instructionsPath, `${markedContent}\n`); + console.log(chalk.green(' โœ“ Generated copilot-instructions.md (with BMAD markers)')); + } + } else { + // No existing file โ€” create fresh with markers + await this.writeFile(instructionsPath, `${markedContent}\n`); + console.log(chalk.green(' โœ“ Generated copilot-instructions.md')); + } + } + + /** + * Load module config.yaml for template variables + * @param {string} bmadDir - BMAD installation directory + * @returns {Object} Config variables + */ + async loadModuleConfig(bmadDir) { + const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml'); + const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml'); + + for (const configPath of [bmmConfigPath, coreConfigPath]) { + if (await fs.pathExists(configPath)) { + try { + const content = await fs.readFile(configPath, 'utf8'); + return yaml.parse(content) || {}; + } catch { + // Fall through to next config + } + } + } + + return {}; + } + + /** + * Escape a string for use inside YAML single-quoted values. + * In YAML, the only escape inside single quotes is '' for a literal '. + * @param {string} value - Raw string + * @returns {string} Escaped string safe for YAML single-quoted context + */ + escapeYamlSingleQuote(value) { + return (value || '').replaceAll("'", "''"); + } + + /** + * Scan existing agent and prompt files for customised tool permissions before cleanup. + * Returns a Map so permissions can be preserved across reinstalls. + * @param {string} projectDir - Project directory + * @returns {Map} Existing tool permissions keyed by filename + */ + async collectExistingToolPermissions(projectDir) { + const permissions = new Map(); + const dirs = [ + [path.join(projectDir, this.githubDir, this.agentsDir), /^bmad.*\.agent\.md$/], + [path.join(projectDir, this.githubDir, this.promptsDir), /^bmad-.*\.prompt\.md$/], + ]; + + for (const [dir, pattern] of dirs) { + if (!(await fs.pathExists(dir))) continue; + const files = await fs.readdir(dir); + + for (const file of files) { + if (!pattern.test(file)) continue; + + try { + const content = await fs.readFile(path.join(dir, file), 'utf8'); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) continue; + + const frontmatter = yaml.parse(fmMatch[1]); + if (frontmatter && Array.isArray(frontmatter.tools)) { + permissions.set(file, frontmatter.tools); + } + } catch { + // Skip unreadable files + } + } + } + + return permissions; + } + + /** + * Get the tools array string for a file, preserving any existing customisation. + * Falls back to the default tools if no prior customisation exists. + * @param {string} fileName - Target filename (e.g. 'bmad-agent-bmm-pm.agent.md') + * @returns {string} YAML inline array string + */ + getToolsForFile(fileName) { + const defaultTools = ['read', 'edit', 'search', 'execute']; + const tools = (this.existingToolPermissions && this.existingToolPermissions.get(fileName)) || defaultTools; + return '[' + tools.map((t) => `'${t}'`).join(', ') + ']'; + } + + /** + * Format name as title + */ + formatTitle(name) { + return name + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * Cleanup GitHub Copilot configuration - surgically remove only BMAD files + */ + async cleanup(projectDir) { + // Clean up agents directory + const agentsDir = path.join(projectDir, this.githubDir, this.agentsDir); + if (await fs.pathExists(agentsDir)) { + const files = await fs.readdir(agentsDir); + let removed = 0; + + for (const file of files) { + if (file.startsWith('bmad') && (file.endsWith('.agent.md') || file.endsWith('.md'))) { + await fs.remove(path.join(agentsDir, file)); + removed++; + } + } + + if (removed > 0) { + console.log(chalk.dim(` Cleaned up ${removed} existing BMAD agents`)); + } + } + + // Clean up prompts directory + const promptsDir = path.join(projectDir, this.githubDir, this.promptsDir); + if (await fs.pathExists(promptsDir)) { + const files = await fs.readdir(promptsDir); + let removed = 0; + + for (const file of files) { + if (file.startsWith('bmad-') && file.endsWith('.prompt.md')) { + await fs.remove(path.join(promptsDir, file)); + removed++; + } + } + + if (removed > 0) { + console.log(chalk.dim(` Cleaned up ${removed} existing BMAD prompts`)); + } + } + + // Note: copilot-instructions.md is NOT cleaned up here. + // generateCopilotInstructions() handles marker-based replacement in a single + // read-modify-write pass, which correctly preserves user content outside the markers. + // Stripping markers here would cause generation to treat the file as legacy (no markers) + // and overwrite user content. + } +} + +module.exports = { GitHubCopilotSetup }; diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index c68527f6a..cb9774307 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -8,7 +8,7 @@ const prompts = require('../../../lib/prompts'); * Dynamically discovers and loads IDE handlers * * Loading strategy: - * 1. Custom installer files (codex.js, kilo.js) - for platforms with unique installation logic + * 1. Custom installer files (codex.js, github-copilot.js, kilo.js) - for platforms with unique installation logic * 2. Config-driven handlers (from platform-codes.yaml) - for standard IDE installation patterns */ class IdeManager { @@ -44,7 +44,7 @@ class IdeManager { /** * Dynamically load all IDE handlers - * 1. Load custom installer files first (codex.js, kilo.js) + * 1. Load custom installer files first (codex.js, github-copilot.js, kilo.js) * 2. Load config-driven handlers from platform-codes.yaml */ async loadHandlers() { @@ -61,7 +61,7 @@ class IdeManager { */ async loadCustomInstallerFiles() { const ideDir = __dirname; - const customFiles = ['codex.js', 'kilo.js']; + const customFiles = ['codex.js', 'github-copilot.js', 'kilo.js']; for (const file of customFiles) { const filePath = path.join(ideDir, file); diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index f65143616..7c2dde2cb 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -89,11 +89,7 @@ platforms: preferred: false category: ide description: "GitHub's AI pair programmer" - installer: - targets: - - target_dir: .github/agents - template_type: copilot_agents - artifact_types: [agents] + # No installer config - uses custom github-copilot.js iflow: name: "iFlow" diff --git a/tools/cli/installers/lib/modules/external-manager.js b/tools/cli/installers/lib/modules/external-manager.js index a68a2ba1e..f1ea2206e 100644 --- a/tools/cli/installers/lib/modules/external-manager.js +++ b/tools/cli/installers/lib/modules/external-manager.js @@ -1,6 +1,7 @@ const fs = require('fs-extra'); const path = require('node:path'); const yaml = require('yaml'); +const prompts = require('../../../lib/prompts'); /** * Manages external official modules defined in external-official-modules.yaml @@ -29,7 +30,7 @@ class ExternalModuleManager { this.cachedModules = config; return config; } catch (error) { - console.warn(`Failed to load external modules config: ${error.message}`); + await prompts.log.warn(`Failed to load external modules config: ${error.message}`); return { modules: {} }; } } diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 0af4312fc..b4acc3aef 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -236,17 +236,11 @@ class ModuleManager { async getModuleInfo(modulePath, defaultName, sourceDescription) { // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); - const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); - const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); let configPath = null; if (await fs.pathExists(moduleConfigPath)) { configPath = moduleConfigPath; - } else if (await fs.pathExists(installerConfigPath)) { - configPath = installerConfigPath; - } else if (await fs.pathExists(customConfigPath)) { - configPath = customConfigPath; } else if (await fs.pathExists(rootCustomConfigPath)) { configPath = rootCustomConfigPath; } @@ -268,7 +262,7 @@ class ModuleManager { description: 'BMAD Module', version: '5.0.0', source: sourceDescription, - isCustom: configPath === customConfigPath || configPath === rootCustomConfigPath || isCustomSource, + isCustom: configPath === rootCustomConfigPath || isCustomSource, }; // Read module config for metadata @@ -458,7 +452,7 @@ class ModuleManager { installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); } catch (error) { installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); - if (!silent) await prompts.log.warn(` Warning: ${error.message}`); + if (!silent) await prompts.log.warn(` ${error.message}`); } } else { // Check if package.json is newer than node_modules @@ -484,7 +478,7 @@ class ModuleManager { installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`); } catch (error) { installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`); - if (!silent) await prompts.log.warn(` Warning: ${error.message}`); + if (!silent) await prompts.log.warn(` ${error.message}`); } } } @@ -541,21 +535,13 @@ class ModuleManager { // Check if this is a custom module and read its custom.yaml values let customConfig = null; const rootCustomConfigPath = path.join(sourcePath, 'custom.yaml'); - const moduleInstallerCustomPath = path.join(sourcePath, '_module-installer', 'custom.yaml'); if (await fs.pathExists(rootCustomConfigPath)) { try { const customContent = await fs.readFile(rootCustomConfigPath, 'utf8'); customConfig = yaml.parse(customContent); } catch (error) { - await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`); - } - } else if (await fs.pathExists(moduleInstallerCustomPath)) { - try { - const customContent = await fs.readFile(moduleInstallerCustomPath, 'utf8'); - customConfig = yaml.parse(customContent); - } catch (error) { - await prompts.log.warn(`Warning: Failed to read custom.yaml for ${moduleName}: ${error.message}`); + await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); } } @@ -563,7 +549,7 @@ class ModuleManager { if (customConfig) { options.moduleConfig = { ...options.moduleConfig, ...customConfig }; if (options.logger) { - options.logger.log(` Merged custom configuration for ${moduleName}`); + await options.logger.log(` Merged custom configuration for ${moduleName}`); } } @@ -585,9 +571,9 @@ class ModuleManager { // Process agent files to inject activation block await this.processAgentFiles(targetPath, moduleName); - // Call module-specific installer if it exists (unless explicitly skipped) + // Create directories declared in module.yaml (unless explicitly skipped) if (!options.skipModuleInstaller) { - await this.runModuleInstaller(moduleName, bmadDir, options); + await this.createModuleDirectories(moduleName, bmadDir, options); } // Capture version info for manifest @@ -743,8 +729,8 @@ class ModuleManager { continue; } - // Skip _module-installer directory - it's only needed at install time - if (file.startsWith('_module-installer/') || file === 'module.yaml') { + // Skip module.yaml at root - it's only needed at install time + if (file === 'module.yaml') { continue; } @@ -871,7 +857,7 @@ class ModuleManager { await fs.writeFile(targetFile, strippedYaml, 'utf8'); } catch { // If anything fails, just copy the file as-is - await prompts.log.warn(` Warning: Could not process ${path.basename(sourceFile)}, copying as-is`); + await prompts.log.warn(` Could not process ${path.basename(sourceFile)}, copying as-is`); await fs.copy(sourceFile, targetFile, { overwrite: true }); } } @@ -1026,7 +1012,7 @@ class ModuleManager { await prompts.log.message(` Sidecar files processed: ${copiedFiles.length} files`); } } else if (process.env.BMAD_VERBOSE_INSTALL === 'true') { - await prompts.log.warn(` Warning: Agent marked as having sidecar but ${sidecarDirName} directory not found`); + await prompts.log.warn(` Agent marked as having sidecar but ${sidecarDirName} directory not found`); } } @@ -1259,62 +1245,175 @@ class ModuleManager { } /** - * Run module-specific installer if it exists + * Create directories declared in module.yaml's `directories` key + * This replaces the security-risky module installer pattern with declarative config + * During updates, if a directory path changed, moves the old directory to the new path * @param {string} moduleName - Name of the module * @param {string} bmadDir - Target bmad directory * @param {Object} options - Installation options + * @param {Object} options.moduleConfig - Module configuration from config collector + * @param {Object} options.existingModuleConfig - Previous module config (for detecting path changes during updates) + * @param {Object} options.coreConfig - Core configuration + * @returns {Promise<{createdDirs: string[], movedDirs: string[], createdWdsFolders: string[]}>} Created directories info */ - async runModuleInstaller(moduleName, bmadDir, options = {}) { + async createModuleDirectories(moduleName, bmadDir, options = {}) { + const moduleConfig = options.moduleConfig || {}; + const existingModuleConfig = options.existingModuleConfig || {}; + const projectRoot = path.dirname(bmadDir); + const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] }; + // Special handling for core module - it's in src/core not src/modules let sourcePath; if (moduleName === 'core') { sourcePath = getSourcePath('core'); } else { - sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); + sourcePath = await this.findModuleSource(moduleName, { silent: true }); if (!sourcePath) { - // No source found, skip module installer - return; + return emptyResult; // No source found, skip } } - const installerPath = path.join(sourcePath, '_module-installer', 'installer.js'); - - // Check if module has a custom installer - if (!(await fs.pathExists(installerPath))) { - return; // No custom installer + // Read module.yaml to find the `directories` key + const moduleYamlPath = path.join(sourcePath, 'module.yaml'); + if (!(await fs.pathExists(moduleYamlPath))) { + return emptyResult; // No module.yaml, skip } + let moduleYaml; try { - // Load the module installer - const moduleInstaller = require(installerPath); + const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); + moduleYaml = yaml.parse(yamlContent); + } catch { + return emptyResult; // Invalid YAML, skip + } - if (typeof moduleInstaller.install === 'function') { - // Get project root (parent of bmad directory) - const projectRoot = path.dirname(bmadDir); + if (!moduleYaml || !moduleYaml.directories) { + return emptyResult; // No directories declared, skip + } - // Prepare logger (use console if not provided) - const logger = options.logger || { - log: console.log, - error: console.error, - warn: console.warn, - }; + const directories = moduleYaml.directories; + const wdsFolders = moduleYaml.wds_folders || []; + const createdDirs = []; + const movedDirs = []; + const createdWdsFolders = []; - // Call the module installer - const result = await moduleInstaller.install({ - projectRoot, - config: options.moduleConfig || {}, - coreConfig: options.coreConfig || {}, - installedIDEs: options.installedIDEs || [], - logger, - }); + for (const dirRef of directories) { + // Parse variable reference like "{design_artifacts}" + const varMatch = dirRef.match(/^\{([^}]+)\}$/); + if (!varMatch) { + // Not a variable reference, skip + continue; + } - if (!result) { - await prompts.log.warn(`Module installer for ${moduleName} returned false`); + const configKey = varMatch[1]; + const dirValue = moduleConfig[configKey]; + if (!dirValue || typeof dirValue !== 'string') { + continue; // No value or not a string, skip + } + + // Strip {project-root}/ prefix if present + let dirPath = dirValue.replace(/^\{project-root\}\/?/, ''); + + // Handle remaining {project-root} anywhere in the path + dirPath = dirPath.replaceAll('{project-root}', ''); + + // Resolve to absolute path + const fullPath = path.join(projectRoot, dirPath); + + // Validate path is within project root (prevent directory traversal) + const normalizedPath = path.normalize(fullPath); + const normalizedRoot = path.normalize(projectRoot); + if (!normalizedPath.startsWith(normalizedRoot + path.sep) && normalizedPath !== normalizedRoot) { + const color = await prompts.getColor(); + await prompts.log.warn(color.yellow(`${configKey} path escapes project root, skipping: ${dirPath}`)); + continue; + } + + // Check if directory path changed from previous config (update/modify scenario) + const oldDirValue = existingModuleConfig[configKey]; + let oldFullPath = null; + let oldDirPath = null; + if (oldDirValue && typeof oldDirValue === 'string') { + // F3: Normalize both values before comparing to avoid false negatives + // from trailing slashes, separator differences, or prefix format variations + let normalizedOld = oldDirValue.replace(/^\{project-root\}\/?/, ''); + normalizedOld = path.normalize(normalizedOld.replaceAll('{project-root}', '')); + const normalizedNew = path.normalize(dirPath); + + if (normalizedOld !== normalizedNew) { + oldDirPath = normalizedOld; + oldFullPath = path.join(projectRoot, oldDirPath); + const normalizedOldAbsolute = path.normalize(oldFullPath); + if (!normalizedOldAbsolute.startsWith(normalizedRoot + path.sep) && normalizedOldAbsolute !== normalizedRoot) { + oldFullPath = null; // Old path escapes project root, ignore it + } + + // F13: Prevent parent/child move (e.g. docs/planning โ†’ docs/planning/v2) + if (oldFullPath) { + const normalizedNewAbsolute = path.normalize(fullPath); + if ( + normalizedOldAbsolute.startsWith(normalizedNewAbsolute + path.sep) || + normalizedNewAbsolute.startsWith(normalizedOldAbsolute + path.sep) + ) { + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `${configKey}: cannot move between parent/child paths (${oldDirPath} / ${dirPath}), creating new directory instead`, + ), + ); + oldFullPath = null; + } + } + } + } + + const dirName = configKey.replaceAll('_', ' '); + + if (oldFullPath && (await fs.pathExists(oldFullPath)) && !(await fs.pathExists(fullPath))) { + // Path changed and old dir exists โ†’ move old to new location + // F1: Use fs.move() instead of fs.rename() for cross-device/volume support + // F2: Wrap in try/catch โ€” fallback to creating new dir on failure + try { + await fs.ensureDir(path.dirname(fullPath)); + await fs.move(oldFullPath, fullPath); + movedDirs.push(`${dirName}: ${oldDirPath} โ†’ ${dirPath}`); + } catch (moveError) { + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `Failed to move ${oldDirPath} โ†’ ${dirPath}: ${moveError.message}\n Creating new directory instead. Please move contents from the old directory manually.`, + ), + ); + await fs.ensureDir(fullPath); + createdDirs.push(`${dirName}: ${dirPath}`); + } + } else if (oldFullPath && (await fs.pathExists(oldFullPath)) && (await fs.pathExists(fullPath))) { + // F5: Both old and new directories exist โ€” warn user about potential orphaned documents + const color = await prompts.getColor(); + await prompts.log.warn( + color.yellow( + `${dirName}: path changed but both directories exist:\n Old: ${oldDirPath}\n New: ${dirPath}\n Old directory may contain orphaned documents โ€” please review and merge manually.`, + ), + ); + } else if (!(await fs.pathExists(fullPath))) { + // New directory doesn't exist yet โ†’ create it + createdDirs.push(`${dirName}: ${dirPath}`); + await fs.ensureDir(fullPath); + } + + // Create WDS subfolders if this is the design_artifacts directory + if (configKey === 'design_artifacts' && wdsFolders.length > 0) { + for (const subfolder of wdsFolders) { + const subPath = path.join(fullPath, subfolder); + if (!(await fs.pathExists(subPath))) { + await fs.ensureDir(subPath); + createdWdsFolders.push(subfolder); + } } } - } catch (error) { - await prompts.log.error(`Error running module installer for ${moduleName}: ${error.message}`); } + + return { createdDirs, movedDirs, createdWdsFolders }; } /** @@ -1383,10 +1482,6 @@ class ModuleManager { 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 { diff --git a/tools/cli/lib/agent/compiler.js b/tools/cli/lib/agent/compiler.js index a0dc4ae01..f9f71baab 100644 --- a/tools/cli/lib/agent/compiler.js +++ b/tools/cli/lib/agent/compiler.js @@ -279,6 +279,9 @@ async function compileToXml(agentYaml, agentName = '', targetPath = '') { `title="${meta.title || ''}"`, `icon="${meta.icon || '๐Ÿค–'}"`, ]; + if (meta.capabilities) { + agentAttrs.push(`capabilities="${escapeXml(meta.capabilities)}"`); + } xml += `\n`; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 9134b4e28..224d147e3 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -189,7 +189,7 @@ class UI { const installedVersion = existingInstall.version || 'unknown'; // Check if version is pre beta - const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir)); + const shouldProceed = await this.showLegacyVersionWarning(installedVersion, currentVersion, path.basename(bmadDir), options); // If user chose to cancel, exit the installer if (!shouldProceed) { @@ -227,6 +227,14 @@ class UI { } 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?', @@ -242,6 +250,7 @@ class UI { actionType: 'quick-update', directory: confirmedDirectory, customContent: { hasCustomContent: false }, + skipPrompts: options.yes || false, }; } @@ -252,6 +261,7 @@ class UI { actionType: 'compile-agents', directory: confirmedDirectory, customContent: { hasCustomContent: false }, + skipPrompts: options.yes || false, }; } @@ -272,9 +282,13 @@ class UI { .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); - selectedModules = selectedModules.filter((m) => m !== 'core'); } // After module selection, ask about custom modules @@ -331,6 +345,22 @@ class UI { }, }; } + } else if (options.yes) { + // Non-interactive mode: preserve existing custom modules (matches default: false) + const cacheDir = path.join(bmadDir, '_config', 'custom'); + if (await fs.pathExists(cacheDir)) { + const entries = await fs.readdir(cacheDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + customModuleResult.selectedCustomModules.push(entry.name); + } + } + await prompts.log.info( + `Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`, + ); + } else { + await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found'); + } } else { const changeCustomModules = await prompts.confirm({ message: 'Modify custom modules, agents, or workflows?', @@ -362,6 +392,9 @@ class UI { selectedModules.push(...customModuleResult.selectedCustomModules); } + // Filter out core - it's always installed via installCore flag + selectedModules = selectedModules.filter((m) => m !== 'core'); + // Get tool selection const toolSelection = await this.promptToolSelection(confirmedDirectory, options); @@ -376,6 +409,7 @@ class UI { skipIde: toolSelection.skipIde, coreConfig: coreConfig, customContent: customModuleResult.customContentConfig, + skipPrompts: options.yes || false, }; } } @@ -527,6 +561,27 @@ class UI { 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)), @@ -689,18 +744,6 @@ class UI { }); } - /** - * Display installation summary - * @param {Object} result - Installation result - */ - async showInstallSummary(result) { - let summary = `Installed to: ${result.path}`; - if (result.modules && result.modules.length > 0) { - summary += `\nModules: ${result.modules.join(', ')}`; - } - await prompts.note(summary, 'BMAD is ready to use!'); - } - /** * Get confirmed directory from user * @returns {string} Confirmed directory path @@ -899,107 +942,10 @@ class UI { } /** - * Prompt for module selection - * @param {Array} moduleChoices - Available module choices - * @returns {Array} Selected module IDs - */ - async selectModules(moduleChoices, defaultSelections = null) { - // If defaultSelections is provided, use it to override checked state - // Otherwise preserve the checked state from moduleChoices (set by getModuleChoices) - const choicesWithDefaults = moduleChoices.map((choice) => ({ - ...choice, - ...(defaultSelections === null ? {} : { checked: defaultSelections.includes(choice.value) }), - })); - - // Add a "None" option at the end for users who changed their mind - const choicesWithSkipOption = [ - ...choicesWithDefaults, - { - value: '__NONE__', - label: '\u26A0 None / I changed my mind - skip module installation', - checked: false, - }, - ]; - - const selected = await prompts.multiselect({ - message: 'Select modules to install (use arrow keys, space to toggle):', - choices: choicesWithSkipOption, - required: true, - }); - - // If user selected both "__NONE__" and other items, honor the "None" choice - if (selected && selected.includes('__NONE__') && selected.length > 1) { - await prompts.log.warn('"None / I changed my mind" was selected, so no modules will be installed.'); - return []; - } - - // Filter out the special '__NONE__' value - return selected ? selected.filter((m) => m !== '__NONE__') : []; - } - - /** - * Get external module choices for selection - * @returns {Array} External module choices for prompt - */ - async getExternalModuleChoices() { - const externalManager = new ExternalModuleManager(); - const modules = await externalManager.listAvailable(); - - return modules.map((mod) => ({ - name: mod.name, - value: mod.code, // Use the code (e.g., 'cis') as the value - checked: mod.defaultSelected || false, - hint: mod.description || undefined, // Show description as hint - module: mod, // Store full module info for later use - })); - } - - /** - * Prompt for external module selection - * @param {Array} externalModuleChoices - Available external module choices - * @param {Array} defaultSelections - Module codes to pre-select - * @returns {Array} Selected external module codes - */ - async selectExternalModules(externalModuleChoices, defaultSelections = []) { - // Build a message showing available modules - const message = 'Select official BMad modules to install (use arrow keys, space to toggle):'; - - // Mark choices as checked based on defaultSelections - const choicesWithDefaults = externalModuleChoices.map((choice) => ({ - ...choice, - checked: defaultSelections.includes(choice.value), - })); - - // Add a "None" option at the end for users who changed their mind - const choicesWithSkipOption = [ - ...choicesWithDefaults, - { - name: 'โš  None / I changed my mind - skip external module installation', - value: '__NONE__', - checked: false, - }, - ]; - - const selected = await prompts.multiselect({ - message, - choices: choicesWithSkipOption, - required: true, - }); - - // If user selected both "__NONE__" and other items, honor the "None" choice - if (selected && selected.includes('__NONE__') && selected.length > 1) { - await prompts.log.warn('"None / I changed my mind" was selected, so no external modules will be installed.'); - return []; - } - - // Filter out the special '__NONE__' value - return selected ? selected.filter((m) => m !== '__NONE__') : []; - } - - /** - * Select all modules (core + official + community) using grouped multiselect + * Select all modules (official + community) using grouped multiselect. + * Core is shown as locked but filtered from the result since it's always installed separately. * @param {Set} installedModuleIds - Currently installed module IDs - * @returns {Array} Selected module codes + * @returns {Array} Selected module codes (excluding core) */ async selectAllModules(installedModuleIds = new Set()) { const { ModuleManager } = require('../installers/lib/modules/manager'); @@ -1068,11 +1014,7 @@ class UI { } } } - allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint })), { - // "None" option at the end - label: '\u26A0 None - Skip module installation', - value: '__NONE__', - }); + allOptions.push(...communityModules.map(({ label, value, hint }) => ({ label, value, hint }))); const selected = await prompts.autocompleteMultiselect({ message: 'Select modules to install:', @@ -1083,14 +1025,7 @@ class UI { maxItems: allOptions.length, }); - // If user selected both "__NONE__" and other items, honor the "None" choice - if (selected && selected.includes('__NONE__') && selected.length > 1) { - await prompts.log.warn('"None" was selected, so no modules will be installed.'); - return []; - } - - // Filter out the special '__NONE__' value - const result = selected ? selected.filter((m) => m !== '__NONE__') : []; + const result = selected ? selected.filter((m) => m !== 'core') : []; // Display selected modules as bulleted list if (result.length > 0) { @@ -1748,7 +1683,7 @@ class UI { * @param {string} bmadFolderName - Name of the BMAD folder * @returns {Promise} True if user wants to proceed, false if they cancel */ - async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName) { + async showLegacyVersionWarning(installedVersion, currentVersion, bmadFolderName, options = {}) { if (!this.isLegacyVersion(installedVersion)) { return true; // Not legacy, proceed } @@ -1774,6 +1709,11 @@ class UI { await prompts.log.warn('VERSION WARNING'); await prompts.note(warningContent, 'Version Warning'); + if (options.yes) { + await prompts.log.warn('Non-interactive mode (--yes): auto-proceeding with legacy update'); + return true; + } + const proceed = await prompts.select({ message: 'How would you like to proceed?', choices: [ diff --git a/tools/schema/agent.js b/tools/schema/agent.js index b6a36a985..93ced7c6e 100644 --- a/tools/schema/agent.js +++ b/tools/schema/agent.js @@ -228,6 +228,7 @@ function buildMetadataSchema(expectedModule) { title: createNonEmptyString('agent.metadata.title'), icon: createNonEmptyString('agent.metadata.icon'), module: createNonEmptyString('agent.metadata.module').optional(), + capabilities: createNonEmptyString('agent.metadata.capabilities').optional(), hasSidecar: z.boolean(), }; diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index 22b02da7f..bf92f31f8 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -42,7 +42,7 @@ const STRICT = process.argv.includes('--strict'); const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']); // Skip directories -const SKIP_DIRS = new Set(['node_modules', '_module-installer', '.git']); +const SKIP_DIRS = new Set(['node_modules', '.git']); // Pattern: {project-root}/_bmad/ references const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g;