@ariso-ai/ari-hooks 0.1.4 → 0.1.6

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
@@ -33,11 +33,29 @@ That single command:
33
33
 
34
34
  Existing settings and hooks are preserved; running it again is a no-op.
35
35
 
36
+ ### Cursor
37
+
38
+ Cursor's agent doesn't read `.claude/settings.json` — it has its own hooks
39
+ system in `.cursor/hooks.json`. When `ari-hooks install` (or `init`) runs
40
+ inside Cursor (detected via the `CURSOR_TRACE_ID` / `CURSOR_AGENT`
41
+ environment variables Cursor sets in its terminal and CLI agent), it also
42
+ writes the equivalent hooks there:
43
+
44
+ - `sessionStart` — injects Ari's suggested tasks as agent context
45
+ - `beforeSubmitPrompt` — records what you asked for
46
+ - `afterAgentResponse` — captures the final assistant text (Cursor's
47
+ transcript isn't the Claude Code format, so the outcome is taken from
48
+ this event instead)
49
+ - `stop` — sends the request/outcome pair to the Ari API
50
+
51
+ `ari-hooks uninstall` cleans up both files, wherever it runs.
52
+
36
53
  ### Commands
37
54
 
38
55
  | Command | What it does |
39
56
  |---|---|
40
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` |
41
59
  | `ari-hooks login` | Browser login, stores the API token |
42
60
  | `ari-hooks init` | Just add the hooks to `./.claude/settings.json` (no login) |
43
61
  | `ari-hooks config` | Show configured URLs and login state |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ariso-ai/ari-hooks",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -1,14 +1,21 @@
1
1
  import { login, logout, status } from './login.js';
2
- import { init } from './init.js';
2
+ import { init, install, uninstall } from './init.js';
3
3
  import { runHook } from './hooks.js';
4
4
  import { loadConfig, setUrls, showConfig } from './config.js';
5
5
 
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 in the current folder
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)
14
+ ari-hooks uninstall Remove the hooks from ./.claude/settings.json,
15
+ ./.claude/settings.local.json, ~/.claude/settings.json,
16
+ and ./.cursor/hooks.json
10
17
  ari-hooks login Log in via the browser and store an API token
11
- ari-hooks init Just add the hooks to ./.claude/settings.json (no login)
18
+ ari-hooks init Just add the hooks (no login)
12
19
  ari-hooks config Show the configured URLs and login state
13
20
  ari-hooks status Show login state
14
21
  ari-hooks logout Remove the stored token
@@ -67,12 +74,15 @@ export async function main(argv) {
67
74
  if (!loadConfig().token) {
68
75
  await login();
69
76
  }
70
- init();
77
+ await install();
71
78
  return;
72
79
  }
73
80
  case 'init':
74
81
  init();
75
82
  return;
83
+ case 'uninstall':
84
+ uninstall();
85
+ return;
76
86
  case 'hook':
77
87
  await runHook(rest[1]);
78
88
  return;
package/src/hooks.js CHANGED
@@ -11,6 +11,14 @@ import { configDir, loadConfig, getApiUrl } from './config.js';
11
11
 
12
12
  const MAX_TEXT_LENGTH = 100_000;
13
13
  const SEND_TIMEOUT_MS = 15_000;
14
+ // Claude Code can fire Stop while the final assistant message is still being
15
+ // flushed to the transcript; poll until the tail settles (or give up).
16
+ const OUTCOME_POLL_INTERVAL_MS = 150;
17
+ const OUTCOME_SETTLE_TIMEOUT_MS = Number(
18
+ process.env.ARI_HOOKS_SETTLE_TIMEOUT_MS ?? 5_000
19
+ );
20
+
21
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
22
 
15
23
  const sessionsDir = () => join(configDir(), 'sessions');
16
24
  const sessionPath = (sessionId) =>
@@ -50,40 +58,97 @@ function saveSession(sessionId, session) {
50
58
  writeFileSync(sessionPath(sessionId), JSON.stringify(session));
51
59
  }
52
60
 
61
+ // Claude Code identifies the session as session_id; Cursor hooks send
62
+ // conversation_id (their sessionStart also has a session_id, but the other
63
+ // events do not, so the conversation is our stable per-turn key).
64
+ const sessionIdOf = (input) => input.session_id ?? input.conversation_id;
65
+
66
+ // Every Cursor hook payload carries cursor_version; Claude Code's never do.
67
+ const isCursorInput = (input) => typeof input.cursor_version === 'string';
68
+
53
69
  /**
54
- * UserPromptSubmit: remember the prompt so the Stop hook can pair it with
55
- * the turn's outcome.
70
+ * UserPromptSubmit (Claude Code) / beforeSubmitPrompt (Cursor): remember the
71
+ * prompt so the Stop hook can pair it with the turn's outcome. Both hosts
72
+ * put the text in `prompt`.
56
73
  */
57
74
  async function onUserPromptSubmit(input) {
58
- if (!input.session_id || typeof input.prompt !== 'string') return;
59
- const session = loadSession(input.session_id);
75
+ const sessionId = sessionIdOf(input);
76
+ if (!sessionId || typeof input.prompt !== 'string') return;
77
+ const session = loadSession(sessionId);
60
78
  session.prompts.push(input.prompt);
61
- saveSession(input.session_id, session);
79
+ saveSession(sessionId, session);
80
+ }
81
+
82
+ /**
83
+ * afterAgentResponse (Cursor only): Cursor's transcript is not the Claude
84
+ * Code JSONL that extractOutcome can parse, so capture the final assistant
85
+ * text as Cursor hands it to us. Fires once per assistant message; the last
86
+ * one before stop is the turn's outcome.
87
+ */
88
+ async function onAgentResponse(input) {
89
+ const sessionId = sessionIdOf(input);
90
+ if (!sessionId || typeof input.text !== 'string' || !input.text.trim()) return;
91
+ const session = loadSession(sessionId);
92
+ session.outcome = input.text;
93
+ saveSession(sessionId, session);
94
+ }
95
+
96
+ function assistantText(entry) {
97
+ if (entry.type !== 'assistant' || !Array.isArray(entry.message?.content)) {
98
+ return '';
99
+ }
100
+ return entry.message.content
101
+ .filter((block) => block.type === 'text' && block.text)
102
+ .map((block) => block.text)
103
+ .join('\n')
104
+ .trim();
62
105
  }
63
106
 
64
107
  /**
65
108
  * Pull the final assistant text out of the transcript (JSONL). This is the
66
109
  * "outcome" — we deliberately skip the intermediate steps/tool calls.
110
+ *
111
+ * `settled` reports whether the exchange actually ends in assistant text.
112
+ * When the transcript instead ends at a tool call/result or a half-written
113
+ * line, the final message hasn't been flushed yet and `text` is only the
114
+ * last narration before a tool ran — the caller should re-read rather than
115
+ * ship that as the outcome.
67
116
  */
68
117
  function extractOutcome(transcriptPath) {
69
- const lines = readFileSync(transcriptPath, 'utf8').split('\n');
70
- for (let i = lines.length - 1; i >= 0; i--) {
71
- if (!lines[i].trim()) continue;
72
- let entry;
118
+ const entries = [];
119
+ let tailPartial = false;
120
+ for (const line of readFileSync(transcriptPath, 'utf8').split('\n')) {
121
+ if (!line.trim()) continue;
73
122
  try {
74
- entry = JSON.parse(lines[i]);
123
+ entries.push(JSON.parse(line));
124
+ tailPartial = false;
75
125
  } catch {
126
+ tailPartial = true; // a line still being written
127
+ }
128
+ }
129
+
130
+ let settled = tailPartial ? false : null;
131
+ for (let i = entries.length - 1; i >= 0; i--) {
132
+ const entry = entries[i];
133
+ // Bookkeeping entries (system, attachment, last-prompt, …) may trail
134
+ // the exchange; they say nothing about whether it is complete.
135
+ if (entry.type !== 'assistant' && entry.type !== 'user') continue;
136
+ let text = assistantText(entry);
137
+ if (!text) {
138
+ // A tool call/result with nothing after it: mid-turn.
139
+ settled ??= false;
76
140
  continue;
77
141
  }
78
- if (entry.type !== 'assistant' || !entry.message?.content) continue;
79
- const text = entry.message.content
80
- .filter((block) => block.type === 'text' && block.text)
81
- .map((block) => block.text)
82
- .join('\n')
83
- .trim();
84
- if (text) return text;
142
+ // The message may span several JSONL entries (one per content block);
143
+ // stitch earlier blocks of the same message back on.
144
+ const id = entry.message?.id;
145
+ for (let j = i - 1; id && j >= 0 && entries[j].message?.id === id; j--) {
146
+ const earlier = assistantText(entries[j]);
147
+ if (earlier) text = `${earlier}\n${text}`;
148
+ }
149
+ return { text, settled: settled ?? true };
85
150
  }
86
- return null;
151
+ return { text: null, settled: false };
87
152
  }
88
153
 
89
154
  const clamp = (text) =>
@@ -97,12 +162,26 @@ async function onStop(input) {
97
162
  // stop_hook_active means a stop hook already forced Claude to continue;
98
163
  // the real end of the turn will fire another Stop event.
99
164
  if (input.stop_hook_active) return;
100
- if (!input.session_id || !input.transcript_path) return;
165
+ const sessionId = sessionIdOf(input);
166
+ if (!sessionId) return;
101
167
 
102
- const session = loadSession(input.session_id);
168
+ const session = loadSession(sessionId);
103
169
  if (session.prompts.length === 0) return;
104
170
 
105
- const outcome = extractOutcome(input.transcript_path);
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;
176
+ if (!outcome && input.transcript_path) {
177
+ const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
178
+ for (;;) {
179
+ const { text, settled } = extractOutcome(input.transcript_path);
180
+ outcome = text;
181
+ if (settled || Date.now() >= deadline) break;
182
+ await sleep(OUTCOME_POLL_INTERVAL_MS);
183
+ }
184
+ }
106
185
  if (!outcome) return;
107
186
 
108
187
  const config = loadConfig();
@@ -117,8 +196,9 @@ async function onStop(input) {
117
196
  body: JSON.stringify({
118
197
  request: clamp(session.prompts.join('\n\n')),
119
198
  outcome: clamp(outcome),
120
- session_id: input.session_id,
121
- cwd: input.cwd ?? process.cwd(),
199
+ session_id: sessionId,
200
+ // Cursor sends workspace_roots instead of cwd.
201
+ cwd: input.cwd ?? input.workspace_roots?.[0] ?? process.cwd(),
122
202
  }),
123
203
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
124
204
  });
@@ -126,7 +206,7 @@ async function onStop(input) {
126
206
  throw new Error(`POST /agent-activities failed: ${response.status}`);
127
207
  }
128
208
 
129
- rmSync(sessionPath(input.session_id), { force: true });
209
+ rmSync(sessionPath(sessionId), { force: true });
130
210
  }
131
211
 
132
212
  const MAX_TASKS = 3;
@@ -145,6 +225,9 @@ async function onSessionStart(input) {
145
225
  // Compaction restarts the session mid-conversation; the tasks were
146
226
  // already offered, so don't show (or inject) them again.
147
227
  if (input.source === 'compact') return;
228
+ // Cursor also fires sessionStart for headless background agents — there is
229
+ // no user watching who could pick a task.
230
+ if (input.is_background_agent) return;
148
231
 
149
232
  const config = loadConfig();
150
233
  if (!config.token) return;
@@ -172,6 +255,36 @@ async function onSessionStart(input) {
172
255
  .slice(0, MAX_TASKS);
173
256
  if (tasks.length === 0) return;
174
257
 
258
+ const additionalContext =
259
+ `The user has Ari connected via ari-hooks. At session start the user was ` +
260
+ `shown this list of suggested tasks:\n\n` +
261
+ tasks
262
+ .map(
263
+ (t, i) =>
264
+ `Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
265
+ )
266
+ .join('\n\n') +
267
+ `\n\nIf the user asks to run one of these tasks (by number or name), ` +
268
+ `carry out that task's prompt as if the user had typed it. Do not start ` +
269
+ `any of these tasks unless the user asks.`;
270
+
271
+ // Cursor's sessionStart output is a flat { additional_context } and it has
272
+ // no user-visible systemMessage channel, so the agent itself must surface
273
+ // the list.
274
+ if (isCursorInput(input)) {
275
+ writeSync(
276
+ 1,
277
+ JSON.stringify({
278
+ additional_context:
279
+ additionalContext +
280
+ `\n\nNote: unlike Claude Code, Cursor did NOT show the user this ` +
281
+ `list — briefly offer these tasks by name at the start of your ` +
282
+ `first reply.`,
283
+ }) + '\n'
284
+ );
285
+ return;
286
+ }
287
+
175
288
  // Claude Code renders systemMessage with ANSI intact; the leading \n
176
289
  // pushes our block below the fixed "SessionStart:<source> says:" prefix.
177
290
  const BOLD = '\x1b[1m';
@@ -186,19 +299,6 @@ async function onSessionStart(input) {
186
299
  `${visibleList}\n` +
187
300
  `${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`;
188
301
 
189
- const additionalContext =
190
- `The user has Ari connected via ari-hooks. At session start the user was ` +
191
- `shown this list of suggested tasks:\n\n` +
192
- tasks
193
- .map(
194
- (t, i) =>
195
- `Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
196
- )
197
- .join('\n\n') +
198
- `\n\nIf the user asks to run one of these tasks (by number or name), ` +
199
- `carry out that task's prompt as if the user had typed it. Do not start ` +
200
- `any of these tasks unless the user asks.`;
201
-
202
302
  // writeSync: process.exit(0) in runHook would race an async stdout write.
203
303
  writeSync(
204
304
  1,
@@ -223,6 +323,8 @@ export async function runHook(event) {
223
323
  const input = raw ? JSON.parse(raw) : {};
224
324
  if (event === 'user-prompt-submit') {
225
325
  await onUserPromptSubmit(input);
326
+ } else if (event === 'agent-response') {
327
+ await onAgentResponse(input);
226
328
  } else if (event === 'stop') {
227
329
  await onStop(input);
228
330
  } else if (event === 'session-start') {
package/src/init.js CHANGED
@@ -1,5 +1,7 @@
1
- import { join } from 'node:path';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { dirname, join } from 'node:path';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { createInterface } from 'node:readline/promises';
3
5
 
4
6
  const HOOK_EVENTS = {
5
7
  SessionStart: 'ari-hooks hook session-start',
@@ -7,25 +9,56 @@ const HOOK_EVENTS = {
7
9
  Stop: 'ari-hooks hook stop',
8
10
  };
9
11
 
12
+ // Cursor's agent doesn't read .claude/settings.json — it has its own hooks
13
+ // system in .cursor/hooks.json with different event names and a flat entry
14
+ // format. Cursor's transcript is not the Claude Code JSONL our stop handler
15
+ // parses, so afterAgentResponse captures the final assistant text instead.
16
+ 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',
21
+ };
22
+
10
23
  /**
11
- * Merge the ari-hooks hook commands into the project's Claude Code
12
- * settings (.claude/settings.json in cwd). Idempotent: existing ari-hooks
13
- * entries are left alone, and unrelated hooks/settings are preserved.
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.
14
26
  */
15
- export function init(cwd = process.cwd()) {
16
- const claudeDir = join(cwd, '.claude');
17
- const settingsPath = join(claudeDir, 'settings.json');
27
+ export const isCursor = (env = process.env) =>
28
+ Boolean(env.CURSOR_TRACE_ID || env.CURSOR_AGENT);
18
29
 
19
- let settings = {};
30
+ function readJson(path) {
20
31
  try {
21
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
32
+ return JSON.parse(readFileSync(path, 'utf8'));
22
33
  } catch (err) {
23
- if (err.code !== 'ENOENT') {
24
- throw new Error(
25
- `${settingsPath} exists but is not valid JSON — fix or remove it, then re-run.`
26
- );
27
- }
34
+ if (err.code === 'ENOENT') return null;
35
+ throw new Error(
36
+ `${path} exists but is not valid JSON — fix or remove it, then re-run.`
37
+ );
38
+ }
39
+ }
40
+
41
+ const writeJson = (path, value) =>
42
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
43
+
44
+ /**
45
+ * Where the Claude Code hooks land, by scope:
46
+ * project — ./.claude/settings.json (shared with everyone on the repo)
47
+ * local — ./.claude/settings.local.json (just this user; Claude Code
48
+ * gitignores it)
49
+ * user — ~/.claude/settings.json (every repo on this machine;
50
+ * honors CLAUDE_CONFIG_DIR like Claude Code does)
51
+ */
52
+ function claudeSettingsPath(scope, cwd, env) {
53
+ if (scope === 'user') {
54
+ return join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
28
55
  }
56
+ const file = scope === 'local' ? 'settings.local.json' : 'settings.json';
57
+ return join(cwd, '.claude', file);
58
+ }
59
+
60
+ function initClaude(settingsPath) {
61
+ const settings = readJson(settingsPath) ?? {};
29
62
 
30
63
  settings.hooks ??= {};
31
64
  let changed = false;
@@ -44,14 +77,182 @@ export function init(cwd = process.cwd()) {
44
77
 
45
78
  if (!changed) {
46
79
  console.log(`Ari hooks already configured in ${settingsPath}`);
47
- return;
80
+ return false;
48
81
  }
49
82
 
50
- mkdirSync(claudeDir, { recursive: true });
51
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
83
+ mkdirSync(dirname(settingsPath), { recursive: true });
84
+ writeJson(settingsPath, settings);
52
85
  console.log(`✓ Ari hooks added to ${settingsPath}`);
86
+ return true;
87
+ }
88
+
89
+ function initCursor(cwd) {
90
+ const cursorDir = join(cwd, '.cursor');
91
+ const hooksPath = join(cursorDir, 'hooks.json');
92
+ const config = readJson(hooksPath) ?? {};
93
+
94
+ config.version ??= 1;
95
+ config.hooks ??= {};
96
+ let changed = false;
97
+
98
+ for (const [event, command] of Object.entries(CURSOR_HOOK_EVENTS)) {
99
+ config.hooks[event] ??= [];
100
+ const already = config.hooks[event].some((h) =>
101
+ h.command?.includes('ari-hooks hook')
102
+ );
103
+ if (already) continue;
104
+ config.hooks[event].push({ command, timeout: 30 });
105
+ changed = true;
106
+ }
107
+
108
+ if (!changed) {
109
+ console.log(`Ari hooks already configured in ${hooksPath}`);
110
+ return false;
111
+ }
112
+
113
+ mkdirSync(cursorDir, { recursive: true });
114
+ writeJson(hooksPath, config);
115
+ console.log(`✓ Ari hooks added to ${hooksPath} (Cursor detected)`);
116
+ return true;
117
+ }
118
+
119
+ async function ask(question) {
120
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
121
+ try {
122
+ return (await rl.question(question)).trim().toLowerCase();
123
+ } catch {
124
+ // Ctrl+D / closed stdin — fall through to the question's default.
125
+ return '';
126
+ } finally {
127
+ rl.close();
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Interactive scope picker for the Claude Code hooks: repo-only or
133
+ * machine-wide, and — when repo-only — private (settings.local.json) or
134
+ * shared with everyone on the repo (settings.json).
135
+ */
136
+ async function chooseClaudeScope() {
137
+ const repoOnly = await ask('Install hooks just for this repo? [Y/n] ');
138
+ if (repoOnly === 'n' || repoOnly === 'no') return 'user';
139
+
140
+ const who = await ask(
141
+ 'Install just for yourself, or for everyone who works on this repo?\n' +
142
+ ' 1) Just me (.claude/settings.local.json, not committed)\n' +
143
+ ' 2) Everyone (.claude/settings.json, committed with the repo)\n' +
144
+ 'Choose [1/2] (default 1): '
145
+ );
146
+ return who === '2' || who === 'everyone' ? 'project' : 'local';
147
+ }
148
+
149
+ /**
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.
155
+ */
156
+ 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;
161
+ const where = scope === 'user' ? 'on this machine' : 'in this folder';
53
162
  console.log(
54
- 'Claude Code sessions in this folder will now share each request and its outcome with Ari,'
163
+ `Agent sessions ${where} will now share each request and its outcome with Ari,`
55
164
  );
56
165
  console.log('and show suggested Ari tasks when a session starts.');
57
166
  }
167
+
168
+ /**
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.
172
+ */
173
+ export async function install(cwd = process.cwd(), env = process.env) {
174
+ const scope =
175
+ process.stdin.isTTY && process.stdout.isTTY
176
+ ? await chooseClaudeScope()
177
+ : 'project';
178
+ init(cwd, env, scope);
179
+ }
180
+
181
+ function uninstallClaude(settingsPath) {
182
+ if (!existsSync(settingsPath)) return false;
183
+ const settings = readJson(settingsPath);
184
+
185
+ const isOurs = (h) => h.command?.includes('ari-hooks hook');
186
+ let changed = false;
187
+
188
+ for (const [event, matchers] of Object.entries(settings.hooks ?? {})) {
189
+ if (!Array.isArray(matchers)) continue;
190
+ const kept = matchers
191
+ .map((matcher) => {
192
+ if (!(matcher.hooks ?? []).some(isOurs)) return matcher;
193
+ changed = true;
194
+ const rest = matcher.hooks.filter((h) => !isOurs(h));
195
+ return rest.length > 0 ? { ...matcher, hooks: rest } : null;
196
+ })
197
+ .filter(Boolean);
198
+ if (kept.length > 0) settings.hooks[event] = kept;
199
+ else delete settings.hooks[event];
200
+ }
201
+
202
+ if (!changed) return false;
203
+
204
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
205
+ delete settings.hooks;
206
+ }
207
+
208
+ writeJson(settingsPath, settings);
209
+ console.log(`✓ Ari hooks removed from ${settingsPath}`);
210
+ return true;
211
+ }
212
+
213
+ function uninstallCursor(cwd) {
214
+ const hooksPath = join(cwd, '.cursor', 'hooks.json');
215
+ if (!existsSync(hooksPath)) return false;
216
+ const config = readJson(hooksPath);
217
+
218
+ const isOurs = (h) => h.command?.includes('ari-hooks hook');
219
+ let changed = false;
220
+
221
+ for (const [event, entries] of Object.entries(config.hooks ?? {})) {
222
+ if (!Array.isArray(entries)) continue;
223
+ const kept = entries.filter((h) => !isOurs(h));
224
+ if (kept.length === entries.length) continue;
225
+ changed = true;
226
+ if (kept.length > 0) config.hooks[event] = kept;
227
+ else delete config.hooks[event];
228
+ }
229
+
230
+ if (!changed) return false;
231
+
232
+ writeJson(hooksPath, config);
233
+ console.log(`✓ Ari hooks removed from ${hooksPath}`);
234
+ return true;
235
+ }
236
+
237
+ /**
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.
244
+ */
245
+ export function uninstall(cwd = process.cwd(), env = process.env) {
246
+ const removedClaude = ['project', 'local', 'user']
247
+ .map((scope) => uninstallClaude(claudeSettingsPath(scope, cwd, env)))
248
+ .some(Boolean);
249
+ const removedCursor = uninstallCursor(cwd);
250
+
251
+ if (!removedClaude && !removedCursor) {
252
+ console.log('No Ari hooks found in this folder — nothing to remove.');
253
+ return;
254
+ }
255
+ console.log(
256
+ 'Agent sessions in this folder will no longer share activity with Ari.'
257
+ );
258
+ }