Skip to content

Commit 3cde0e4

Browse files
tonychang04claude
andauthored
fix(observe): Claude Code hook must be a single shell-string command (#17)
The installer emitted command:'node' + an args array containing a ${CLAUDE_PROJECT_DIR} template — Claude Code's hooks schema has no args field and only shell-expands the command string, so node received the template verbatim and threw MODULE_NOT_FOUND after EVERY tool call in every linked project (live user report). Re-install replaces legacy broken entries (marker-based upsert), so 'insta observe install' heals existing projects. Claude-Session: https://claude.ai/code/session_01GMbAe5K1RfwaAcge5inX7P Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 94d9c98 commit 3cde0e4

2 files changed

Lines changed: 45 additions & 2 deletions

File tree

src/observe/install.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ function materialize(cwd: string, assetDir: string): void {
5656
}
5757

5858
function claudeEntry(): Group {
59-
return { matcher: '*', hooks: [{ type: 'command', command: 'node',
60-
args: ['${CLAUDE_PROJECT_DIR}/.insta/observe/hook.js'], timeout: 15, _insta: MARKER }] }
59+
// Claude Code executes `command` as ONE shell string with $CLAUDE_PROJECT_DIR in the env —
60+
// there is no `args` field in its hooks schema, so a ${…} template in args reaches node
61+
// verbatim and throws MODULE_NOT_FOUND after every tool call.
62+
return { matcher: '*', hooks: [{ type: 'command',
63+
command: 'node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"', timeout: 15, _insta: MARKER }] }
6164
}
6265
function codexEntry(cwd: string): Group {
6366
const abs = join(cwd, '.insta', 'observe', 'hook.js') // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path

test/observe-install.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// The Claude Code hooks schema executes `command` as ONE shell string ($CLAUDE_PROJECT_DIR is an
2+
// env var the shell expands). The installer used to emit command:'node' + an args array with a
3+
// ${CLAUDE_PROJECT_DIR} template — nothing expands it, so node threw MODULE_NOT_FOUND after EVERY
4+
// tool call in every linked project. Found live (user report, 2026-07-12).
5+
import { test, expect } from 'vitest'
6+
import { mkdtempSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
7+
import { tmpdir } from 'node:os'
8+
import { join } from 'node:path'
9+
import { installObserve } from '../src/observe/install.js'
10+
11+
function fakeAssets(): string {
12+
const d = mkdtempSync(join(tmpdir(), 'obs-assets-'))
13+
writeFileSync(join(d, 'hook.js'), '// hook')
14+
writeFileSync(join(d, 'scanner.js'), '// scanner')
15+
return d
16+
}
17+
18+
test('claude hook entry is a single shell-string command with $CLAUDE_PROJECT_DIR (no args array)', () => {
19+
const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-'))
20+
mkdirSync(join(cwd, '.claude'), { recursive: true })
21+
installObserve({ cwd, assetDir: fakeAssets() })
22+
const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8'))
23+
const hook = settings.hooks.PostToolUse.at(-1).hooks[0]
24+
expect(hook.args).toBeUndefined()
25+
expect(hook.command).toBe('node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"')
26+
})
27+
28+
test('re-install replaces a broken legacy args-array entry instead of stacking', () => {
29+
const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-'))
30+
mkdirSync(join(cwd, '.claude'), { recursive: true })
31+
writeFileSync(join(cwd, '.claude', 'settings.json'), JSON.stringify({
32+
hooks: { PostToolUse: [{ matcher: '*', hooks: [{ type: 'command', command: 'node',
33+
args: ['${CLAUDE_PROJECT_DIR}/.insta/observe/hook.js'], _insta: 'insta-observe' }] }] },
34+
}))
35+
installObserve({ cwd, assetDir: fakeAssets() })
36+
const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8'))
37+
const all = settings.hooks.PostToolUse.flatMap((g: any) => g.hooks)
38+
expect(all).toHaveLength(1)
39+
expect(all[0].args).toBeUndefined()
40+
})

0 commit comments

Comments
 (0)