Browse Source

fix(installer): strip stale auto-sync hooks on install and uninstall (#278)

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) <noreply@anthropic.com>
Colby Mchenry 1 month ago
parent
commit
b8aec39abd
3 changed files with 225 additions and 0 deletions
  1. 14 0
      CHANGELOG.md
  2. 115 0
      __tests__/installer-targets.test.ts
  3. 96 0
      src/installer/targets/claude.ts

+ 14 - 0
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

+ 115 - 0
__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, any>): 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<string, any> {
+    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', () => {

+ 96 - 0
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 <subcommand>` 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);