fix(install): skip Gemini local commands/gsd when global GSD present (#3037) (#3041)

* fix(install): skip Gemini local commands/gsd when global GSD present (#3037)

Reporter showed that running `npx get-shit-done-cc --gemini --global`
followed by `--gemini --local` in a project creates the same 65 GSD
command files in both Gemini scopes:
  - ~/.gemini/commands/gsd/ (user scope)
  - <project>/.gemini/commands/gsd/ (workspace scope)

Gemini conflict-detects by command name across scopes and renames every
overlapping /gsd:* command to /workspace.gsd:* and /user.gsd:*, breaking
the documented /gsd:* namespace.

Fix: in bin/install.js, when handling --gemini --local, detect whether
~/.gemini/commands/gsd/ already exists with managed-shape content. If
so, skip the local copy and print a clear three-line warning explaining
the conflict avoidance. The user-scope install already provides the
same /gsd:* commands in this project; the local copy adds zero value.

Sibling fixes (test isolation):
- tests/install-minimal-all-runtimes.test.cjs: pass HOME/USERPROFILE
  through the spawned installer's env so the developer's real
  ~/.gemini/commands/gsd/ doesn't trigger the new skip path during
  test runs that want to assert the local-install populates
  commands/gsd/.
- tests/gemini-namespacing.test.cjs: the "Gemini Install (Behavioral)"
  describe block now creates an isolated tmpHome and points
  process.env.HOME at it before calling install(false, 'gemini'),
  with proper restore in afterEach.

Test:
- tests/bug-3037-gemini-duplicate-commands.test.cjs — 4 structural
  tests:
    1. global install populates HOME/.gemini/commands/gsd
    2. local install AFTER global skips the local copy
    3. local install with NO existing global still populates locally
       (no-regression)
    4. local install when HOME has .gemini/ but no GSD-managed
       commands/gsd/ still populates locally (non-GSD-Gemini-user
       no-regression)

6909/6909 full suite pass. Lints clean.

Closes #3037

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address CR feedback on PR #3041 — narrower detection + USERPROFILE restore

CR findings:

1. **bin/install.js (Major)** — userScopeHasGsd used
   `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the
   local install for any non-empty directory, including a user who
   hand-dropped a single override at ~/.gemini/commands/gsd/<thing>
   .toml without ever running --gemini --global. Narrowed the
   detection to require at least 3 canonical GSD command files
   (help.toml, progress.toml, new-project.toml) — a marker that
   ships in every GSD Gemini install (minimal mode included) and is
   structurally impossible to produce by accident.

2. **tests/bug-3037-...:59 (Minor)** — beforeEach overwrites
   process.env.USERPROFILE but afterEach only restores HOME, leaking
   the temp home into later tests on Windows or any code path that
   reads USERPROFILE. Added save/restore symmetric with HOME.

Plus added a 5th regression test covering the narrowed detection:
"local install when HOME has hand-dropped overrides UNDER commands/gsd/
(but no full GSD) still populates locally" — directly exercises the
edge case CR identified.

5/5 targeted tests pass. 6910/6910 full suite pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-02 17:44:52 -04:00
committed by GitHub
parent 7714b5244b
commit dca12242b5
5 changed files with 262 additions and 8 deletions

View File

@@ -7556,15 +7556,55 @@ function install(isGlobal, runtime = 'claude') {
// No skills/commands directory needed. Engine is installed via copyWithPathReplacement.
console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`);
} else if (isGemini) {
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode);
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
if (verifyInstalled(gsdDest, 'commands/gsd')) {
console.log(` ${green}✓${reset} Installed commands/gsd`);
// #3037: when running --local --gemini and a GSD-managed user-scope
// command directory already exists at ~/.gemini/commands/gsd/, skip
// the local copy. Gemini conflict-detects by command name across
// scopes and renames every overlapping /gsd:* command to
// /workspace.gsd:* and /user.gsd:*, breaking the documented namespace.
// The user-scope install already provides the same commands, so the
// local copy adds zero value at the cost of namespace conflicts.
//
// CR #3041 (Major): the detection must be specific to PACKAGE-MANAGED
// GSD content, not just "directory is non-empty". A user who hand-
// dropped a single override (e.g. ~/.gemini/commands/gsd/my-override
// .toml) would otherwise be unable to run a local install at all.
// Detection rule: at least 3 of the canonical GSD command files
// ('help.toml', 'progress.toml', 'new-project.toml') must be present.
// These three ship in every GSD Gemini install (minimal mode included
// — they're in the core skill set per #2790's consolidation), and 3-of-
// 3 with that specific basename set is structurally impossible to
// produce by accident.
const homeGeminiGsd = path.join(os.homedir(), '.gemini', 'commands', 'gsd');
const GSD_MANAGED_CANARIES = ['help.toml', 'progress.toml', 'new-project.toml'];
const userScopeHasGsd =
!isGlobal &&
path.resolve(targetDir) !== path.resolve(path.join(os.homedir(), '.gemini')) &&
fs.existsSync(homeGeminiGsd) &&
GSD_MANAGED_CANARIES.every((f) =>
fs.existsSync(path.join(homeGeminiGsd, f))
);
if (userScopeHasGsd) {
console.log(
` ${yellow}⚠${reset} Skipping commands/gsd/ for local install — GSD is already installed at user scope (${homeGeminiGsd}).`
);
console.log(
` Gemini conflict-detects across scopes and would rename every /gsd:* command to /workspace.gsd:* and /user.gsd:*.`
);
console.log(
` The user-scope install already provides /gsd:* commands in this project; no local copy is needed.`
);
} else {
failures.push('commands/gsd');
const commandsDir = path.join(targetDir, 'commands');
fs.mkdirSync(commandsDir, { recursive: true });
const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode);
const gsdDest = path.join(commandsDir, 'gsd');
copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal);
if (verifyInstalled(gsdDest, 'commands/gsd')) {
console.log(` ${green}✓${reset} Installed commands/gsd`);
} else {
failures.push('commands/gsd');
}
}
} else if (isGlobal) {
// Claude Code global: skills/ format (2.1.88+ compatibility)