@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 +18 -1
- package/package.json +1 -1
- package/src/cli.js +11 -5
- package/src/hooks.js +84 -32
- package/src/init.js +209 -42
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
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
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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(
|
|
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
|
-
|
|
165
|
+
const sessionId = sessionIdOf(input);
|
|
166
|
+
if (!sessionId) return;
|
|
142
167
|
|
|
143
|
-
const session = loadSession(
|
|
168
|
+
const session = loadSession(sessionId);
|
|
144
169
|
if (session.prompts.length === 0) return;
|
|
145
170
|
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
171
|
-
|
|
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(
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
51
|
+
function readJson(path) {
|
|
20
52
|
try {
|
|
21
|
-
|
|
53
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
22
54
|
} catch (err) {
|
|
23
|
-
if (err.code
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
51
|
-
|
|
104
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
105
|
+
writeJson(settingsPath, settings);
|
|
52
106
|
console.log(`✓ Ari hooks added to ${settingsPath}`);
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
return (await rl.question(question)).trim().toLowerCase();
|
|
75
144
|
} catch {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
277
|
+
'Agent sessions in this folder will no longer share activity with Ari.'
|
|
111
278
|
);
|
|
112
279
|
}
|