From 1b5a12654edde76cc6827242841aa4497c732b6e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 24 Apr 2026 03:19:58 +0200 Subject: [PATCH] Keep Claude hook installs path-stable Claude hook failures showed up as exit 127 when the runtime had an empty or sparse PATH. The installer already knows the absolute Node and CLI paths, so preserve user hook entries while replacing only stale Colony hook commands with the absolute-node form. Uninstall now removes only Colony hook entries instead of deleting the user's adjacent Claude hooks. Constraint: Claude Code stores hooks as shell command strings, so the installed command must not rely on PATH resolution. Rejected: Re-run the old installer as-is | it would clobber custom PostToolUse and Stop hooks in ~/.claude/settings.json. Confidence: high Scope-risk: narrow Directive: Do not replace entire Claude hook event arrays without preserving non-Colony user hooks. Tested: pnpm --filter @colony/installers test Tested: pnpm --filter @colony/installers typecheck Tested: pnpm --filter @colony/installers build Tested: pnpm exec biome check packages/installers/src/claude-code.ts packages/installers/test/installers.test.ts .changeset/claude-hook-path-independence.md Tested: empty-PATH live Claude PostToolUse and Stop hook commands exit 0 --- .changeset/claude-hook-path-independence.md | 5 ++ packages/installers/src/claude-code.ts | 59 ++++++++++++++++----- packages/installers/test/installers.test.ts | 39 +++++++++++++- 3 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 .changeset/claude-hook-path-independence.md diff --git a/.changeset/claude-hook-path-independence.md b/.changeset/claude-hook-path-independence.md new file mode 100644 index 0000000..840c83a --- /dev/null +++ b/.changeset/claude-hook-path-independence.md @@ -0,0 +1,5 @@ +--- +"@colony/installers": patch +--- + +Preserve existing Claude Code hooks while normalizing Colony hooks to absolute Node commands. diff --git a/packages/installers/src/claude-code.ts b/packages/installers/src/claude-code.ts index 0d84e1d..1d90029 100644 --- a/packages/installers/src/claude-code.ts +++ b/packages/installers/src/claude-code.ts @@ -7,7 +7,10 @@ import type { InstallContext, Installer } from './types.js'; interface ClaudeSettings { hooks?: Record< string, - Array<{ matcher?: string; hooks: Array<{ type: string; command: string }> }> + Array<{ + matcher?: string; + hooks: Array<{ type: string; command: string; [key: string]: unknown }>; + }> >; mcpServers?: Record }>; } @@ -24,6 +27,42 @@ function settingsFile(): string { return join(homedir(), '.claude', 'settings.json'); } +function isColonyHookCommand(command: string, hookId: string): boolean { + const normalized = command.replace(/["']/g, ' ').replace(/\s+/g, ' ').trim(); + return /\bcolony(?:\.js)?\b/.test(normalized) && normalized.includes(` hook run ${hookId}`); +} + +function installColonyHook( + existing: NonNullable[string] | undefined, + command: string, + hookId: string, +): NonNullable[string] { + const filtered = removeColonyHook(existing, hookId); + return [ + ...filtered, + { + hooks: [ + { + type: 'command', + command, + }, + ], + }, + ]; +} + +function removeColonyHook( + existing: NonNullable[string] | undefined, + hookId: string, +): NonNullable[string] { + return (existing ?? []) + .map((entry) => ({ + ...entry, + hooks: entry.hooks.filter((hook) => !isColonyHookCommand(hook.command, hookId)), + })) + .filter((entry) => entry.hooks.length > 0); +} + export const claudeCode: Installer = { id: 'claude-code', label: 'Claude Code', @@ -40,16 +79,8 @@ export const claudeCode: Installer = { const nodeBin = shellQuote(ctx.nodeBin); const cliPath = shellQuote(ctx.cliPath); for (const [claudeName, hookId] of HOOK_NAMES) { - hooks[claudeName] = [ - { - hooks: [ - { - type: 'command', - command: `${nodeBin} ${cliPath} hook run ${hookId} --ide claude-code`, - }, - ], - }, - ]; + const command = `${nodeBin} ${cliPath} hook run ${hookId} --ide claude-code`; + hooks[claudeName] = installColonyHook(hooks[claudeName], command, hookId); } const mcpServers: NonNullable = { ...(current.mcpServers ?? {}) }; delete mcpServers.cavemem; @@ -67,7 +98,11 @@ export const claudeCode: Installer = { const path = settingsFile(); const current = readJson(path, {}); if (current.hooks) { - for (const [claudeName] of HOOK_NAMES) delete current.hooks[claudeName]; + for (const [claudeName, hookId] of HOOK_NAMES) { + const remaining = removeColonyHook(current.hooks[claudeName], hookId); + if (remaining.length > 0) current.hooks[claudeName] = remaining; + else delete current.hooks[claudeName]; + } } if (current.mcpServers) { delete current.mcpServers.colony; diff --git a/packages/installers/test/installers.test.ts b/packages/installers/test/installers.test.ts index 7d6d3f9..119847b 100644 --- a/packages/installers/test/installers.test.ts +++ b/packages/installers/test/installers.test.ts @@ -81,6 +81,7 @@ describe('claude-code installer', () => { await claudeCode.install(ctx); // run twice const second = JSON.parse(readFileSync(settingsPath, 'utf8')) as typeof first; expect(Object.keys(second.hooks).sort()).toEqual(Object.keys(first.hooks).sort()); + expect(second.hooks.SessionStart).toHaveLength(1); // No duplicate or stale MCP namespace entries. expect(Object.keys(second.mcpServers)).toEqual(['colony']); }); @@ -96,7 +97,23 @@ describe('claude-code installer', () => { other: { command: '/other/bin' }, cavemem: { command: '/old/bin', args: ['old-mcp'] }, }, - hooks: { CustomEvent: [{ hooks: [{ type: 'command', command: 'noop' }] }] }, + hooks: { + CustomEvent: [{ hooks: [{ type: 'command', command: 'noop' }] }], + PostToolUse: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'node /home/me/.claude/hooks/context.js' }], + }, + { + hooks: [ + { + type: 'command', + command: '/old/bin/colony hook run post-tool-use --ide claude-code', + }, + ], + }, + ], + }, }), ); @@ -114,6 +131,20 @@ describe('claude-code installer', () => { }); expect(installed.mcpServers.cavemem).toBeUndefined(); expect(installed.hooks.CustomEvent).toBeDefined(); + expect(installed.hooks.PostToolUse).toEqual([ + { + matcher: '*', + hooks: [{ type: 'command', command: 'node /home/me/.claude/hooks/context.js' }], + }, + { + hooks: [ + { + type: 'command', + command: `${ctx.nodeBin} ${ctx.cliPath} hook run post-tool-use --ide claude-code`, + }, + ], + }, + ]); await claudeCode.uninstall(ctx); const after = JSON.parse(readFileSync(settingsPath, 'utf8')) as typeof installed; @@ -123,6 +154,12 @@ describe('claude-code installer', () => { expect(after.mcpServers.cavemem).toBeUndefined(); expect(after.hooks.SessionStart).toBeUndefined(); expect(after.hooks.CustomEvent).toBeDefined(); + expect(after.hooks.PostToolUse).toEqual([ + { + matcher: '*', + hooks: [{ type: 'command', command: 'node /home/me/.claude/hooks/context.js' }], + }, + ]); }); it('quotes paths with spaces in hook command strings (Windows)', async () => {