From 7b58ce38fdf5db553d0698af691889df31766202 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 24 Oct 2026 13:44:22 +0800 Subject: [PATCH] Refactor the Claude Code wiring into an agent adapter Payload parsing, response shapes and hook registration now sit behind an Agent interface in src/agents/, so other agents can be added without touching detection. No behavior change. --- ...laude-Code-wiring-into-an-agent-adapter.md | 9 +- src/agents/claude.ts | 66 +++++++++++ src/agents/index.ts | 13 +++ src/agents/types.ts | 56 +++++++++ src/commands/clean.ts | 5 +- src/commands/doctor.ts | 50 ++++---- src/commands/init.ts | 13 ++- src/commands/session.ts | 12 +- src/commands/status.ts | 11 +- src/hook.ts | 110 +++++++++--------- src/settings.ts | 56 ++++++--- 11 files changed, 267 insertions(+), 134 deletions(-) create mode 100644 src/agents/claude.ts create mode 100644 src/agents/index.ts create mode 100644 src/agents/types.ts diff --git a/backlog/tasks/task-1 - Refactor-the-Claude-Code-wiring-into-an-agent-adapter.md b/backlog/tasks/task-1 - Refactor-the-Claude-Code-wiring-into-an-agent-adapter.md index 4ac48b7..c78ed86 100644 --- a/backlog/tasks/task-1 - Refactor-the-Claude-Code-wiring-into-an-agent-adapter.md +++ b/backlog/tasks/task-1 - Refactor-the-Claude-Code-wiring-into-an-agent-adapter.md @@ -1,9 +1,10 @@ --- id: TASK-1 title: Refactor the Claude Code wiring into an agent adapter -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:44' labels: - multi-agent dependencies: [] @@ -19,7 +20,7 @@ Move the Claude-specific parts (settings path, hook registration, payload parsin ## Acceptance Criteria -- [ ] #1 src/agents/claude.ts holds everything Claude-specific -- [ ] #2 hook, session-start, init, status, doctor and clean go through the adapter -- [ ] #3 Existing tests pass unchanged +- [x] #1 src/agents/claude.ts holds everything Claude-specific +- [x] #2 hook, session-start, init, status, doctor and clean go through the adapter +- [x] #3 Existing tests pass unchanged diff --git a/src/agents/claude.ts b/src/agents/claude.ts new file mode 100644 index 0000000..a50f5d4 --- /dev/null +++ b/src/agents/claude.ts @@ -0,0 +1,66 @@ +import { HOOK_COMMAND, registeredSpecs, registerSpecs, SESSION_COMMAND, SETTINGS_PATH, SPECS, unregisterSpecs } from "../settings.js"; +import type { Agent, ToolCall } from "./types.js"; + +interface ClaudeInput { + session_id?: string; + cwd?: string; + hook_event_name?: string; + tool_name?: string; + tool_input?: unknown; + tool_response?: unknown; +} + +export const claude: Agent = { + name: "claude", + label: "Claude Code", + configPath: SETTINGS_PATH, + hookCommand: HOOK_COMMAND, + sessionCommand: SESSION_COMMAND, + + wiring(root) { + const [pre, session, post] = registeredSpecs(root, SETTINGS_PATH, SPECS); + return { pre, post, session }; + }, + register: (root) => registerSpecs(root, SETTINGS_PATH, SPECS), + unregister: (root) => unregisterSpecs(root, SETTINGS_PATH, SPECS), + + parse(payload) { + if (!payload || typeof payload !== "object") return null; + const input = payload as ClaudeInput; + const call: ToolCall = { + phase: input.hook_event_name === "PostToolUse" ? "post" : "pre", + // The agent's cwd may be a subdirectory of the project; fall back to the + // directory Claude Code was started in. + dirs: [input.cwd, process.env.CLAUDE_PROJECT_DIR, process.cwd()], + tool: input.tool_name ?? "unknown", + input: input.tool_input, + output: input.tool_response, + sessionId: input.session_id, + }; + return call; + }, + pass: () => "", + deny: (reason) => + JSON.stringify({ + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, + }), + flag: (reason) => JSON.stringify({ decision: "block", reason }), + report: (context) => + JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }), + + probe: (root, path) => ({ + session_id: "canaryhook-doctor", + cwd: root, + tool_name: "Read", + tool_input: { file_path: path }, + }), + verdict(stdout) { + if (stdout.trim() === "") return "pass"; + try { + const parsed = JSON.parse(stdout) as { hookSpecificOutput?: { permissionDecision?: string } }; + return parsed.hookSpecificOutput?.permissionDecision ?? "pass"; + } catch { + return null; + } + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 0000000..ea1f578 --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,13 @@ +import { claude } from "./claude.js"; +import type { Agent } from "./types.js"; + +export type { Agent, ToolCall, Wiring } from "./types.js"; + +/** Every agent canaryhook can wire, in the order they are reported. */ +export const AGENTS: Agent[] = [claude]; + +export function agentNamed(name: string): Agent | undefined { + return AGENTS.find((agent) => agent.name === name); +} + +export { claude }; diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 0000000..e776f68 --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,56 @@ +/** One tool call as an agent's hook reported it, in canaryhook's terms. */ +export interface ToolCall { + /** Before the call runs, when it can still be denied, or after, with its output. */ + phase: "pre" | "post"; + /** Directories to look for the project in, nearest first. */ + dirs: (string | undefined)[]; + tool: string; + /** Tool input, with the shell command at `command` and the path at `file_path`. */ + input: unknown; + output: unknown; + sessionId?: string; +} + +export interface Wiring { + /** The hook that checks a call before it runs. */ + pre: boolean; + /** The hook that checks what a call returned. */ + post: boolean; + /** The hook that reports past trips when a session starts. */ + session: boolean; +} + +/** + * Everything canaryhook needs to know about one coding agent: where its hooks + * are configured, what its hook payloads look like, and how it expects to be + * answered. The detection itself is the same for every agent. + */ +export interface Agent { + name: string; + label: string; + /** Project-relative file the hooks are registered in. */ + configPath: string; + hookCommand: string; + sessionCommand: string; + /** Throws SettingsError when the config file cannot be read safely. */ + wiring(root: string): Wiring; + register(root: string): boolean; + unregister(root: string): boolean; + /** The hook payload as JSON, or null when it is not one this agent sends. */ + parse(payload: unknown): ToolCall | null; + /** Stdout for a call that tripped nothing, or "" to print nothing. */ + pass(phase: ToolCall["phase"]): string; + /** Stdout denying a call before it runs. */ + deny(reason: string): string; + /** Stdout telling the agent a call that already ran read a decoy. */ + flag(reason: string): string; + /** Stdout handing past trips to the agent at the start of a session. */ + report(context: string): string; + /** A payload for `doctor` to send through the hook, asking about a read of `path`. */ + probe(root: string, path: string): unknown; + /** + * What the hook's stdout decided about a probe: "deny", "pass" for letting it + * through, another decision by name, or null when the output is unreadable. + */ + verdict(stdout: string): string | null; +} diff --git a/src/commands/clean.ts b/src/commands/clean.ts index 5ec1ee4..ffb6fc4 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -1,6 +1,7 @@ import { existsSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { SettingsError, unregister } from "../settings.js"; +import { claude } from "../agents/index.js"; +import { SettingsError } from "../settings.js"; import { isDecoyFile, readState, STATE_DIR } from "../state.js"; export function clean(root: string, options: { json: boolean; keepAlerts: boolean }): number { @@ -25,7 +26,7 @@ export function clean(root: string, options: { json: boolean; keepAlerts: boolea let hookRemoved = false; let settingsProblem: string | null = null; try { - hookRemoved = unregister(root); + hookRemoved = claude.unregister(root); } catch (error) { if (!(error instanceof SettingsError)) throw error; settingsProblem = error.message; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d1fbd4e..3e7bf6b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; -import { HOOK_COMMAND, isRegistered, SettingsError, SETTINGS_PATH } from "../settings.js"; +import { claude, type Agent } from "../agents/index.js"; +import { SettingsError } from "../settings.js"; import { isDecoyFile, readState, type Decoy } from "../state.js"; interface Check { @@ -10,19 +11,13 @@ interface Check { /** * Runs the hook command exactly as the agent would, with a payload on stdin, - * and returns the permission decision it printed. CANARYHOOK_DRY_RUN keeps the - * probe out of the alert record. + * and returns what the agent would make of its answer. CANARYHOOK_DRY_RUN + * keeps the probe out of the alert record. */ -function askHook(root: string, toolInput: unknown): { decision: string | null; error: string | null } { - const payload = JSON.stringify({ - session_id: "canaryhook-doctor", +function askHook(agent: Agent, root: string, path: string): { decision: string | null; error: string | null } { + const run = spawnSync(agent.hookCommand, { cwd: root, - tool_name: "Read", - tool_input: toolInput, - }); - const run = spawnSync(HOOK_COMMAND, { - cwd: root, - input: payload, + input: JSON.stringify(agent.probe(root, path)), shell: true, encoding: "utf8", env: { ...process.env, CANARYHOOK_DRY_RUN: "1" }, @@ -32,13 +27,9 @@ function askHook(root: string, toolInput: unknown): { decision: string | null; e return { decision: null, error: `exited ${run.status}: ${(run.stderr ?? "").trim()}` }; } const out = (run.stdout ?? "").trim(); - if (out.length === 0) return { decision: null, error: null }; - try { - const parsed = JSON.parse(out) as { hookSpecificOutput?: { permissionDecision?: string } }; - return { decision: parsed.hookSpecificOutput?.permissionDecision ?? null, error: null }; - } catch { - return { decision: null, error: `hook printed something that is not JSON: ${out.slice(0, 120)}` }; - } + const decision = agent.verdict(out); + if (decision === null) return { decision: null, error: `hook printed something it should not: ${out.slice(0, 120)}` }; + return { decision, error: null }; } function decoyChecks(root: string, decoys: Decoy[]): Check { @@ -53,7 +44,7 @@ function decoyChecks(root: string, decoys: Decoy[]): Check { }; } -export function doctor(root: string, options: { json: boolean }): number { +export function doctor(root: string, options: { json: boolean }, agent: Agent = claude): number { const state = readState(root); if (!state) { const message = "canaryhook is not initialized here. Run: canaryhook init"; @@ -64,39 +55,38 @@ export function doctor(root: string, options: { json: boolean }): number { const checks: Check[] = [decoyChecks(root, state.decoys)]; - let registered = false; try { - registered = isRegistered(root); + const registered = agent.wiring(root).pre; checks.push({ name: "hook registered", ok: registered, - detail: registered ? `${HOOK_COMMAND} in ${SETTINGS_PATH}` : `not found in ${SETTINGS_PATH}`, + detail: registered ? `${agent.hookCommand} in ${agent.configPath}` : `not found in ${agent.configPath}`, }); } catch (error) { if (!(error instanceof SettingsError)) throw error; checks.push({ name: "hook registered", ok: false, detail: error.message }); } - const trip = askHook(root, { file_path: state.decoys[0]?.path ?? ".env.backup" }); + const trip = askHook(agent, root, state.decoys[0]?.path ?? ".env.backup"); checks.push({ name: "hook denies a decoy read", ok: trip.decision === "deny", detail: trip.error !== null - ? `could not run ${HOOK_COMMAND}: ${trip.error}` + ? `could not run ${agent.hookCommand}: ${trip.error}` : trip.decision === "deny" ? "a planted read came back denied" : "the hook ran but let a decoy read through", }); - const quiet = askHook(root, { file_path: "src/index.ts" }); + const quiet = askHook(agent, root, "src/index.ts"); checks.push({ name: "hook passes normal calls", - ok: quiet.error === null && quiet.decision === null, + ok: quiet.error === null && quiet.decision === "pass", detail: quiet.error !== null - ? `could not run ${HOOK_COMMAND}: ${quiet.error}` - : quiet.decision === null + ? `could not run ${agent.hookCommand}: ${quiet.error}` + : quiet.decision === "pass" ? "an ordinary read was left alone" : `an ordinary read came back ${quiet.decision}`, }); @@ -114,7 +104,7 @@ export function doctor(root: string, options: { json: boolean }): number { process.stdout.write( ok ? "\nThe hook is wired and firing.\n" - : `\nSomething is wrong. If ${HOOK_COMMAND} cannot run, the hook fails open and\n` + + : `\nSomething is wrong. If ${agent.hookCommand} cannot run, the hook fails open and\n` + "nothing is being watched. Reinstall canaryhook, or run canaryhook init --force.\n", ); return ok ? 0 : 1; diff --git a/src/commands/init.ts b/src/commands/init.ts index 73113db..aeca5fa 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildDecoys } from "../canaries.js"; -import { isRegistered, register, SettingsError, SETTINGS_PATH } from "../settings.js"; +import { claude } from "../agents/index.js"; +import { SettingsError } from "../settings.js"; import { isDecoyFile, readState, writeState, STATE_DIR, type Decoy } from "../state.js"; const IGNORE_MARKER = "# canaryhook decoys"; @@ -31,7 +32,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo // Read the settings before planting anything, so a settings file canaryhook // cannot parse stops the run instead of leaving half an install behind. try { - isRegistered(root); + claude.wiring(root); } catch (error) { if (error instanceof SettingsError) return fail(error.message, options.json); throw error; @@ -62,7 +63,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo } const decoys = built.map((b) => b.decoy); - const hookAdded = register(root); + const hookAdded = claude.register(root); writeState(root, { version: 1, created: new Date().toISOString(), @@ -78,7 +79,7 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo ok: true, planted: decoys.map((d) => d.path), hookRegistered: hookAdded, - settings: SETTINGS_PATH, + settings: claude.configPath, gitignoreUpdated: ignored, })}\n`, ); @@ -89,8 +90,8 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo for (const decoy of decoys) process.stdout.write(` ${decoy.path} (${decoy.kind})\n`); process.stdout.write( hookAdded - ? `\nRegistered the PreToolUse, PostToolUse and SessionStart hooks in ${SETTINGS_PATH}.\n` - : `\nThe hooks were already registered in ${SETTINGS_PATH}.\n`, + ? `\nRegistered the PreToolUse, PostToolUse and SessionStart hooks in ${claude.configPath}.\n` + : `\nThe hooks were already registered in ${claude.configPath}.\n`, ); if (ignored) process.stdout.write("Added the decoys and .canaryhook/ to .gitignore.\n"); if (options.commit) process.stdout.write("Left .gitignore alone, as --commit was passed.\n"); diff --git a/src/commands/session.ts b/src/commands/session.ts index d751307..fdc519d 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -1,3 +1,4 @@ +import { claude, type Agent } from "../agents/index.js"; import { readAlerts, STATE_DIR, type Alert } from "../state.js"; const RECENT = 3; @@ -21,18 +22,11 @@ function summarize(alerts: Alert[]): string { * session. Silent when the record is clean, and never fails loudly: a reporting * problem must not get in the way of a session. */ -export function sessionStart(root: string): number { +export function sessionStart(root: string, agent: Agent = claude): number { try { const alerts = readAlerts(root); if (alerts.length === 0) return 0; - process.stdout.write( - `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext: summarize(alerts), - }, - })}\n`, - ); + process.stdout.write(`${agent.report(summarize(alerts))}\n`); } catch { return 0; } diff --git a/src/commands/status.ts b/src/commands/status.ts index 804304c..8494722 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { isOutputRegistered, isRegistered, isSessionRegistered, SettingsError, SETTINGS_PATH } from "../settings.js"; +import { claude } from "../agents/index.js"; +import { SettingsError } from "../settings.js"; import { readAlerts, readState } from "../state.js"; export function status(root: string, options: { json: boolean }): number { @@ -22,9 +23,7 @@ export function status(root: string, options: { json: boolean }): number { let outputHook = false; let settingsProblem: string | null = null; try { - hook = isRegistered(root); - sessionHook = isSessionRegistered(root); - outputHook = isOutputRegistered(root); + ({ pre: hook, session: sessionHook, post: outputHook } = claude.wiring(root)); } catch (error) { if (!(error instanceof SettingsError)) throw error; settingsProblem = error.message; @@ -41,7 +40,7 @@ export function status(root: string, options: { json: boolean }): number { hookRegistered: hook, sessionHookRegistered: sessionHook, outputHookRegistered: outputHook, - settings: SETTINGS_PATH, + settings: claude.configPath, decoys, alerts: alerts.length, settingsProblem, @@ -52,7 +51,7 @@ export function status(root: string, options: { json: boolean }): number { } process.stdout.write(`Initialized ${state.created}\n`); - process.stdout.write(`Hook ${hook ? `registered in ${SETTINGS_PATH}` : "NOT registered"}\n`); + process.stdout.write(`Hook ${hook ? `registered in ${claude.configPath}` : "NOT registered"}\n`); process.stdout.write(`Session hook ${sessionHook ? "registered" : "NOT registered"}\n`); process.stdout.write(`Output hook ${outputHook ? "registered" : "NOT registered"}\n`); process.stdout.write("Decoys\n"); diff --git a/src/hook.ts b/src/hook.ts index f81b75c..4e9f7e9 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -1,17 +1,9 @@ import { basename } from "node:path"; +import { claude, type Agent } from "./agents/index.js"; import { appendAlert, findRoot, readState, type Alert, type Decoy } from "./state.js"; import { HOOK_COMMAND } from "./settings.js"; import { STATE_DIR, STATE_FILE } from "./state.js"; -interface HookInput { - session_id?: string; - cwd?: string; - hook_event_name?: string; - tool_name?: string; - tool_input?: unknown; - tool_response?: unknown; -} - interface Hit { reason: Alert["reason"]; decoy: string; @@ -345,29 +337,59 @@ export function inspectOutput(decoys: Decoy[], toolResponse: unknown): Hit | nul return null; } -export async function runHook(): Promise { - let input: HookInput; +/** What canaryhook tells the agent about a trip; the agent decides how it is shown. */ +function tripReason(hit: Hit, phase: "pre" | "post"): string { + if (phase === "post") { + return ( + `canaryhook: ${hit.detail}. That file is a tripwire planted to detect credential ` + + `hunting, and its contents are fake. This was recorded in ${STATE_DIR}/alerts.jsonl. ` + + `Do not use or repeat the value; tell the user instead.` + ); + } + if (hit.reason === "tamper") { + return ( + `canaryhook: ${hit.detail}. Its record and hook are protected from agent ` + + `changes, so this call was blocked and recorded in ${STATE_DIR}/alerts.jsonl. ` + + `If the user wants canaryhook changed or removed, they can run it themselves ` + + `from a terminal. Do not retry it; tell the user instead.` + ); + } + return ( + `canaryhook: ${hit.detail}. It is a tripwire planted to detect credential ` + + `hunting, not real project data. This call was blocked and recorded in ` + + `${STATE_DIR}/alerts.jsonl. Do not retry it; tell the user instead.` + ); +} + +function write(out: string): void { + if (out.length > 0) process.stdout.write(`${out}\n`); +} + +export async function runHook(agent: Agent = claude): Promise { + let call; try { - input = JSON.parse(await readStdin()) as HookInput; + call = agent.parse(JSON.parse(await readStdin())); } catch { - // Malformed input must never block a real tool call. + call = null; + } + // Malformed input must never block a real tool call. + if (!call) { + write(agent.pass("pre")); return 0; } - // The agent's cwd may be a subdirectory of the project; fall back to the - // directory Claude Code was started in. - const root = [input.cwd, process.env.CLAUDE_PROJECT_DIR, process.cwd()] - .map((dir) => (dir ? findRoot(dir) : null)) - .find((dir) => dir !== null); - if (!root) return 0; - const state = readState(root); - if (!state) return 0; - - const after = input.hook_event_name === "PostToolUse"; - const hit = after ? inspectOutput(state.decoys, input.tool_response) : inspect(state.decoys, input.tool_input); - if (!hit) return 0; + const root = call.dirs.map((dir) => (dir ? findRoot(dir) : null)).find((dir) => dir !== null); + const state = root ? readState(root) : null; + const hit = !state + ? null + : call.phase === "post" + ? inspectOutput(state.decoys, call.output) + : inspect(state.decoys, call.input); + if (!root || !hit) { + write(agent.pass(call.phase)); + return 0; + } - const tool = input.tool_name ?? "unknown"; // canaryhook doctor fires a real payload through this command to prove the // hook is wired, and must not leave a trip in the record for it. if (process.env.CANARYHOOK_DRY_RUN !== "1") { @@ -375,41 +397,13 @@ export async function runHook(): Promise { at: new Date().toISOString(), reason: hit.reason, decoy: hit.decoy, - tool, + tool: call.tool, detail: hit.detail, - sessionId: input.session_id, + sessionId: call.sessionId, }); } - if (after) { - process.stdout.write( - `${JSON.stringify({ - decision: "block", - reason: - `canaryhook: ${hit.detail}. That file is a tripwire planted to detect credential ` + - `hunting, and its contents are fake. This was recorded in ${STATE_DIR}/alerts.jsonl. ` + - `Do not use or repeat the value; tell the user instead.`, - })}\n`, - ); - return 0; - } - - process.stdout.write( - `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: - hit.reason === "tamper" - ? `canaryhook: ${hit.detail}. Its record and hook are protected from agent ` + - `changes, so this call was blocked and recorded in ${STATE_DIR}/alerts.jsonl. ` + - `If the user wants canaryhook changed or removed, they can run it themselves ` + - `from a terminal. Do not retry it; tell the user instead.` - : `canaryhook: ${hit.detail}. It is a tripwire planted to detect credential ` + - `hunting, not real project data. This call was blocked and recorded in ` + - `${STATE_DIR}/alerts.jsonl. Do not retry it; tell the user instead.`, - }, - })}\n`, - ); + const reason = tripReason(hit, call.phase); + write(call.phase === "post" ? agent.flag(reason) : agent.deny(reason)); return 0; } diff --git a/src/settings.ts b/src/settings.ts index 9d2bd72..a35265f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; export const SETTINGS_PATH = join(".claude", "settings.json"); export const HOOK_COMMAND = "canaryhook hook"; @@ -22,7 +22,7 @@ interface Settings { [key: string]: unknown; } -interface HookSpec { +export interface HookSpec { event: string; matcher?: string; command: string; @@ -32,7 +32,7 @@ interface HookSpec { * Every hook canaryhook installs: one to catch a trip before a call runs, one * to catch a decoy read through a call's output, and one to report trips. */ -const SPECS: HookSpec[] = [ +export const SPECS: HookSpec[] = [ { event: "PreToolUse", matcher: "*", command: HOOK_COMMAND }, { event: "SessionStart", command: SESSION_COMMAND }, { event: "PostToolUse", matcher: "*", command: HOOK_COMMAND }, @@ -41,8 +41,8 @@ const SPECS: HookSpec[] = [ /** Raised instead of silently replacing settings canaryhook cannot parse. */ export class SettingsError extends Error {} -function load(root: string): Settings { - const path = join(root, SETTINGS_PATH); +function load(root: string, file: string): Settings { + const path = join(root, file); if (!existsSync(path)) return {}; let parsed: unknown; try { @@ -52,16 +52,16 @@ function load(root: string): Settings { } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new SettingsError( - `${SETTINGS_PATH} is not a JSON object. canaryhook will not overwrite it: ` + + `${file} is not a JSON object. canaryhook will not overwrite it: ` + `fix or move the file, then run again.`, ); } return parsed as Settings; } -function save(root: string, settings: Settings): void { - mkdirSync(join(root, ".claude"), { recursive: true }); - writeFileSync(join(root, SETTINGS_PATH), `${JSON.stringify(settings, null, 2)}\n`, "utf8"); +function save(root: string, file: string, settings: Settings): void { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } function has(settings: Settings, spec: HookSpec): boolean { @@ -70,26 +70,44 @@ function has(settings: Settings, spec: HookSpec): boolean { ); } +/** + * Which of `specs` are in place in a settings file shaped like Claude Code's: + * `hooks` keyed by event, each a list of matcher groups holding commands. + * Codex and Gemini CLI use the same shape under their own file and event names. + */ +export function registeredSpecs(root: string, file: string, specs: HookSpec[]): boolean[] { + const settings = load(root, file); + return specs.map((spec) => has(settings, spec)); +} + /** Whether the PreToolUse hook, the one that catches a trip, is in place. */ export function isRegistered(root: string): boolean { - return has(load(root), SPECS[0]); + return registeredSpecs(root, SETTINGS_PATH, SPECS)[0]; } /** Whether the SessionStart hook, the one that reports past trips, is in place. */ export function isSessionRegistered(root: string): boolean { - return has(load(root), SPECS[1]); + return registeredSpecs(root, SETTINGS_PATH, SPECS)[1]; } /** Whether the PostToolUse hook, the one that checks tool output, is in place. */ export function isOutputRegistered(root: string): boolean { - return has(load(root), SPECS[2]); + return registeredSpecs(root, SETTINGS_PATH, SPECS)[2]; } /** Adds the hooks that are missing without disturbing hooks already there. */ export function register(root: string): boolean { - const settings = load(root); + return registerSpecs(root, SETTINGS_PATH, SPECS); +} + +export function unregister(root: string): boolean { + return unregisterSpecs(root, SETTINGS_PATH, SPECS); +} + +export function registerSpecs(root: string, file: string, specs: HookSpec[]): boolean { + const settings = load(root, file); let added = false; - for (const spec of SPECS) { + for (const spec of specs) { if (has(settings, spec)) continue; settings.hooks ??= {}; settings.hooks[spec.event] ??= []; @@ -98,13 +116,13 @@ export function register(root: string): boolean { settings.hooks[spec.event].push(group); added = true; } - if (added) save(root, settings); + if (added) save(root, file, settings); return added; } -export function unregister(root: string): boolean { - const settings = load(root); - const ours = new Set(SPECS.map((spec) => spec.command)); +export function unregisterSpecs(root: string, file: string, specs: HookSpec[]): boolean { + const settings = load(root, file); + const ours = new Set(specs.map((spec) => spec.command)); let removed = false; for (const [event, groups] of Object.entries(settings.hooks ?? {})) { @@ -124,6 +142,6 @@ export function unregister(root: string): boolean { if (!removed) return false; if (Object.keys(settings.hooks ?? {}).length === 0) delete settings.hooks; - save(root, settings); + save(root, file, settings); return true; }