diff --git a/docs/custom-content-installation.md b/docs/custom-content-installation.md index e56f0906..a19873ee 100644 --- a/docs/custom-content-installation.md +++ b/docs/custom-content-installation.md @@ -64,7 +64,7 @@ A custom module follows this structure: my-module/ ├── _module-installer/ │ ├── installer.js # optional, when it exists it will run with module installation -│ └── install-config.yaml # Module installation configuration with custom question and answer capture +├── module.yaml # Module installation configuration with custom question and answer capture ├── docs/ # Module documentation ├── agents/ # Module-specific agents ├── workflows/ # Module-specific workflows @@ -77,7 +77,7 @@ my-module/ #### Module Configuration -The `_module-installer/install-config.yaml` file defines how your module is installed: +The `module.yaml` file defines how your module is installed: ```yaml # Module metadata @@ -99,12 +99,6 @@ my_setting: See `/example-custom-module` for a complete example: -```bash -# The example is ready to use - just rename the _module-installer/install-config file: -mv example-custom-module/mwm/_module-installer/install-config.bak \ - example-custom-module/mwm/_module-installer/install-config.yaml -``` - ## Installation Process ### Step 1: Running the Installer @@ -129,7 +123,7 @@ If you select "Enter a directory path", the installer will prompt for the locati The installer will: - Scan the directory and all subdirectories for the presence of a `custom.yaml` file (standalone content such as agents and workflows) -- Scan for `_module-installer/install-config.yaml` files (modules) +- Scan for `module.yaml` files (modules) - Display an indication of how many installable folders it has found. Note that a project with stand along agents and workflows all under a single folder like the example will just list the count as 1 for that directory. ### Step 3: Selecting Content @@ -230,7 +224,7 @@ Custom content can be distributed: ### No Custom Content Found -- Ensure your `custom.yaml` or `install-config.yaml` files are properly named +- Ensure your `custom.yaml` or `module.yaml` files are properly named - Check file permissions - Verify the directory path is correct diff --git a/docs/installers-bundlers/installers-modules-platforms-reference.md b/docs/installers-bundlers/installers-modules-platforms-reference.md index 62f1a398..45108177 100644 --- a/docs/installers-bundlers/installers-modules-platforms-reference.md +++ b/docs/installers-bundlers/installers-modules-platforms-reference.md @@ -59,6 +59,7 @@ project-root/ ### Key Exclusions - `_module-installer/` directories are never copied to destination +- module.yaml - `localskip="true"` agents are filtered out - Source `config.yaml` templates are replaced with generated configs @@ -92,8 +93,8 @@ Creative Innovation Studio for design workflows ``` src/modules/{module}/ ├── _module-installer/ # Not copied to destination -│ ├── installer.js # Post-install logic -│ └── install-config.yaml +│ ├── installer.js # Post-install logic +├── module.yaml ├── agents/ ├── tasks/ ├── templates/ @@ -107,7 +108,7 @@ src/modules/{module}/ ### Collection Process -Modules define prompts in `install-config.yaml`: +Modules define prompts in `module.yaml`: ```yaml project_name: @@ -218,12 +219,12 @@ Platform-specific content without source modification: src/modules/mymod/ ├── _module-installer/ │ ├── installer.js - │ └── install-config.yaml + ├── module.yaml ├── agents/ └── tasks/ ``` -2. **Configuration** (`install-config.yaml`) +2. **Configuration** (`module.yaml`) ```yaml code: mymod diff --git a/example-custom-content/agents/toolsmith/toolsmith-sidecar/instructions.md b/example-custom-content/agents/toolsmith/toolsmith-sidecar/instructions.md index 5d702a57..3c0121f5 100644 --- a/example-custom-content/agents/toolsmith/toolsmith-sidecar/instructions.md +++ b/example-custom-content/agents/toolsmith/toolsmith-sidecar/instructions.md @@ -41,7 +41,7 @@ CLI uses Commander.js, commands auto-loaded from `tools/cli/commands/`: ### Core Architecture Patterns 1. **IDE Handlers**: Each IDE extends BaseIdeSetup class -2. **Module Installers**: Modules can have `_module-installer/installer.js` +2. **Module Installers**: Modules can have `module.yaml` and `_module-installer/installer.js` 3. **Sub-modules**: IDE-specific customizations in `sub-modules/{ide-name}/` 4. **Shared Utilities**: `tools/cli/installers/lib/ide/shared/` contains generators diff --git a/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md b/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md index d25d8e27..71498d59 100644 --- a/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md +++ b/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/installers.md @@ -117,7 +117,7 @@ Contains: - Add new IDE handler: Create file in /tools/cli/installers/lib/ide/, extend BaseIdeSetup - Fix installer bug: Check installer.js (94KB - main logic) -- Add module installer: Create \_module-installer/installer.js in module +- Add module installer: Create \_module-installer/installer.js if custom installer logic needed - Update shared generators: Modify files in /shared/ directory ## Relationships diff --git a/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/modules.md b/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/modules.md index a2386254..496356f6 100644 --- a/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/modules.md +++ b/example-custom-content/agents/toolsmith/toolsmith-sidecar/knowledge/modules.md @@ -27,7 +27,7 @@ src/modules/{module-name}/ │ ├── injections.yaml │ ├── config.yaml │ └── sub-agents/ -├── install-config.yaml # Module install configuration +├── module.yaml # Module install configuration └── README.md # Module documentation ``` @@ -145,7 +145,7 @@ Defined in @/tools/cli/lib/platform-codes.js - Create new module installer: Add \_module-installer/installer.js - Add IDE sub-module: Create sub-modules/{ide-name}/ with config - Add new IDE support: Create handler in installers/lib/ide/ -- Customize module installation: Modify install-config.yaml +- Customize module installation: Modify module.yaml ## Relationships diff --git a/example-custom-content/custom.yaml b/example-custom-content/module.yaml similarity index 89% rename from example-custom-content/custom.yaml rename to example-custom-content/module.yaml index 63263f29..85daca32 100644 --- a/example-custom-content/custom.yaml +++ b/example-custom-content/module.yaml @@ -1,3 +1,4 @@ code: bmad-custom name: "BMAD-Custom: Sample Stand Alone Custom Agents and Workflows" default_selected: true +type: custom diff --git a/example-custom-module/mwm/README.md b/example-custom-module/mwm/README.md index 09e8aba8..7ac6f328 100644 --- a/example-custom-module/mwm/README.md +++ b/example-custom-module/mwm/README.md @@ -3,9 +3,6 @@ This module is an example and is not at all recommended for any usage, this module was not vetted by any medical professionals and should be considered at best for entertainment purposes only. -IF you want to see how a custom module installation works, copy this whole folder to where you will be installing from with npx, and rename -"\_module-installer/install-config.bak" to "\_module-installer/install-config.yaml". - You should see the option in the module selector when installing. If you have received a module from someone else that is not in the official installation - you can install it similarly by running the diff --git a/example-custom-module/mwm/_module-installer/install-config.yaml b/example-custom-module/mwm/module.yaml similarity index 98% rename from example-custom-module/mwm/_module-installer/install-config.yaml rename to example-custom-module/mwm/module.yaml index ccfe66c8..7f91165b 100644 --- a/example-custom-module/mwm/_module-installer/install-config.yaml +++ b/example-custom-module/mwm/module.yaml @@ -4,6 +4,7 @@ code: mwm name: "MWM: Mental Wellness Module" default_selected: false +type: module header: "MWM™: Custom Wellness Module" subheader: "Demo of Potential Non Coding Custom Module Use case" diff --git a/src/core/_module-installer/installer.js b/src/core/_module-installer/installer.js index 2fef9562..d77bc62f 100644 --- a/src/core/_module-installer/installer.js +++ b/src/core/_module-installer/installer.js @@ -6,7 +6,7 @@ const chalk = require('chalk'); * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @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 diff --git a/src/core/_module-installer/install-config.yaml b/src/core/module.yaml similarity index 100% rename from src/core/_module-installer/install-config.yaml rename to src/core/module.yaml diff --git a/src/modules/bmb/_module-installer/installer.js b/src/modules/bmb/_module-installer/installer.js index a1897c89..9b201f57 100644 --- a/src/modules/bmb/_module-installer/installer.js +++ b/src/modules/bmb/_module-installer/installer.js @@ -8,7 +8,7 @@ const chalk = require('chalk'); * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @param {Object} options.config - Module configuration from module.yaml * @param {Object} options.coreConfig - Core configuration containing user_name * @param {Array} options.installedIDEs - Array of IDE codes that were installed * @param {Object} options.logger - Logger instance for output diff --git a/src/modules/bmb/_module-installer/install-config.yaml b/src/modules/bmb/module.yaml similarity index 100% rename from src/modules/bmb/_module-installer/install-config.yaml rename to src/modules/bmb/module.yaml diff --git a/src/modules/bmb/workflows/create-module/steps/step-04-structure.md b/src/modules/bmb/workflows/create-module/steps/step-04-structure.md index 0e4cc7d8..ed12122d 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-04-structure.md +++ b/src/modules/bmb/workflows/create-module/steps/step-04-structure.md @@ -113,10 +113,10 @@ For a [module type] module, we'll create this structure:" │ └── [template-files] ├── data/ # Module data files │ └── [data-files] +├── module.yaml # Required ├── _module-installer/ # Installation configuration -│ ├── install-config.yaml # Required -│ ├── installer.js # Optional -│ └── assets/ # Optional install assets +│ ├── installer.js # Optional +│ └── assets/ # Optional install assets └── README.md # Module documentation ``` diff --git a/src/modules/bmb/workflows/create-module/steps/step-05-config.md b/src/modules/bmb/workflows/create-module/steps/step-05-config.md index 6ee043e2..55da3c50 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-05-config.md +++ b/src/modules/bmb/workflows/create-module/steps/step-05-config.md @@ -184,7 +184,7 @@ Update module-plan.md with configuration section: ### Result Configuration Structure -The install-config.yaml will generate: +The module.yaml will generate: - Module configuration at: {bmad_folder}/{module_code}/config.yaml - User settings stored as: [describe structure] ```` diff --git a/src/modules/bmb/workflows/create-module/steps/step-08-installer.md b/src/modules/bmb/workflows/create-module/steps/step-08-installer.md index 1f9bc369..4332ab68 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-08-installer.md +++ b/src/modules/bmb/workflows/create-module/steps/step-08-installer.md @@ -37,7 +37,7 @@ partyModeWorkflow: '{project-root}/{bmad_folder}/core/workflows/party-mode/workf ## EXECUTION PROTOCOLS: - 🎯 Use configuration plan from step 5 -- 💾 Create install-config.yaml with all fields +- 💾 Create module.yaml with all fields - 📖 Add "step-08-installer" to stepsCompleted array` before loading next step - 🚫 FORBIDDEN to load next step until user selects 'C' @@ -50,7 +50,7 @@ partyModeWorkflow: '{project-root}/{bmad_folder}/core/workflows/party-mode/workf ## STEP GOAL: -To create the module installer configuration (install-config.yaml) that defines how users will install and configure the module. +To create the module installer configuration (module.yaml) that defines how users will install and configure the module. ## INSTALLER SETUP PROCESS: @@ -74,11 +74,11 @@ From step 5, we planned these configuration fields: Ensure \_module-installer directory exists Directory: {custom_module_location}/{module_name}/\_module-installer/ -### 3. Create install-config.yaml +### 3. Create module.yaml -"I'll create the install-config.yaml file based on your configuration plan. This is the core installer configuration file." +"I'll create the module.yaml file based on your configuration plan. This is the core installer configuration file." -Create file: {custom_module_location}/{module_name}/\_module-installer/install-config.yaml from template {installConfigTemplate} +Create file: {custom_module_location}/{module_name}/module.yaml from template {installConfigTemplate} ### 4. Handle Custom Installation Logic @@ -117,7 +117,7 @@ Update module-plan.md with installer section: ### Install Configuration -- File: \_module-installer/install-config.yaml +- File: module.yaml - Module code: {module_name} - Default selected: false - Configuration fields: [count] @@ -166,7 +166,7 @@ Display: **Select an Option:** [A] Advanced Elicitation [P] Party Mode [C] Conti ### ✅ SUCCESS: -- install-config.yaml created with all planned fields +- module.yaml created with all planned fields - YAML syntax valid - Custom installation logic prepared (if needed) - Installer follows BMAD standards @@ -174,7 +174,7 @@ Display: **Select an Option:** [A] Advanced Elicitation [P] Party Mode [C] Conti ### ❌ SYSTEM FAILURE: -- Not creating install-config.yaml +- Not creating module.yaml - Invalid YAML syntax - Missing required fields - Not using proper path templates diff --git a/src/modules/bmb/workflows/create-module/steps/step-09-documentation.md b/src/modules/bmb/workflows/create-module/steps/step-09-documentation.md index dd74db4b..8d98c239 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-09-documentation.md +++ b/src/modules/bmb/workflows/create-module/steps/step-09-documentation.md @@ -133,7 +133,8 @@ bmad install {module_name} ├── tasks/ # Task files ├── templates/ # Shared templates ├── data/ # Module data -├── _module-installer/ # Installation config +├── _module-installer/ # Installation optional js file with custom install routine +├── module.yaml # yaml config and install questions └── README.md # This file ``` diff --git a/src/modules/bmb/workflows/create-module/steps/step-10-roadmap.md b/src/modules/bmb/workflows/create-module/steps/step-10-roadmap.md index 4168bc8c..39807a7d 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-10-roadmap.md +++ b/src/modules/bmb/workflows/create-module/steps/step-10-roadmap.md @@ -207,9 +207,10 @@ workflow {workflow_name} ├── workflows/ # ✅ Structure created, plans written ├── tasks/ # ✅ Created ├── templates/ # ✅ Created -├── data/ # ✅ Created +├── data/ # ✅ Created ├── _module-installer/ # ✅ Configured -└── README.md # ✅ Complete +└── README.md # ✅ Complete +└── module.yaml # ✅ Complete ``` ## Completion Criteria diff --git a/src/modules/bmb/workflows/create-module/steps/step-11-validate.md b/src/modules/bmb/workflows/create-module/steps/step-11-validate.md index 1c186b7e..31182408 100644 --- a/src/modules/bmb/workflows/create-module/steps/step-11-validate.md +++ b/src/modules/bmb/workflows/create-module/steps/step-11-validate.md @@ -73,8 +73,8 @@ Expected Structure: ├── templates/ [✅/❌] ├── data/ [✅/❌] ├── _module-installer/ [✅/❌] -│ ├── install-config.yaml [✅/❌] -│ └── installer.js [✅/N/A] +│ └── installer.js [✅/N/A] +├── module.yaml [✅/❌] └── README.md [✅/❌] ``` @@ -87,7 +87,7 @@ Expected Structure: "**2. Configuration Files Check**" **Install Configuration:** -Validate install-config.yaml +Validate module.yaml - [ ] YAML syntax valid - [ ] Module code matches folder name diff --git a/src/modules/bmb/workflows/create-module/templates/installer.template.js b/src/modules/bmb/workflows/create-module/templates/installer.template.js index f9114425..428a57e4 100644 --- a/src/modules/bmb/workflows/create-module/templates/installer.template.js +++ b/src/modules/bmb/workflows/create-module/templates/installer.template.js @@ -6,7 +6,7 @@ /** * @param {Object} options - Installation options * @param {string} options.projectRoot - Project root directory - * @param {Object} options.config - Module configuration from install-config.yaml + * @param {Object} options.config - Module configuration from module.yaml * @param {Array} options.installedIDEs - List of IDE codes being configured * @param {Object} options.logger - Logger instance (log, warn, error methods) * @returns {boolean} - true if successful, false to abort installation diff --git a/src/modules/bmb/workflows/create-module/templates/install-config.template.yaml b/src/modules/bmb/workflows/create-module/templates/module.template.yaml similarity index 100% rename from src/modules/bmb/workflows/create-module/templates/install-config.template.yaml rename to src/modules/bmb/workflows/create-module/templates/module.template.yaml diff --git a/src/modules/bmb/workflows/create-module/validation.md b/src/modules/bmb/workflows/create-module/validation.md index 001e28a2..3783b2aa 100644 --- a/src/modules/bmb/workflows/create-module/validation.md +++ b/src/modules/bmb/workflows/create-module/validation.md @@ -13,15 +13,15 @@ This document provides the validation criteria used in step-11-validate.md to en - [ ] data/ - Module data - [ ] \_module-installer/ - Installation config - [ ] README.md - Module documentation +- [ ] module.yaml - module config file -### Required Files in \_module-installer/ +### Optional File in \_module-installer/ -- [ ] install-config.yaml - Installation configuration - [ ] installer.js - Custom logic (if needed) ## Configuration Validation -### install-config.yaml +### module.yaml - [ ] Valid YAML syntax - [ ] Module code matches folder name diff --git a/src/modules/bmb/workflows/create-workflow/steps/step-01-init.md b/src/modules/bmb/workflows/create-workflow/steps/step-01-init.md index 901207f3..796d2eb6 100644 --- a/src/modules/bmb/workflows/create-workflow/steps/step-01-init.md +++ b/src/modules/bmb/workflows/create-workflow/steps/step-01-init.md @@ -98,7 +98,7 @@ After getting the workflow name: Based on the module selection, confirm the target location: - For bmb module: `{custom_workflow_location}` (defaults to `{bmad_folder}/custom/src/workflows`) -- For other modules: Check their install-config.yaml for custom workflow locations +- For other modules: Check their module.yaml for custom workflow locations - Confirm the exact folder path where the workflow will be created - Store the confirmed path as `{targetWorkflowPath}` diff --git a/src/modules/bmb/workflows/create-workflow/steps/step-07-build.md b/src/modules/bmb/workflows/create-workflow/steps/step-07-build.md index a62c8969..9a505b0d 100644 --- a/src/modules/bmb/workflows/create-workflow/steps/step-07-build.md +++ b/src/modules/bmb/workflows/create-workflow/steps/step-07-build.md @@ -109,7 +109,7 @@ Create the workflow folder structure in the target location: ``` For bmb module, this will be: `{bmad_folder}/custom/src/workflows/{workflow_name}/` -For other modules, check their install-config.yaml for custom_workflow_location +For other modules, check their module.yaml for custom_workflow_location ### 3. Generate workflow.md diff --git a/src/modules/bmgd/README.md b/src/modules/bmgd/README.md index 8116b54e..f007cf01 100644 --- a/src/modules/bmgd/README.md +++ b/src/modules/bmgd/README.md @@ -129,8 +129,9 @@ bmgd/ │ (Uses BMM workflows via cross-module references) ├── templates/ ├── data/ +├── module.yaml └── _module-installer/ - └── install-config.yaml + └── installer.js (optional) ``` ## Configuration diff --git a/src/modules/bmgd/_module-installer/install-config.yaml b/src/modules/bmgd/module.yaml similarity index 100% rename from src/modules/bmgd/_module-installer/install-config.yaml rename to src/modules/bmgd/module.yaml diff --git a/src/modules/bmm/_module-installer/installer.js b/src/modules/bmm/_module-installer/installer.js index 79b360a1..d5ddf930 100644 --- a/src/modules/bmm/_module-installer/installer.js +++ b/src/modules/bmm/_module-installer/installer.js @@ -9,7 +9,7 @@ const platformCodes = require(path.join(__dirname, '../../../../tools/cli/lib/pl * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @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 diff --git a/src/modules/bmm/_module-installer/platform-specifics/claude-code.js b/src/modules/bmm/_module-installer/platform-specifics/claude-code.js index 8fee8579..ab96fad0 100644 --- a/src/modules/bmm/_module-installer/platform-specifics/claude-code.js +++ b/src/modules/bmm/_module-installer/platform-specifics/claude-code.js @@ -5,7 +5,7 @@ const chalk = require('chalk'); * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @param {Object} options.config - Module configuration from module.yaml * @param {Object} options.logger - Logger instance for output * @param {Object} options.platformInfo - Platform metadata from global config * @returns {Promise} - Success status diff --git a/src/modules/bmm/_module-installer/platform-specifics/windsurf.js b/src/modules/bmm/_module-installer/platform-specifics/windsurf.js index 13c65d10..d1c6f012 100644 --- a/src/modules/bmm/_module-installer/platform-specifics/windsurf.js +++ b/src/modules/bmm/_module-installer/platform-specifics/windsurf.js @@ -5,7 +5,7 @@ const chalk = require('chalk'); * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @param {Object} options.config - Module configuration from module.yaml * @param {Object} options.logger - Logger instance for output * @returns {Promise} - Success status */ diff --git a/src/modules/bmm/_module-installer/install-config.yaml b/src/modules/bmm/module.yaml similarity index 100% rename from src/modules/bmm/_module-installer/install-config.yaml rename to src/modules/bmm/module.yaml diff --git a/src/modules/cis/_module-installer/installer.js b/src/modules/cis/_module-installer/installer.js index 5178259f..dec5f372 100644 --- a/src/modules/cis/_module-installer/installer.js +++ b/src/modules/cis/_module-installer/installer.js @@ -8,7 +8,7 @@ const chalk = require('chalk'); * * @param {Object} options - Installation options * @param {string} options.projectRoot - The root directory of the target project - * @param {Object} options.config - Module configuration from install-config.yaml + * @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 diff --git a/src/modules/cis/_module-installer/install-config.yaml b/src/modules/cis/module.yaml similarity index 100% rename from src/modules/cis/_module-installer/install-config.yaml rename to src/modules/cis/module.yaml diff --git a/tools/cli/README.md b/tools/cli/README.md index c2dc6d1f..0c8bf4bd 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -98,7 +98,7 @@ The installer is a multi-stage system that handles agent compilation, IDE integr ``` 1. Collect User Input - Target directory, modules, IDEs - - Custom module configuration (via install-config.yaml) + - Custom module configuration (via module.yaml) 2. Pre-Installation - Validate target, check conflicts, backup existing installations @@ -183,12 +183,12 @@ The installer supports **15 IDE environments** through a base-derived architectu ### Custom Module Configuration -Modules define interactive configuration menus via `install-config.yaml` files in their `_module-installer/` directories. +Modules define interactive configuration menus via `module.yaml` files in their `_module-installer/` directories. **Config File Location**: -- Core: `src/core/_module-installer/install-config.yaml` -- Modules: `src/modules/{module}/_module-installer/install-config.yaml` +- Core: `src/core/module.yaml` +- Modules: `src/modules/{module}/module.yaml` **Configuration Types**: diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index 8335d8ee..743c1954 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -183,24 +183,28 @@ class ConfigCollector { // Load module's install config schema // First, try the standard src/modules location - let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml'); + 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))) { + 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 (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'install-config.yaml'); + installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } let configPath = null; let isCustomModule = false; - if (await fs.pathExists(installerConfigPath)) { + 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 @@ -448,22 +452,26 @@ class ConfigCollector { } // Load module's config // First, try the standard src/modules location - let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml'); + 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))) { + 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 (moduleSourcePath) { - installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'install-config.yaml'); + installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'module.yaml'); + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } let configPath = null; - if (await fs.pathExists(installerConfigPath)) { + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else if (await fs.pathExists(installerConfigPath)) { configPath = installerConfigPath; } else { // No config for this module diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/cli/installers/lib/core/custom-module-cache.js new file mode 100644 index 00000000..3ece246d --- /dev/null +++ b/tools/cli/installers/lib/core/custom-module-cache.js @@ -0,0 +1,239 @@ +/** + * Custom Module Source Cache + * Caches custom module sources under _cfg/custom/ to ensure they're never lost + * and can be checked into source control + */ + +const fs = require('fs-extra'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +class CustomModuleCache { + constructor(bmadDir) { + this.bmadDir = bmadDir; + this.customCacheDir = path.join(bmadDir, '_cfg', 'custom'); + this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml'); + } + + /** + * Ensure the custom cache directory exists + */ + async ensureCacheDir() { + await fs.ensureDir(this.customCacheDir); + } + + /** + * Get cache manifest + */ + async getCacheManifest() { + if (!(await fs.pathExists(this.manifestPath))) { + return {}; + } + + const content = await fs.readFile(this.manifestPath, 'utf8'); + const yaml = require('js-yaml'); + return yaml.load(content) || {}; + } + + /** + * Update cache manifest + */ + async updateCacheManifest(manifest) { + const yaml = require('js-yaml'); + const content = yaml.dump(manifest, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(this.manifestPath, content); + } + + /** + * Calculate hash of a file or directory + */ + async calculateHash(sourcePath) { + const hash = crypto.createHash('sha256'); + + const isDir = (await fs.stat(sourcePath)).isDirectory(); + + if (isDir) { + // For directories, hash all files + const files = []; + async function collectFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + files.push(path.join(dir, entry.name)); + } else if (entry.isDirectory() && !entry.name.startsWith('.')) { + await collectFiles(path.join(dir, entry.name)); + } + } + } + + await collectFiles(sourcePath); + files.sort(); // Ensure consistent order + + for (const file of files) { + const content = await fs.readFile(file); + const relativePath = path.relative(sourcePath, file); + hash.update(relativePath + '|' + content.toString('base64')); + } + } else { + // For single files + const content = await fs.readFile(sourcePath); + hash.update(content); + } + + return hash.digest('hex'); + } + + /** + * Cache a custom module source + * @param {string} moduleId - Module ID + * @param {string} sourcePath - Original source path + * @param {Object} metadata - Additional metadata to store + * @returns {Object} Cached module info + */ + async cacheModule(moduleId, sourcePath, metadata = {}) { + await this.ensureCacheDir(); + + const cacheDir = path.join(this.customCacheDir, moduleId); + const cacheManifest = await this.getCacheManifest(); + + // Check if already cached and unchanged + if (cacheManifest[moduleId]) { + const cached = cacheManifest[moduleId]; + if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) { + // Source unchanged, return existing cache info + return { + moduleId, + cachePath: cacheDir, + ...cached, + }; + } + } + + // Remove existing cache if it exists + if (await fs.pathExists(cacheDir)) { + await fs.remove(cacheDir); + } + + // Copy module to cache + await fs.copy(sourcePath, cacheDir, { + filter: (src) => { + const relative = path.relative(sourcePath, src); + // Skip node_modules, .git, and other common ignore patterns + return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store'); + }, + }); + + // Calculate hash of the source + const sourceHash = await this.calculateHash(sourcePath); + const cacheHash = await this.calculateHash(cacheDir); + + // Update manifest - don't store originalPath for source control friendliness + cacheManifest[moduleId] = { + originalHash: sourceHash, + cacheHash: cacheHash, + cachedAt: new Date().toISOString(), + ...metadata, + }; + + await this.updateCacheManifest(cacheManifest); + + return { + moduleId, + cachePath: cacheDir, + ...cacheManifest[moduleId], + }; + } + + /** + * Get cached module info + * @param {string} moduleId - Module ID + * @returns {Object|null} Cached module info or null + */ + async getCachedModule(moduleId) { + const cacheManifest = await this.getCacheManifest(); + const cached = cacheManifest[moduleId]; + + if (!cached) { + return null; + } + + const cacheDir = path.join(this.customCacheDir, moduleId); + + if (!(await fs.pathExists(cacheDir))) { + // Cache dir missing, remove from manifest + delete cacheManifest[moduleId]; + await this.updateCacheManifest(cacheManifest); + return null; + } + + // Verify cache integrity + const currentCacheHash = await this.calculateHash(cacheDir); + if (currentCacheHash !== cached.cacheHash) { + console.warn(`Warning: Cache integrity check failed for ${moduleId}`); + } + + return { + moduleId, + cachePath: cacheDir, + ...cached, + }; + } + + /** + * Get all cached modules + * @returns {Array} Array of cached module info + */ + async getAllCachedModules() { + const cacheManifest = await this.getCacheManifest(); + const cached = []; + + for (const [moduleId, info] of Object.entries(cacheManifest)) { + const cachedModule = await this.getCachedModule(moduleId); + if (cachedModule) { + cached.push(cachedModule); + } + } + + return cached; + } + + /** + * Remove a cached module + * @param {string} moduleId - Module ID to remove + */ + async removeCachedModule(moduleId) { + const cacheManifest = await this.getCacheManifest(); + const cacheDir = path.join(this.customCacheDir, moduleId); + + // Remove cache directory + if (await fs.pathExists(cacheDir)) { + await fs.remove(cacheDir); + } + + // Remove from manifest + delete cacheManifest[moduleId]; + await this.updateCacheManifest(cacheManifest); + } + + /** + * Sync cached modules with a list of module IDs + * @param {Array} moduleIds - Module IDs to keep + */ + async syncCache(moduleIds) { + const cached = await this.getAllCachedModules(); + + for (const cachedModule of cached) { + if (!moduleIds.includes(cachedModule.moduleId)) { + await this.removeCachedModule(cachedModule.moduleId); + } + } + } +} + +module.exports = { CustomModuleCache }; diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js index 5f6edd81..28a91de7 100644 --- a/tools/cli/installers/lib/core/detector.js +++ b/tools/cli/installers/lib/core/detector.js @@ -17,6 +17,7 @@ class Detector { hasCore: false, modules: [], ides: [], + customModules: [], manifest: null, }; @@ -32,6 +33,10 @@ class Detector { result.manifest = manifestData; result.version = manifestData.version; result.installed = true; + // Copy custom modules if they exist + if (manifestData.customModules) { + result.customModules = manifestData.customModules; + } } // Check for core @@ -275,10 +280,9 @@ class Detector { hasV6Installation = true; // Don't break - continue scanning to be thorough } else { - // Not V6+, check if folder name contains "bmad" (case insensitive) - const nameLower = name.toLowerCase(); - if (nameLower.includes('bmad')) { - // Potential V4 legacy folder + // Not V6+, check if this is the exact V4 folder name "bmad-method" + if (name === 'bmad-method') { + // This is the V4 default folder - flag it as legacy potentialV4Folders.push(fullPath); } } diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 7cc6f019..fc299960 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -22,6 +22,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); const ora = require('ora'); +const inquirer = require('inquirer'); const { Detector } = require('./detector'); const { Manifest } = require('./manifest'); const { ModuleManager } = require('../modules/manager'); @@ -750,13 +751,81 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: spinner.text = 'Creating directory structure...'; await this.createDirectoryStructure(bmadDir); - // Resolve dependencies for selected modules - spinner.text = 'Resolving dependencies...'; + // Get project root const projectRoot = getProjectRoot(); - const modulesToInstall = config.installCore ? ['core', ...config.modules] : config.modules; + + // Step 1: Install core module first (if requested) + if (config.installCore) { + spinner.start('Installing BMAD core...'); + await this.installCoreWithDependencies(bmadDir, { core: {} }); + spinner.succeed('Core installed'); + + // Generate core config file + await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); + } + + // Custom content is already handled in UI before module selection + let finalCustomContent = config.customContent; + + // Step 3: Prepare modules list including cached custom modules + let allModules = [...(config.modules || [])]; + + // During quick update, we might have custom module sources from the manifest + if (config._customModuleSources) { + // Add custom modules from stored sources + for (const [moduleId, customInfo] of config._customModuleSources) { + if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) { + allModules.push(moduleId); + } + } + } + + // Add cached custom modules + if (finalCustomContent && finalCustomContent.cachedModules) { + for (const cachedModule of finalCustomContent.cachedModules) { + if (!allModules.includes(cachedModule.id)) { + allModules.push(cachedModule.id); + } + } + } + + // Regular custom content from user input (non-cached) + if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { + // Add custom modules to the installation list + for (const customFile of finalCustomContent.selectedFiles) { + const { CustomHandler } = require('../custom/handler'); + const customHandler = new CustomHandler(); + const customInfo = await customHandler.getCustomInfo(customFile, projectDir); + if (customInfo && customInfo.id) { + allModules.push(customInfo.id); + } + } + } + + // Don't include core again if already installed + if (config.installCore) { + allModules = allModules.filter((m) => m !== 'core'); + } + + const modulesToInstall = allModules; // For dependency resolution, we need to pass the project root - const resolution = await this.dependencyResolver.resolve(projectRoot, config.modules || [], { verbose: config.verbose }); + // Create a temporary module manager that knows about custom content locations + const tempModuleManager = new ModuleManager({ + scanProjectForModules: true, + bmadDir: bmadDir, // Pass bmadDir so we can check cache + }); + + // Make sure custom modules are discoverable + if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { + // The dependency resolver needs to know about these modules + // We'll handle custom modules separately in the installation loop + } + + const resolution = await this.dependencyResolver.resolve(projectRoot, allModules, { + verbose: config.verbose, + moduleManager: tempModuleManager, + }); if (config.verbose) { spinner.succeed('Dependencies resolved'); @@ -764,24 +833,159 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: spinner.succeed('Dependencies resolved'); } - // Install core if requested or if dependencies require it - if (config.installCore || resolution.byModule.core) { - spinner.start('Installing BMAD core...'); - await this.installCoreWithDependencies(bmadDir, resolution.byModule.core); - spinner.succeed('Core installed'); - } + // Core is already installed above, skip if included in resolution // Install modules with their dependencies - if (config.modules && config.modules.length > 0) { - for (const moduleName of config.modules) { + if (allModules && allModules.length > 0) { + const installedModuleNames = new Set(); + + for (const moduleName of allModules) { + // Skip if already installed + if (installedModuleNames.has(moduleName)) { + continue; + } + installedModuleNames.add(moduleName); + spinner.start(`Installing module: ${moduleName}...`); - await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); + + // Check if this is a custom module + let isCustomModule = false; + let customInfo = null; + let useCache = false; + + // First check if we have a cached version + if (finalCustomContent && finalCustomContent.cachedModules) { + const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); + if (cachedModule) { + isCustomModule = true; + customInfo = { + id: moduleName, + path: cachedModule.cachePath, + config: {}, + }; + useCache = true; + } + } + + // Then check if we have custom module sources from the manifest (for quick update) + if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { + customInfo = config._customModuleSources.get(moduleName); + isCustomModule = true; + + // Check if this is a cached module (source path starts with _cfg) + if (customInfo.sourcePath && (customInfo.sourcePath.startsWith('_cfg') || customInfo.sourcePath.includes('_cfg/custom'))) { + useCache = true; + // Make sure we have the right path structure + if (!customInfo.path) { + customInfo.path = customInfo.sourcePath; + } + } + } + + // Finally check regular custom content + if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { + const { CustomHandler } = require('../custom/handler'); + const customHandler = new CustomHandler(); + for (const customFile of finalCustomContent.selectedFiles) { + const info = await customHandler.getCustomInfo(customFile, projectDir); + if (info && info.id === moduleName) { + isCustomModule = true; + customInfo = info; + break; + } + } + } + + if (isCustomModule && customInfo) { + // Install custom module using CustomHandler but as a proper module + const { CustomHandler } = require('../custom/handler'); + const customHandler = new CustomHandler(); + + // Install to module directory instead of custom directory + const moduleTargetPath = path.join(bmadDir, moduleName); + await fs.ensureDir(moduleTargetPath); + + const result = await customHandler.install( + customInfo.path, + path.join(bmadDir, 'temp-custom'), + { ...config.coreConfig, ...customInfo.config, _bmadDir: bmadDir }, + (filePath) => { + // Track installed files with correct path + const relativePath = path.relative(path.join(bmadDir, 'temp-custom'), filePath); + const finalPath = path.join(moduleTargetPath, relativePath); + this.installedFiles.push(finalPath); + }, + ); + + // Move from temp-custom to actual module directory + const tempCustomPath = path.join(bmadDir, 'temp-custom'); + if (await fs.pathExists(tempCustomPath)) { + const customDir = path.join(tempCustomPath, 'custom'); + if (await fs.pathExists(customDir)) { + // Move contents to module directory + const items = await fs.readdir(customDir); + for (const item of items) { + const srcPath = path.join(customDir, item); + const destPath = path.join(moduleTargetPath, item); + + // If destination exists, remove it first (or we could merge) + if (await fs.pathExists(destPath)) { + await fs.remove(destPath); + } + + await fs.move(srcPath, destPath); + } + } + await fs.remove(tempCustomPath); + } + + // Create module config + await this.generateModuleConfigs(bmadDir, { [moduleName]: { ...config.coreConfig, ...customInfo.config } }); + + // Store custom module info for later manifest update + if (!config._customModulesToTrack) { + config._customModulesToTrack = []; + } + + // For cached modules, use appropriate path handling + let sourcePath; + if (useCache) { + // Check if we have cached modules info (from initial install) + if (finalCustomContent && finalCustomContent.cachedModules) { + sourcePath = finalCustomContent.cachedModules.find((m) => m.id === moduleName)?.relativePath; + } else { + // During update, the sourcePath is already cache-relative if it starts with _cfg + sourcePath = + customInfo.sourcePath && customInfo.sourcePath.startsWith('_cfg') + ? customInfo.sourcePath + : path.relative(bmadDir, customInfo.path || customInfo.sourcePath); + } + } else { + sourcePath = path.resolve(customInfo.path || customInfo.sourcePath); + } + + config._customModulesToTrack.push({ + id: customInfo.id, + name: customInfo.name, + sourcePath: sourcePath, + installDate: new Date().toISOString(), + }); + } else { + // Regular module installation + // Special case for core module + if (moduleName === 'core') { + await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); + } else { + await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); + } + } + spinner.succeed(`Module installed: ${moduleName}`); } // Install partial modules (only dependencies) for (const [module, files] of Object.entries(resolution.byModule)) { - if (!config.modules.includes(module) && module !== 'core') { + if (!allModules.includes(module) && module !== 'core') { const totalFiles = files.agents.length + files.tasks.length + @@ -799,6 +1003,11 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } // Install custom content if provided AND selected + // Process custom content that wasn't installed as modules + // This is now handled in the module installation loop above + // This section is kept for backward compatibility with any custom content + // that doesn't have a module structure + const remainingCustomContent = []; if ( config.customContent && config.customContent.hasCustomContent && @@ -806,12 +1015,26 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: config.customContent.selected && config.customContent.selectedFiles ) { - spinner.start('Installing custom content...'); + // Filter out custom modules that were already installed + for (const customFile of config.customContent.selectedFiles) { + const { CustomHandler } = require('../custom/handler'); + const customHandler = new CustomHandler(); + const customInfo = await customHandler.getCustomInfo(customFile, projectDir); + + // Skip if this was installed as a module + if (!customInfo || !customInfo.id || !allModules.includes(customInfo.id)) { + remainingCustomContent.push(customFile); + } + } + } + + if (remainingCustomContent.length > 0) { + spinner.start('Installing remaining custom content...'); const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); - // Use the selected files instead of finding all files - const customFiles = config.customContent.selectedFiles; + // Use the remaining files + const customFiles = remainingCustomContent; if (customFiles.length > 0) { console.log(chalk.cyan(`\n Found ${customFiles.length} custom content file(s):`)); @@ -867,14 +1090,37 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: spinner.start('Generating workflow and agent manifests...'); const manifestGen = new ManifestGenerator(); - // Include preserved modules (from quick update) in the manifest - const allModulesToList = config._preserveModules ? [...(config.modules || []), ...config._preserveModules] : config.modules || []; + // 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 || []; - const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || [], this.installedFiles, { + // 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: config._preserveModules || [], // Scan these from installed bmad/ dir + preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir }); + // Add custom modules to manifest (now that it exists) + if (config._customModulesToTrack && config._customModulesToTrack.length > 0) { + spinner.text = 'Storing custom module sources...'; + for (const customModule of config._customModulesToTrack) { + await this.manifest.addCustomModule(bmadDir, customModule); + } + } + spinner.succeed( `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`, ); @@ -1137,6 +1383,30 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: const currentVersion = existingInstall.version; const newVersion = require(path.join(getProjectRoot(), 'package.json')).version; + // Check for custom modules with missing sources before update + const customModuleSources = new Map(); + if (existingInstall.customModules) { + for (const customModule of existingInstall.customModules) { + customModuleSources.set(customModule.id, customModule); + } + } + + if (customModuleSources.size > 0) { + spinner.stop(); + console.log(chalk.yellow('\nChecking custom module sources before update...')); + + const projectRoot = getProjectRoot(); + await this.handleMissingCustomSources( + customModuleSources, + bmadDir, + projectRoot, + 'update', + existingInstall.modules.map((m) => m.id), + ); + + spinner.start('Preparing update...'); + } + if (config.dryRun) { spinner.stop(); console.log(chalk.cyan('\n🔍 Update Preview (Dry Run)\n')); @@ -1905,6 +2175,24 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: throw new Error(`BMAD not installed at ${bmadDir}`); } + // Check for custom modules with missing sources + const manifest = await this.manifest.read(bmadDir); + if (manifest && manifest.customModules && manifest.customModules.length > 0) { + spinner.stop(); + console.log(chalk.yellow('\nChecking custom module sources before compilation...')); + + const customModuleSources = new Map(); + for (const customModule of manifest.customModules) { + customModuleSources.set(customModule.id, customModule); + } + + const projectRoot = getProjectRoot(); + const installedModules = manifest.modules || []; + await this.handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, 'compile-agents', installedModules); + + spinner.start('Rebuilding agent files...'); + } + let agentCount = 0; let taskCount = 0; @@ -2056,17 +2344,245 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: const existingInstall = await this.detector.detect(bmadDir); const installedModules = existingInstall.modules.map((m) => m.id); const configuredIdes = existingInstall.ides || []; + const projectRoot = path.dirname(bmadDir); + + // Get custom module sources from manifest + const customModuleSources = new Map(); + if (existingInstall.customModules) { + for (const customModule of existingInstall.customModules) { + // Ensure we have an absolute sourcePath + let absoluteSourcePath = customModule.sourcePath; + + // Check if sourcePath is a cache-relative path (starts with _cfg/) + if (absoluteSourcePath && absoluteSourcePath.startsWith('_cfg')) { + // Convert cache-relative path to absolute path + absoluteSourcePath = path.join(bmadDir, absoluteSourcePath); + } + // If no sourcePath but we have relativePath, convert it + else if (!absoluteSourcePath && customModule.relativePath) { + // relativePath is relative to the project root (parent of bmad dir) + absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath); + } + // Ensure sourcePath is absolute for anything else + else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) { + absoluteSourcePath = path.resolve(absoluteSourcePath); + } + + // Update the custom module object with the absolute path + const updatedModule = { + ...customModule, + sourcePath: absoluteSourcePath, + }; + + customModuleSources.set(customModule.id, updatedModule); + } + } // Load saved IDE configurations const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); // Get available modules (what we have source for) - const availableModules = await this.moduleManager.listAvailable(); - const availableModuleIds = new Set(availableModules.map((m) => m.id)); + const availableModulesData = await this.moduleManager.listAvailable(); + const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; + + // Add custom modules from manifest if their sources exist + for (const [moduleId, customModule] of customModuleSources) { + // Use the absolute sourcePath + const sourcePath = customModule.sourcePath; + + // Check if source exists at the recorded path + if ( + sourcePath && + (await fs.pathExists(sourcePath)) && // Add to available modules if not already there + !availableModules.some((m) => m.id === moduleId) + ) { + availableModules.push({ + id: moduleId, + name: customModule.name || moduleId, + path: sourcePath, + isCustom: true, + fromManifest: true, + }); + } + } + + // Check for untracked custom modules (installed but not in manifest) + const untrackedCustomModules = []; + for (const installedModule of installedModules) { + // Skip standard modules and core + const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core']; + if (standardModuleIds.includes(installedModule)) { + continue; + } + + // Check if this installed module is not tracked in customModules + if (!customModuleSources.has(installedModule)) { + const modulePath = path.join(bmadDir, installedModule); + if (await fs.pathExists(modulePath)) { + untrackedCustomModules.push({ + id: installedModule, + name: installedModule, // We don't have the original name + path: modulePath, + untracked: true, + }); + } + } + } + + // If we found untracked custom modules, offer to track them + if (untrackedCustomModules.length > 0) { + spinner.stop(); + console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`)); + + for (const untracked of untrackedCustomModules) { + console.log(chalk.dim(` • ${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`)); + } + + const { trackModules } = await inquirer.prompt([ + { + type: 'confirm', + name: 'trackModules', + message: chalk.cyan('Would you like to scan for their source locations?'), + default: true, + }, + ]); + + if (trackModules) { + const { scanDirectory } = await inquirer.prompt([ + { + type: 'input', + name: 'scanDirectory', + message: 'Enter directory to scan for custom module sources (or leave blank to skip):', + default: projectRoot, + validate: async (input) => { + if (input && input.trim() !== '') { + const expandedPath = path.resolve(input.trim()); + if (!(await fs.pathExists(expandedPath))) { + return 'Directory does not exist'; + } + const stats = await fs.stat(expandedPath); + if (!stats.isDirectory()) { + return 'Path must be a directory'; + } + } + return true; + }, + }, + ]); + + if (scanDirectory && scanDirectory.trim() !== '') { + console.log(chalk.dim('\nScanning for custom module sources...')); + + // Scan for all module.yaml files + const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory); + const { ModuleManager } = require('../modules/manager'); + const mm = new ModuleManager({ scanProjectForModules: true }); + + for (const untracked of untrackedCustomModules) { + let foundSource = null; + + // Try to find by module ID + for (const modulePath of allModulePaths) { + try { + const moduleInfo = await mm.getModuleInfo(modulePath); + if (moduleInfo && moduleInfo.id === untracked.id) { + foundSource = { + path: modulePath, + info: moduleInfo, + }; + break; + } + } catch { + // Continue searching + } + } + + if (foundSource) { + console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`)); + + // Add to manifest + await this.manifest.addCustomModule(bmadDir, { + id: untracked.id, + name: foundSource.info.name || untracked.name, + sourcePath: path.resolve(foundSource.path), + installDate: new Date().toISOString(), + tracked: true, + }); + + // Add to customModuleSources for processing + customModuleSources.set(untracked.id, { + id: untracked.id, + name: foundSource.info.name || untracked.name, + sourcePath: path.resolve(foundSource.path), + }); + } else { + console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`)); + } + } + } + } + + console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.')); + spinner.start('Preparing update...'); + } + + // Handle missing custom module sources using shared method + const customModuleResult = await this.handleMissingCustomSources( + customModuleSources, + bmadDir, + projectRoot, + 'update', + installedModules, + ); + + // Handle both old return format (array) and new format (object) + let validCustomModules = []; + let keptModulesWithoutSources = []; + + if (Array.isArray(customModuleResult)) { + // Old format - just an array + validCustomModules = customModuleResult; + } else if (customModuleResult && typeof customModuleResult === 'object') { + // New format - object with two arrays + validCustomModules = customModuleResult.validCustomModules || []; + keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || []; + } + + const customModulesFromManifest = validCustomModules.map((m) => ({ + ...m, + isCustom: true, + hasUpdate: true, + })); + + // Add untracked modules to the update list but mark them as untrackable + for (const untracked of untrackedCustomModules) { + if (!customModuleSources.has(untracked.id)) { + customModulesFromManifest.push({ + ...untracked, + isCustom: true, + hasUpdate: false, // Can't update without source + untracked: true, + }); + } + } + + const allAvailableModules = [...availableModules, ...customModulesFromManifest]; + const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); + + // Core module is special - never include it in update flow + const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core'); // Only update modules that are BOTH installed AND available (we have source for) - const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id)); - const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id)); + const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id)); + const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id)); + + // Add custom modules that were kept without sources to the skipped modules + // This ensures their agents are preserved in the manifest + for (const keptModule of keptModulesWithoutSources) { + if (!skippedModules.includes(keptModule)) { + skippedModules.push(keptModule); + } + } spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`); @@ -2131,6 +2647,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: _quickUpdate: true, // Flag to skip certain prompts _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them _savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer + _customModuleSources: customModuleSources, // Pass custom module sources for updates + _existingModules: installedModules, // Pass all installed modules for manifest generation }; // Call the standard install method @@ -2770,6 +3288,230 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } } } + + /** + * Handle missing custom module sources interactively + * @param {Map} customModuleSources - Map of custom module ID to info + * @param {string} bmadDir - BMAD directory + * @param {string} projectRoot - Project root directory + * @param {string} operation - Current operation ('update', 'compile', etc.) + * @param {Array} installedModules - Array of installed module IDs (will be modified) + * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array + */ + async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) { + const validCustomModules = []; + const keptModulesWithoutSources = []; // Track modules kept without sources + const customModulesWithMissingSources = []; + + // Check which sources exist + for (const [moduleId, customInfo] of customModuleSources) { + if (await fs.pathExists(customInfo.sourcePath)) { + validCustomModules.push({ + id: moduleId, + name: customInfo.name, + path: customInfo.sourcePath, + info: customInfo, + }); + } else { + customModulesWithMissingSources.push({ + id: moduleId, + name: customInfo.name, + sourcePath: customInfo.sourcePath, + relativePath: customInfo.relativePath, + info: customInfo, + }); + } + } + + // If no missing sources, return immediately + if (customModulesWithMissingSources.length === 0) { + return validCustomModules; + } + + // Stop any spinner for interactive prompts + const currentSpinner = ora(); + if (currentSpinner.isSpinning) { + currentSpinner.stop(); + } + + console.log(chalk.yellow(`\n⚠️ Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`)); + + const inquirer = require('inquirer'); + let keptCount = 0; + let updatedCount = 0; + let removedCount = 0; + + for (const missing of customModulesWithMissingSources) { + console.log(chalk.dim(` • ${missing.name} (${missing.id})`)); + console.log(chalk.dim(` Original source: ${missing.relativePath}`)); + console.log(chalk.dim(` Full path: ${missing.sourcePath}`)); + + const choices = [ + { + name: 'Keep installed (will not be processed)', + value: 'keep', + short: 'Keep', + }, + { + name: 'Specify new source location', + value: 'update', + short: 'Update', + }, + ]; + + // Only add remove option if not just compiling agents + if (operation !== 'compile-agents') { + choices.push({ + name: '⚠️ REMOVE module completely (destructive!)', + value: 'remove', + short: 'Remove', + }); + } + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: `How would you like to handle "${missing.name}"?`, + choices, + }, + ]); + + switch (action) { + case 'update': { + const { newSourcePath } = await inquirer.prompt([ + { + type: 'input', + name: 'newSourcePath', + message: 'Enter the new path to the custom module:', + default: missing.sourcePath, + validate: async (input) => { + if (!input || input.trim() === '') { + return 'Please enter a path'; + } + const expandedPath = path.resolve(input.trim()); + if (!(await fs.pathExists(expandedPath))) { + return 'Path does not exist'; + } + // Check if it looks like a valid module + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + const agentsPath = path.join(expandedPath, 'agents'); + const workflowsPath = path.join(expandedPath, 'workflows'); + + if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) { + return 'Path does not appear to contain a valid custom module'; + } + return true; + }, + }, + ]); + + // Update the source in manifest + const resolvedPath = path.resolve(newSourcePath.trim()); + missing.info.sourcePath = resolvedPath; + // Remove relativePath - we only store absolute sourcePath now + delete missing.info.relativePath; + await this.manifest.addCustomModule(bmadDir, missing.info); + + validCustomModules.push({ + id: moduleId, + name: missing.name, + path: resolvedPath, + info: missing.info, + }); + + updatedCount++; + console.log(chalk.green(`✓ Updated source location`)); + + break; + } + case 'remove': { + // Extra confirmation for destructive remove + console.log(chalk.red.bold(`\n⚠️ WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`)); + console.log(chalk.red(` Module location: ${path.join(bmadDir, moduleId)}`)); + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), + default: false, + }, + ]); + + if (confirm) { + const { typedConfirm } = await inquirer.prompt([ + { + type: 'input', + name: 'typedConfirm', + message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), + validate: (input) => { + if (input !== 'DELETE') { + return chalk.red('You must type "DELETE" exactly to proceed'); + } + return true; + }, + }, + ]); + + if (typedConfirm === 'DELETE') { + // Remove the module from filesystem and manifest + const modulePath = path.join(bmadDir, moduleId); + if (await fs.pathExists(modulePath)) { + const fsExtra = require('fs-extra'); + await fsExtra.remove(modulePath); + console.log(chalk.yellow(` ✓ Deleted module directory: ${path.relative(projectRoot, modulePath)}`)); + } + + await this.manifest.removeModule(bmadDir, moduleId); + await this.manifest.removeCustomModule(bmadDir, moduleId); + console.log(chalk.yellow(` ✓ Removed from manifest`)); + + // Also remove from installedModules list + if (installedModules && installedModules.includes(moduleId)) { + const index = installedModules.indexOf(moduleId); + if (index !== -1) { + installedModules.splice(index, 1); + } + } + + removedCount++; + console.log(chalk.red.bold(`✓ "${missing.name}" has been permanently removed`)); + } else { + console.log(chalk.dim(' Removal cancelled - module will be kept')); + keptCount++; + } + } else { + console.log(chalk.dim(' Removal cancelled - module will be kept')); + keptCount++; + } + + break; + } + case 'keep': { + keptCount++; + keptModulesWithoutSources.push(moduleId); + console.log(chalk.dim(` Module will be kept as-is`)); + + break; + } + // No default + } + } + + // Show summary + if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { + console.log(chalk.dim(`\nSummary for custom modules with missing sources:`)); + if (keptCount > 0) console.log(chalk.dim(` • ${keptCount} module(s) kept as-is`)); + if (updatedCount > 0) console.log(chalk.dim(` • ${updatedCount} module(s) updated with new sources`)); + if (removedCount > 0) console.log(chalk.red(` • ${removedCount} module(s) permanently deleted`)); + } + + return { + validCustomModules, + keptModulesWithoutSources, + }; + } } module.exports = { Installer }; diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 683e1438..71b23605 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -41,7 +41,11 @@ class ManifestGenerator { // Deduplicate modules list to prevent duplicates this.modules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])]; this.updatedModules = [...new Set(['core', ...selectedModules, ...installedModules])]; // All installed modules get rescanned - this.preservedModules = preservedModules; // These stay as-is in CSVs + + // For CSV manifests, we need to include ALL modules that are installed + // preservedModules controls which modules stay as-is in the CSV (don't get rescanned) + // But all modules should be included in the final manifest + this.preservedModules = [...new Set([...preservedModules, ...selectedModules, ...installedModules])]; // Include all installed modules this.bmadDir = bmadDir; this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '.bmad' or 'bmad') this.allInstalledFiles = installedFiles; @@ -61,14 +65,14 @@ class ManifestGenerator { // Collect workflow data await this.collectWorkflows(selectedModules); - // Collect agent data - await this.collectAgents(selectedModules); + // Collect agent data - use updatedModules which includes all installed modules + await this.collectAgents(this.updatedModules); // Collect task data - await this.collectTasks(selectedModules); + await this.collectTasks(this.updatedModules); // Collect tool data - await this.collectTools(selectedModules); + await this.collectTools(this.updatedModules); // Write manifest files and collect their paths const manifestFiles = [ @@ -450,6 +454,21 @@ class ManifestGenerator { async writeMainManifest(cfgDir) { const manifestPath = path.join(cfgDir, 'manifest.yaml'); + // Read existing manifest to preserve custom modules + let existingCustomModules = []; + if (await fs.pathExists(manifestPath)) { + try { + const existingContent = await fs.readFile(manifestPath, 'utf8'); + const existingManifest = yaml.load(existingContent); + if (existingManifest && existingManifest.customModules) { + existingCustomModules = existingManifest.customModules; + } + } catch { + // If we can't read the existing manifest, continue without preserving custom modules + console.warn('Warning: Could not read existing manifest to preserve custom modules'); + } + } + const manifest = { installation: { version: packageJson.version, @@ -457,6 +476,7 @@ class ManifestGenerator { lastUpdated: new Date().toISOString(), }, modules: this.modules, + customModules: existingCustomModules, // Preserve custom modules ides: this.selectedIdes, }; @@ -562,12 +582,47 @@ class ManifestGenerator { async writeWorkflowManifest(cfgDir) { const csvPath = path.join(cfgDir, 'workflow-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 4) { + const name = parts[0].replace(/^"/, ''); + const module = parts[2]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header - removed standalone column as ALL workflows now generate commands let csv = 'name,description,module,path\n'; - // Add all workflows - no standalone property needed anymore + // Combine existing and new workflows + const allWorkflows = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allWorkflows.set(key, value); + } + + // Add/update new workflows for (const workflow of this.workflows) { - csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`; + const key = `${workflow.module}:${workflow.name}`; + allWorkflows.set(key, `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"`); + } + + // Write all workflows + for (const [, value] of allWorkflows) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -581,12 +636,50 @@ class ManifestGenerator { async writeAgentManifest(cfgDir) { const csvPath = path.join(cfgDir, 'agent-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 11) { + const name = parts[0].replace(/^"/, ''); + const module = parts[8]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with persona fields let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; - // Add all agents + // Combine existing and new agents, preferring new data for duplicates + const allAgents = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allAgents.set(key, value); + } + + // Add/update new agents for (const agent of this.agents) { - csv += `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"\n`; + const key = `${agent.module}:${agent.name}`; + allAgents.set( + key, + `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"`, + ); + } + + // Write all agents + for (const [, value] of allAgents) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -600,12 +693,47 @@ class ManifestGenerator { async writeTaskManifest(cfgDir) { const csvPath = path.join(cfgDir, 'task-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 6) { + const name = parts[0].replace(/^"/, ''); + const module = parts[3]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with standalone column let csv = 'name,displayName,description,module,path,standalone\n'; - // Add all tasks + // Combine existing and new tasks + const allTasks = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allTasks.set(key, value); + } + + // Add/update new tasks for (const task of this.tasks) { - csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"\n`; + const key = `${task.module}:${task.name}`; + allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`); + } + + // Write all tasks + for (const [, value] of allTasks) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -619,12 +747,47 @@ class ManifestGenerator { async writeToolManifest(cfgDir) { const csvPath = path.join(cfgDir, 'tool-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 6) { + const name = parts[0].replace(/^"/, ''); + const module = parts[3]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with standalone column let csv = 'name,displayName,description,module,path,standalone\n'; - // Add all tools + // Combine existing and new tools + const allTools = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allTools.set(key, value); + } + + // Add/update new tools for (const tool of this.tools) { - csv += `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"\n`; + const key = `${tool.module}:${tool.name}`; + allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`); + } + + // Write all tools + for (const [, value] of allTools) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js index e0cf1cd8..ce12304f 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/cli/installers/lib/core/manifest.js @@ -61,6 +61,7 @@ class Manifest { installDate: manifestData.installation?.installDate, lastUpdated: manifestData.installation?.lastUpdated, modules: manifestData.modules || [], + customModules: manifestData.customModules || [], ides: manifestData.ides || [], }; } catch (error) { @@ -93,6 +94,7 @@ class Manifest { lastUpdated: manifest.lastUpdated, }, modules: manifest.modules || [], + customModules: manifest.customModules || [], ides: manifest.ides || [], }; @@ -535,6 +537,51 @@ class Manifest { return configs; } + /** + * Add a custom module to the manifest with its source path + * @param {string} bmadDir - Path to bmad directory + * @param {Object} customModule - Custom module info + */ + async addCustomModule(bmadDir, customModule) { + const manifest = await this.read(bmadDir); + if (!manifest) { + throw new Error('No manifest found'); + } + + if (!manifest.customModules) { + manifest.customModules = []; + } + + // Check if custom module already exists + const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id); + if (existingIndex === -1) { + // Add new entry + manifest.customModules.push(customModule); + } else { + // Update existing entry + manifest.customModules[existingIndex] = customModule; + } + + await this.update(bmadDir, { customModules: manifest.customModules }); + } + + /** + * Remove a custom module from the manifest + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleId - Module ID to remove + */ + async removeCustomModule(bmadDir, moduleId) { + const manifest = await this.read(bmadDir); + if (!manifest || !manifest.customModules) { + return; + } + + const index = manifest.customModules.findIndex((m) => m.id === moduleId); + if (index !== -1) { + manifest.customModules.splice(index, 1); + await this.update(bmadDir, { customModules: manifest.customModules }); + } + } } module.exports = { Manifest }; diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index dddec7e5..3f6f46d0 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -3,6 +3,7 @@ const fs = require('fs-extra'); const chalk = require('chalk'); const yaml = require('js-yaml'); const { FileOps } = require('../../../lib/file-ops'); +const { XmlHandler } = require('../../../lib/xml-handler'); /** * Handler for custom content (custom.yaml) @@ -11,6 +12,7 @@ const { FileOps } = require('../../../lib/file-ops'); class CustomHandler { constructor() { this.fileOps = new FileOps(); + this.xmlHandler = new XmlHandler(); } /** @@ -52,6 +54,12 @@ class CustomHandler { } else if (entry.name === 'custom.yaml') { // 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) + // Skip if it's in src/modules (those are standard modules) + !fullPath.includes(path.join('src', 'modules')) + ) { + customPaths.push(fullPath); } } } catch { @@ -66,40 +74,44 @@ class CustomHandler { } /** - * Get custom content info from a custom.yaml file - * @param {string} customYamlPath - Path to custom.yaml file + * Get custom content info from a custom.yaml or module.yaml file + * @param {string} configPath - Path to config file * @param {string} projectRoot - Project root directory for calculating relative paths * @returns {Object|null} Custom content info */ - async getCustomInfo(customYamlPath, projectRoot = null) { + async getCustomInfo(configPath, projectRoot = null) { try { - const configContent = await fs.readFile(customYamlPath, 'utf8'); + const configContent = await fs.readFile(configPath, 'utf8'); // Try to parse YAML with error handling let config; try { config = yaml.load(configContent); } catch (parseError) { - console.warn(chalk.yellow(`Warning: YAML parse error in ${customYamlPath}:`, parseError.message)); + console.warn(chalk.yellow(`Warning: YAML parse error in ${configPath}:`, parseError.message)); return null; } - const customDir = path.dirname(customYamlPath); + // Check if this is an module.yaml (module) or custom.yaml (custom content) + const isInstallConfig = configPath.endsWith('module.yaml'); + const configDir = path.dirname(configPath); + // Use provided projectRoot or fall back to process.cwd() const basePath = projectRoot || process.cwd(); - const relativePath = path.relative(basePath, customDir); + const relativePath = path.relative(basePath, configDir); return { - id: config.code || path.basename(customDir), - name: config.name || `Custom: ${path.basename(customDir)}`, - description: config.description || 'Custom agents and workflows', - path: customDir, + id: config.code || 'unknown-code', + name: config.name, + description: config.description || '', + path: configDir, relativePath: relativePath, defaultSelected: config.default_selected === true, config: config, + isInstallConfig: isInstallConfig, // Track which type this is }; } catch (error) { - console.warn(chalk.yellow(`Warning: Failed to read ${customYamlPath}:`, error.message)); + console.warn(chalk.yellow(`Warning: Failed to read ${configPath}:`, error.message)); return null; } } @@ -131,10 +143,10 @@ class CustomHandler { await fs.ensureDir(bmadAgentsDir); await fs.ensureDir(bmadWorkflowsDir); - // Process agents - copy entire agents directory structure + // Process agents - compile and copy agents const agentsDir = path.join(customPath, 'agents'); if (await fs.pathExists(agentsDir)) { - await this.copyDirectory(agentsDir, bmadAgentsDir, results, fileTrackingCallback, config); + await this.compileAndCopyAgents(agentsDir, bmadAgentsDir, bmadDir, config, fileTrackingCallback, results); // Count agent files const agentFiles = await this.findFilesRecursively(agentsDir, ['.agent.yaml', '.md']); @@ -271,6 +283,114 @@ class CustomHandler { } } } + + /** + * Compile .agent.yaml files to .md format and handle sidecars + * @param {string} sourceAgentsPath - Source agents directory + * @param {string} targetAgentsPath - Target agents directory + * @param {string} bmadDir - BMAD installation directory + * @param {Object} config - Configuration for placeholder replacement + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} results - Results object to update + */ + async compileAndCopyAgents(sourceAgentsPath, targetAgentsPath, bmadDir, config, fileTrackingCallback, results) { + // Get all .agent.yaml files recursively + const agentFiles = await this.findFilesRecursively(sourceAgentsPath, ['.agent.yaml']); + + for (const agentFile of agentFiles) { + const relativePath = path.relative(sourceAgentsPath, agentFile); + const targetDir = path.join(targetAgentsPath, path.dirname(relativePath)); + + await fs.ensureDir(targetDir); + + const agentName = path.basename(agentFile, '.agent.yaml'); + const targetMdPath = path.join(targetDir, `${agentName}.md`); + // Use the actual bmadDir if available (for when installing to temp dir) + const actualBmadDir = config._bmadDir || bmadDir; + const customizePath = path.join(actualBmadDir, '_cfg', 'agents', `custom-${agentName}.customize.yaml`); + + // Read and compile the YAML + try { + const yamlContent = await fs.readFile(agentFile, 'utf8'); + const { compileAgent } = require('../../../lib/agent/compiler'); + + // Create customize template if it doesn't exist + if (!(await fs.pathExists(customizePath))) { + const { getSourcePath } = require('../../../lib/project-root'); + const genericTemplatePath = getSourcePath('utility', 'templates', 'agent.customize.template.yaml'); + if (await fs.pathExists(genericTemplatePath)) { + // Copy with placeholder replacement + let templateContent = await fs.readFile(genericTemplatePath, 'utf8'); + templateContent = templateContent.replaceAll('{bmad_folder}', config.bmad_folder || 'bmad'); + await fs.writeFile(customizePath, templateContent, 'utf8'); + console.log(chalk.dim(` Created customize: custom-${agentName}.customize.yaml`)); + } + } + + // Compile the agent + const { xml } = compileAgent(yamlContent, {}, agentName, relativePath, { config }); + + // Replace placeholders in the compiled content + let processedXml = xml; + processedXml = processedXml.replaceAll('{bmad_folder}', config.bmad_folder || 'bmad'); + processedXml = processedXml.replaceAll('{user_name}', config.user_name || 'User'); + processedXml = processedXml.replaceAll('{communication_language}', config.communication_language || 'English'); + processedXml = processedXml.replaceAll('{output_folder}', config.output_folder || 'docs'); + + // Write the compiled MD file + await fs.writeFile(targetMdPath, processedXml, 'utf8'); + + // Check if agent has sidecar + let hasSidecar = false; + try { + const yamlLib = require('yaml'); + const agentYaml = yamlLib.parse(yamlContent); + hasSidecar = agentYaml?.agent?.metadata?.hasSidecar === true; + } catch { + // Continue without sidecar processing + } + + // Copy sidecar files if agent has hasSidecar flag + if (hasSidecar && config.agent_sidecar_folder) { + const { copyAgentSidecarFiles } = require('../../../lib/agent/installer'); + + // Resolve agent sidecar folder path + const projectDir = path.dirname(bmadDir); + const resolvedSidecarFolder = config.agent_sidecar_folder + .replaceAll('{project-root}', projectDir) + .replaceAll('{bmad_folder}', path.basename(bmadDir)); + + // Create sidecar directory for this agent + const agentSidecarDir = path.join(resolvedSidecarFolder, agentName); + await fs.ensureDir(agentSidecarDir); + + // Copy sidecar files + const sidecarResult = copyAgentSidecarFiles(path.dirname(agentFile), agentSidecarDir, agentFile); + + if (sidecarResult.copied.length > 0) { + console.log(chalk.dim(` Copied ${sidecarResult.copied.length} sidecar file(s) to: ${agentSidecarDir}`)); + } + if (sidecarResult.preserved.length > 0) { + console.log(chalk.dim(` Preserved ${sidecarResult.preserved.length} existing sidecar file(s)`)); + } + } + + // Track the file + if (fileTrackingCallback) { + fileTrackingCallback(targetMdPath); + } + + console.log( + chalk.dim( + ` Compiled agent: ${agentName} -> ${path.relative(targetAgentsPath, targetMdPath)}${hasSidecar ? ' (with sidecar)' : ''}`, + ), + ); + } catch (error) { + console.warn(chalk.yellow(` Failed to compile agent ${agentName}:`, error.message)); + results.errors.push(`Failed to compile agent ${agentName}: ${error.message}`); + } + } + } } module.exports = { CustomHandler }; diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 79fd183d..9fc63caa 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -22,11 +22,12 @@ const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/p * await manager.install('core-module', '/path/to/bmad'); */ class ModuleManager { - constructor() { + constructor(options = {}) { // Path to source modules directory this.modulesSourcePath = getSourcePath('modules'); this.xmlHandler = new XmlHandler(); this.bmadFolderName = 'bmad'; // Default, can be overridden + this.scanProjectForModules = options.scanProjectForModules !== false; // Default to true for backward compatibility } /** @@ -106,7 +107,7 @@ class ModuleManager { } /** - * Find all modules in the project by searching for install-config.yaml files + * Find all modules in the project by searching for module.yaml files * @returns {Array} List of module paths */ async findModulesInProject() { @@ -143,12 +144,14 @@ class ModuleManager { continue; } - // Check if this directory contains a module (install-config.yaml OR custom.yaml) - const installerConfigPath = path.join(fullPath, '_module-installer', 'install-config.yaml'); + // Check if this directory contains a module (module.yaml OR custom.yaml) + const moduleConfigPath = path.join(fullPath, 'module.yaml'); + const installerConfigPath = path.join(fullPath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(fullPath, '_module-installer', 'custom.yaml'); const rootCustomConfigPath = path.join(fullPath, 'custom.yaml'); if ( + (await fs.pathExists(moduleConfigPath)) || (await fs.pathExists(installerConfigPath)) || (await fs.pathExists(customConfigPath)) || (await fs.pathExists(rootCustomConfigPath)) @@ -175,10 +178,11 @@ class ModuleManager { /** * List all available modules (excluding core which is always installed) - * @returns {Array} List of available modules with metadata + * @returns {Object} Object with modules array and customModules array */ async listAvailable() { const modules = []; + const customModules = []; // First, scan src/modules (the standard location) if (await fs.pathExists(this.modulesSourcePath)) { @@ -187,12 +191,17 @@ class ModuleManager { for (const entry of entries) { if (entry.isDirectory()) { const modulePath = path.join(this.modulesSourcePath, entry.name); - // Check for module structure (install-config.yaml OR custom.yaml) - const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml'); + // Check for module structure (module.yaml OR custom.yaml) + const moduleConfigPath = path.join(modulePath, 'module.yaml'); + const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); // Skip if this doesn't look like a module - if (!(await fs.pathExists(installerConfigPath)) && !(await fs.pathExists(customConfigPath))) { + if ( + !(await fs.pathExists(moduleConfigPath)) && + !(await fs.pathExists(installerConfigPath)) && + !(await fs.pathExists(customConfigPath)) + ) { continue; } @@ -209,25 +218,50 @@ class ModuleManager { } } - // Then, find all other modules in the project - const otherModulePaths = await this.findModulesInProject(); - for (const modulePath of otherModulePaths) { - const moduleName = path.basename(modulePath); - const relativePath = path.relative(getProjectRoot(), modulePath); + // Then, find all other modules in the project (only if scanning is enabled) + if (this.scanProjectForModules) { + const otherModulePaths = await this.findModulesInProject(); + for (const modulePath of otherModulePaths) { + const moduleName = path.basename(modulePath); + const relativePath = path.relative(getProjectRoot(), modulePath); - // Skip core module - it's always installed first and not selectable - if (moduleName === 'core') { - continue; + // Skip core module - it's always installed first and not selectable + if (moduleName === 'core') { + continue; + } + + const moduleInfo = await this.getModuleInfo(modulePath, moduleName, relativePath); + if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { + // Avoid duplicates - skip if we already have this module ID + if (moduleInfo.isCustom) { + customModules.push(moduleInfo); + } else { + modules.push(moduleInfo); + } + } } - const moduleInfo = await this.getModuleInfo(modulePath, moduleName, relativePath); - if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id)) { - // Avoid duplicates - skip if we already have this module ID - modules.push(moduleInfo); + // Also check for cached custom modules in _cfg/custom/ + if (this.bmadDir) { + const customCacheDir = path.join(this.bmadDir, '_cfg', 'custom'); + if (await fs.pathExists(customCacheDir)) { + const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true }); + for (const entry of cacheEntries) { + if (entry.isDirectory()) { + const cachePath = path.join(customCacheDir, entry.name); + const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_cfg/custom'); + if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { + moduleInfo.isCustom = true; + moduleInfo.fromCache = true; + customModules.push(moduleInfo); + } + } + } + } } } - return modules; + return { modules, customModules }; } /** @@ -238,13 +272,16 @@ class ModuleManager { * @returns {Object|null} Module info or null if not a valid module */ async getModuleInfo(modulePath, defaultName, sourceDescription) { - // Check for module structure (install-config.yaml OR custom.yaml) - const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml'); + // 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(installerConfigPath)) { + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else if (await fs.pathExists(installerConfigPath)) { configPath = installerConfigPath; } else if (await fs.pathExists(customConfigPath)) { configPath = customConfigPath; @@ -305,10 +342,11 @@ class ModuleManager { // First, check src/modules const srcModulePath = path.join(this.modulesSourcePath, moduleName); if (await fs.pathExists(srcModulePath)) { - // Check if this looks like a module (has install-config.yaml) - const installerConfigPath = path.join(srcModulePath, '_module-installer', 'install-config.yaml'); + // Check if this looks like a module (has module.yaml) + const moduleConfigPath = path.join(srcModulePath, 'module.yaml'); + const installerConfigPath = path.join(srcModulePath, '_module-installer', 'module.yaml'); - if (await fs.pathExists(installerConfigPath)) { + if ((await fs.pathExists(moduleConfigPath)) || (await fs.pathExists(installerConfigPath))) { return srcModulePath; } @@ -330,12 +368,15 @@ class ModuleManager { // Also check by module ID (not just folder name) // Need to read configs to match by ID for (const modulePath of allModulePaths) { - const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.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(installerConfigPath)) { + if (await fs.pathExists(moduleConfigPath)) { + configPath = moduleConfigPath; + } else if (await fs.pathExists(installerConfigPath)) { configPath = installerConfigPath; } else if (await fs.pathExists(customConfigPath)) { configPath = customConfigPath; @@ -576,7 +617,7 @@ class ModuleManager { } // Skip _module-installer directory - it's only needed at install time - if (file.startsWith('_module-installer/')) { + if (file.startsWith('_module-installer/') || file === 'module.yaml') { continue; } diff --git a/tools/cli/lib/cli-utils.js b/tools/cli/lib/cli-utils.js index 57489970..da193363 100644 --- a/tools/cli/lib/cli-utils.js +++ b/tools/cli/lib/cli-utils.js @@ -3,6 +3,7 @@ const boxen = require('boxen'); const wrapAnsi = require('wrap-ansi'); const figlet = require('figlet'); const path = require('node:path'); +const os = require('node:os'); const CLIUtils = { /** @@ -84,8 +85,8 @@ const CLIUtils = { /** * Display module configuration header * @param {string} moduleName - Module name (fallback if no custom header) - * @param {string} header - Custom header from install-config.yaml - * @param {string} subheader - Custom subheader from install-config.yaml + * @param {string} header - Custom header from module.yaml + * @param {string} subheader - Custom subheader from module.yaml */ displayModuleConfigHeader(moduleName, header = null, subheader = null) { // Simple blue banner with custom header/subheader if provided @@ -100,8 +101,8 @@ const CLIUtils = { /** * Display module with no custom configuration * @param {string} moduleName - Module name (fallback if no custom header) - * @param {string} header - Custom header from install-config.yaml - * @param {string} subheader - Custom subheader from install-config.yaml + * @param {string} header - Custom header from module.yaml + * @param {string} subheader - Custom subheader from module.yaml */ displayModuleNoConfig(moduleName, header = null, subheader = null) { // Show full banner with header/subheader, just like modules with config @@ -205,6 +206,22 @@ const CLIUtils = { // No longer clear screen or show boxes - just a simple completion message // This is deprecated but kept for backwards compatibility }, + + /** + * Expand path with ~ expansion + * @param {string} inputPath - Path to expand + * @returns {string} Expanded path + */ + expandPath(inputPath) { + if (!inputPath) return inputPath; + + // Expand ~ to home directory + if (inputPath.startsWith('~')) { + return path.join(os.homedir(), inputPath.slice(1)); + } + + return inputPath; + }, }; module.exports = { CLIUtils }; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 9b7078fa..29b5cff7 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -52,9 +52,6 @@ class UI { await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); } - // Prompt for custom content location (separate from installation directory) - const customContentConfig = await this.promptCustomContentLocation(); - // Check if there's an existing BMAD installation const fs = require('fs-extra'); const path = require('node:path'); @@ -62,6 +59,17 @@ class UI { const bmadDir = await installer.findBmadDir(confirmedDirectory); const hasExistingInstall = await fs.pathExists(bmadDir); + // Always ask for custom content, but we'll handle it differently for new installs + let customContentConfig = { hasCustomContent: false }; + if (hasExistingInstall) { + // Existing installation - prompt to add/update custom content + customContentConfig = await this.promptCustomContentForExisting(); + } else { + // New installation - we'll prompt after creating the directory structure + // For now, set a flag to indicate we should ask later + customContentConfig._shouldAsk = true; + } + // Track action type (only set if there's an existing installation) let actionType; @@ -88,12 +96,11 @@ class UI { // Handle quick update separately if (actionType === 'quick-update') { - // Even for quick update, ask about custom content - const customContentConfig = await this.promptCustomContentLocation(); + // Quick update doesn't install custom content - just updates existing modules return { actionType: 'quick-update', directory: confirmedDirectory, - customContent: customContentConfig, + customContent: { hasCustomContent: false }, }; } @@ -123,6 +130,64 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); const coreConfig = await this.collectCoreConfig(confirmedDirectory); + // For new installations, create the directory structure first so we can cache custom content + if (!hasExistingInstall && customContentConfig._shouldAsk) { + // Create the bmad directory based on core config + const path = require('node:path'); + const fs = require('fs-extra'); + const bmadFolderName = coreConfig.bmad_folder || 'bmad'; + const bmadDir = path.join(confirmedDirectory, bmadFolderName); + + await fs.ensureDir(bmadDir); + await fs.ensureDir(path.join(bmadDir, '_cfg')); + await fs.ensureDir(path.join(bmadDir, '_cfg', 'custom')); + + // Now prompt for custom content + customContentConfig = await this.promptCustomContentLocation(); + + // If custom content found, cache it + if (customContentConfig.hasCustomContent) { + const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache'); + const cache = new CustomModuleCache(bmadDir); + + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo && customInfo.id) { + // Cache the module source + await cache.cacheModule(customInfo.id, customInfo.path, { + name: customInfo.name, + type: 'custom', + }); + + console.log(chalk.dim(` Cached ${customInfo.name} to _cfg/custom/${customInfo.id}`)); + } + } + + // Update config to use cached modules + customContentConfig.cachedModules = []; + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo && customInfo.id) { + customContentConfig.cachedModules.push({ + id: customInfo.id, + cachePath: path.join(bmadDir, '_cfg', 'custom', customInfo.id), + // Store relative path from cache for the manifest + relativePath: path.join('_cfg', 'custom', customInfo.id), + }); + } + } + + console.log(chalk.green(`✓ Cached ${customFiles.length} custom module(s)`)); + } + + // Clear the flag + delete customContentConfig._shouldAsk; + } + // Skip module selection during update/reinstall - keep existing modules let selectedModules; if (actionType === 'update' || actionType === 'reinstall') { @@ -136,15 +201,46 @@ class UI { // Check which custom content items were selected const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__')); - if (selectedCustomContent.length > 0) { + + // For cached modules (new installs), check if any cached modules were selected + let selectedCachedModules = []; + if (customContentConfig.cachedModules) { + selectedCachedModules = selectedModules.filter( + (mod) => !mod.startsWith('__CUSTOM_CONTENT__') && customContentConfig.cachedModules.some((cm) => cm.id === mod), + ); + } + + if (selectedCustomContent.length > 0 || selectedCachedModules.length > 0) { customContentConfig.selected = true; - customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); - // Filter out custom content markers since they're not real modules - selectedModules = selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')); + + // Handle directory-based custom content (existing installs) + if (selectedCustomContent.length > 0) { + customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); + // Convert custom content to module IDs for installation + const customContentModuleIds = []; + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + for (const customFile of customContentConfig.selectedFiles) { + // Get the module info to extract the ID + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo) { + customContentModuleIds.push(customInfo.id); + } + } + // Filter out custom content markers and add module IDs + selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds]; + } + + // For cached modules, they're already module IDs, just mark as selected + if (selectedCachedModules.length > 0) { + customContentConfig.selectedCachedModules = selectedCachedModules; + // No need to filter since they're already proper module IDs + } } else if (customContentConfig.hasCustomContent) { // User provided custom content but didn't select any customContentConfig.selected = false; customContentConfig.selectedFiles = []; + customContentConfig.selectedCachedModules = []; } } @@ -511,42 +607,134 @@ class UI { const moduleChoices = []; const isNewInstallation = installedModuleIds.size === 0; - // Add custom content items first if found - if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) { - // Add separator before custom content - moduleChoices.push(new inquirer.Separator('── Custom Content ──')); + const customContentItems = []; + const hasCustomContentItems = false; - // Get the custom content info to display proper names - const { CustomHandler } = require('../installers/lib/custom/handler'); - const customHandler = new CustomHandler(); - const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + // Add custom content items + if (customContentConfig && customContentConfig.hasCustomContent) { + if (customContentConfig.cachedModules) { + // New installation - show cached modules + for (const cachedModule of customContentConfig.cachedModules) { + // Get the module info from cache + const yaml = require('js-yaml'); + const fs = require('fs-extra'); - for (const customFile of customFiles) { - const customInfo = await customHandler.getCustomInfo(customFile); - if (customInfo) { - moduleChoices.push({ - name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, - value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content - checked: true, // Default to selected since user chose to provide custom content - }); + // Try multiple possible config file locations + const possibleConfigPaths = [ + path.join(cachedModule.cachePath, 'module.yaml'), + path.join(cachedModule.cachePath, 'custom.yaml'), + path.join(cachedModule.cachePath, '_module-installer', 'module.yaml'), + path.join(cachedModule.cachePath, '_module-installer', 'custom.yaml'), + ]; + + let moduleData = null; + let foundPath = null; + + for (const configPath of possibleConfigPaths) { + if (await fs.pathExists(configPath)) { + try { + const yamlContent = await fs.readFile(configPath, 'utf8'); + moduleData = yaml.load(yamlContent); + foundPath = configPath; + break; + } catch { + // Continue to next path + } + } + } + + if (moduleData) { + // Use the name from the custom info if we have it + const moduleName = cachedModule.name || moduleData.name || cachedModule.id; + + customContentItems.push({ + name: `${chalk.cyan('✓')} ${moduleName} ${chalk.gray('(cached)')}`, + value: cachedModule.id, // Use module ID directly + checked: true, // Default to selected + cached: true, + }); + } else { + // Debug: show what paths we tried to check + console.log(chalk.dim(`DEBUG: No module config found for ${cachedModule.id}`)); + console.log( + chalk.dim( + `DEBUG: Tried paths:`, + possibleConfigPaths.map((p) => p.replace(cachedModule.cachePath, '.')), + ), + ); + console.log(chalk.dim(`DEBUG: cachedModule:`, JSON.stringify(cachedModule, null, 2))); + } + } + } else if (customContentConfig.customPath) { + // Existing installation - show from directory + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo) { + customContentItems.push({ + name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, + value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content + checked: true, // Default to selected since user chose to provide custom content + path: customInfo.path, // Track path to avoid duplicates + }); + } } } - - // Add separator for official content - moduleChoices.push(new inquirer.Separator('── Official Content ──')); } // Add official modules const { ModuleManager } = require('../installers/lib/modules/manager'); - const moduleManager = new ModuleManager(); - const availableModules = await moduleManager.listAvailable(); + // For new installations, don't scan project yet (will do after custom content is discovered) + // For existing installations, scan if user selected custom content + const shouldScanProject = + !isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected; + const moduleManager = new ModuleManager({ + scanProjectForModules: shouldScanProject, + }); + const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable(); + // First, add all items to appropriate sections + const allCustomModules = []; + + // Add custom content items from directory + allCustomModules.push(...customContentItems); + + // Add custom modules from project scan (if scanning is enabled) + for (const mod of customModulesFromProject) { + // Skip if this module is already in customContentItems (by path) + const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path)); + + if (!isDuplicate) { + allCustomModules.push({ + name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(${mod.source})`)}`, + value: mod.id, + checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), + }); + } + } + + // Add separators and modules in correct order + if (allCustomModules.length > 0) { + // Add separator for custom content, all custom modules, and official content separator + moduleChoices.push( + new inquirer.Separator('── Custom Content ──'), + ...allCustomModules, + new inquirer.Separator('── Official Content ──'), + ); + } + + // Add official modules (only non-custom ones) for (const mod of availableModules) { - moduleChoices.push({ - name: mod.name, - value: mod.id, - checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), - }); + if (!mod.isCustom) { + moduleChoices.push({ + name: mod.name, + value: mod.id, + checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), + }); + } } return moduleChoices; @@ -632,7 +820,7 @@ class UI { */ async promptCustomContentLocation() { try { - CLIUtils.displaySection('Custom Content', 'Optional: Add custom agents and workflows'); + CLIUtils.displaySection('Custom Content', 'Optional: Add custom agents, workflows, and modules'); const { hasCustomContent } = await inquirer.prompt([ { @@ -666,7 +854,7 @@ class UI { { type: 'input', name: 'directory', - message: 'Enter the path to your custom content directory:', + message: 'Enter directory to search for custom content (will scan subfolders):', default: process.cwd(), // Use actual current working directory validate: async (input) => { if (!input || input.trim() === '') { @@ -699,7 +887,7 @@ class UI { const customFiles = await customHandler.findCustomContent(expandedPath); if (customFiles.length === 0) { - console.log(chalk.yellow(`\nNo custom.yaml files found in ${expandedPath}`)); + console.log(chalk.yellow(`\nNo custom content found in ${expandedPath}`)); const { tryAgain } = await inquirer.prompt([ { @@ -718,7 +906,12 @@ class UI { } customPath = expandedPath; - console.log(chalk.green(`\n✓ Found ${customFiles.length} custom content file(s)`)); + console.log(chalk.green(`\n✓ Found ${customFiles.length} custom content item(s):`)); + for (const file of customFiles) { + const relativePath = path.relative(expandedPath, path.dirname(file)); + const folderName = path.dirname(file).split(path.sep).pop(); + console.log(chalk.dim(` • ${folderName} ${chalk.gray(`(${relativePath})`)}`)); + } } return { hasCustomContent: true, customPath }; @@ -1016,6 +1209,144 @@ class UI { return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath)); } + + /** + * Prompt for custom content for existing installations + * @returns {Object} Custom content configuration + */ + async promptCustomContentForExisting() { + try { + CLIUtils.displaySection('Custom Content', 'Add new custom agents, workflows, or modules to your installation'); + + const { hasCustomContent } = await inquirer.prompt([ + { + type: 'list', + name: 'hasCustomContent', + message: 'Do you want to add or update custom content?', + choices: [ + { + name: 'No, continue with current installation only', + value: false, + }, + { + name: 'Yes, I have custom content to add or update', + value: true, + }, + ], + default: false, + }, + ]); + + if (!hasCustomContent) { + return { hasCustomContent: false }; + } + + // Get directory path + const { customPath } = await inquirer.prompt([ + { + type: 'input', + name: 'customPath', + message: 'Enter directory to search for custom content (will scan subfolders):', + default: process.cwd(), + validate: async (input) => { + if (!input || input.trim() === '') { + return 'Please enter a directory path'; + } + + // Normalize and check if path exists + const expandedPath = CLIUtils.expandPath(input.trim()); + const pathExists = await fs.pathExists(expandedPath); + if (!pathExists) { + return 'Directory does not exist'; + } + + // Check if it's actually a directory + const stats = await fs.stat(expandedPath); + if (!stats.isDirectory()) { + return 'Path must be a directory'; + } + + return true; + }, + transformer: (input) => { + return CLIUtils.expandPath(input); + }, + }, + ]); + + const resolvedPath = CLIUtils.expandPath(customPath); + + // Find custom content + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(resolvedPath); + + if (customFiles.length === 0) { + console.log(chalk.yellow(`\nNo custom content found in ${resolvedPath}`)); + + const { tryDifferent } = await inquirer.prompt([ + { + type: 'confirm', + name: 'tryDifferent', + message: 'Try a different directory?', + default: true, + }, + ]); + + if (tryDifferent) { + return await this.promptCustomContentForExisting(); + } + + return { hasCustomContent: false }; + } + + // Display found items + console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`)); + const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler'); + const customHandler2 = new CustomHandler2(); + const customContentItems = []; + + for (const customFile of customFiles) { + const customInfo = await customHandler2.getCustomInfo(customFile); + if (customInfo) { + customContentItems.push({ + name: `${chalk.cyan('✓')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, + value: `__CUSTOM_CONTENT__${customFile}`, + checked: true, + }); + } + } + + // Add option to keep existing custom content + console.log(chalk.yellow('\nExisting custom modules will be preserved unless you remove them')); + + const { selectedFiles } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedFiles', + message: 'Select custom content to add:', + choices: customContentItems, + pageSize: 15, + validate: (answer) => { + if (answer.length === 0) { + return 'You must select at least one item'; + } + return true; + }, + }, + ]); + + return { + hasCustomContent: true, + customPath: resolvedPath, + selected: true, + selectedFiles: selectedFiles, + }; + } catch (error) { + console.error(chalk.red('Error configuring custom content:'), error); + return { hasCustomContent: false }; + } + } } module.exports = { UI }; diff --git a/tools/migrate-custom-module-paths.js b/tools/migrate-custom-module-paths.js new file mode 100755 index 00000000..ad82e981 --- /dev/null +++ b/tools/migrate-custom-module-paths.js @@ -0,0 +1,124 @@ +/** + * Migration script to convert relative paths to absolute paths in custom module manifests + * This should be run once to update existing installations + */ + +const fs = require('fs-extra'); +const path = require('node:path'); +const yaml = require('yaml'); +const chalk = require('chalk'); + +/** + * Find BMAD directory in project + */ +function findBmadDir(projectDir = process.cwd()) { + const possibleNames = ['bmad', '.bmad']; + + for (const name of possibleNames) { + const bmadDir = path.join(projectDir, name); + if (fs.existsSync(bmadDir)) { + return bmadDir; + } + } + + return null; +} + +/** + * Update manifest to use absolute paths + */ +async function updateManifest(manifestPath, projectRoot) { + console.log(chalk.cyan(`\nUpdating manifest: ${manifestPath}`)); + + const content = await fs.readFile(manifestPath, 'utf8'); + const manifest = yaml.parse(content); + + if (!manifest.customModules || manifest.customModules.length === 0) { + console.log(chalk.dim(' No custom modules found')); + return false; + } + + let updated = false; + + for (const customModule of manifest.customModules) { + if (customModule.relativePath && !customModule.sourcePath) { + // Convert relative path to absolute + const absolutePath = path.resolve(projectRoot, customModule.relativePath); + customModule.sourcePath = absolutePath; + + // Remove the old relativePath + delete customModule.relativePath; + + console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.relativePath} → ${absolutePath}`)); + updated = true; + } else if (customModule.sourcePath && !path.isAbsolute(customModule.sourcePath)) { + // Source path exists but is not absolute + const absolutePath = path.resolve(customModule.sourcePath); + customModule.sourcePath = absolutePath; + + console.log(chalk.green(` ✓ Updated ${customModule.id}: ${customModule.sourcePath} → ${absolutePath}`)); + updated = true; + } + } + + if (updated) { + // Write back the updated manifest + const yamlStr = yaml.dump(manifest, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(manifestPath, yamlStr); + console.log(chalk.green(' Manifest updated successfully')); + } else { + console.log(chalk.dim(' All paths already absolute')); + } + + return updated; +} + +/** + * Main migration function + */ +async function migrate(directory) { + const projectRoot = path.resolve(directory || process.cwd()); + const bmadDir = findBmadDir(projectRoot); + + if (!bmadDir) { + console.error(chalk.red('✗ No BMAD installation found in directory')); + process.exit(1); + } + + console.log(chalk.blue.bold('🔄 BMAD Custom Module Path Migration')); + console.log(chalk.dim(`Project: ${projectRoot}`)); + console.log(chalk.dim(`BMAD Directory: ${bmadDir}`)); + + const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); + + if (!fs.existsSync(manifestPath)) { + console.error(chalk.red('✗ No manifest.yaml found')); + process.exit(1); + } + + const updated = await updateManifest(manifestPath, projectRoot); + + if (updated) { + console.log(chalk.green.bold('\n✨ Migration completed successfully!')); + console.log(chalk.dim('Custom modules now use absolute source paths.')); + } else { + console.log(chalk.yellow('\n⚠ No migration needed - paths already absolute')); + } +} + +// Run migration if called directly +if (require.main === module) { + const directory = process.argv[2]; + migrate(directory).catch((error) => { + console.error(chalk.red('\n✗ Migration failed:'), error.message); + process.exit(1); + }); +} + +module.exports = { migrate };