Skip to content

fix(installer): refuse install when ancestor dir has BMAD commands#1735

Merged
bmadcode merged 5 commits intobmad-code-org:mainfrom
alexeyv:fix/installer-ancestor-conflict
Feb 26, 2026
Merged

fix(installer): refuse install when ancestor dir has BMAD commands#1735
bmadcode merged 5 commits intobmad-code-org:mainfrom
alexeyv:fix/installer-ancestor-conflict

Conversation

@alexeyv
Copy link
Copy Markdown
Collaborator

@alexeyv alexeyv commented Feb 22, 2026

Summary

  • Claude Code inherits slash commands from parent .claude/commands/ directories. When BMAD is installed in both a parent and child directory, all commands appear duplicated in autocomplete.
  • Add ancestor_conflict_check flag to the claude-code platform config. Before installing, the handler walks up ancestor directories looking for existing BMAD files in the same target dir. If found, it refuses to install with an actionable error message.
  • Fix pre-existing bug in IdeManager.setup() that unconditionally returned success: true, ignoring handler failure status.

Files changed

  • platform-codes.yaml: add ancestor_conflict_check: true to claude-code config
  • _config-driven.js: add findAncestorConflict() method and pre-install check in setup()
  • manager.js: propagate handler success status instead of hardcoding true

Test plan

  • Install BMAD into a project nested under a parent that already has .claude/commands/bmad-* files - should refuse with error
  • Install BMAD into a standalone project (no ancestor conflicts) - should succeed normally
  • Verify error message includes the correct path and a working rm command
  • Verify other IDEs (cursor, windsurf) are unaffected by the change

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.
@augmentcode
Copy link
Copy Markdown

augmentcode bot commented Feb 22, 2026

🤖 Augment PR Summary

Summary: Prevents duplicate Claude Code slash-commands by refusing installs when a parent directory already contains BMAD command artifacts.

Changes:

  • Enabled ancestor_conflict_check for the claude-code platform installer config
  • Added an ancestor-directory scan in ConfigDrivenIdeSetup to detect existing bmad* entries under the same target_dir and abort with an actionable error
  • Updated IdeManager.setup() to propagate the handler’s success/failure status (and surface handler errors) instead of always returning success: true
  • Documented ancestor_conflict_check in the installer config schema comments

Technical Notes: The ancestor scan resolves the project path via realpath and checks each parent’s configured target_dir for existing BMAD-prefixed entries before proceeding.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 1 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 48b3bb1 and 2515e81.

📒 Files selected for processing (2)
  • tools/cli/installers/lib/ide/_config-driven.js
  • tools/cli/installers/lib/ide/platform-codes.yaml
🚧 Files skipped from review as they are similar to previous changes (2)
  • tools/cli/installers/lib/ide/platform-codes.yaml
  • tools/cli/installers/lib/ide/_config-driven.js

📝 Walkthrough

Walkthrough

Adds an ancestor-directory conflict check to IDE setup: ConfigDrivenIdeSetup now detects BMAD files in ancestor target directories and aborts install if found; IdeManager.setup return now propagates handler success/error; the check is enabled for claude-code via config.

Changes

Cohort / File(s) Summary
Ancestor conflict detection
tools/cli/installers/lib/ide/_config-driven.js
Added async findAncestorConflict(projectDir) that traverses parent directories (boundary-safe, skips unreadable dirs) to detect existing BMAD files in configured target_dir. Integrated pre-install ancestor_conflict_check and early return { reason: 'ancestor-conflict', conflictDir } on detection.
Setup return propagation
tools/cli/installers/lib/ide/manager.js
Changed IdeManager.setup() return shape to { success, ide, detail, error, handlerResult }, deriving success from handlerResult?.success !== false and including error: handlerResult?.error.
Configuration flag
tools/cli/installers/lib/ide/platform-codes.yaml
Added optional installer config ancestor_conflict_check: boolean and set ancestor_conflict_check: true for claude-code installer to enable the new check.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Manager as IdeManager
    participant Setup as ConfigDrivenIdeSetup
    participant FS as FileSystem

    Client->>Manager: setup(config)
    Manager->>Setup: setup(handler, config)
    Setup->>Setup: check installer.ancestor_conflict_check?
    alt enabled
        Setup->>FS: findAncestorConflict(projectDir)
        FS->>FS: traverse parents, look for BMAD files in target_dir
        alt conflict found
            FS-->>Setup: conflictDir
            Setup-->>Manager: handlerResult { success: false, error:'ancestor-conflict', conflictDir }
        else no conflict
            FS-->>Setup: null
            Setup->>Setup: proceed with cleanup/install
            Setup-->>Manager: handlerResult { success: true, results... }
        end
    else disabled
        Setup->>Setup: proceed with cleanup/install
        Setup-->>Manager: handlerResult { success: true, results... }
    end
    Manager->>Manager: compute success = handlerResult?.success !== false
    Manager-->>Client: { success, ide, detail, error, handlerResult }
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • bmadcode
  • muratkeremozcan
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding ancestor conflict detection to refuse BMAD installer when parent directories already contain BMAD commands.
Description check ✅ Passed The description clearly relates to the changeset, explaining the problem, solution, affected files, and test plan for the ancestor conflict check feature.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
tools/cli/installers/lib/ide/_config-driven.js (3)

569-569: Redundant second loop condition.

current.length > root.length is unreachable given current !== root. On every POSIX path, path.dirname(root) returns root itself, so current !== root already prevents infinite looping. The extra check adds noise without protection.

♻️ Suggested fix
- while (current !== root && current.length > root.length) {
+ while (current !== root) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/_config-driven.js` at line 569, The while loop
condition `while (current !== root && current.length > root.length)` is
redundant because `current !== root` already prevents infinite looping for POSIX
paths; update the loop in the block that iterates parent directories (using the
`current` and `root` variables) to remove the unnecessary `current.length >
root.length` check and use only `current !== root` so the loop remains clear and
terminates as intended.

554-586: findAncestorConflict has no test coverage despite being the core logic of this PR.

The PR description calls out four test scenarios (conflict found, no conflict, correct rm command, other IDEs unaffected), but no test files are present in the changeset. Edge cases worth covering beyond the happy path:

  • projectDir is itself the filesystem root
  • Ancestor directory exists but is readable with zero entries
  • Ancestor has a bmad-named directory (not just a file)
  • conflict path contains spaces
  • Platform uses targets (multi-target) with the flag set

Would you like me to draft unit tests for these scenarios? I can open a new issue to track this if preferred.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/_config-driven.js` around lines 554 - 586, Add
unit tests for the core logic in findAncestorConflict (in the module containing
the _config-driven.js installer) to cover: conflict found (ancestor contains a
bmad* entry as a file or directory), no conflict, projectDir being the
filesystem root, an ancestor directory that's readable but empty, conflict paths
with spaces, and behavior when installerConfig.target_dir is a
multi-targets-like value; use fs stubs/mocks or a temp filesystem to create
ancestor candidate directories and ensure entries include both file and
directory named starting with "bmad" and that pathExists/readdir error handling
is exercised. Ensure tests instantiate the class (so installerConfig?.target_dir
is set) and call findAncestorConflict(projectDir); assert returned path when
conflict exists and null otherwise, and include a case where readdir throws to
confirm the catch branch is skipping unreadable directories.

579-581: Silently swallowing directory-read errors lets a permissions failure masquerade as "no conflict".

If a directory exists but is unreadable (broken mount, permission denied), the catch fires and the traversal continues, ultimately returning null. The installer proceeds, potentially writing into a project that already has a BMAD ancestor installation the code simply couldn't read. At minimum, a debug/warn log should be emitted so the user can investigate.

♻️ Suggested fix
      } catch {
-       // Can't read directory — skip
+       // Log rather than silently ignore — a read failure could mask a real conflict
+       await prompts.log.warn(`  Could not read ${candidatePath} during ancestor check — skipping`);
      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tools/cli/installers/lib/ide/_config-driven.js` around lines 579 - 581, The
catch that silently swallows directory-read errors in _config-driven.js (the
block currently using catch { // Can't read directory — skip }) should capture
the exception (e.g., catch (err)) and emit a warning or debug log including the
directory path and the error so permission/mount failures are visible (keep the
existing skip behavior after logging); update the catch to log a message like
"Can't read directory <path>" along with err so operators can investigate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tools/cli/installers/lib/ide/_config-driven.js`:
- Around line 43-44: The error text passed to prompts.log.error incorrectly
labels the resolved target path (variable conflict) as an "ancestor directory";
update the message in the prompts.log.error call so it calls out the resolved
path as the "ancestor installation directory" (or similar phrasing) and/or
append the ancestor project root separately for clarity (e.g., "Found existing
BMAD commands in ancestor installation directory: ${conflict} (project root:
<ancestorRoot>)"); modify the single line using prompts.log.error and the
conflict variable accordingly so users see the correct context.
- Line 574: The check that builds hasBmad uses a case-sensitive startsWith and
misses names like "Bmad-foo.md"; change the predicate in the entries.some call
(the const hasBmad) to perform a case-insensitive comparison (e.g., coerce the
string to lower case before testing startsWith('bmad') while still guarding
typeof e === 'string'), and make the corresponding analogous change inside
cleanupTarget so both detections use the same toLowerCase() + startsWith('bmad')
logic to handle case-insensitive filesystems consistently.
- Around line 43-48: The user-facing rm hint in the prompts.log.error call is
unsafe and can fail; update the message so it suggests a robust command that
includes recursive and force flags and places the glob outside the quoted
directory variable — e.g. recommend using rm -rf "${conflict}"/bmad* — so modify
the string in the prompts.log.error invocation (where conflict and this.name are
referenced) to show rm -rf "${conflict}"/bmad*.
- Around line 561-563: The findAncestorConflict function currently only checks
installerConfig.target_dir and returns null when absent, which silently bypasses
ancestor conflict checks for multi-target platforms; update findAncestorConflict
(and references to installerConfig.target_dir) to handle the case where
installerConfig.targets is present by either asserting when
ancestor_conflict_check is true and neither target_dir nor targets exist, or
iterating over installerConfig.targets to run the same ancestor conflict logic
for each target entry when target_dir is undefined so platforms using a targets
array are properly validated.
- Line 37: The info log "Setting up ${this.name}..." is emitted before the
conflict check (findAncestorConflict) and should be delayed until after that
check confirms the install will proceed; update the code so that the call to
prompts.log.info (used with options.silent and this.name) is moved to run only
after findAncestorConflict returns no abort/conflict (or conditionally logged
only when installation continues), ensuring the message is not shown if
findAncestorConflict aborts.

In `@tools/cli/installers/lib/ide/manager.js`:
- Around line 209-211: The current success sentinel and inconsistent return
shape in setup() are unsafe; change the success calculation to fail-closed using
Boolean(handlerResult?.success) instead of handlerResult?.success !== false, and
normalize all return paths (normal, unsupported IDE, and exception) to include a
handlerResult field (use handlerResult ?? null) so callers can reliably access
handlerResult properties; update the returns that currently omit handlerResult
(the unsupported-IDE path and the catch/exception path) to return the same shape
{ success, ide: ideName, detail, error, handlerResult } while preserving
existing ideName/detail/error variables.

In `@tools/cli/installers/lib/ide/platform-codes.yaml`:
- Line 43: The schema docs are missing the ancestor_conflict_check flag: add an
entry describing ancestor_conflict_check (boolean, default false, controls
whether command-inheritance conflicts are checked when inheriting from ancestor
installers) to the "Installer Config Schema" block alongside the other installer
fields so contributors can discover and use it; update the schema list that
currently enumerates other installer fields (the Installer Config Schema
section) to include ancestor_conflict_check and a short sentence about its
behavior and default.

---

Nitpick comments:
In `@tools/cli/installers/lib/ide/_config-driven.js`:
- Line 569: The while loop condition `while (current !== root && current.length
> root.length)` is redundant because `current !== root` already prevents
infinite looping for POSIX paths; update the loop in the block that iterates
parent directories (using the `current` and `root` variables) to remove the
unnecessary `current.length > root.length` check and use only `current !== root`
so the loop remains clear and terminates as intended.
- Around line 554-586: Add unit tests for the core logic in findAncestorConflict
(in the module containing the _config-driven.js installer) to cover: conflict
found (ancestor contains a bmad* entry as a file or directory), no conflict,
projectDir being the filesystem root, an ancestor directory that's readable but
empty, conflict paths with spaces, and behavior when installerConfig.target_dir
is a multi-targets-like value; use fs stubs/mocks or a temp filesystem to create
ancestor candidate directories and ensure entries include both file and
directory named starting with "bmad" and that pathExists/readdir error handling
is exercised. Ensure tests instantiate the class (so installerConfig?.target_dir
is set) and call findAncestorConflict(projectDir); assert returned path when
conflict exists and null otherwise, and include a case where readdir throws to
confirm the catch branch is skipping unreadable directories.
- Around line 579-581: The catch that silently swallows directory-read errors in
_config-driven.js (the block currently using catch { // Can't read directory —
skip }) should capture the exception (e.g., catch (err)) and emit a warning or
debug log including the directory path and the error so permission/mount
failures are visible (keep the existing skip behavior after logging); update the
catch to log a message like "Can't read directory <path>" along with err so
operators can investigate.

@alexeyv alexeyv marked this pull request as draft February 24, 2026 00:14
alexeyv and others added 2 commits February 24, 2026 19:34
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@alexeyv
Copy link
Copy Markdown
Collaborator Author

alexeyv commented Feb 25, 2026

Test Report: PR #1735fix(installer): refuse install when ancestor dir has BMAD commands

Branch: fix/installer-ancestor-conflict
Date: 2026-02-25
Testers: 4 parallel agents (unit-tester, integration-tester, code-reviewer, regression-tester)


1. Description of Tests (Reproduction Instructions)

All tests run against the repo on the fix/installer-ancestor-conflict branch.

1.1 Regression Suite (existing tests)

npm install --ignore-scripts
node test/test-installation-components.js   # 12 tests - installer components
node test/test-agent-schema.js              # 52 tests - agent schema validation
node test/test-file-refs-csv.js             # 7 tests  - file reference CSV parsing
npx eslint . --ext .js,.cjs,.mjs,.yaml --max-warnings=0  # lint

1.2 Unit Tests — findAncestorConflict() method

Test file written at test/test-ancestor-conflict.js. Creates temporary directory hierarchies and tests the method in isolation:

  1. No conflict — project nested in dirs with no .claude/commands/bmad* files → returns null
  2. Immediate parent conflict — parent has .claude/commands/bmad-foo.md → returns path
  3. Grandparent conflict — grandparent has the bmad files → returns path
  4. Dir exists but no bmad files.claude/commands/ exists with non-bmad files → returns null
  5. Case-insensitive match — files named BMAD-Something → detected
  6. No target_dir in config — handler has no target_dir → returns null
  7. Multiple ancestors, returns nearest — both parent and grandparent conflict → returns parent

1.3 Integration Tests — setup() + IdeManager propagation

Test file written at test/test-integration-ancestor.js. Mocks @clack/prompts and tests full setup flow:

  1. setup() with ancestor conflict → returns {success: false, reason: 'ancestor-conflict'}
  2. setup() without conflict → proceeds past the check
  3. Error includes conflicting path string
  4. Error includes rm -rf removal suggestion
  5. IdeManager.setup() propagates success: false from handler
  6. IdeManager.setup() returns success: true for successful handlers
  7. IdeManager.setup() returns success: true when handler omits success field (backward compat)

1.4 Code Review — Edge Case & Security Analysis

Manual review of all 3 changed files for: symlink traversal, case sensitivity, root boundary handling, race conditions, Dirent type safety, multi-target gap, manager truthiness semantics, platform-codes.yaml isolation, and security (path injection, rm command in error message).


2. Detailed Test Results

Suite Tests Passed Failed Status
Installation Components 12 12 0 ✅ PASS
Agent Schema Validation 52 52 0 ✅ PASS
File Refs CSV 7 7 0 ✅ PASS
ESLint Lint 0 errors ✅ PASS
Unit: findAncestorConflict 7 7 0 ✅ PASS
Integration: setup + manager 7 7 0 ✅ PASS
Code Review Findings 10 See below
TOTAL 85+ 85 0 ✅ ALL PASS

Code Review Findings

# Severity Finding
1 MAJOR Multi-target installers silently skip ancestor conflict check (targets array has no target_dir at top level, so findAncestorConflict returns null). Zero current impact — only claude-code uses the flag and it's single-target. Latent design gap if a future multi-target platform enables the flag.
2 MINOR Symlink resolution uses path.resolve not fs.realpath Fixed in 1c789c0 — now uses fs.realpath().
3 MINOR handlerResult?.success !== false treats 0, "", null as success. Correct for current handlers (all use boolean), but could surprise non-boolean future returns.
4 MINOR Error message rm command would display oddly if directory name contains ". Cosmetic only — not executed.
5 INFO Case-insensitive startsWith('bmad') is appropriate and defensive. ✅
6 INFO Root boundary handling correct on Linux / and Windows C:\. ✅
7 INFO TOCTOU race between pathExists and readdir handled by try/catch. ✅
8 INFO typeof e === 'string' Dirent guard is defensive and correct. ✅
9 INFO Only claude-code has ancestor_conflict_check: true — all 18 other platforms verified unaffected. ✅
10 INFO No security vulnerabilities — rm -rf is display-only, no path traversal or injection risks. ✅

3. Failures Discovered

No functional failures were found. All 85 tests pass and no regressions detected.

Non-blocking Recommendations

  1. MAJOR — Multi-target gap (future-proofing): If a multi-target platform ever enables ancestor_conflict_check, the check would silently no-op because this.installerConfig?.target_dir is undefined for multi-target configs. Consider iterating over targets[].target_dir or documenting the limitation.
  2. MINOR — Strictness of success check: Consider handlerResult?.success === true instead of !== false for stricter truthiness, though this would be a behavioral change for handlers that return no success field.

Overall verdict: PR is well-written, defensively coded, and safe to merge.

Keep findAncestorConflict() with fs.realpath fix and adopt upstream's
updated JSDoc for removeEmptyParents().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@alexeyv alexeyv marked this pull request as ready for review February 25, 2026 21:20
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

@bmadcode bmadcode merged commit 2d2f485 into bmad-code-org:main Feb 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants