Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/observe/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ function claudeEntry(): Group {
// Claude Code executes `command` as ONE shell string with $CLAUDE_PROJECT_DIR in the env —
// there is no `args` field in its hooks schema, so a ${…} template in args reaches node
// verbatim and throws MODULE_NOT_FOUND after every tool call.
// .claude/settings.json is often committed while ./.insta stays local-only, so a fresh
// clone (cloud session, teammate) gets the hook without the script — no-op there.
const hook = '"$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"'
return { matcher: '*', hooks: [{ type: 'command',
command: 'node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"', timeout: 15, _insta: MARKER }] }
command: `[ ! -f ${hook} ] || node ${hook}`, timeout: 15, _insta: MARKER }] }
}
function codexEntry(cwd: string): Group {
const abs = join(cwd, '.insta', 'observe', 'hook.js') // Codex doesn't expand ${CLAUDE_PROJECT_DIR}; use an absolute path
Expand Down
22 changes: 21 additions & 1 deletion test/observe-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// tool call in every linked project. Found live (user report, 2026-07-12).
import { test, expect } from 'vitest'
import { mkdtempSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { installObserve } from '../src/observe/install.js'
Expand All @@ -22,7 +23,26 @@ test('claude hook entry is a single shell-string command with $CLAUDE_PROJECT_DI
const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8'))
const hook = settings.hooks.PostToolUse.at(-1).hooks[0]
expect(hook.args).toBeUndefined()
expect(hook.command).toBe('node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"')
expect(hook.command).toBe(
'[ ! -f "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js" ] || node "$CLAUDE_PROJECT_DIR/.insta/observe/hook.js"')
})

// .claude/settings.json is often committed while ./.insta stays local-only, so a fresh clone
// (cloud code session, teammate checkout) has the hook registered but no hook.js — the command
// must no-op there, not spam MODULE_NOT_FOUND after every tool call. Found live (2026-07-15).
test('hook command exits 0 when .insta/observe/hook.js is absent, runs it when present', () => {
const cwd = mkdtempSync(join(tmpdir(), 'obs-proj-'))
mkdirSync(join(cwd, '.claude'), { recursive: true })
installObserve({ cwd, assetDir: fakeAssets() })
const settings = JSON.parse(readFileSync(join(cwd, '.claude', 'settings.json'), 'utf8'))
const cmd = settings.hooks.PostToolUse.at(-1).hooks[0].command
const run = (projectDir: string) =>
spawnSync('sh', ['-c', cmd], { env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir } })
const bare = mkdtempSync(join(tmpdir(), 'obs-clone-')) // fresh clone: no .insta at all
expect(run(bare).status).toBe(0)
expect(run(bare).stderr.toString()).toBe('')
writeFileSync(join(cwd, '.insta', 'observe', 'hook.js'), 'process.stdout.write("ran")')
expect(run(cwd).stdout.toString()).toBe('ran')
})

test('re-install replaces a broken legacy args-array entry instead of stacking', () => {
Expand Down
Loading