From 8d81edf8475874444c868315043778de575c50bb Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Sun, 26 Oct 2025 16:17:37 -0500 Subject: [PATCH] install quick updates --- .claude/commands/bmad/bmd/agents/cli-chief.md | 4 +- .../commands/bmad/bmd/agents/doc-keeper.md | 4 +- .../commands/bmad/bmd/agents/release-chief.md | 4 +- bmad/bmd/agents/cli-chief.md | 4 +- bmad/bmd/agents/doc-keeper.md | 4 +- bmad/bmd/agents/release-chief.md | 4 +- bmd/agents/cli-chief.agent.yaml | 4 +- bmd/agents/doc-keeper.agent.yaml | 4 +- bmd/agents/release-chief.agent.yaml | 4 +- bmd/bmad-custom-module-installer-plan.md | 2 +- .../workflow-status/project-levels.yaml | 2 +- tools/cli/commands/install.js | 9 + .../installers/lib/core/config-collector.js | 226 +++++++++++++++++- tools/cli/installers/lib/core/detector.js | 44 +++- tools/cli/installers/lib/core/installer.js | 198 ++++++++++++++- .../installers/lib/core/manifest-generator.js | 86 ++++++- tools/cli/installers/lib/ide/claude-code.js | 8 +- tools/cli/lib/ui.js | 12 +- 18 files changed, 565 insertions(+), 58 deletions(-) diff --git a/.claude/commands/bmad/bmd/agents/cli-chief.md b/.claude/commands/bmad/bmd/agents/cli-chief.md index 27b206bb..e7361bf6 100644 --- a/.claude/commands/bmad/bmd/agents/cli-chief.md +++ b/.claude/commands/bmad/bmd/agents/cli-chief.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is {project-root}/tools/cli/ - this is your territory You may read other project files for context but focus changes on CLI domain diff --git a/.claude/commands/bmad/bmd/agents/doc-keeper.md b/.claude/commands/bmad/bmd/agents/doc-keeper.md index b7fc5373..ecd648c1 100644 --- a/.claude/commands/bmad/bmd/agents/doc-keeper.md +++ b/.claude/commands/bmad/bmd/agents/doc-keeper.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is all documentation files (*.md, README, guides, examples) Monitor code changes that affect documented behavior diff --git a/.claude/commands/bmad/bmd/agents/release-chief.md b/.claude/commands/bmad/bmd/agents/release-chief.md index 1c2aed72..00927e40 100644 --- a/.claude/commands/bmad/bmd/agents/release-chief.md +++ b/.claude/commands/bmad/bmd/agents/release-chief.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is releases, versioning, changelogs, git tags, npm publishing Monitor {project-root}/package.json for version management diff --git a/bmad/bmd/agents/cli-chief.md b/bmad/bmd/agents/cli-chief.md index 27b206bb..e7361bf6 100644 --- a/bmad/bmd/agents/cli-chief.md +++ b/bmad/bmd/agents/cli-chief.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is {project-root}/tools/cli/ - this is your territory You may read other project files for context but focus changes on CLI domain diff --git a/bmad/bmd/agents/doc-keeper.md b/bmad/bmd/agents/doc-keeper.md index b7fc5373..ecd648c1 100644 --- a/bmad/bmd/agents/doc-keeper.md +++ b/bmad/bmd/agents/doc-keeper.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is all documentation files (*.md, README, guides, examples) Monitor code changes that affect documented behavior diff --git a/bmad/bmd/agents/release-chief.md b/bmad/bmd/agents/release-chief.md index 1c2aed72..00927e40 100644 --- a/bmad/bmd/agents/release-chief.md +++ b/bmad/bmd/agents/release-chief.md @@ -12,8 +12,8 @@ - VERIFY: If config not loaded, STOP and report error to user - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored Remember: user's name is {user_name} - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/memories.md into permanent context + Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives + Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/memories.md into permanent context You MUST follow all rules in instructions.md on EVERY interaction PRIMARY domain is releases, versioning, changelogs, git tags, npm publishing Monitor {project-root}/package.json for version management diff --git a/bmd/agents/cli-chief.agent.yaml b/bmd/agents/cli-chief.agent.yaml index 8dfd5edc..84f02746 100644 --- a/bmd/agents/cli-chief.agent.yaml +++ b/bmd/agents/cli-chief.agent.yaml @@ -32,8 +32,8 @@ agent: critical_actions: # CRITICAL: Load sidecar files FIRST for Expert agent - - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives - - Load COMPLETE file {project-root}/src/modules/bmd/agents/cli-chief-sidecar/memories.md into permanent context + - Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/instructions.md and follow ALL directives + - Load COMPLETE file {project-root}/bmd/agents/cli-chief-sidecar/memories.md into permanent context - You MUST follow all rules in instructions.md on EVERY interaction # Domain restriction for CLI focus - PRIMARY domain is {project-root}/tools/cli/ - this is your territory diff --git a/bmd/agents/doc-keeper.agent.yaml b/bmd/agents/doc-keeper.agent.yaml index cf48bce9..91b19605 100644 --- a/bmd/agents/doc-keeper.agent.yaml +++ b/bmd/agents/doc-keeper.agent.yaml @@ -32,8 +32,8 @@ agent: critical_actions: # CRITICAL: Load sidecar files FIRST for Expert agent - - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives - - Load COMPLETE file {project-root}/src/modules/bmd/agents/doc-keeper-sidecar/memories.md into permanent context + - Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/instructions.md and follow ALL directives + - Load COMPLETE file {project-root}/bmd/agents/doc-keeper-sidecar/memories.md into permanent context - You MUST follow all rules in instructions.md on EVERY interaction # Domain restriction for documentation focus - PRIMARY domain is all documentation files (*.md, README, guides, examples) diff --git a/bmd/agents/release-chief.agent.yaml b/bmd/agents/release-chief.agent.yaml index ac9b433f..d6b1fd44 100644 --- a/bmd/agents/release-chief.agent.yaml +++ b/bmd/agents/release-chief.agent.yaml @@ -32,8 +32,8 @@ agent: critical_actions: # CRITICAL: Load sidecar files FIRST for Expert agent - - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives - - Load COMPLETE file {project-root}/src/modules/bmd/agents/release-chief-sidecar/memories.md into permanent context + - Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/instructions.md and follow ALL directives + - Load COMPLETE file {project-root}/bmd/agents/release-chief-sidecar/memories.md into permanent context - You MUST follow all rules in instructions.md on EVERY interaction # Domain restriction for release focus - PRIMARY domain is releases, versioning, changelogs, git tags, npm publishing diff --git a/bmd/bmad-custom-module-installer-plan.md b/bmd/bmad-custom-module-installer-plan.md index 1d768cf4..6971e10d 100644 --- a/bmd/bmad-custom-module-installer-plan.md +++ b/bmd/bmad-custom-module-installer-plan.md @@ -89,7 +89,7 @@ my-custom-module/ ### Example: install-config.yaml -**Reference**: `/Users/brianmadison/dev/BMAD-METHOD/src/modules/bmm/_module-installer/install-config.yaml` +**Reference**: `/src/modules/bmm/_module-installer/install-config.yaml` ```yaml # Module metadata diff --git a/src/modules/bmm/workflows/workflow-status/project-levels.yaml b/src/modules/bmm/workflows/workflow-status/project-levels.yaml index fc38be03..75cf7fd6 100644 --- a/src/modules/bmm/workflows/workflow-status/project-levels.yaml +++ b/src/modules/bmm/workflows/workflow-status/project-levels.yaml @@ -1,5 +1,5 @@ # BMM Project Scale Levels - Source of Truth -# Reference: /src/modules/bmm/README.md lines 77-85 +# Reference: /bmad/bmm/README.md lines 77-85 levels: 0: diff --git a/tools/cli/commands/install.js b/tools/cli/commands/install.js index 714b45ae..e968ca4f 100644 --- a/tools/cli/commands/install.js +++ b/tools/cli/commands/install.js @@ -23,6 +23,15 @@ module.exports = { return; } + // Handle quick update separately + if (config.actionType === 'quick-update') { + const result = await installer.quickUpdate(config); + console.log(chalk.green('\n✨ Quick update complete!')); + console.log(chalk.cyan(`Updated ${result.moduleCount} modules with preserved settings`)); + process.exit(0); + return; + } + // Regular install/update flow const result = await installer.install(config); diff --git a/tools/cli/installers/lib/core/config-collector.js b/tools/cli/installers/lib/core/config-collector.js index 90b3a547..f55ee958 100644 --- a/tools/cli/installers/lib/core/config-collector.js +++ b/tools/cli/installers/lib/core/config-collector.js @@ -26,22 +26,25 @@ class ConfigCollector { return false; } - // Try to load existing module configs - const modules = ['core', 'bmm', 'cis']; + // Dynamically discover all installed modules by scanning bmad directory + // A directory is a module ONLY if it contains a config.yaml file let foundAny = false; + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const moduleName of modules) { - const moduleConfigPath = path.join(bmadDir, moduleName, 'config.yaml'); - if (await fs.pathExists(moduleConfigPath)) { - try { - const content = await fs.readFile(moduleConfigPath, 'utf8'); - const moduleConfig = yaml.load(content); - if (moduleConfig) { - this.existingConfig[moduleName] = moduleConfig; - foundAny = true; + for (const entry of entries) { + if (entry.isDirectory()) { + const moduleConfigPath = path.join(bmadDir, entry.name, 'config.yaml'); + if (await fs.pathExists(moduleConfigPath)) { + try { + const content = await fs.readFile(moduleConfigPath, 'utf8'); + const moduleConfig = yaml.load(content); + if (moduleConfig) { + this.existingConfig[entry.name] = moduleConfig; + foundAny = true; + } + } catch { + // Ignore parse errors for individual modules } - } catch { - // Ignore parse errors for individual modules } } } @@ -86,6 +89,203 @@ class ConfigCollector { return this.collectedConfig; } + /** + * Collect configuration for a single module (Quick Update mode - only new fields) + * @param {string} moduleName - Module name + * @param {string} projectDir - Target project directory + * @param {boolean} silentMode - If true, only prompt for new/missing fields + * @returns {boolean} True if new fields were prompted, false if all fields existed + */ + async collectModuleConfigQuick(moduleName, projectDir, silentMode = true) { + this.currentProjectDir = projectDir; + + // Load existing config if not already loaded + if (!this.existingConfig) { + await this.loadExistingConfig(projectDir); + } + + // Initialize allAnswers if not already initialized + if (!this.allAnswers) { + this.allAnswers = {}; + } + + // Load module's install config schema + const installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml'); + const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml'); + + let configPath = null; + if (await fs.pathExists(installerConfigPath)) { + configPath = installerConfigPath; + } else if (await fs.pathExists(legacyConfigPath)) { + configPath = legacyConfigPath; + } else { + // No config schema for this module - use existing values + if (this.existingConfig && this.existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; + } + return false; + } + + const configContent = await fs.readFile(configPath, 'utf8'); + const moduleConfig = yaml.load(configContent); + + if (!moduleConfig) { + return false; + } + + // Compare schema with existing config to find new/missing fields + const configKeys = Object.keys(moduleConfig).filter((key) => key !== 'prompt'); + const existingKeys = this.existingConfig && this.existingConfig[moduleName] ? Object.keys(this.existingConfig[moduleName]) : []; + + const newKeys = configKeys.filter((key) => { + const item = moduleConfig[key]; + // Check if it's a config item and doesn't exist in existing config + return item && typeof item === 'object' && item.prompt && !existingKeys.includes(key); + }); + + // If in silent mode and no new keys, use existing config and skip prompts + if (silentMode && newKeys.length === 0) { + if (this.existingConfig && this.existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName] = { ...this.existingConfig[moduleName] }; + + // Also populate allAnswers for cross-referencing + for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { + this.allAnswers[`${moduleName}_${key}`] = value; + } + } + return false; // No new fields + } + + // If we have new fields, show prompt section and collect only new fields + if (newKeys.length > 0) { + console.log(chalk.yellow(`\n📋 New configuration options available for ${moduleName}`)); + if (moduleConfig.prompt) { + const prompts = Array.isArray(moduleConfig.prompt) ? moduleConfig.prompt : [moduleConfig.prompt]; + CLIUtils.displayPromptSection(prompts); + } + + const questions = []; + for (const key of newKeys) { + const item = moduleConfig[key]; + const question = await this.buildQuestion(moduleName, key, item); + if (question) { + questions.push(question); + } + } + + if (questions.length > 0) { + console.log(); // Line break before questions + const answers = await inquirer.prompt(questions); + + // Store answers for cross-referencing + Object.assign(this.allAnswers, answers); + + // Process answers and build result values + for (const key of Object.keys(answers)) { + const originalKey = key.replace(`${moduleName}_`, ''); + const item = moduleConfig[originalKey]; + const value = answers[key]; + + let result; + if (Array.isArray(value)) { + result = value; + } else if (item.result) { + result = this.processResultTemplate(item.result, value); + } else { + result = value; + } + + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + this.collectedConfig[moduleName][originalKey] = result; + } + } + } + + // Copy over existing values for fields that weren't prompted + if (this.existingConfig && this.existingConfig[moduleName]) { + if (!this.collectedConfig[moduleName]) { + this.collectedConfig[moduleName] = {}; + } + for (const [key, value] of Object.entries(this.existingConfig[moduleName])) { + if (!this.collectedConfig[moduleName][key]) { + this.collectedConfig[moduleName][key] = value; + this.allAnswers[`${moduleName}_${key}`] = value; + } + } + } + + return newKeys.length > 0; // Return true if we prompted for new fields + } + + /** + * Process a result template with value substitution + * @param {*} resultTemplate - The result template + * @param {*} value - The value to substitute + * @returns {*} Processed result + */ + processResultTemplate(resultTemplate, value) { + let result = resultTemplate; + + if (typeof result === 'string' && value !== undefined) { + if (typeof value === 'string') { + result = result.replace('{value}', value); + } else if (typeof value === 'boolean' || typeof value === 'number') { + if (result === '{value}') { + result = value; + } else { + result = result.replace('{value}', value); + } + } else { + result = value; + } + + if (typeof result === 'string') { + result = result.replaceAll(/{([^}]+)}/g, (match, configKey) => { + if (configKey === 'project-root') { + return '{project-root}'; + } + if (configKey === 'value') { + return match; + } + + let configValue = this.allAnswers[configKey] || this.allAnswers[`${configKey}`]; + if (!configValue) { + for (const [answerKey, answerValue] of Object.entries(this.allAnswers)) { + if (answerKey.endsWith(`_${configKey}`)) { + configValue = answerValue; + break; + } + } + } + + if (!configValue) { + for (const mod of Object.keys(this.collectedConfig)) { + if (mod !== '_meta' && this.collectedConfig[mod] && this.collectedConfig[mod][configKey]) { + configValue = this.collectedConfig[mod][configKey]; + if (typeof configValue === 'string' && configValue.includes('{project-root}/')) { + configValue = configValue.replace('{project-root}/', ''); + } + break; + } + } + } + + return configValue || match; + }); + } + } + + return result; + } + /** * Collect configuration for a single module * @param {string} moduleName - Module name diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js index d3e090af..d8df39c5 100644 --- a/tools/cli/installers/lib/core/detector.js +++ b/tools/cli/installers/lib/core/detector.js @@ -55,14 +55,16 @@ class Detector { } // Check for modules - const entries = await fs.readdir(bmadDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') { - const modulePath = path.join(bmadDir, entry.name); + // If manifest exists, use it as the source of truth for installed modules + // Otherwise fall back to directory scanning (legacy installations) + if (manifestData && manifestData.modules && manifestData.modules.length > 0) { + // Use manifest module list - these are officially installed modules + for (const moduleId of manifestData.modules) { + const modulePath = path.join(bmadDir, moduleId); const moduleConfigPath = path.join(modulePath, 'config.yaml'); const moduleInfo = { - id: entry.name, + id: moduleId, path: modulePath, version: 'unknown', }; @@ -72,7 +74,7 @@ class Detector { const configContent = await fs.readFile(moduleConfigPath, 'utf8'); const config = yaml.load(configContent); moduleInfo.version = config.version || 'unknown'; - moduleInfo.name = config.name || entry.name; + moduleInfo.name = config.name || moduleId; moduleInfo.description = config.description; } catch { // Ignore config read errors @@ -81,6 +83,36 @@ class Detector { result.modules.push(moduleInfo); } + } else { + // Fallback: scan directory for modules (legacy installations without manifest) + const entries = await fs.readdir(bmadDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'core' && entry.name !== '_cfg') { + const modulePath = path.join(bmadDir, entry.name); + const moduleConfigPath = path.join(modulePath, 'config.yaml'); + + // Only treat it as a module if it has a config.yaml + if (await fs.pathExists(moduleConfigPath)) { + const moduleInfo = { + id: entry.name, + path: modulePath, + version: 'unknown', + }; + + try { + const configContent = await fs.readFile(moduleConfigPath, 'utf8'); + const config = yaml.load(configContent); + moduleInfo.version = config.version || 'unknown'; + moduleInfo.name = config.name || entry.name; + moduleInfo.description = config.description; + } catch { + // Ignore config read errors + } + + result.modules.push(moduleInfo); + } + } + } } // Check for IDE configurations from manifest diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 6df7b66a..f52488dd 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -162,8 +162,15 @@ class Installer { } } - // Collect configurations for modules (core was already collected in UI.promptInstall if interactive) - const moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory)); + // Collect configurations for modules (skip if quick update already collected them) + let moduleConfigs; + if (config._quickUpdate) { + // Quick update already collected all configs, use them directly + moduleConfigs = this.configCollector.collectedConfig; + } else { + // Regular install - collect configurations (core was already collected in UI.promptInstall if interactive) + moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory)); + } // Tool selection will be collected after we determine if it's a reinstall/update/new install @@ -199,7 +206,7 @@ class Installer { spinner.text = 'Checking for existing installation...'; const existingInstall = await this.detector.detect(bmadDir); - if (existingInstall.installed && !config.force) { + if (existingInstall.installed && !config.force && !config._quickUpdate) { spinner.stop(); console.log(chalk.yellow('\n⚠️ Existing BMAD installation detected')); @@ -300,18 +307,78 @@ class Installer { console.log(chalk.dim('DEBUG: No modified files detected')); } } + } else if (existingInstall.installed && config._quickUpdate) { + // Quick update mode - automatically treat as update without prompting + spinner.text = 'Preparing quick update...'; + config._isUpdate = true; + config._existingInstall = existingInstall; + + // Detect custom and modified files BEFORE updating + const existingFilesManifest = await this.readFilesManifest(bmadDir); + const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); + + config._customFiles = customFiles; + config._modifiedFiles = modifiedFiles; + + // Back up custom files + if (customFiles.length > 0) { + const tempBackupDir = path.join(projectDir, '.bmad-custom-backup-temp'); + await fs.ensureDir(tempBackupDir); + + spinner.start(`Backing up ${customFiles.length} custom files...`); + for (const customFile of customFiles) { + const relativePath = path.relative(bmadDir, customFile); + const backupPath = path.join(tempBackupDir, relativePath); + await fs.ensureDir(path.dirname(backupPath)); + await fs.copy(customFile, backupPath); + } + spinner.succeed(`Backed up ${customFiles.length} custom files`); + config._tempBackupDir = tempBackupDir; + } + + // Back up modified files + if (modifiedFiles.length > 0) { + const tempModifiedBackupDir = path.join(projectDir, '.bmad-modified-backup-temp'); + await fs.ensureDir(tempModifiedBackupDir); + + spinner.start(`Backing up ${modifiedFiles.length} modified files...`); + for (const modifiedFile of modifiedFiles) { + const relativePath = path.relative(bmadDir, modifiedFile.path); + const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); + await fs.ensureDir(path.dirname(tempBackupPath)); + await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); + } + spinner.succeed(`Backed up ${modifiedFiles.length} modified files`); + config._tempModifiedBackupDir = tempModifiedBackupDir; + } } // Now collect tool configurations after we know if it's a reinstall + // Skip for quick update since we already have the IDE list spinner.stop(); - const toolSelection = await this.collectToolConfigurations( - path.resolve(config.directory), - config.modules, - config._isFullReinstall || false, - config._previouslyConfiguredIdes || [], - ); + let toolSelection; + if (config._quickUpdate) { + // Quick update already has IDEs configured, skip prompting + // Set a flag to indicate all IDEs are pre-configured + const preConfiguredIdes = {}; + for (const ide of config.ides || []) { + preConfiguredIdes[ide] = { _alreadyConfigured: true }; + } + toolSelection = { + ides: config.ides || [], + skipIde: !config.ides || config.ides.length === 0, + configurations: preConfiguredIdes, + }; + } else { + toolSelection = await this.collectToolConfigurations( + path.resolve(config.directory), + config.modules, + config._isFullReinstall || false, + config._previouslyConfiguredIdes || [], + ); + } - // Merge tool selection into config + // Merge tool selection into config (for both quick update and regular flow) config.ides = toolSelection.ides; config.skipIde = toolSelection.skipIde; const ideConfigurations = toolSelection.configurations; @@ -385,8 +452,13 @@ class Installer { // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup spinner.start('Generating workflow and agent manifests...'); const manifestGen = new ManifestGenerator(); + + // Include preserved modules (from quick update) in the manifest + const allModulesToList = config._preserveModules ? [...(config.modules || []), ...config._preserveModules] : config.modules || []; + const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || [], this.installedFiles, { ides: config.ides || [], + preservedModules: config._preserveModules || [], // Scan these from installed bmad/ dir }); spinner.succeed( @@ -1349,6 +1421,112 @@ class Installer { } } + /** + * Quick update method - preserves all settings and only prompts for new config fields + * @param {Object} config - Configuration with directory + * @returns {Object} Update result + */ + async quickUpdate(config) { + const ora = require('ora'); + const spinner = ora('Starting quick update...').start(); + + try { + const projectDir = path.resolve(config.directory); + const bmadDir = path.join(projectDir, 'bmad'); + + // Check if bmad directory exists + if (!(await fs.pathExists(bmadDir))) { + spinner.fail('No BMAD installation found'); + throw new Error(`BMAD not installed at ${bmadDir}. Use regular install for first-time setup.`); + } + + spinner.text = 'Detecting installed modules and configuration...'; + + // Detect existing installation + const existingInstall = await this.detector.detect(bmadDir); + const installedModules = existingInstall.modules.map((m) => m.id); + const configuredIdes = existingInstall.ides || []; + + // Get available modules (what we have source for) + const availableModules = await this.moduleManager.listAvailable(); + const availableModuleIds = new Set(availableModules.map((m) => m.id)); + + // 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)); + + spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`); + + if (skippedModules.length > 0) { + console.log(chalk.yellow(`⚠️ Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`)); + } + + // Load existing configs and collect new fields (if any) + console.log(chalk.cyan('\n📋 Checking for new configuration options...')); + await this.configCollector.loadExistingConfig(projectDir); + + let promptedForNewFields = false; + + // Check core config for new fields + const corePrompted = await this.configCollector.collectModuleConfigQuick('core', projectDir, true); + if (corePrompted) { + promptedForNewFields = true; + } + + // Check each module we're updating for new fields (NOT skipped modules) + for (const moduleName of modulesToUpdate) { + const modulePrompted = await this.configCollector.collectModuleConfigQuick(moduleName, projectDir, true); + if (modulePrompted) { + promptedForNewFields = true; + } + } + + if (!promptedForNewFields) { + console.log(chalk.green('✓ All configuration is up to date, no new options to configure')); + } + + // Add metadata + this.configCollector.collectedConfig._meta = { + version: require(path.join(getProjectRoot(), 'package.json')).version, + installDate: new Date().toISOString(), + lastModified: new Date().toISOString(), + }; + + // Now run the full installation with the collected configs + spinner.start('Updating BMAD installation...'); + + // Build the config object for the installer + const installConfig = { + directory: projectDir, + installCore: true, + modules: modulesToUpdate, // Only update modules we have source for + ides: configuredIdes, + skipIde: configuredIdes.length === 0, + coreConfig: this.configCollector.collectedConfig.core, + actionType: 'install', // Use regular install flow + _quickUpdate: true, // Flag to skip certain prompts + _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them + }; + + // Call the standard install method + const result = await this.install(installConfig); + + spinner.succeed('Quick update complete!'); + + return { + success: true, + moduleCount: modulesToUpdate.length + 1, // +1 for core + hadNewFields: promptedForNewFields, + modules: ['core', ...modulesToUpdate], + skippedModules: skippedModules, + ides: configuredIdes, + }; + } catch (error) { + spinner.fail('Quick update failed'); + throw error; + } + } + /** * Private: Prompt for update action */ diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 2e1c759a..b3543e88 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -28,8 +28,11 @@ class ManifestGenerator { const cfgDir = path.join(bmadDir, '_cfg'); await fs.ensureDir(cfgDir); - // Store modules list - this.modules = ['core', ...selectedModules]; + // Store modules list (all modules including preserved ones) + const preservedModules = options.preservedModules || []; + this.modules = ['core', ...selectedModules, ...preservedModules]; + this.updatedModules = ['core', ...selectedModules]; // Only these get rescanned + this.preservedModules = preservedModules; // These stay as-is in CSVs this.bmadDir = bmadDir; this.allInstalledFiles = installedFiles; @@ -364,6 +367,45 @@ class ManifestGenerator { return manifestPath; } + /** + * Read existing CSV and preserve rows for modules NOT being updated + * @param {string} csvPath - Path to existing CSV file + * @param {number} moduleColumnIndex - Which column contains the module name (0-indexed) + * @returns {Array} Preserved CSV rows (without header) + */ + async getPreservedCsvRows(csvPath, moduleColumnIndex) { + if (!(await fs.pathExists(csvPath)) || this.preservedModules.length === 0) { + return []; + } + + try { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.trim().split('\n'); + + // Skip header row + const dataRows = lines.slice(1); + const preservedRows = []; + + for (const row of dataRows) { + // Simple CSV parsing (handles quoted values) + const columns = row.match(/(".*?"|[^",\s]+)(?=\s*,|\s*$)/g) || []; + const cleanColumns = columns.map((c) => c.replaceAll(/^"|"$/g, '')); + + const moduleValue = cleanColumns[moduleColumnIndex]; + + // Keep this row if it belongs to a preserved module + if (this.preservedModules.includes(moduleValue)) { + preservedRows.push(row); + } + } + + return preservedRows; + } catch (error) { + console.warn(`Warning: Failed to read existing CSV ${csvPath}:`, error.message); + return []; + } + } + /** * Write workflow manifest CSV * @returns {string} Path to the manifest file @@ -371,14 +413,22 @@ class ManifestGenerator { async writeWorkflowManifest(cfgDir) { const csvPath = path.join(cfgDir, 'workflow-manifest.csv'); + // Get preserved rows from existing CSV (module is column 2, 0-indexed) + const preservedRows = await this.getPreservedCsvRows(csvPath, 2); + // Create CSV header let csv = 'name,description,module,path\n'; - // Add rows + // Add new rows for updated modules for (const workflow of this.workflows) { csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`; } + // Add preserved rows for modules we didn't update + for (const row of preservedRows) { + csv += row + '\n'; + } + await fs.writeFile(csvPath, csv); return csvPath; } @@ -390,14 +440,22 @@ class ManifestGenerator { async writeAgentManifest(cfgDir) { const csvPath = path.join(cfgDir, 'agent-manifest.csv'); + // Get preserved rows from existing CSV (module is column 8, 0-indexed) + const preservedRows = await this.getPreservedCsvRows(csvPath, 8); + // Create CSV header with persona fields let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; - // Add rows + // Add new rows for updated modules 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`; } + // Add preserved rows for modules we didn't update + for (const row of preservedRows) { + csv += row + '\n'; + } + await fs.writeFile(csvPath, csv); return csvPath; } @@ -409,14 +467,22 @@ class ManifestGenerator { async writeTaskManifest(cfgDir) { const csvPath = path.join(cfgDir, 'task-manifest.csv'); + // Get preserved rows from existing CSV (module is column 3, 0-indexed) + const preservedRows = await this.getPreservedCsvRows(csvPath, 3); + // Create CSV header let csv = 'name,displayName,description,module,path\n'; - // Add rows + // Add new rows for updated modules for (const task of this.tasks) { csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}"\n`; } + // Add preserved rows for modules we didn't update + for (const row of preservedRows) { + csv += row + '\n'; + } + await fs.writeFile(csvPath, csv); return csvPath; } @@ -444,6 +510,9 @@ class ManifestGenerator { async writeFilesManifest(cfgDir) { const csvPath = path.join(cfgDir, 'files-manifest.csv'); + // Get preserved rows from existing CSV (module is column 2, 0-indexed) + const preservedRows = await this.getPreservedCsvRows(csvPath, 2); + // Create CSV header with hash column let csv = 'type,name,module,path,hash\n'; @@ -490,11 +559,16 @@ class ManifestGenerator { return a.name.localeCompare(b.name); }); - // Add rows + // Add rows for updated modules for (const file of allFiles) { csv += `"${file.type}","${file.name}","${file.module}","${file.path}","${file.hash}"\n`; } + // Add preserved rows for modules we didn't update + for (const row of preservedRows) { + csv += row + '\n'; + } + await fs.writeFile(csvPath, csv); return csvPath; } diff --git a/tools/cli/installers/lib/ide/claude-code.js b/tools/cli/installers/lib/ide/claude-code.js index 83721553..98e03ee9 100644 --- a/tools/cli/installers/lib/ide/claude-code.js +++ b/tools/cli/installers/lib/ide/claude-code.js @@ -128,8 +128,12 @@ class ClaudeCodeSetup extends BaseIdeSetup { } // Process Claude Code specific injections for installed modules - // Use pre-collected configuration if available - if (options.preCollectedConfig) { + // Use pre-collected configuration if available, or skip if already configured + if (options.preCollectedConfig && options.preCollectedConfig._alreadyConfigured) { + // IDE is already configured from previous installation, skip prompting + // Just process with default/existing configuration + await this.processModuleInjectionsWithConfig(projectDir, bmadDir, options, {}); + } else if (options.preCollectedConfig) { await this.processModuleInjectionsWithConfig(projectDir, bmadDir, options, options.preCollectedConfig); } else { await this.processModuleInjections(projectDir, bmadDir, options); diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index de576aa0..c48f8ded 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -35,12 +35,22 @@ class UI { name: 'actionType', message: 'What would you like to do?', choices: [ - { name: 'Update BMAD Installation', value: 'install' }, + { name: 'Quick Update (Settings Preserved)', value: 'quick-update' }, + { name: 'Modify BMAD Installation (Confirm or change each setting)', value: 'install' }, { name: 'Compile Agents (Quick rebuild of all agent .md files)', value: 'compile' }, ], + default: 'quick-update', }, ]); + // Handle quick update separately + if (actionType === 'quick-update') { + return { + actionType: 'quick-update', + directory: confirmedDirectory, + }; + } + // Handle agent compilation separately if (actionType === 'compile') { return {