@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 +18 -0
- package/package.json +1 -1
- package/src/cli.js +14 -4
- package/src/hooks.js +139 -37
- package/src/init.js +220 -19
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
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
165
|
+
const sessionId = sessionIdOf(input);
|
|
166
|
+
if (!sessionId) return;
|
|
101
167
|
|
|
102
|
-
const session = loadSession(
|
|
168
|
+
const session = loadSession(sessionId);
|
|
103
169
|
if (session.prompts.length === 0) return;
|
|
104
170
|
|
|
105
|
-
|
|
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:
|
|
121
|
-
|
|
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(
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
30
|
+
function readJson(path) {
|
|
20
31
|
try {
|
|
21
|
-
|
|
32
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
22
33
|
} catch (err) {
|
|
23
|
-
if (err.code
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
51
|
-
|
|
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
|
-
|
|
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
|
+
}
|