From 8021e860380ae030458d23f34da3c653b999c98c Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Tue, 7 Apr 2026 14:26:29 -0700 Subject: [PATCH] fix(install): anchor local hook paths to $CLAUDE_PROJECT_DIR (#1906) (#1917) Local installs wrote bare relative paths (e.g. `node .claude/hooks/...`) into settings.json. Claude Code persists the shell's cwd between tool calls, so a single `cd subdir` broke every hook for the rest of the session. Prefix all 9 local hook commands with "$CLAUDE_PROJECT_DIR"/ so path resolution is always anchored to the project root regardless of cwd. Co-authored-by: Claude Opus 4.6 (1M context) --- bin/install.js | 21 +++--- tests/bug-1906-hook-relative-paths.test.cjs | 82 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 tests/bug-1906-hook-relative-paths.test.cjs diff --git a/bin/install.js b/bin/install.js index 2a5237016..bfc820213 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5547,21 +5547,24 @@ 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; const statuslineCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-statusline.js') - : 'node ' + dirName + '/hooks/gsd-statusline.js'; + : 'node ' + localPrefix + '/hooks/gsd-statusline.js'; const updateCheckCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-check-update.js') - : 'node ' + dirName + '/hooks/gsd-check-update.js'; + : 'node ' + localPrefix + '/hooks/gsd-check-update.js'; const contextMonitorCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-context-monitor.js') - : 'node ' + dirName + '/hooks/gsd-context-monitor.js'; + : 'node ' + localPrefix + '/hooks/gsd-context-monitor.js'; const promptGuardCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-prompt-guard.js') - : 'node ' + dirName + '/hooks/gsd-prompt-guard.js'; + : 'node ' + localPrefix + '/hooks/gsd-prompt-guard.js'; const readGuardCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-read-guard.js') - : 'node ' + dirName + '/hooks/gsd-read-guard.js'; + : 'node ' + localPrefix + '/hooks/gsd-read-guard.js'; // Enable experimental agents for Gemini CLI (required for custom sub-agents) if (isGemini) { @@ -5714,7 +5717,7 @@ function install(isGlobal, runtime = 'claude') { // /gsd-quick or /gsd-fast for state-tracked changes. Advisory only. const workflowGuardCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-workflow-guard.js') - : 'node ' + dirName + '/hooks/gsd-workflow-guard.js'; + : 'node ' + localPrefix + '/hooks/gsd-workflow-guard.js'; const hasWorkflowGuardHook = settings.hooks[preToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-workflow-guard')) ); @@ -5739,7 +5742,7 @@ function install(isGlobal, runtime = 'claude') { // Configure commit validation hook (Conventional Commits enforcement, opt-in) const validateCommitCommand = isGlobal ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-validate-commit.sh' - : 'bash ' + dirName + '/hooks/gsd-validate-commit.sh'; + : 'bash ' + localPrefix + '/hooks/gsd-validate-commit.sh'; const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-validate-commit')) ); @@ -5766,7 +5769,7 @@ function install(isGlobal, runtime = 'claude') { // Configure session state orientation hook (opt-in) const sessionStateCommand = isGlobal ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-session-state.sh' - : 'bash ' + dirName + '/hooks/gsd-session-state.sh'; + : 'bash ' + localPrefix + '/hooks/gsd-session-state.sh'; const hasSessionStateHook = settings.hooks.SessionStart.some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-session-state')) ); @@ -5788,7 +5791,7 @@ function install(isGlobal, runtime = 'claude') { // Configure phase boundary detection hook (opt-in) const phaseBoundaryCommand = isGlobal ? 'bash ' + targetDir.replace(/\\/g, '/') + '/hooks/gsd-phase-boundary.sh' - : 'bash ' + dirName + '/hooks/gsd-phase-boundary.sh'; + : 'bash ' + localPrefix + '/hooks/gsd-phase-boundary.sh'; const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry => entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-phase-boundary')) ); diff --git a/tests/bug-1906-hook-relative-paths.test.cjs b/tests/bug-1906-hook-relative-paths.test.cjs new file mode 100644 index 000000000..f3c112414 --- /dev/null +++ b/tests/bug-1906-hook-relative-paths.test.cjs @@ -0,0 +1,82 @@ +/** + * Regression tests for bug #1906 + * + * Local installs must anchor hook command paths to $CLAUDE_PROJECT_DIR so + * hooks resolve correctly regardless of the shell's current working directory. + * + * The original bug: local install hook commands used bare relative paths like + * `node .claude/hooks/gsd-context-monitor.js`. Claude Code persists the bash + * tool's cwd between calls, so a single `cd subdir && …` early in a session + * permanently broke every hook for the rest of that session. + * + * The fix prefixes all local hook commands with "$CLAUDE_PROJECT_DIR"/ so + * path resolution is always anchored to the project root. + */ + +'use strict'; + +const { describe, test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); + +// All hooks that the installer registers for local installs +const HOOKS = [ + 'gsd-statusline.js', + 'gsd-check-update.js', + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-workflow-guard.js', + 'gsd-validate-commit.sh', + 'gsd-session-state.sh', + 'gsd-phase-boundary.sh', +]; + +describe('bug #1906: local hook commands use $CLAUDE_PROJECT_DIR', () => { + let src; + + before(() => { + 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'); + }); + + for (const hook of HOOKS) { + test(`${hook} local command uses localPrefix (not bare dirName)`, () => { + // Find all local command strings for this hook + // The pattern is: ` ' + localPrefix + '/hooks/'` + // or the old broken pattern: ` ' + dirName + '/hooks/'` + const hookEscaped = hook.replace(/\./g, '\\.'); + const brokenPattern = new RegExp( + `['"](?:node|bash)\\s['"]\\s*\\+\\s*dirName\\s*\\+\\s*['"]/hooks/${hookEscaped}['"]` + ); + assert.ok( + !brokenPattern.test(src), + `${hook} must not use bare dirName — should use localPrefix for cwd-independent resolution` + ); + }); + } + + test('no local hook command uses bare dirName + /hooks/', () => { + // Broader check: no local (non-global) hook path should use dirName directly + // The pattern `': ' ' + dirName + '/hooks/'` is the broken form + const lines = src.split('\n'); + const offenders = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Match lines that build local hook commands with bare dirName + if (/['"](?:node|bash)\s['"][^;]*\+\s*dirName\s*\+\s*['"]\/hooks\//.test(line)) { + offenders.push(`line ${i + 1}: ${line.trim()}`); + } + } + assert.equal(offenders.length, 0, + 'Found local hook commands using bare dirName instead of localPrefix:\n' + + offenders.join('\n')); + }); +});