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) <noreply@anthropic.com>
This commit is contained in:
@@ -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'))
|
||||
);
|
||||
|
||||
82
tests/bug-1906-hook-relative-paths.test.cjs
Normal file
82
tests/bug-1906-hook-relative-paths.test.cjs
Normal file
@@ -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: `<runner> ' + localPrefix + '/hooks/<hook>'`
|
||||
// or the old broken pattern: `<runner> ' + dirName + '/hooks/<hook>'`
|
||||
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 `': '<runner> ' + 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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user