fix(#2557): Gemini/Antigravity local hook commands use relative paths, not \$CLAUDE_PROJECT_DIR (#2589)

\$CLAUDE_PROJECT_DIR is Claude Code-specific. Gemini CLI doesn't set it, and on
Windows its path-join logic doubled the value producing unresolvable paths like
D:\Projects\GSD\'D:\Projects\GSD'. Gemini runs project hooks with project root
as cwd, so bare relative paths (e.g. node .gemini/hooks/gsd-check-update.js)
are cross-platform and correct. Claude Code and others still use the env var.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-22 12:04:06 -04:00
committed by GitHub
parent 7032f44633
commit fba040c72c
3 changed files with 86 additions and 6 deletions

View File

@@ -6075,9 +6075,13 @@ function install(isGlobal, runtime = 'claude') {
return;
}
const settings = validateHookFields(cleanupOrphanedHooks(rawSettings));
// Local installs anchor paths to $CLAUDE_PROJECT_DIR so hooks resolve
// correctly regardless of the shell's current working directory (#1906).
const localPrefix = '"$CLAUDE_PROJECT_DIR"/' + dirName;
// Local installs anchor hook paths so they resolve regardless of cwd (#1906).
// Claude Code sets $CLAUDE_PROJECT_DIR; Gemini/Antigravity do not — and on
// Windows their own substitution logic doubles the path (#2557). Those runtimes
// run project hooks with the project dir as cwd, so bare relative paths work.
const localPrefix = (runtime === 'gemini' || runtime === 'antigravity')
? dirName
: '"$CLAUDE_PROJECT_DIR"/' + dirName;
const hookOpts = { portableHooks: hasPortableHooks };
const statuslineCommand = isGlobal
? buildHookCommand(targetDir, 'gsd-statusline.js', hookOpts)

View File

@@ -42,9 +42,11 @@ describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => {
src = fs.readFileSync(INSTALL_SRC, 'utf-8');
});
test('localPrefix variable is defined with $CLAUDE_PROJECT_DIR', () => {
assert.match(src, /const localPrefix\s*=\s*['"]\"\$CLAUDE_PROJECT_DIR['"]\s*\//,
'localPrefix should be defined using $CLAUDE_PROJECT_DIR');
test('localPrefix definition includes $CLAUDE_PROJECT_DIR for non-Gemini runtimes', () => {
// localPrefix is now a ternary — Gemini/Antigravity use bare dirName (#2557),
// all other runtimes use "$CLAUDE_PROJECT_DIR"/ to anchor hook paths.
assert.match(src, /const localPrefix\s*=[\s\S]*?"\$CLAUDE_PROJECT_DIR"/,
'localPrefix definition must include "$CLAUDE_PROJECT_DIR" branch for non-Gemini runtimes');
});
for (const hook of HOOKS) {

View File

@@ -0,0 +1,74 @@
'use strict';
/**
* Bug #2557: Gemini CLI local hook commands must NOT use $CLAUDE_PROJECT_DIR.
*
* $CLAUDE_PROJECT_DIR is a Claude Code-specific env variable. Gemini CLI does
* not set it. On Windows, Gemini's own variable-substitution + path-join logic
* produced a doubled path like `D:\Projects\GSD\'D:\Projects\GSD'`, causing
* every local project hook to fail at SessionStart.
*
* Fix: localPrefix is now runtime-conditional. Gemini/Antigravity use bare
* dirName (relative path) since they always run project hooks with the project
* dir as cwd. Claude Code and others still use "$CLAUDE_PROJECT_DIR"/ (#1906).
*/
const { describe, test, before } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
describe('bug #2557: Gemini/Antigravity local hooks use relative paths (not $CLAUDE_PROJECT_DIR)', () => {
let src;
before(() => {
src = fs.readFileSync(INSTALL_SRC, 'utf-8');
});
test('localPrefix is a ternary that branches on Gemini/Antigravity', () => {
assert.match(
src,
/const localPrefix\s*=\s*\(runtime\s*===\s*['"]gemini['"]/,
'localPrefix must branch on runtime === "gemini"',
);
});
test('Gemini/Antigravity branch of localPrefix uses bare dirName (relative path)', () => {
// The ternary must assign `dirName` (not `"$CLAUDE_PROJECT_DIR"/` + dirName)
// for the Gemini branch so hooks use a relative path on all platforms.
assert.match(
src,
/const localPrefix\s*=\s*\(runtime\s*===\s*['"]gemini['"]\s*\|\|\s*runtime\s*===\s*['"]antigravity['"]\)\s*\n?\s*\?\s*dirName/,
'Gemini/Antigravity branch must resolve to bare dirName',
);
});
test('non-Gemini branch of localPrefix uses "$CLAUDE_PROJECT_DIR"/', () => {
// The else branch must still use "$CLAUDE_PROJECT_DIR"/ to fix #1906 for
// Claude Code and other runtimes that do set the variable.
assert.match(
src,
/:\s*['"]\"\$CLAUDE_PROJECT_DIR\"\//,
'non-Gemini branch must use "$CLAUDE_PROJECT_DIR"/ prefix',
);
});
test('Gemini/Antigravity hook commands do not contain "$CLAUDE_PROJECT_DIR" literal', () => {
// Since localPrefix is now dirName for Gemini/Antigravity, no command
// string built via `localPrefix` should contain the variable literal.
// We verify by checking that the only occurrence of $CLAUDE_PROJECT_DIR
// in the localPrefix definition is in the non-Gemini (else) branch.
const lines = src.split('\n');
const prefixDefIdx = lines.findIndex(l => /const localPrefix\s*=/.test(l));
assert.ok(prefixDefIdx >= 0, 'localPrefix definition not found');
// The Gemini (truthy) branch is the line right after the ternary condition.
// It must NOT contain $CLAUDE_PROJECT_DIR.
const geminiLine = lines[prefixDefIdx + 1] || '';
assert.ok(
!geminiLine.includes('$CLAUDE_PROJECT_DIR'),
'Gemini branch of localPrefix must not reference $CLAUDE_PROJECT_DIR',
);
});
});