From 2d2f4855b1eba120a536889f33bbaef69657a555 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Wed, 25 Feb 2026 20:01:04 -0700 Subject: [PATCH] fix(installer): refuse install when ancestor dir has BMAD commands (#1735) * fix(installer): refuse install when ancestor dir has BMAD commands Claude Code inherits slash commands from parent directories, so installing into a nested project when a parent already has .claude/commands with bmad-* files causes duplicate entries in the autocomplete. Add ancestor_conflict_check flag (enabled for claude-code) that walks up the directory tree before install. If BMAD files are found in an ancestor target_dir, the installer refuses with an actionable error. Also fix IdeManager.setup() to propagate handler success status instead of unconditionally returning success: true. * Address code review feedback from CodeRabbit and Augment - Move "Setting up..." log after conflict check so it only shows when install will proceed - Fix rm command: add -rf flags and correct quoting for glob outside quotes - Improve error wording: "ancestor installation" instead of misleading "ancestor directory" - Use case-insensitive startsWith for bmad file detection (macOS/Windows) - Document ancestor_conflict_check in the installer config schema Co-Authored-By: Claude Opus 4.6 * fix(installer): resolve symlinks before ancestor conflict walk Use fs.realpath() instead of path.resolve() so the ancestor directory walk follows the physical filesystem path, not the logical symlink path. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../cli/installers/lib/ide/_config-driven.js | 53 +++++++++++++++++++ tools/cli/installers/lib/ide/manager.js | 4 +- .../installers/lib/ide/platform-codes.yaml | 4 ++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tools/cli/installers/lib/ide/_config-driven.js b/tools/cli/installers/lib/ide/_config-driven.js index d1552700f..813a6e674 100644 --- a/tools/cli/installers/lib/ide/_config-driven.js +++ b/tools/cli/installers/lib/ide/_config-driven.js @@ -34,6 +34,25 @@ class ConfigDrivenIdeSetup extends BaseIdeSetup { * @returns {Promise} Setup result */ async setup(projectDir, bmadDir, options = {}) { + // Check for BMAD files in ancestor directories that would cause duplicates + if (this.installerConfig?.ancestor_conflict_check) { + const conflict = await this.findAncestorConflict(projectDir); + if (conflict) { + await prompts.log.error( + `Found existing BMAD commands in ancestor installation: ${conflict}\n` + + ` ${this.name} inherits commands from parent directories, so this would cause duplicates.\n` + + ` Please remove the BMAD files from that directory first:\n` + + ` rm -rf "${conflict}"/bmad*`, + ); + return { + success: false, + reason: 'ancestor-conflict', + error: `Ancestor conflict: ${conflict}`, + conflictDir: conflict, + }; + } + } + if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Clean up any old BMAD installation first @@ -540,6 +559,40 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}} } } } + /** + * Check ancestor directories for existing BMAD files in the same target_dir. + * IDEs like Claude Code inherit commands from parent directories, so an existing + * installation in an ancestor would cause duplicate commands. + * @param {string} projectDir - Project directory being installed to + * @returns {Promise} Path to conflicting directory, or null if clean + */ + async findAncestorConflict(projectDir) { + const targetDir = this.installerConfig?.target_dir; + if (!targetDir) return null; + + const resolvedProject = await fs.realpath(path.resolve(projectDir)); + let current = path.dirname(resolvedProject); + const root = path.parse(current).root; + + while (current !== root && current.length > root.length) { + const candidatePath = path.join(current, targetDir); + try { + if (await fs.pathExists(candidatePath)) { + const entries = await fs.readdir(candidatePath); + const hasBmad = entries.some((e) => typeof e === 'string' && e.toLowerCase().startsWith('bmad')); + if (hasBmad) { + return candidatePath; + } + } + } catch { + // Can't read directory — skip + } + current = path.dirname(current); + } + + return null; + } + /** * Walk up ancestor directories from relativeDir toward projectDir, removing each if empty * Stops at projectDir boundary — never removes projectDir itself diff --git a/tools/cli/installers/lib/ide/manager.js b/tools/cli/installers/lib/ide/manager.js index 9b8df1597..9e286fdd3 100644 --- a/tools/cli/installers/lib/ide/manager.js +++ b/tools/cli/installers/lib/ide/manager.js @@ -206,7 +206,9 @@ class IdeManager { if (handlerResult.tools > 0) parts.push(`${handlerResult.tools} tools`); detail = parts.join(', '); } - return { success: true, ide: ideName, detail, handlerResult }; + // Propagate handler's success status (default true for backward compat) + const success = handlerResult?.success !== false; + return { success, ide: ideName, detail, error: handlerResult?.error, handlerResult }; } catch (error) { await prompts.log.error(`Failed to setup ${ideName}: ${error.message}`); return { success: false, ide: ideName, error: error.message }; diff --git a/tools/cli/installers/lib/ide/platform-codes.yaml b/tools/cli/installers/lib/ide/platform-codes.yaml index 2d9e8c129..4e6ca8070 100644 --- a/tools/cli/installers/lib/ide/platform-codes.yaml +++ b/tools/cli/installers/lib/ide/platform-codes.yaml @@ -40,6 +40,7 @@ platforms: installer: target_dir: .claude/commands template_type: default + ancestor_conflict_check: true cline: name: "Cline" @@ -202,6 +203,9 @@ platforms: # artifact_types: [agents, workflows, tasks, tools] # artifact_types: array (optional) # Filter which artifacts to install (default: all) # skip_existing: boolean (optional) # Skip files that already exist (default: false) +# ancestor_conflict_check: boolean (optional) # Refuse install when ancestor dir has BMAD files +# # in the same target_dir (for IDEs that inherit +# # commands from parent directories) # ============================================================================ # Platform Categories