The standalone bundle's bin dir exposes only codegraph.cmd, and Claude Code executes UserPromptSubmit hooks through Git Bash, which applies no PATHEXT — so the bare `codegraph prompt-hook` the installer wrote was "command not found" (exit 127) on every prompt. Write the platform-correct spelling, recognize both spellings on uninstall/opt-out, and self-heal an installer-written entry from the other platform in place on install/upgrade re-runs (npx/hand-edited variants stay untouched). Reproduced and validated on the Windows VM: bare form exits 127 under Git Bash on a standalone-only PATH, codegraph.cmd exits 0; full installer suite (165 tests, including the new migration coverage) green on Windows + macOS. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
572d22bfbe
commit
0682137a42
@@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466)
|
||||||
|
|
||||||
## [1.5.0] - 2026-07-21
|
## [1.5.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -1144,6 +1144,11 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
// Opt-in (default-yes in the installer) UserPromptSubmit hook that runs
|
// Opt-in (default-yes in the installer) UserPromptSubmit hook that runs
|
||||||
// `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and
|
// `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and
|
||||||
// round-trip an opt-out — without disturbing the user's own hooks.
|
// round-trip an opt-out — without disturbing the user's own hooks.
|
||||||
|
// Platform-aware since #1466: Windows writes `codegraph.cmd prompt-hook`
|
||||||
|
// (Git Bash applies no PATHEXT, so the bare form is exit 127 there), and
|
||||||
|
// install self-heals the other platform's spelling in place.
|
||||||
|
const HOOK_CMD = process.platform === 'win32' ? 'codegraph.cmd prompt-hook' : 'codegraph prompt-hook';
|
||||||
|
const OTHER_PLATFORM_HOOK_CMD = process.platform === 'win32' ? 'codegraph prompt-hook' : 'codegraph.cmd prompt-hook';
|
||||||
const promptCommands = (s: any): string[] =>
|
const promptCommands = (s: any): string[] =>
|
||||||
(s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
|
(s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
|
||||||
|
|
||||||
@@ -1151,7 +1156,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
const claude = getTarget('claude')!;
|
const claude = getTarget('claude')!;
|
||||||
claude.install('global', { autoAllow: true, promptHook: true });
|
claude.install('global', { autoAllow: true, promptHook: true });
|
||||||
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
||||||
expect(promptCommands(s)).toContain('codegraph prompt-hook');
|
expect(promptCommands(s)).toContain(HOOK_CMD);
|
||||||
expect(s.permissions?.allow).toContain('mcp__codegraph__*');
|
expect(s.permissions?.allow).toContain('mcp__codegraph__*');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1159,7 +1164,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
const claude = getTarget('claude')!;
|
const claude = getTarget('claude')!;
|
||||||
claude.install('global', { autoAllow: true });
|
claude.install('global', { autoAllow: true });
|
||||||
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
||||||
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
|
expect(promptCommands(s)).not.toContain(HOOK_CMD);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => {
|
it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => {
|
||||||
@@ -1170,7 +1175,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
claude.install('global', { autoAllow: true, promptHook: true });
|
claude.install('global', { autoAllow: true, promptHook: true });
|
||||||
expect(fs.readFileSync(file, 'utf-8')).toBe(first);
|
expect(fs.readFileSync(file, 'utf-8')).toBe(first);
|
||||||
const s = JSON.parse(first);
|
const s = JSON.parse(first);
|
||||||
expect(promptCommands(s).filter((c: string) => c === 'codegraph prompt-hook')).toHaveLength(1);
|
expect(promptCommands(s).filter((c: string) => c === HOOK_CMD)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => {
|
it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => {
|
||||||
@@ -1178,7 +1183,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
claude.install('global', { autoAllow: true, promptHook: true });
|
claude.install('global', { autoAllow: true, promptHook: true });
|
||||||
claude.install('global', { autoAllow: true, promptHook: false });
|
claude.install('global', { autoAllow: true, promptHook: false });
|
||||||
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8'));
|
||||||
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
|
expect(promptCommands(s)).not.toContain(HOOK_CMD);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => {
|
it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => {
|
||||||
@@ -1187,14 +1192,37 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
});
|
});
|
||||||
expect(writePromptHookEntry('global').action).toBe('updated');
|
expect(writePromptHookEntry('global').action).toBe('updated');
|
||||||
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
expect(promptCommands(s)).toEqual(['my-own-hook', 'codegraph prompt-hook']);
|
expect(promptCommands(s)).toEqual(['my-own-hook', HOOK_CMD]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: writePromptHookEntry migrates the other platform\'s spelling in place (#1466 self-heal)', () => {
|
||||||
|
const file = seedSettings('global', {
|
||||||
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }] },
|
||||||
|
});
|
||||||
|
expect(writePromptHookEntry('global').action).toBe('updated');
|
||||||
|
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
|
expect(promptCommands(s)).toEqual([HOOK_CMD]);
|
||||||
|
// A re-run after migration is byte-identical.
|
||||||
|
const healed = fs.readFileSync(file, 'utf-8');
|
||||||
|
expect(writePromptHookEntry('global').action).toBe('unchanged');
|
||||||
|
expect(fs.readFileSync(file, 'utf-8')).toBe(healed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claude: writePromptHookEntry leaves an npx-form hook untouched (no duplicate, no rewrite)', () => {
|
||||||
|
const npxCmd = 'npx @colbymchenry/codegraph prompt-hook';
|
||||||
|
const file = seedSettings('global', {
|
||||||
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: npxCmd }] }] },
|
||||||
|
});
|
||||||
|
expect(writePromptHookEntry('global').action).toBe('unchanged');
|
||||||
|
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
|
expect(promptCommands(s)).toEqual([npxCmd]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => {
|
it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => {
|
||||||
const file = seedSettings('global', {
|
const file = seedSettings('global', {
|
||||||
hooks: {
|
hooks: {
|
||||||
UserPromptSubmit: [
|
UserPromptSubmit: [
|
||||||
{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] },
|
{ hooks: [{ type: 'command', command: HOOK_CMD }] },
|
||||||
{ hooks: [{ type: 'command', command: 'my-own-hook' }] },
|
{ hooks: [{ type: 'command', command: 'my-own-hook' }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1204,16 +1232,27 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
expect(promptCommands(s)).toEqual(['my-own-hook']);
|
expect(promptCommands(s)).toEqual(['my-own-hook']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('claude: removePromptHookEntry removes the other platform\'s spelling too', () => {
|
||||||
|
const file = seedSettings('global', {
|
||||||
|
hooks: {
|
||||||
|
UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(removePromptHookEntry('global').action).toBe('removed');
|
||||||
|
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
|
expect(promptCommands(s)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => {
|
it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => {
|
||||||
const file = seedSettings('global', {
|
const file = seedSettings('global', {
|
||||||
hooks: {
|
hooks: {
|
||||||
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }],
|
UserPromptSubmit: [{ hooks: [{ type: 'command', command: HOOK_CMD }] }],
|
||||||
Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }],
|
Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(removePromptHookEntry('global').action).toBe('removed');
|
expect(removePromptHookEntry('global').action).toBe('removed');
|
||||||
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
const s = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||||
expect(promptCommands(s)).not.toContain('codegraph prompt-hook');
|
expect(promptCommands(s)).not.toContain(HOOK_CMD);
|
||||||
const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
|
const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command));
|
||||||
expect(stopCmds).toContain('codegraph sync-if-dirty');
|
expect(stopCmds).toContain('codegraph sync-if-dirty');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -296,12 +296,24 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The front-load prompt-hook command the installer writes into Claude's
|
* The front-load prompt-hook command the installer writes into Claude's
|
||||||
* `UserPromptSubmit` (see writePromptHookEntry). Matched by substring so an
|
* `UserPromptSubmit` (see writePromptHookEntry). On Windows the launcher on
|
||||||
|
* PATH is `codegraph.cmd`, and Claude Code executes hooks through Git Bash,
|
||||||
|
* which — unlike cmd.exe — applies no PATHEXT: a bare `codegraph` is
|
||||||
|
* "command not found", exit 127 (#1466). Write the extension there; the
|
||||||
|
* `.cmd` spelling also resolves fine under cmd.exe and PowerShell.
|
||||||
|
*/
|
||||||
|
const PROMPT_HOOK_COMMAND = process.platform === 'win32'
|
||||||
|
? 'codegraph.cmd prompt-hook'
|
||||||
|
: 'codegraph prompt-hook';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every spelling the installer has ever written (a settings.json can carry
|
||||||
|
* the other platform's form across a sync). Matched by substring so an
|
||||||
* `npx @colbymchenry/codegraph prompt-hook` form is recognized too.
|
* `npx @colbymchenry/codegraph prompt-hook` form is recognized too.
|
||||||
*/
|
*/
|
||||||
const PROMPT_HOOK_COMMAND = 'codegraph prompt-hook';
|
const PROMPT_HOOK_FORMS = ['codegraph prompt-hook', 'codegraph.cmd prompt-hook'];
|
||||||
function isPromptHookCommand(command: unknown): boolean {
|
function isPromptHookCommand(command: unknown): boolean {
|
||||||
return typeof command === 'string' && command.includes(PROMPT_HOOK_COMMAND);
|
return typeof command === 'string' && PROMPT_HOOK_FORMS.some((f) => command.includes(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -424,10 +436,31 @@ export function writePromptHookEntry(loc: Location): WriteResult['files'][number
|
|||||||
}
|
}
|
||||||
if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
|
if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
|
||||||
|
|
||||||
|
// Self-heal (#1466): a pre-fix install on Windows wrote the bare
|
||||||
|
// `codegraph prompt-hook`, which Git Bash resolves to nothing; a
|
||||||
|
// settings.json carried across platforms can hold the other spelling too.
|
||||||
|
// Rewrite an installer-written command to this platform's form in place.
|
||||||
|
// Only the exact installer spellings migrate — an `npx …` or hand-edited
|
||||||
|
// variant is the user's own and stays untouched.
|
||||||
|
let migrated = false;
|
||||||
|
for (const group of settings.hooks.UserPromptSubmit) {
|
||||||
|
if (!group || !Array.isArray(group.hooks)) continue;
|
||||||
|
for (const h of group.hooks) {
|
||||||
|
if (h && PROMPT_HOOK_FORMS.includes(h.command) && h.command !== PROMPT_HOOK_COMMAND) {
|
||||||
|
h.command = PROMPT_HOOK_COMMAND;
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const already = settings.hooks.UserPromptSubmit.some(
|
const already = settings.hooks.UserPromptSubmit.some(
|
||||||
(g: any) => g && Array.isArray(g.hooks) && g.hooks.some((h: any) => isPromptHookCommand(h?.command)),
|
(g: any) => g && Array.isArray(g.hooks) && g.hooks.some((h: any) => isPromptHookCommand(h?.command)),
|
||||||
);
|
);
|
||||||
if (already) return { path: file, action: 'unchanged' };
|
if (already) {
|
||||||
|
if (!migrated) return { path: file, action: 'unchanged' };
|
||||||
|
writeJsonFile(file, settings);
|
||||||
|
return { path: file, action: 'updated' };
|
||||||
|
}
|
||||||
|
|
||||||
settings.hooks.UserPromptSubmit.push({
|
settings.hooks.UserPromptSubmit.push({
|
||||||
hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND }],
|
hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND }],
|
||||||
|
|||||||
Reference in New Issue
Block a user