@ariso-ai/ari-hooks 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,11 @@ That single command:
24
24
 
25
25
  1. Opens your browser to log in to Ari and mint an API token (stored in
26
26
  `~/.ari-hooks/config.json`, `0600`). Only needed once per machine.
27
- 2. Adds three hooks to `./.claude/settings.json`:
27
+ 2. Detects which coding agents you use — Claude Code, Codex, and Cursor — and
28
+ wires up the hooks for each one it finds (Claude Code by `~/.claude`, Codex
29
+ by `~/.codex`, Cursor by the environment variables it sets in its terminal).
30
+ If it can't tell, it defaults to Claude Code.
31
+ 3. For Claude Code, adds three hooks to `./.claude/settings.json`:
28
32
  - `SessionStart` — fetches Ari's top suggested tasks and shows them when
29
33
  Claude Code boots; reply with a task number or name to run one
30
34
  - `UserPromptSubmit` — records what you asked for
@@ -33,6 +37,21 @@ That single command:
33
37
 
34
38
  Existing settings and hooks are preserved; running it again is a no-op.
35
39
 
40
+ ### Codex
41
+
42
+ Codex reads hooks from `.codex/hooks.json` (project) or `~/.codex/hooks.json`
43
+ (machine-wide), using the same nested shape as Claude Code. When `ari-hooks
44
+ install` (or `init`) detects Codex, it writes the two activity-sharing hooks
45
+ there:
46
+
47
+ - `UserPromptSubmit` — records what you asked for
48
+ - `Stop` — Codex hands the final assistant text to the hook directly (as
49
+ `last_assistant_message`), and that request/outcome pair is sent to the Ari
50
+ API
51
+
52
+ Codex has no user-visible channel to render Ari's suggested-task list, so the
53
+ `SessionStart` task prompt is skipped there.
54
+
36
55
  ### Cursor
37
56
 
38
57
  Cursor's agent doesn't read `.claude/settings.json` — it has its own hooks
@@ -48,14 +67,15 @@ writes the equivalent hooks there:
48
67
  this event instead)
49
68
  - `stop` — sends the request/outcome pair to the Ari API
50
69
 
51
- `ari-hooks uninstall` cleans up both files, wherever it runs.
70
+ `ari-hooks uninstall` cleans up every file — Claude Code, Codex, and Cursor —
71
+ wherever it runs.
52
72
 
53
73
  ### Commands
54
74
 
55
75
  | Command | What it does |
56
76
  |---|---|
57
- | `ari-hooks install` | Login (if needed) + set up hooks in the current folder |
58
- | `ari-hooks uninstall` | Remove the hooks from `./.claude/settings.json` and `./.cursor/hooks.json` |
77
+ | `ari-hooks install` | Login (if needed) + detect your agents and set up their hooks in the current folder |
78
+ | `ari-hooks uninstall` | Remove the hooks from the Claude Code, Codex (`./.codex/hooks.json`, `~/.codex/hooks.json`), and Cursor files |
59
79
  | `ari-hooks login` | Browser login, stores the API token |
60
80
  | `ari-hooks init` | Just add the hooks to `./.claude/settings.json` (no login) |
61
81
  | `ari-hooks config` | Show configured URLs and login state |
@@ -88,3 +108,7 @@ precedence over the stored config:
88
108
  logged to `~/.ari-hooks/error.log`.
89
109
  - Only the request text and the final assistant message are sent — no tool
90
110
  calls, diffs, or intermediate steps.
111
+
112
+ ## Author
113
+
114
+ Max Heckel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ariso-ai/ari-hooks",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Set up Claude Code hooks that share your requests and their outcomes with Ari",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -6,13 +6,16 @@ import { loadConfig, setUrls, showConfig } from './config.js';
6
6
  const USAGE = `ari-hooks — share your Claude Code activity with Ari
7
7
 
8
8
  Usage:
9
- ari-hooks install Log in (if needed) and set up hooks; asks whether to
10
- install for just this repo (and then just for you —
11
- .claude/settings.local.json or everyone on the repo
12
- .claude/settings.json) or machine-wide
13
- (inside Cursor, also writes ./.cursor/hooks.json)
9
+ ari-hooks install Log in (if needed) and set up hooks. Detects which
10
+ coding agents you use (Claude Code, Codex, Cursor) and
11
+ wires up each one; asks whether to install for just this
12
+ repo (and then just for you — .claude/settings.local.json
13
+ or everyone on the repo — .claude/settings.json) or
14
+ machine-wide. Codex hooks go in ./.codex/hooks.json (or
15
+ ~/.codex/hooks.json), Cursor's in ./.cursor/hooks.json
14
16
  ari-hooks uninstall Remove the hooks from ./.claude/settings.json,
15
17
  ./.claude/settings.local.json, ~/.claude/settings.json,
18
+ ./.codex/hooks.json, ~/.codex/hooks.json,
16
19
  and ./.cursor/hooks.json
17
20
  ari-hooks login Log in via the browser and store an API token
18
21
  ari-hooks init Just add the hooks (no login)
@@ -37,6 +40,7 @@ function parseFlags(args) {
37
40
  if (args[i] === '--web-url') flags.webUrl = args[++i];
38
41
  else if (args[i] === '--api-url') flags.apiUrl = args[++i];
39
42
  else if (args[i] === '--reset-urls') flags.resetUrls = true;
43
+ else if (args[i] === '--agent') flags.agent = args[++i];
40
44
  else rest.push(args[i]);
41
45
  }
42
46
  return { flags, rest };
@@ -84,7 +88,7 @@ export async function main(argv) {
84
88
  uninstall();
85
89
  return;
86
90
  case 'hook':
87
- await runHook(rest[1]);
91
+ await runHook(rest[1], flags.agent);
88
92
  return;
89
93
  case undefined:
90
94
  // URL-only invocations (e.g. `ari-hooks --web-url ...`) are a
package/src/hooks.js CHANGED
@@ -66,6 +66,21 @@ const sessionIdOf = (input) => input.session_id ?? input.conversation_id;
66
66
  // Every Cursor hook payload carries cursor_version; Claude Code's never do.
67
67
  const isCursorInput = (input) => typeof input.cursor_version === 'string';
68
68
 
69
+ // agent_type values, keyed by the --agent slug the hook command passes (init
70
+ // bakes it into each agent's config file).
71
+ const AGENT_TYPES = { claude: 'claude-code', codex: 'codex', cursor: 'cursor' };
72
+
73
+ // Which coding agent produced this turn. The --agent flag from the hook command
74
+ // is the reliable source; fall back to sniffing the payload for installs that
75
+ // predate the flag — Cursor stamps cursor_version, Claude Code sends a
76
+ // transcript_path, and Codex has neither (it hands us last_assistant_message).
77
+ function agentTypeOf(input, agent) {
78
+ if (AGENT_TYPES[agent]) return AGENT_TYPES[agent];
79
+ if (isCursorInput(input)) return 'cursor';
80
+ if (input.transcript_path) return 'claude-code';
81
+ return 'codex';
82
+ }
83
+
69
84
  /**
70
85
  * UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor): remember the
71
86
  * prompt so the Stop hook can pair it with the turn's outcome. Both hosts
@@ -158,7 +173,7 @@ const clamp = (text) =>
158
173
  * Stop: the turn is over — send the accumulated request(s) plus the final
159
174
  * assistant message to Ari, then clear the per-session state.
160
175
  */
161
- async function onStop(input) {
176
+ async function onStop(input, agent) {
162
177
  // stop_hook_active means a stop hook already forced Claude to continue;
163
178
  // the real end of the turn will fire another Stop event.
164
179
  if (input.stop_hook_active) return;
@@ -168,11 +183,12 @@ async function onStop(input) {
168
183
  const session = loadSession(sessionId);
169
184
  if (session.prompts.length === 0) return;
170
185
 
171
- // Cursor sessions get the outcome pushed to us via afterAgentResponse;
172
- // Claude Code sessions read it from the transcript, waiting for the final
173
- // assistant message to land there (on timeout, fall back to the last text
174
- // we did find best effort).
175
- let outcome = session.outcome ?? null;
186
+ // Cursor sessions get the outcome pushed to us via afterAgentResponse; Codex
187
+ // hands us the final text directly on the Stop payload as
188
+ // last_assistant_message; Claude Code sessions read it from the transcript,
189
+ // waiting for the final assistant message to land there (on timeout, fall
190
+ // back to the last text we did find — best effort).
191
+ let outcome = session.outcome ?? input.last_assistant_message ?? null;
176
192
  if (!outcome && input.transcript_path) {
177
193
  const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
178
194
  for (;;) {
@@ -197,6 +213,7 @@ async function onStop(input) {
197
213
  request: clamp(session.prompts.join('\n\n')),
198
214
  outcome: clamp(outcome),
199
215
  session_id: sessionId,
216
+ agent_type: agentTypeOf(input, agent),
200
217
  // Cursor sends workspace_roots instead of cwd.
201
218
  cwd: input.cwd ?? input.workspace_roots?.[0] ?? process.cwd(),
202
219
  }),
@@ -317,7 +334,7 @@ async function onSessionStart(input) {
317
334
  * user's Claude Code session: all failures are swallowed (logged to
318
335
  * ~/.ari-hooks/error.log) and we always exit 0.
319
336
  */
320
- export async function runHook(event) {
337
+ export async function runHook(event, agent) {
321
338
  try {
322
339
  const raw = await readStdin();
323
340
  const input = raw ? JSON.parse(raw) : {};
@@ -326,7 +343,7 @@ export async function runHook(event) {
326
343
  } else if (event === 'agent-response') {
327
344
  await onAgentResponse(input);
328
345
  } else if (event === 'stop') {
329
- await onStop(input);
346
+ await onStop(input, agent);
330
347
  } else if (event === 'session-start') {
331
348
  await onSessionStart(input);
332
349
  }
package/src/init.js CHANGED
@@ -1,12 +1,28 @@
1
1
  import { dirname, join } from 'node:path';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
 
6
+ // Each command carries an --agent flag so the Stop hook can report which coding
7
+ // agent produced the activity (agent_name) without having to guess from the
8
+ // payload — the config file it lives in is the reliable source of that fact.
6
9
  const HOOK_EVENTS = {
7
- SessionStart: 'ari-hooks hook session-start',
8
- UserPromptSubmit: 'ari-hooks hook user-prompt-submit',
9
- Stop: 'ari-hooks hook stop',
10
+ SessionStart: 'ari-hooks hook session-start --agent claude',
11
+ UserPromptSubmit: 'ari-hooks hook user-prompt-submit --agent claude',
12
+ Stop: 'ari-hooks hook stop --agent claude',
13
+ };
14
+
15
+ // Codex copied Claude Code's hooks design: the same { hooks: { Event: [{ hooks:
16
+ // [{ type, command }] }] } } shape and the same event names, read from
17
+ // .codex/hooks.json (or ~/.codex/hooks.json). We only wire up the
18
+ // activity-sharing pair: UserPromptSubmit remembers the prompt and Stop ships
19
+ // the outcome (Codex hands us the final text on the Stop payload as
20
+ // last_assistant_message — no transcript to parse; see onStop). We skip
21
+ // SessionStart because Codex has no user-visible channel to render the
22
+ // suggested-task list, so there is nothing to show.
23
+ const CODEX_HOOK_EVENTS = {
24
+ UserPromptSubmit: 'ari-hooks hook user-prompt-submit --agent codex',
25
+ Stop: 'ari-hooks hook stop --agent codex',
10
26
  };
11
27
 
12
28
  // Cursor's agent doesn't read .claude/settings.json — it has its own hooks
@@ -14,18 +30,70 @@ const HOOK_EVENTS = {
14
30
  // format. Cursor's transcript is not the Claude Code JSONL our stop handler
15
31
  // parses, so afterAgentResponse captures the final assistant text instead.
16
32
  const CURSOR_HOOK_EVENTS = {
17
- sessionStart: 'ari-hooks hook session-start',
18
- beforeSubmitPrompt: 'ari-hooks hook user-prompt-submit',
19
- afterAgentResponse: 'ari-hooks hook agent-response',
20
- stop: 'ari-hooks hook stop',
33
+ sessionStart: 'ari-hooks hook session-start --agent cursor',
34
+ beforeSubmitPrompt: 'ari-hooks hook user-prompt-submit --agent cursor',
35
+ afterAgentResponse: 'ari-hooks hook agent-response --agent cursor',
36
+ stop: 'ari-hooks hook stop --agent cursor',
21
37
  };
22
38
 
39
+ // Cursor's app install shows up in the env vars its integrated terminal
40
+ // inherits: the git-askpass helpers point into Cursor.app (macOS),
41
+ // AppData\Local\Programs\cursor (Windows), or a cursor install dir (Linux).
42
+ const CURSOR_PATH_VARS = [
43
+ 'GIT_ASKPASS',
44
+ 'VSCODE_GIT_ASKPASS_NODE',
45
+ 'VSCODE_GIT_ASKPASS_MAIN',
46
+ ];
47
+
48
+ // Cursor ships via ToDesktop, so its macOS bundle id is this opaque token
49
+ // rather than anything containing "cursor".
50
+ const CURSOR_BUNDLE_ID = 'com.todesktop.230313mzl4w4u92';
51
+
23
52
  /**
24
- * Cursor's integrated terminal exports CURSOR_TRACE_ID and its CLI agent
25
- * exports CURSOR_AGENT; neither is set by plain VS Code or a bare shell.
53
+ * Cursor's agent terminals export CURSOR_TRACE_ID and its CLI agent exports
54
+ * CURSOR_AGENT, but a regular integrated terminal in Cursor sets neither
55
+ * it looks like VS Code (TERM_PROGRAM=vscode). There, the tells are the
56
+ * app's bundle id and the helper paths pointing into the Cursor install;
57
+ * plain VS Code and a bare shell match none of these.
26
58
  */
27
59
  export const isCursor = (env = process.env) =>
28
- Boolean(env.CURSOR_TRACE_ID || env.CURSOR_AGENT);
60
+ Boolean(
61
+ env.CURSOR_TRACE_ID ||
62
+ env.CURSOR_AGENT ||
63
+ env.__CFBundleIdentifier === CURSOR_BUNDLE_ID ||
64
+ CURSOR_PATH_VARS.some((key) => /cursor/i.test(env[key] ?? ''))
65
+ );
66
+
67
+ const dirExists = (path) => {
68
+ try {
69
+ return statSync(path).isDirectory();
70
+ } catch {
71
+ return false;
72
+ }
73
+ };
74
+
75
+ // Each agent keeps its config under a home directory whose presence is the
76
+ // tell that the user runs that agent; both honor the same env overrides the
77
+ // agents themselves do (CLAUDE_CONFIG_DIR, CODEX_HOME), which also lets tests
78
+ // point them at a scratch dir.
79
+ const claudeHome = (env) => env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
80
+ const codexHome = (env) => env.CODEX_HOME || join(homedir(), '.codex');
81
+
82
+ /**
83
+ * Which coding agents does this user run? Claude Code and Codex are detected by
84
+ * their config directory; Cursor by the marks it leaves in an integrated
85
+ * terminal (see isCursor). Returns a Set so install can branch to one config
86
+ * path per detected agent. Falls back to Claude Code when nothing is found so a
87
+ * fresh machine (or CI) still gets the primary target wired up.
88
+ */
89
+ export function detectAgents(env = process.env) {
90
+ const agents = new Set();
91
+ if (dirExists(claudeHome(env))) agents.add('claude');
92
+ if (dirExists(codexHome(env))) agents.add('codex');
93
+ if (isCursor(env)) agents.add('cursor');
94
+ if (agents.size === 0) agents.add('claude');
95
+ return agents;
96
+ }
29
97
 
30
98
  function readJson(path) {
31
99
  try {
@@ -51,19 +119,36 @@ const writeJson = (path, value) =>
51
119
  */
52
120
  function claudeSettingsPath(scope, cwd, env) {
53
121
  if (scope === 'user') {
54
- return join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
122
+ return join(claudeHome(env), 'settings.json');
55
123
  }
56
124
  const file = scope === 'local' ? 'settings.local.json' : 'settings.json';
57
125
  return join(cwd, '.claude', file);
58
126
  }
59
127
 
60
- function initClaude(settingsPath) {
128
+ // Codex has one project file and one user file — no committed/private split
129
+ // like Claude Code's settings.local.json — so 'local' collapses onto the
130
+ // project file.
131
+ function codexHooksPath(scope, cwd, env) {
132
+ if (scope === 'user') {
133
+ return join(codexHome(env), 'hooks.json');
134
+ }
135
+ return join(cwd, '.codex', 'hooks.json');
136
+ }
137
+
138
+ /**
139
+ * Merge the ari-hooks commands into a settings file that uses the shared
140
+ * Claude Code / Codex nested hook shape ({ hooks: { Event: [{ hooks: [...] }] }
141
+ * }). Idempotent: an event that already has an ari-hooks command is left alone,
142
+ * everything else in the file is preserved. `label` tags the success line so
143
+ * the user can tell which agent a file belongs to.
144
+ */
145
+ function initNestedHooks(settingsPath, events, label) {
61
146
  const settings = readJson(settingsPath) ?? {};
62
147
 
63
148
  settings.hooks ??= {};
64
149
  let changed = false;
65
150
 
66
- for (const [event, command] of Object.entries(HOOK_EVENTS)) {
151
+ for (const [event, command] of Object.entries(events)) {
67
152
  settings.hooks[event] ??= [];
68
153
  const already = settings.hooks[event].some((matcher) =>
69
154
  (matcher.hooks ?? []).some((h) => h.command?.includes('ari-hooks hook'))
@@ -82,10 +167,15 @@ function initClaude(settingsPath) {
82
167
 
83
168
  mkdirSync(dirname(settingsPath), { recursive: true });
84
169
  writeJson(settingsPath, settings);
85
- console.log(`✓ Ari hooks added to ${settingsPath}`);
170
+ console.log(`✓ Ari hooks added to ${settingsPath}${label ? ` (${label})` : ''}`);
86
171
  return true;
87
172
  }
88
173
 
174
+ const initClaude = (settingsPath) => initNestedHooks(settingsPath, HOOK_EVENTS);
175
+
176
+ const initCodex = (hooksPath) =>
177
+ initNestedHooks(hooksPath, CODEX_HOOK_EVENTS, 'Codex detected');
178
+
89
179
  function initCursor(cwd) {
90
180
  const cursorDir = join(cwd, '.cursor');
91
181
  const hooksPath = join(cursorDir, 'hooks.json');
@@ -147,17 +237,22 @@ async function chooseClaudeScope() {
147
237
  }
148
238
 
149
239
  /**
150
- * Merge the ari-hooks hook commands into Claude Code settings and, when
151
- * running inside Cursor, into .cursor/hooks.json as well. Idempotent:
152
- * existing ari-hooks entries are left alone, and unrelated hooks/settings
153
- * are preserved. `scope` picks the Claude settings file (see
154
- * claudeSettingsPath); the Cursor hooks file is always project-level.
240
+ * Merge the ari-hooks hook commands into the config of every coding agent this
241
+ * user runs (see detectAgents): Claude Code settings, Codex hooks.json, and
242
+ * inside Cursor — .cursor/hooks.json. Idempotent: existing ari-hooks entries
243
+ * are left alone, and unrelated hooks/settings are preserved. `scope` picks the
244
+ * Claude Code and Codex file (see claudeSettingsPath / codexHooksPath); the
245
+ * Cursor hooks file is always project-level.
155
246
  */
156
247
  export function init(cwd = process.cwd(), env = process.env, scope = 'project') {
157
- const changedClaude = initClaude(claudeSettingsPath(scope, cwd, env));
158
- const changedCursor = isCursor(env) ? initCursor(cwd) : false;
159
-
160
- if (!changedClaude && !changedCursor) return;
248
+ const agents = detectAgents(env);
249
+ const changed = [
250
+ agents.has('claude') && initClaude(claudeSettingsPath(scope, cwd, env)),
251
+ agents.has('codex') && initCodex(codexHooksPath(scope, cwd, env)),
252
+ agents.has('cursor') && initCursor(cwd),
253
+ ];
254
+
255
+ if (!changed.some(Boolean)) return;
161
256
  const where = scope === 'user' ? 'on this machine' : 'in this folder';
162
257
  console.log(
163
258
  `Agent sessions ${where} will now share each request and its outcome with Ari,`
@@ -166,11 +261,14 @@ export function init(cwd = process.cwd(), env = process.env, scope = 'project')
166
261
  }
167
262
 
168
263
  /**
169
- * The `install` flavor of init: when attached to a terminal, ask where the
170
- * Claude Code hooks should live before writing them. Non-interactive runs
171
- * (CI, piped stdin) keep the old default of ./.claude/settings.json.
264
+ * The `install` flavor of init: report which agents were detected, and when
265
+ * attached to a terminal — ask where the hooks should live before writing them.
266
+ * Non-interactive runs (CI, piped stdin) keep the old default of the
267
+ * project-level files.
172
268
  */
173
269
  export async function install(cwd = process.cwd(), env = process.env) {
270
+ const agents = detectAgents(env);
271
+ console.log(`Detected coding agent(s): ${[...agents].join(', ')}`);
174
272
  const scope =
175
273
  process.stdin.isTTY && process.stdout.isTTY
176
274
  ? await chooseClaudeScope()
@@ -178,7 +276,7 @@ export async function install(cwd = process.cwd(), env = process.env) {
178
276
  init(cwd, env, scope);
179
277
  }
180
278
 
181
- function uninstallClaude(settingsPath) {
279
+ function uninstallNestedHooks(settingsPath) {
182
280
  if (!existsSync(settingsPath)) return false;
183
281
  const settings = readJson(settingsPath);
184
282
 
@@ -235,20 +333,24 @@ function uninstallCursor(cwd) {
235
333
  }
236
334
 
237
335
  /**
238
- * Remove the ari-hooks hook commands that init/install added to the
239
- * Claude Code settings and Cursor hooks file. The inverse of init: only
240
- * ari-hooks entries are touched, everything else in the files is
241
- * preserved. Cleans every location install can write to (project
242
- * settings.json, settings.local.json, the user-level settings, and the
243
- * Cursor hooks file), so hooks don't linger wherever they were put.
336
+ * Remove the ari-hooks hook commands that init/install added to the Claude
337
+ * Code settings, Codex hooks.json, and Cursor hooks file. The inverse of init:
338
+ * only ari-hooks entries are touched, everything else in the files is
339
+ * preserved. Cleans every location install can write to — regardless of which
340
+ * agents are currently detected so hooks don't linger wherever they were put:
341
+ * the project/local/user Claude settings, the project and user Codex hooks, and
342
+ * the Cursor hooks file.
244
343
  */
245
344
  export function uninstall(cwd = process.cwd(), env = process.env) {
246
345
  const removedClaude = ['project', 'local', 'user']
247
- .map((scope) => uninstallClaude(claudeSettingsPath(scope, cwd, env)))
346
+ .map((scope) => uninstallNestedHooks(claudeSettingsPath(scope, cwd, env)))
347
+ .some(Boolean);
348
+ const removedCodex = ['project', 'user']
349
+ .map((scope) => uninstallNestedHooks(codexHooksPath(scope, cwd, env)))
248
350
  .some(Boolean);
249
351
  const removedCursor = uninstallCursor(cwd);
250
352
 
251
- if (!removedClaude && !removedCursor) {
353
+ if (!removedClaude && !removedCodex && !removedCursor) {
252
354
  console.log('No Ari hooks found in this folder — nothing to remove.');
253
355
  return;
254
356
  }