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>
This commit is contained in:
Colby Mchenry
2026-05-21 13:56:08 -05:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 4329a52bec
commit b8aec39abd
3 changed files with 225 additions and 0 deletions
+96
View File
@@ -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);