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 () => {