From e56791bc927dbd7f70d43b6ccdc359249172856a Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 21 May 2026 13:55:47 -0500 Subject: [PATCH] fix(installer): strip stale auto-sync hooks on install and uninstall Pre-0.8 installers wrote `codegraph mark-dirty` / `sync-if-dirty` hooks to Claude Code's settings.json. Both subcommands were removed from the CLI, so the Stop hook fails every turn ("unknown command 'sync-if-dirty'"). The cleanup that once removed them was lost when the installer moved to the per-target architecture. Add cleanupLegacyHooks(), wired into both install (upgrades self-heal) and uninstall (so the npm preuninstall step fully reverses a legacy install). Surgical at the command level: only codegraph's own hook entries are dropped, so unrelated hooks sharing a matcher group or event (e.g. GitKraken's `gk ai hook run`) survive, and a settings.json with no legacy hooks is left byte-for-byte untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 ++++ __tests__/installer-targets.test.ts | 115 ++++++++++++++++++++++++++++ src/installer/targets/claude.ts | 96 +++++++++++++++++++++++ 3 files changed, 225 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b924af9e..51fd187c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,20 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). signatures, generics, and Roblox instance-path `require(script.Parent.X)` imports. +### Fixed +- **Installer**: re-running `codegraph install` now removes the broken + auto-sync hooks that pre-0.8 versions wrote to Claude Code's + `settings.json`. Those builds added a `Stop → codegraph sync-if-dirty` + hook (and a `PostToolUse → codegraph mark-dirty` partner); both + subcommands were later removed from the CLI, so Claude Code reported + `Stop hook error: ... unknown command 'sync-if-dirty'` on every turn. + The cleanup is surgical — only codegraph's own hook entries are + stripped, so unrelated hooks sharing the same file or event (e.g. a + GitKraken `gk ai hook run` hook) are left untouched — and it also runs + on uninstall, so the npm `preuninstall` step fully reverses a legacy + install. Re-run `codegraph install` once on an affected machine to + clear the error. + ## [0.8.0] - 2026-05-20 ### Added diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index d2ee23e50..bb6c69eaf 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -20,6 +20,7 @@ import * as path from 'path'; import * as os from 'os'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; +import { cleanupLegacyHooks } from '../src/installer/targets/claude'; function mkTmpDir(label: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); @@ -433,6 +434,120 @@ describe('Installer targets — partial-state idempotency', () => { expect(legacy.mcpServers.codegraph).toBeUndefined(); expect(legacy.mcpServers.other).toBeDefined(); }); + + // ---- Legacy auto-sync hook cleanup ---- + // Pre-0.8 installs wrote `codegraph mark-dirty` / `sync-if-dirty` + // hooks to settings.json. Both subcommands were removed from the CLI, + // so the Stop hook fails every turn ("unknown command + // 'sync-if-dirty'"). The installer must strip them on upgrade and + // uninstall — without touching the user's unrelated hooks. + + function seedSettings(loc: 'global' | 'local', settings: Record): string { + const dir = path.join(loc === 'global' ? tmpHome : tmpCwd, '.claude'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'settings.json'); + fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n'); + return file; + } + + // Realistic pre-0.8 settings.json: our two auto-sync hooks plus an + // unrelated GitKraken Stop hook the user added (matches the report). + function legacyHookSettings(): Record { + return { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }, + { hooks: [{ type: 'command', command: '"/Users/me/gk" ai hook run --host claude-code' }] }, + ], + }, + }; + } + + it('claude: install strips stale codegraph auto-sync hooks but keeps the user\'s GitKraken hook', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + + claude.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // The only PostToolUse group held mark-dirty → the event is gone. + expect(after.hooks?.PostToolUse).toBeUndefined(); + const stopCommands = (after.hooks?.Stop ?? []).flatMap((g: any) => + (g.hooks ?? []).map((h: any) => h.command), + ); + expect(stopCommands).not.toContain('codegraph sync-if-dirty'); + // The unrelated GitKraken hook survives untouched. + expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true); + // Permissions still written as normal alongside the cleanup. + expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search'); + }); + + it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => { + const file = seedSettings('global', { + hooks: { + Stop: [ + { + hooks: [ + { type: 'command', command: 'codegraph sync-if-dirty' }, + { type: 'command', command: 'gk ai hook run --host claude-code' }, + ], + }, + ], + }, + }); + + expect(cleanupLegacyHooks('global').action).toBe('removed'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(after.hooks.Stop[0].hooks.map((h: any) => h.command)).toEqual([ + 'gk ai hook run --host claude-code', + ]); + }); + + it('claude: cleanupLegacyHooks is a byte-for-byte no-op without codegraph hooks', () => { + const original = + JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'gk ai hook run' }] }] } }, null, 2) + '\n'; + const file = seedSettings('global', JSON.parse(original)); + + expect(cleanupLegacyHooks('global').action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(original); + }); + + it('claude: cleanupLegacyHooks reports not-found when settings.json is absent', () => { + expect(cleanupLegacyHooks('global').action).toBe('not-found'); + }); + + it('claude: re-running install after a legacy cleanup leaves settings.json unchanged', () => { + const claude = getTarget('claude')!; + const file = seedSettings('global', legacyHookSettings()); + claude.install('global', { autoAllow: true }); + const firstPass = fs.readFileSync(file, 'utf-8'); + claude.install('global', { autoAllow: true }); + expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass); + }); + + it('claude: uninstall strips stale hooks written in the npx form (local)', () => { + const claude = getTarget('claude')!; + const file = seedSettings('local', { + hooks: { + PostToolUse: [ + { matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph mark-dirty', async: true }] }, + ], + Stop: [ + { hooks: [{ type: 'command', command: 'npx @colbymchenry/codegraph sync-if-dirty' }] }, + ], + }, + }); + + claude.uninstall('local'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + // Both events emptied → the whole `hooks` object is removed. + expect(after.hooks).toBeUndefined(); + }); }); describe('Installer targets — registry', () => { diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index 80e2c9d8f..d5e878824 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -114,6 +114,15 @@ class ClaudeCodeTarget implements AgentTarget { files.push(writePermissionsEntry(loc)); } + // 2b. Strip stale auto-sync hooks left by a pre-0.8 install. Those + // versions wrote `codegraph mark-dirty` / `sync-if-dirty` hooks to + // settings.json; both subcommands are gone from the CLI, so the + // Stop hook now fails every turn with "unknown command + // 'sync-if-dirty'". Cleaning up on install makes an upgrade + // self-healing. Only surfaced when something was actually removed. + const hookCleanup = cleanupLegacyHooks(loc); + if (hookCleanup.action === 'removed') files.push(hookCleanup); + // 3. CLAUDE.md instructions files.push(writeInstructionsEntry(loc)); @@ -168,6 +177,14 @@ class ClaudeCodeTarget implements AgentTarget { files.push({ path: settingsPath, action: 'not-found' }); } + // 2b. Strip any stale auto-sync hooks a pre-0.8 install left in + // settings.json. The hook-cleanup step was lost when the installer + // moved to the per-target architecture; restoring it here means + // uninstall — and the npm `preuninstall` hook that drives it — fully + // reverses a legacy install. + const hookCleanup = cleanupLegacyHooks(loc); + if (hookCleanup.action === 'removed') files.push(hookCleanup); + // 3. Instructions const instr = instructionsPath(loc); const action = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); @@ -241,6 +258,85 @@ function cleanupLegacyLocalMcp(): WriteResult['files'][number] | null { return { path: file, action: 'removed' }; } +/** + * True when a Claude Code hook `command` is one of the auto-sync hooks + * a pre-0.8 install wrote. Those installers added + * `PostToolUse(Edit|Write) → codegraph mark-dirty` and + * `Stop → codegraph sync-if-dirty` (local builds used the + * `npx @colbymchenry/codegraph …` form, which still contains the + * `codegraph ` substring). Both subcommands were later + * removed from the CLI, so the Stop hook fails every turn with + * "unknown command 'sync-if-dirty'". Matching on the codegraph-scoped + * subcommand keeps unrelated user hooks (e.g. GitKraken's + * `gk ai hook run`) untouched. + */ +function isLegacyCodegraphHookCommand(command: unknown): boolean { + if (typeof command !== 'string') return false; + return ( + command.includes('codegraph mark-dirty') || + command.includes('codegraph sync-if-dirty') + ); +} + +/** + * Remove stale codegraph auto-sync hooks from Claude `settings.json`. + * + * Surgical at the individual-command level: only entries matching + * `isLegacyCodegraphHookCommand` are dropped, so a sibling hook sharing + * a matcher group (or the Stop event) with ours survives. We prune a + * matcher group only once its `hooks` array is empty, an event only + * once it has no groups left, and `hooks` itself only once every event + * is gone — and none of that runs unless we actually removed a + * codegraph command, so a settings.json with no legacy hooks is left + * byte-for-byte untouched and reported `unchanged`. + * + * Exported so it can be unit-tested directly and reused by both + * `install` (an upgrade self-heals) and `uninstall`. + */ +export function cleanupLegacyHooks(loc: Location): WriteResult['files'][number] { + const file = settingsJsonPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + + const settings = readJsonFile(file); + const hooks = settings.hooks; + if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) { + return { path: file, action: 'unchanged' }; + } + + // Pass 1: drop the legacy command(s) from inside every matcher group. + let removedAny = false; + for (const event of Object.keys(hooks)) { + const groups = hooks[event]; + if (!Array.isArray(groups)) continue; + for (const group of groups) { + if (!group || !Array.isArray(group.hooks)) continue; + const before = group.hooks.length; + group.hooks = group.hooks.filter( + (h: any) => !isLegacyCodegraphHookCommand(h?.command), + ); + if (group.hooks.length !== before) removedAny = true; + } + } + + if (!removedAny) return { path: file, action: 'unchanged' }; + + // Pass 2: prune empty matcher groups, then events with no groups + // left, then an empty top-level `hooks`. Guarded by `removedAny` so + // we never restructure a settings.json that had no codegraph hooks. + for (const event of Object.keys(hooks)) { + const groups = hooks[event]; + if (!Array.isArray(groups)) continue; + hooks[event] = groups.filter( + (g: any) => !(g && Array.isArray(g.hooks) && g.hooks.length === 0), + ); + if (hooks[event].length === 0) delete hooks[event]; + } + if (Object.keys(hooks).length === 0) delete settings.hooks; + + writeJsonFile(file, settings); + return { path: file, action: 'removed' }; +} + export function writePermissionsEntry(loc: Location): WriteResult['files'][number] { const file = settingsJsonPath(loc); const settings = readJsonFile(file);