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:
co-authored by
Claude Opus 4.7
parent
4329a52bec
commit
b8aec39abd
@@ -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)`
|
signatures, generics, and Roblox instance-path `require(script.Parent.X)`
|
||||||
imports.
|
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
|
## [0.8.0] - 2026-05-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
|
import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
|
||||||
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
|
import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
|
||||||
|
import { cleanupLegacyHooks } from '../src/installer/targets/claude';
|
||||||
|
|
||||||
function mkTmpDir(label: string): string {
|
function mkTmpDir(label: string): string {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
|
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.codegraph).toBeUndefined();
|
||||||
expect(legacy.mcpServers.other).toBeDefined();
|
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', () => {
|
describe('Installer targets — registry', () => {
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ class ClaudeCodeTarget implements AgentTarget {
|
|||||||
files.push(writePermissionsEntry(loc));
|
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
|
// 3. CLAUDE.md instructions
|
||||||
files.push(writeInstructionsEntry(loc));
|
files.push(writeInstructionsEntry(loc));
|
||||||
|
|
||||||
@@ -168,6 +177,14 @@ class ClaudeCodeTarget implements AgentTarget {
|
|||||||
files.push({ path: settingsPath, action: 'not-found' });
|
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
|
// 3. Instructions
|
||||||
const instr = instructionsPath(loc);
|
const instr = instructionsPath(loc);
|
||||||
const action = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
|
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' };
|
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] {
|
export function writePermissionsEntry(loc: Location): WriteResult['files'][number] {
|
||||||
const file = settingsJsonPath(loc);
|
const file = settingsJsonPath(loc);
|
||||||
const settings = readJsonFile(file);
|
const settings = readJsonFile(file);
|
||||||
|
|||||||
Reference in New Issue
Block a user