Skip to content

Commit 6ee387f

Browse files
committed
feat(cursor): add IDE abstraction layer and Cursor hook adapter
Phase 1 / 3 of cursor-full-support series — purely additive abstractions that let later PRs ship Cursor setup writers and a Cursor SDK adapter without re-touching the hook hot path. - New src/utils/ide-detect.ts: IdeKind type + parseIdeFlag(argv) + detectIdeFromEnv + detectIdeFromHookStdin + resolveIde precedence (argv → env → stdin heuristic → claude-code default). - New src/hooks/adapters/{types,claude-code,cursor}.ts: NormalizedHookEvent + HookInputAdapter / HookOutputAdapter contracts; per-IDE stdin parsers (Cursor: conversation_id, cursor_version, transcript_path|null); per-IDE deny emitters (Claude: hookSpecificOutput JSON, exit 0; Cursor: flat permission/user_message JSON, exit 2). - Refactor src/hooks/{pre,post,session-end}.ts to consume NormalizedHookEvent + a HookOutputAdapter. Safety-checking core in pre-tool-use.ts is byte-identical for Claude Code users. - src/cli.ts case "hook" now parses --ide and forwards into the right adapter; defaults to claude-code so existing setups are unaffected. - src/audit-spawner.ts forwards --ide to the detached worker argv (used by PR-3 to dispatch the correct transcript parser). - src/types.ts: add IdeKind type; ClaudeSessionRef gains optional ide?: IdeKind. ensureAxmeSessionForClaude / attachClaudeSession in src/storage/sessions.ts thread the optional ide param through. Tests (+30): test/ide-detect.test.ts covers flag/env/stdin precedence; test/cursor-hook-adapter.test.ts covers Cursor stdin parsing (incl. session_id vs conversation_id selection per hook kind, transcript_path null-preservation) and exit-code-2 deny emission; test/ claude-code-hook-adapter.test.ts asserts byte-for-byte regression of the existing deny JSON. Full suite: 572 / 572 (was 542). tsc --noEmit clean. npm run build clean. No behaviour change for Claude Code users. Decisions: D-145 (Phase 1 ships before VS Code Extension). Memories saved: cursor-sdk-system-prompt-via-inline-agent-definition, cursor-hook-protocol-exit-code-2-deny, cursor-sdk-1-0-12-no-win-arm64, cursor-agent-transcript-format-jsonl-with-top-level-role. #!axme pr=none repo=AxmeAI/axme-code
1 parent 9fbc907 commit 6ee387f

14 files changed

Lines changed: 686 additions & 83 deletions

src/audit-spawner.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { openSync, closeSync } from "node:fs";
3030
import { join } from "node:path";
3131
import { ensureDir } from "./storage/engine.js";
3232
import { AXME_CODE_DIR } from "./types.js";
33+
import type { IdeKind } from "./types.js";
3334

3435
const AUDIT_WORKER_LOGS_DIR = "audit-worker-logs";
3536

@@ -49,7 +50,14 @@ const AUDIT_WORKER_LOGS_DIR = "audit-worker-logs";
4950
* parent may be exiting imminently and holding a pipe fd open would
5051
* kill the writer once the reader closes)
5152
*/
52-
export function spawnDetachedAuditWorker(workspacePath: string, sessionId: string): void {
53+
export function spawnDetachedAuditWorker(
54+
workspacePath: string,
55+
sessionId: string,
56+
/** Which IDE produced this session — forwarded to the worker so the
57+
* auditor can dispatch the right transcript parser. Optional for
58+
* backward compatibility; absent value means "claude-code". */
59+
ide?: IdeKind,
60+
): void {
5361
const logsDir = join(workspacePath, AXME_CODE_DIR, AUDIT_WORKER_LOGS_DIR);
5462
ensureDir(logsDir);
5563
const logPath = join(logsDir, `${sessionId}.log`);
@@ -62,9 +70,11 @@ export function spawnDetachedAuditWorker(workspacePath: string, sessionId: strin
6270
try {
6371
const cliPath = process.argv[1];
6472
if (!cliPath) throw new Error("audit-spawner: cannot determine CLI path from process.argv[1]");
73+
const argv: string[] = [cliPath, "audit-session", "--workspace", workspacePath, "--session", sessionId];
74+
if (ide) argv.push("--ide", ide);
6575
const child = spawn(
6676
process.execPath,
67-
[cliPath, "audit-session", "--workspace", workspacePath, "--session", sessionId],
77+
argv,
6878
{
6979
detached: true,
7080
stdio: ["ignore", fd, fd],
@@ -73,7 +83,7 @@ export function spawnDetachedAuditWorker(workspacePath: string, sessionId: strin
7383
);
7484
child.unref();
7585
process.stderr.write(
76-
`AXME: spawned detached audit worker pid=${child.pid} session=${sessionId} log=${logPath}\n`,
86+
`AXME: spawned detached audit worker pid=${child.pid} session=${sessionId} ide=${ide ?? "claude-code"} log=${logPath}\n`,
7787
);
7888
} finally {
7989
// The child now holds its own dup of the fd; we can close our copy.

src/cli.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -558,16 +558,19 @@ async function main() {
558558
// Parse --workspace flag from CLI args
559559
const wsIdx = args.indexOf("--workspace");
560560
const workspacePath = wsIdx >= 0 && args[wsIdx + 1] ? args[wsIdx + 1] : undefined;
561+
// Parse --ide flag from CLI args (defaults to claude-code).
562+
const { parseIdeFlag } = await import("./utils/ide-detect.js");
563+
const ide = parseIdeFlag(args) ?? "claude-code";
561564

562565
if (hookName === "pre-tool-use") {
563566
const { runPreToolUseHook } = await import("./hooks/pre-tool-use.js");
564-
await runPreToolUseHook(workspacePath);
567+
await runPreToolUseHook(workspacePath, ide);
565568
} else if (hookName === "post-tool-use") {
566569
const { runPostToolUseHook } = await import("./hooks/post-tool-use.js");
567-
await runPostToolUseHook(workspacePath);
570+
await runPostToolUseHook(workspacePath, ide);
568571
} else if (hookName === "session-end") {
569572
const { runSessionEndHook } = await import("./hooks/session-end.js");
570-
await runSessionEndHook(workspacePath);
573+
await runSessionEndHook(workspacePath, ide);
571574
}
572575
break;
573576
}

src/hooks/adapters/claude-code.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Claude Code hook adapter.
3+
*
4+
* Stdin shape (per Anthropic's hooks API):
5+
* { tool_name, tool_input, session_id?, transcript_path? }
6+
*
7+
* Deny is signalled by writing a JSON object to stdout and exiting 0. Exit
8+
* codes are not used to convey allow/deny; only the JSON payload matters:
9+
*
10+
* { hookSpecificOutput: { hookEventName, permissionDecision, permissionDecisionReason } }
11+
*/
12+
13+
import type { HookInputAdapter, HookKind, HookOutputAdapter, NormalizedHookEvent, DenyResult } from "./types.js";
14+
15+
function pascalCaseFor(kind: HookKind): string {
16+
switch (kind) {
17+
case "preToolUse": return "PreToolUse";
18+
case "postToolUse": return "PostToolUse";
19+
case "sessionEnd": return "SessionEnd";
20+
}
21+
}
22+
23+
export const claudeCodeInputAdapter: HookInputAdapter = {
24+
parse(raw, kind): NormalizedHookEvent {
25+
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
26+
const toolName = typeof obj.tool_name === "string" ? obj.tool_name : undefined;
27+
const toolInput = (obj.tool_input && typeof obj.tool_input === "object")
28+
? obj.tool_input as Record<string, unknown>
29+
: undefined;
30+
const sessionId = typeof obj.session_id === "string" ? obj.session_id : undefined;
31+
const transcriptPath = typeof obj.transcript_path === "string" ? obj.transcript_path : undefined;
32+
return {
33+
kind,
34+
ide: "claude-code",
35+
toolName,
36+
toolInput,
37+
sessionId,
38+
transcriptPath,
39+
raw: obj,
40+
};
41+
},
42+
};
43+
44+
export const claudeCodeOutputAdapter: HookOutputAdapter = {
45+
emitDeny(reason, kind): DenyResult {
46+
const output = {
47+
hookSpecificOutput: {
48+
hookEventName: pascalCaseFor(kind),
49+
permissionDecision: "deny",
50+
permissionDecisionReason: `[AXME Safety] ${reason}`,
51+
},
52+
};
53+
return { stdout: JSON.stringify(output), exitCode: 0 };
54+
},
55+
};

src/hooks/adapters/cursor.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Cursor hook adapter.
3+
*
4+
* Stdin shape (verified against cursor.com/docs/agent/hooks, 2026-05):
5+
* common base fields:
6+
* conversation_id, generation_id, model, hook_event_name,
7+
* cursor_version, workspace_roots[], user_email?, transcript_path|null
8+
* preToolUse / postToolUse add:
9+
* tool_name, tool_input, tool_use_id, cwd, agent_message
10+
* sessionEnd adds:
11+
* session_id, reason ("completed"|"aborted"|"error"|"window_close"|...),
12+
* duration_ms, is_background_agent, final_status, error_message?
13+
*
14+
* Deny on preToolUse / beforeShellExecution / beforeMCPExecution:
15+
* stdout JSON: { permission: "allow"|"deny"|"ask", user_message, agent_message, updated_input? }
16+
* exit code 0 = success, 2 = deny, other = fail-open
17+
*/
18+
19+
import type { HookInputAdapter, HookKind, HookOutputAdapter, NormalizedHookEvent, DenyResult } from "./types.js";
20+
21+
function asString(v: unknown): string | undefined {
22+
return typeof v === "string" ? v : undefined;
23+
}
24+
25+
function asObject(v: unknown): Record<string, unknown> | undefined {
26+
return v && typeof v === "object" && !Array.isArray(v)
27+
? v as Record<string, unknown>
28+
: undefined;
29+
}
30+
31+
export const cursorInputAdapter: HookInputAdapter = {
32+
parse(raw, kind): NormalizedHookEvent {
33+
const obj = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
34+
const toolName = asString(obj.tool_name);
35+
const toolInput = asObject(obj.tool_input);
36+
37+
// Pre/postToolUse: Cursor identifies the running session via
38+
// conversation_id (per-conversation) + generation_id (per-turn).
39+
// Use conversation_id as the stable session id so consecutive tool calls
40+
// in the same conversation route to the same AXME session.
41+
// sessionEnd: Cursor sends a top-level session_id that may differ from
42+
// conversation_id (it identifies the SDK session, not the chat).
43+
let sessionId: string | undefined;
44+
if (kind === "sessionEnd") {
45+
sessionId = asString(obj.session_id) ?? asString(obj.conversation_id);
46+
} else {
47+
sessionId = asString(obj.conversation_id) ?? asString(obj.session_id);
48+
}
49+
50+
// transcript_path may legitimately be null on Cursor (e.g. very first
51+
// turn). Preserve null so callers can distinguish "no transcript yet"
52+
// from "field absent".
53+
let transcriptPath: string | null | undefined;
54+
if (obj.transcript_path === null) transcriptPath = null;
55+
else transcriptPath = asString(obj.transcript_path);
56+
57+
const reason = kind === "sessionEnd" ? asString(obj.reason) : undefined;
58+
59+
return {
60+
kind,
61+
ide: "cursor",
62+
toolName,
63+
toolInput,
64+
sessionId,
65+
transcriptPath,
66+
reason,
67+
raw: obj,
68+
};
69+
},
70+
};
71+
72+
export const cursorOutputAdapter: HookOutputAdapter = {
73+
emitDeny(reason, _kind): DenyResult {
74+
const message = `[AXME Safety] ${reason}`;
75+
const output = {
76+
permission: "deny",
77+
user_message: message,
78+
agent_message: message,
79+
};
80+
// Exit code 2 is Cursor's documented "deny" signal; the JSON body is
81+
// also required so Cursor's UI shows the reason. Both must agree.
82+
return { stdout: JSON.stringify(output), exitCode: 2 };
83+
},
84+
};

src/hooks/adapters/types.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Shared types for IDE-specific hook input parsers and deny-output emitters.
3+
*
4+
* Both Claude Code and Cursor hooks deliver per-event JSON to the hook
5+
* subprocess on stdin, but the field names, the deny-response shape, and the
6+
* exit-code semantics differ. The adapter pattern keeps the safety-checking
7+
* core logic in pre-tool-use.ts byte-identical across IDEs and isolates the
8+
* IDE-specific shapes here.
9+
*/
10+
11+
import type { IdeKind } from "../../types.js";
12+
13+
export type HookKind = "preToolUse" | "postToolUse" | "sessionEnd";
14+
15+
/**
16+
* The IDE-agnostic shape of a hook event after parsing. Hook handlers in
17+
* src/hooks/* read from this type instead of touching raw IDE-specific keys.
18+
*/
19+
export interface NormalizedHookEvent {
20+
kind: HookKind;
21+
ide: IdeKind;
22+
/** PreToolUse / PostToolUse only — the tool the agent is about to run. */
23+
toolName?: string;
24+
/** PreToolUse / PostToolUse only — the arguments the agent passed. */
25+
toolInput?: Record<string, unknown>;
26+
/** IDE's own session id (Claude Code session_id, Cursor session_id, etc.). */
27+
sessionId?: string;
28+
/** Path to the IDE's transcript file, if any. May be null on Cursor. */
29+
transcriptPath?: string | null;
30+
/** SessionEnd only — Cursor reports a `reason` (completed/aborted/...). */
31+
reason?: string;
32+
/** The original parsed JSON, for debug logging or fallback inspection. */
33+
raw: Record<string, unknown>;
34+
}
35+
36+
/** Parses a raw stdin JSON value into a NormalizedHookEvent. */
37+
export interface HookInputAdapter {
38+
parse(raw: unknown, kind: HookKind): NormalizedHookEvent;
39+
}
40+
41+
/** Result of an IDE-specific deny: stdout payload + exit code to use. */
42+
export interface DenyResult {
43+
stdout: string;
44+
exitCode: number;
45+
}
46+
47+
/** Emits a deny response in the IDE's native protocol. */
48+
export interface HookOutputAdapter {
49+
emitDeny(reason: string, kind: HookKind): DenyResult;
50+
}

src/hooks/post-tool-use.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,48 @@
11
/**
2-
* PostToolUse hook - runs after Edit/Write tool calls.
2+
* PostToolUse hook runs after Edit/Write tool calls.
33
*
4-
* Tracks filesChanged in session metadata, and attaches the Claude Code
5-
* session (session_id + transcript_path from the hook event) to the current
6-
* AXME session so the LLM auditor can later read the full transcript.
4+
* Tracks filesChanged in session metadata, and attaches the IDE's session
5+
* (id + transcript_path from the hook event) to the current AXME session
6+
* so the LLM auditor can later read the full transcript.
77
*
8-
* Input: JSON on stdin from Claude Code hooks system.
9-
* Workspace path: passed via --workspace flag (hardcoded at setup time).
8+
* Input: JSON on stdin from the IDE's hooks system. Stdin shape varies by
9+
* IDE (Claude Code vs Cursor); the adapter in src/hooks/adapters/ handles
10+
* the per-IDE field renames before the handler core runs.
1011
*
11-
* Session ID: read from .axme-code/active-session (written by MCP server),
12-
* NOT from Claude Code's session_id (which is a different ID).
12+
* Session ID: read from `.axme-code/active-sessions/<ide-session-id>.txt`
13+
* (written by MCP server / earlier hooks), NOT from the IDE's session id
14+
* directly (which is a different ID space).
1315
*/
1416

1517
import { trackFileChanged, ensureAxmeSessionForClaude } from "../storage/sessions.js";
1618
import { pathExists } from "../storage/engine.js";
1719
import { join } from "node:path";
1820
import { AXME_CODE_DIR } from "../types.js";
21+
import type { IdeKind } from "../types.js";
22+
import { claudeCodeInputAdapter } from "./adapters/claude-code.js";
23+
import { cursorInputAdapter } from "./adapters/cursor.js";
24+
import type { HookInputAdapter, NormalizedHookEvent } from "./adapters/types.js";
1925

20-
interface HookInput {
21-
tool_name: string;
22-
tool_input: Record<string, any>;
23-
session_id?: string;
24-
transcript_path?: string;
26+
function inputAdapterFor(ide: IdeKind): HookInputAdapter {
27+
return ide === "cursor" ? cursorInputAdapter : claudeCodeInputAdapter;
2528
}
2629

27-
function handlePostToolUse(workspacePath: string, event: HookInput): void {
28-
const { tool_name, tool_input } = event;
30+
function handlePostToolUse(workspacePath: string, event: NormalizedHookEvent): void {
31+
const tool_name = event.toolName ?? "";
32+
const tool_input = (event.toolInput ?? {}) as Record<string, any>;
2933

3034
if (!pathExists(join(workspacePath, AXME_CODE_DIR))) return;
3135

3236
// Ensure the AXME session exists for this Claude session_id (lazy creation).
3337
// Without session_id we cannot route this hook call — silently skip.
34-
if (!event.session_id || !event.transcript_path) return;
38+
if (!event.sessionId || !event.transcriptPath) return;
3539

3640
const axmeSessionId = ensureAxmeSessionForClaude(
3741
workspacePath,
38-
event.session_id,
39-
event.transcript_path,
42+
event.sessionId,
43+
event.transcriptPath,
44+
undefined,
45+
event.ide,
4046
);
4147

4248
// filesChanged tracking only for mutation tools
@@ -52,8 +58,9 @@ function handlePostToolUse(workspacePath: string, event: HookInput): void {
5258
/**
5359
* CLI entry point - reads JSON from stdin.
5460
* @param workspacePath - from --workspace CLI flag
61+
* @param ide - from --ide CLI flag (defaults to "claude-code")
5562
*/
56-
export async function runPostToolUseHook(workspacePath?: string): Promise<void> {
63+
export async function runPostToolUseHook(workspacePath?: string, ide: IdeKind = "claude-code"): Promise<void> {
5764
if (!workspacePath) workspacePath = process.cwd();
5865
if (!workspacePath) return;
5966

@@ -66,8 +73,9 @@ export async function runPostToolUseHook(workspacePath?: string): Promise<void>
6673
try {
6774
const chunks: Buffer[] = [];
6875
for await (const chunk of process.stdin) chunks.push(chunk);
69-
const input = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as HookInput;
70-
handlePostToolUse(workspacePath, input);
76+
const raw = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
77+
const event = inputAdapterFor(ide).parse(raw, "postToolUse");
78+
handlePostToolUse(workspacePath, event);
7179
} catch (err) {
7280
// Hook failures must be silent — but reported to telemetry for visibility.
7381
// Use blocking send: hook subprocess exits ms after this catch and would

0 commit comments

Comments
 (0)