@ariso-ai/ari-hooks 0.1.5 → 0.1.7

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,12 +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 |
41
- | `ari-hooks uninstall` | Remove the hooks from `./.claude/settings.json` |
58
+ | `ari-hooks uninstall` | Remove the hooks from `./.claude/settings.json` and `./.cursor/hooks.json` |
42
59
  | `ari-hooks login` | Browser login, stores the API token |
43
60
  | `ari-hooks init` | Just add the hooks to `./.claude/settings.json` (no login) |
44
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.5",
3
+ "version": "0.1.7",
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,15 +1,21 @@
1
1
  import { login, logout, status } from './login.js';
2
- import { init, uninstall } 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
10
- ari-hooks uninstall Remove the hooks from ./.claude/settings.json
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
11
17
  ari-hooks login Log in via the browser and store an API token
12
- ari-hooks init Just add the hooks to ./.claude/settings.json (no login)
18
+ ari-hooks init Just add the hooks (no login)
13
19
  ari-hooks config Show the configured URLs and login state
14
20
  ari-hooks status Show login state
15
21
  ari-hooks logout Remove the stored token
@@ -68,7 +74,7 @@ export async function main(argv) {
68
74
  if (!loadConfig().token) {
69
75
  await login();
70
76
  }
71
- init();
77
+ await install();
72
78
  return;
73
79
  }
74
80
  case 'init':
package/src/hooks.js CHANGED
@@ -58,15 +58,39 @@ function saveSession(sessionId, session) {
58
58
  writeFileSync(sessionPath(sessionId), JSON.stringify(session));
59
59
  }
60
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
+
61
69
  /**
62
- * UserPromptSubmit: remember the prompt so the Stop hook can pair it with
63
- * 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`.
64
73
  */
65
74
  async function onUserPromptSubmit(input) {
66
- if (!input.session_id || typeof input.prompt !== 'string') return;
67
- const session = loadSession(input.session_id);
75
+ const sessionId = sessionIdOf(input);
76
+ if (!sessionId || typeof input.prompt !== 'string') return;
77
+ const session = loadSession(sessionId);
68
78
  session.prompts.push(input.prompt);
69
- 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);
70
94
  }
71
95
 
72
96
  function assistantText(entry) {
@@ -138,20 +162,25 @@ async function onStop(input) {
138
162
  // stop_hook_active means a stop hook already forced Claude to continue;
139
163
  // the real end of the turn will fire another Stop event.
140
164
  if (input.stop_hook_active) return;
141
- if (!input.session_id || !input.transcript_path) return;
165
+ const sessionId = sessionIdOf(input);
166
+ if (!sessionId) return;
142
167
 
143
- const session = loadSession(input.session_id);
168
+ const session = loadSession(sessionId);
144
169
  if (session.prompts.length === 0) return;
145
170
 
146
- // Wait for the final assistant message to land in the transcript; on
147
- // timeout fall back to the last text we did find (best effort).
148
- const deadline = Date.now() + OUTCOME_SETTLE_TIMEOUT_MS;
149
- let outcome;
150
- for (;;) {
151
- const { text, settled } = extractOutcome(input.transcript_path);
152
- outcome = text;
153
- if (settled || Date.now() >= deadline) break;
154
- await sleep(OUTCOME_POLL_INTERVAL_MS);
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
+ }
155
184
  }
156
185
  if (!outcome) return;
157
186
 
@@ -167,8 +196,9 @@ async function onStop(input) {
167
196
  body: JSON.stringify({
168
197
  request: clamp(session.prompts.join('\n\n')),
169
198
  outcome: clamp(outcome),
170
- session_id: input.session_id,
171
- 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(),
172
202
  }),
173
203
  signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
174
204
  });
@@ -176,7 +206,7 @@ async function onStop(input) {
176
206
  throw new Error(`POST /agent-activities failed: ${response.status}`);
177
207
  }
178
208
 
179
- rmSync(sessionPath(input.session_id), { force: true });
209
+ rmSync(sessionPath(sessionId), { force: true });
180
210
  }
181
211
 
182
212
  const MAX_TASKS = 3;
@@ -195,6 +225,9 @@ async function onSessionStart(input) {
195
225
  // Compaction restarts the session mid-conversation; the tasks were
196
226
  // already offered, so don't show (or inject) them again.
197
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;
198
231
 
199
232
  const config = loadConfig();
200
233
  if (!config.token) return;
@@ -222,6 +255,36 @@ async function onSessionStart(input) {
222
255
  .slice(0, MAX_TASKS);
223
256
  if (tasks.length === 0) return;
224
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
+
225
288
  // Claude Code renders systemMessage with ANSI intact; the leading \n
226
289
  // pushes our block below the fixed "SessionStart:<source> says:" prefix.
227
290
  const BOLD = '\x1b[1m';
@@ -236,19 +299,6 @@ async function onSessionStart(input) {
236
299
  `${visibleList}\n` +
237
300
  `${GREY}Reply "run task 1" (or the task name) to start one.${RESET}`;
238
301
 
239
- const additionalContext =
240
- `The user has Ari connected via ari-hooks. At session start the user was ` +
241
- `shown this list of suggested tasks:\n\n` +
242
- tasks
243
- .map(
244
- (t, i) =>
245
- `Task ${i + 1}: ${oneLine(t.taskName)}\nPrompt: ${clamp(t.prompt)}`
246
- )
247
- .join('\n\n') +
248
- `\n\nIf the user asks to run one of these tasks (by number or name), ` +
249
- `carry out that task's prompt as if the user had typed it. Do not start ` +
250
- `any of these tasks unless the user asks.`;
251
-
252
302
  // writeSync: process.exit(0) in runHook would race an async stdout write.
253
303
  writeSync(
254
304
  1,
@@ -273,6 +323,8 @@ export async function runHook(event) {
273
323
  const input = raw ? JSON.parse(raw) : {};
274
324
  if (event === 'user-prompt-submit') {
275
325
  await onUserPromptSubmit(input);
326
+ } else if (event === 'agent-response') {
327
+ await onAgentResponse(input);
276
328
  } else if (event === 'stop') {
277
329
  await onStop(input);
278
330
  } else if (event === 'session-start') {
package/src/init.js CHANGED
@@ -1,5 +1,7 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
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,77 @@ 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
+
23
+ // Cursor's app install shows up in the env vars its integrated terminal
24
+ // inherits: the git-askpass helpers point into Cursor.app (macOS),
25
+ // AppData\Local\Programs\cursor (Windows), or a cursor install dir (Linux).
26
+ const CURSOR_PATH_VARS = [
27
+ 'GIT_ASKPASS',
28
+ 'VSCODE_GIT_ASKPASS_NODE',
29
+ 'VSCODE_GIT_ASKPASS_MAIN',
30
+ ];
31
+
32
+ // Cursor ships via ToDesktop, so its macOS bundle id is this opaque token
33
+ // rather than anything containing "cursor".
34
+ const CURSOR_BUNDLE_ID = 'com.todesktop.230313mzl4w4u92';
35
+
10
36
  /**
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.
37
+ * Cursor's agent terminals export CURSOR_TRACE_ID and its CLI agent exports
38
+ * CURSOR_AGENT, but a regular integrated terminal in Cursor sets neither
39
+ * it looks like VS Code (TERM_PROGRAM=vscode). There, the tells are the
40
+ * app's bundle id and the helper paths pointing into the Cursor install;
41
+ * plain VS Code and a bare shell match none of these.
14
42
  */
15
- export function init(cwd = process.cwd()) {
16
- const claudeDir = join(cwd, '.claude');
17
- const settingsPath = join(claudeDir, 'settings.json');
43
+ export const isCursor = (env = process.env) =>
44
+ Boolean(
45
+ env.CURSOR_TRACE_ID ||
46
+ env.CURSOR_AGENT ||
47
+ env.__CFBundleIdentifier === CURSOR_BUNDLE_ID ||
48
+ CURSOR_PATH_VARS.some((key) => /cursor/i.test(env[key] ?? ''))
49
+ );
18
50
 
19
- let settings = {};
51
+ function readJson(path) {
20
52
  try {
21
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
53
+ return JSON.parse(readFileSync(path, 'utf8'));
22
54
  } 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
- }
55
+ if (err.code === 'ENOENT') return null;
56
+ throw new Error(
57
+ `${path} exists but is not valid JSON — fix or remove it, then re-run.`
58
+ );
28
59
  }
60
+ }
61
+
62
+ const writeJson = (path, value) =>
63
+ writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
64
+
65
+ /**
66
+ * Where the Claude Code hooks land, by scope:
67
+ * project — ./.claude/settings.json (shared with everyone on the repo)
68
+ * local — ./.claude/settings.local.json (just this user; Claude Code
69
+ * gitignores it)
70
+ * user — ~/.claude/settings.json (every repo on this machine;
71
+ * honors CLAUDE_CONFIG_DIR like Claude Code does)
72
+ */
73
+ function claudeSettingsPath(scope, cwd, env) {
74
+ if (scope === 'user') {
75
+ return join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
76
+ }
77
+ const file = scope === 'local' ? 'settings.local.json' : 'settings.json';
78
+ return join(cwd, '.claude', file);
79
+ }
80
+
81
+ function initClaude(settingsPath) {
82
+ const settings = readJson(settingsPath) ?? {};
29
83
 
30
84
  settings.hooks ??= {};
31
85
  let changed = false;
@@ -44,39 +98,110 @@ export function init(cwd = process.cwd()) {
44
98
 
45
99
  if (!changed) {
46
100
  console.log(`Ari hooks already configured in ${settingsPath}`);
47
- return;
101
+ return false;
48
102
  }
49
103
 
50
- mkdirSync(claudeDir, { recursive: true });
51
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
104
+ mkdirSync(dirname(settingsPath), { recursive: true });
105
+ writeJson(settingsPath, settings);
52
106
  console.log(`✓ Ari hooks added to ${settingsPath}`);
53
- console.log(
54
- 'Claude Code sessions in this folder will now share each request and its outcome with Ari,'
55
- );
56
- console.log('and show suggested Ari tasks when a session starts.');
107
+ return true;
57
108
  }
58
109
 
59
- /**
60
- * Remove the ari-hooks hook commands that init/install added to the
61
- * project's Claude Code settings. The inverse of init: only ari-hooks
62
- * entries are touched, everything else in the file is preserved.
63
- */
64
- export function uninstall(cwd = process.cwd()) {
65
- const settingsPath = join(cwd, '.claude', 'settings.json');
110
+ function initCursor(cwd) {
111
+ const cursorDir = join(cwd, '.cursor');
112
+ const hooksPath = join(cursorDir, 'hooks.json');
113
+ const config = readJson(hooksPath) ?? {};
66
114
 
67
- if (!existsSync(settingsPath)) {
68
- console.log(`No Claude Code settings found at ${settingsPath} — nothing to remove.`);
69
- return;
115
+ config.version ??= 1;
116
+ config.hooks ??= {};
117
+ let changed = false;
118
+
119
+ for (const [event, command] of Object.entries(CURSOR_HOOK_EVENTS)) {
120
+ config.hooks[event] ??= [];
121
+ const already = config.hooks[event].some((h) =>
122
+ h.command?.includes('ari-hooks hook')
123
+ );
124
+ if (already) continue;
125
+ config.hooks[event].push({ command, timeout: 30 });
126
+ changed = true;
70
127
  }
71
128
 
72
- let settings;
129
+ if (!changed) {
130
+ console.log(`Ari hooks already configured in ${hooksPath}`);
131
+ return false;
132
+ }
133
+
134
+ mkdirSync(cursorDir, { recursive: true });
135
+ writeJson(hooksPath, config);
136
+ console.log(`✓ Ari hooks added to ${hooksPath} (Cursor detected)`);
137
+ return true;
138
+ }
139
+
140
+ async function ask(question) {
141
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
73
142
  try {
74
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
143
+ return (await rl.question(question)).trim().toLowerCase();
75
144
  } catch {
76
- throw new Error(
77
- `${settingsPath} exists but is not valid JSON — fix or remove it, then re-run.`
78
- );
145
+ // Ctrl+D / closed stdin — fall through to the question's default.
146
+ return '';
147
+ } finally {
148
+ rl.close();
79
149
  }
150
+ }
151
+
152
+ /**
153
+ * Interactive scope picker for the Claude Code hooks: repo-only or
154
+ * machine-wide, and — when repo-only — private (settings.local.json) or
155
+ * shared with everyone on the repo (settings.json).
156
+ */
157
+ async function chooseClaudeScope() {
158
+ const repoOnly = await ask('Install hooks just for this repo? [Y/n] ');
159
+ if (repoOnly === 'n' || repoOnly === 'no') return 'user';
160
+
161
+ const who = await ask(
162
+ 'Install just for yourself, or for everyone who works on this repo?\n' +
163
+ ' 1) Just me (.claude/settings.local.json, not committed)\n' +
164
+ ' 2) Everyone (.claude/settings.json, committed with the repo)\n' +
165
+ 'Choose [1/2] (default 1): '
166
+ );
167
+ return who === '2' || who === 'everyone' ? 'project' : 'local';
168
+ }
169
+
170
+ /**
171
+ * Merge the ari-hooks hook commands into Claude Code settings — and, when
172
+ * running inside Cursor, into .cursor/hooks.json as well. Idempotent:
173
+ * existing ari-hooks entries are left alone, and unrelated hooks/settings
174
+ * are preserved. `scope` picks the Claude settings file (see
175
+ * claudeSettingsPath); the Cursor hooks file is always project-level.
176
+ */
177
+ export function init(cwd = process.cwd(), env = process.env, scope = 'project') {
178
+ const changedClaude = initClaude(claudeSettingsPath(scope, cwd, env));
179
+ const changedCursor = isCursor(env) ? initCursor(cwd) : false;
180
+
181
+ if (!changedClaude && !changedCursor) return;
182
+ const where = scope === 'user' ? 'on this machine' : 'in this folder';
183
+ console.log(
184
+ `Agent sessions ${where} will now share each request and its outcome with Ari,`
185
+ );
186
+ console.log('and show suggested Ari tasks when a session starts.');
187
+ }
188
+
189
+ /**
190
+ * The `install` flavor of init: when attached to a terminal, ask where the
191
+ * Claude Code hooks should live before writing them. Non-interactive runs
192
+ * (CI, piped stdin) keep the old default of ./.claude/settings.json.
193
+ */
194
+ export async function install(cwd = process.cwd(), env = process.env) {
195
+ const scope =
196
+ process.stdin.isTTY && process.stdout.isTTY
197
+ ? await chooseClaudeScope()
198
+ : 'project';
199
+ init(cwd, env, scope);
200
+ }
201
+
202
+ function uninstallClaude(settingsPath) {
203
+ if (!existsSync(settingsPath)) return false;
204
+ const settings = readJson(settingsPath);
80
205
 
81
206
  const isOurs = (h) => h.command?.includes('ari-hooks hook');
82
207
  let changed = false;
@@ -95,18 +220,60 @@ export function uninstall(cwd = process.cwd()) {
95
220
  else delete settings.hooks[event];
96
221
  }
97
222
 
98
- if (!changed) {
99
- console.log(`No Ari hooks found in ${settingsPath} — nothing to remove.`);
100
- return;
101
- }
223
+ if (!changed) return false;
102
224
 
103
225
  if (settings.hooks && Object.keys(settings.hooks).length === 0) {
104
226
  delete settings.hooks;
105
227
  }
106
228
 
107
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
229
+ writeJson(settingsPath, settings);
108
230
  console.log(`✓ Ari hooks removed from ${settingsPath}`);
231
+ return true;
232
+ }
233
+
234
+ function uninstallCursor(cwd) {
235
+ const hooksPath = join(cwd, '.cursor', 'hooks.json');
236
+ if (!existsSync(hooksPath)) return false;
237
+ const config = readJson(hooksPath);
238
+
239
+ const isOurs = (h) => h.command?.includes('ari-hooks hook');
240
+ let changed = false;
241
+
242
+ for (const [event, entries] of Object.entries(config.hooks ?? {})) {
243
+ if (!Array.isArray(entries)) continue;
244
+ const kept = entries.filter((h) => !isOurs(h));
245
+ if (kept.length === entries.length) continue;
246
+ changed = true;
247
+ if (kept.length > 0) config.hooks[event] = kept;
248
+ else delete config.hooks[event];
249
+ }
250
+
251
+ if (!changed) return false;
252
+
253
+ writeJson(hooksPath, config);
254
+ console.log(`✓ Ari hooks removed from ${hooksPath}`);
255
+ return true;
256
+ }
257
+
258
+ /**
259
+ * Remove the ari-hooks hook commands that init/install added to the
260
+ * Claude Code settings and Cursor hooks file. The inverse of init: only
261
+ * ari-hooks entries are touched, everything else in the files is
262
+ * preserved. Cleans every location install can write to (project
263
+ * settings.json, settings.local.json, the user-level settings, and the
264
+ * Cursor hooks file), so hooks don't linger wherever they were put.
265
+ */
266
+ export function uninstall(cwd = process.cwd(), env = process.env) {
267
+ const removedClaude = ['project', 'local', 'user']
268
+ .map((scope) => uninstallClaude(claudeSettingsPath(scope, cwd, env)))
269
+ .some(Boolean);
270
+ const removedCursor = uninstallCursor(cwd);
271
+
272
+ if (!removedClaude && !removedCursor) {
273
+ console.log('No Ari hooks found in this folder — nothing to remove.');
274
+ return;
275
+ }
109
276
  console.log(
110
- 'Claude Code sessions in this folder will no longer share activity with Ari.'
277
+ 'Agent sessions in this folder will no longer share activity with Ari.'
111
278
  );
112
279
  }