@ariso-ai/ari-hooks 0.1.2 → 0.1.5
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 +1 -0
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/hooks.js +64 -14
- package/src/init.js +56 -1
- package/src/login.js +8 -8
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ Existing settings and hooks are preserved; running it again is a no-op.
|
|
|
38
38
|
| Command | What it does |
|
|
39
39
|
|---|---|
|
|
40
40
|
| `ari-hooks install` | Login (if needed) + set up hooks in the current folder |
|
|
41
|
+
| `ari-hooks uninstall` | Remove the hooks from `./.claude/settings.json` |
|
|
41
42
|
| `ari-hooks login` | Browser login, stores the API token |
|
|
42
43
|
| `ari-hooks init` | Just add the hooks to `./.claude/settings.json` (no login) |
|
|
43
44
|
| `ari-hooks config` | Show configured URLs and login state |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { login, logout, status } from './login.js';
|
|
2
|
-
import { init } from './init.js';
|
|
2
|
+
import { init, uninstall } from './init.js';
|
|
3
3
|
import { runHook } from './hooks.js';
|
|
4
4
|
import { loadConfig, setUrls, showConfig } from './config.js';
|
|
5
5
|
|
|
@@ -7,6 +7,7 @@ const USAGE = `ari-hooks — share your Claude Code activity with Ari
|
|
|
7
7
|
|
|
8
8
|
Usage:
|
|
9
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
|
|
10
11
|
ari-hooks login Log in via the browser and store an API token
|
|
11
12
|
ari-hooks init Just add the hooks to ./.claude/settings.json (no login)
|
|
12
13
|
ari-hooks config Show the configured URLs and login state
|
|
@@ -73,6 +74,9 @@ export async function main(argv) {
|
|
|
73
74
|
case 'init':
|
|
74
75
|
init();
|
|
75
76
|
return;
|
|
77
|
+
case 'uninstall':
|
|
78
|
+
uninstall();
|
|
79
|
+
return;
|
|
76
80
|
case 'hook':
|
|
77
81
|
await runHook(rest[1]);
|
|
78
82
|
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) =>
|
|
@@ -61,29 +69,62 @@ async function onUserPromptSubmit(input) {
|
|
|
61
69
|
saveSession(input.session_id, session);
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
function assistantText(entry) {
|
|
73
|
+
if (entry.type !== 'assistant' || !Array.isArray(entry.message?.content)) {
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
return entry.message.content
|
|
77
|
+
.filter((block) => block.type === 'text' && block.text)
|
|
78
|
+
.map((block) => block.text)
|
|
79
|
+
.join('\n')
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
/**
|
|
65
84
|
* Pull the final assistant text out of the transcript (JSONL). This is the
|
|
66
85
|
* "outcome" — we deliberately skip the intermediate steps/tool calls.
|
|
86
|
+
*
|
|
87
|
+
* `settled` reports whether the exchange actually ends in assistant text.
|
|
88
|
+
* When the transcript instead ends at a tool call/result or a half-written
|
|
89
|
+
* line, the final message hasn't been flushed yet and `text` is only the
|
|
90
|
+
* last narration before a tool ran — the caller should re-read rather than
|
|
91
|
+
* ship that as the outcome.
|
|
67
92
|
*/
|
|
68
93
|
function extractOutcome(transcriptPath) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
const entries = [];
|
|
95
|
+
let tailPartial = false;
|
|
96
|
+
for (const line of readFileSync(transcriptPath, 'utf8').split('\n')) {
|
|
97
|
+
if (!line.trim()) continue;
|
|
73
98
|
try {
|
|
74
|
-
|
|
99
|
+
entries.push(JSON.parse(line));
|
|
100
|
+
tailPartial = false;
|
|
75
101
|
} catch {
|
|
102
|
+
tailPartial = true; // a line still being written
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let settled = tailPartial ? false : null;
|
|
107
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
108
|
+
const entry = entries[i];
|
|
109
|
+
// Bookkeeping entries (system, attachment, last-prompt, …) may trail
|
|
110
|
+
// the exchange; they say nothing about whether it is complete.
|
|
111
|
+
if (entry.type !== 'assistant' && entry.type !== 'user') continue;
|
|
112
|
+
let text = assistantText(entry);
|
|
113
|
+
if (!text) {
|
|
114
|
+
// A tool call/result with nothing after it: mid-turn.
|
|
115
|
+
settled ??= false;
|
|
76
116
|
continue;
|
|
77
117
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
// The message may span several JSONL entries (one per content block);
|
|
119
|
+
// stitch earlier blocks of the same message back on.
|
|
120
|
+
const id = entry.message?.id;
|
|
121
|
+
for (let j = i - 1; id && j >= 0 && entries[j].message?.id === id; j--) {
|
|
122
|
+
const earlier = assistantText(entries[j]);
|
|
123
|
+
if (earlier) text = `${earlier}\n${text}`;
|
|
124
|
+
}
|
|
125
|
+
return { text, settled: settled ?? true };
|
|
85
126
|
}
|
|
86
|
-
return null;
|
|
127
|
+
return { text: null, settled: false };
|
|
87
128
|
}
|
|
88
129
|
|
|
89
130
|
const clamp = (text) =>
|
|
@@ -102,7 +143,16 @@ async function onStop(input) {
|
|
|
102
143
|
const session = loadSession(input.session_id);
|
|
103
144
|
if (session.prompts.length === 0) return;
|
|
104
145
|
|
|
105
|
-
|
|
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);
|
|
155
|
+
}
|
|
106
156
|
if (!outcome) return;
|
|
107
157
|
|
|
108
158
|
const config = loadConfig();
|
package/src/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
|
|
4
4
|
const HOOK_EVENTS = {
|
|
5
5
|
SessionStart: 'ari-hooks hook session-start',
|
|
@@ -55,3 +55,58 @@ export function init(cwd = process.cwd()) {
|
|
|
55
55
|
);
|
|
56
56
|
console.log('and show suggested Ari tasks when a session starts.');
|
|
57
57
|
}
|
|
58
|
+
|
|
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');
|
|
66
|
+
|
|
67
|
+
if (!existsSync(settingsPath)) {
|
|
68
|
+
console.log(`No Claude Code settings found at ${settingsPath} — nothing to remove.`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let settings;
|
|
73
|
+
try {
|
|
74
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`${settingsPath} exists but is not valid JSON — fix or remove it, then re-run.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isOurs = (h) => h.command?.includes('ari-hooks hook');
|
|
82
|
+
let changed = false;
|
|
83
|
+
|
|
84
|
+
for (const [event, matchers] of Object.entries(settings.hooks ?? {})) {
|
|
85
|
+
if (!Array.isArray(matchers)) continue;
|
|
86
|
+
const kept = matchers
|
|
87
|
+
.map((matcher) => {
|
|
88
|
+
if (!(matcher.hooks ?? []).some(isOurs)) return matcher;
|
|
89
|
+
changed = true;
|
|
90
|
+
const rest = matcher.hooks.filter((h) => !isOurs(h));
|
|
91
|
+
return rest.length > 0 ? { ...matcher, hooks: rest } : null;
|
|
92
|
+
})
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
if (kept.length > 0) settings.hooks[event] = kept;
|
|
95
|
+
else delete settings.hooks[event];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!changed) {
|
|
99
|
+
console.log(`No Ari hooks found in ${settingsPath} — nothing to remove.`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
104
|
+
delete settings.hooks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
108
|
+
console.log(`✓ Ari hooks removed from ${settingsPath}`);
|
|
109
|
+
console.log(
|
|
110
|
+
'Claude Code sessions in this folder will no longer share activity with Ari.'
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/login.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
13
13
|
|
|
14
14
|
const SUCCESS_HTML = `<!doctype html>
|
|
15
|
-
<html><head><title>Ari Hooks</title></head>
|
|
15
|
+
<html><head><meta charset="utf-8"><title>Ari Hooks</title></head>
|
|
16
16
|
<body style="font-family: sans-serif; text-align: center; padding-top: 4rem;">
|
|
17
17
|
<h2>✓ Logged in</h2>
|
|
18
18
|
<p>The Ari Hooks CLI received your token. You can close this window.</p>
|
|
@@ -36,7 +36,7 @@ function openBrowser(url) {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Browser login: start a one-shot loopback HTTP server, send the user to
|
|
39
|
-
* the web app's /cli-auth page with our callback
|
|
39
|
+
* the web app's /cli-auth page with our callback port, and wait for the
|
|
40
40
|
* page to redirect back with a freshly minted API token.
|
|
41
41
|
*/
|
|
42
42
|
export async function login() {
|
|
@@ -45,7 +45,7 @@ export async function login() {
|
|
|
45
45
|
|
|
46
46
|
const token = await new Promise((resolve, reject) => {
|
|
47
47
|
const server = createServer((req, res) => {
|
|
48
|
-
const url = new URL(req.url, 'http://
|
|
48
|
+
const url = new URL(req.url, 'http://localhost');
|
|
49
49
|
if (url.pathname !== '/callback') {
|
|
50
50
|
res.writeHead(404).end();
|
|
51
51
|
return;
|
|
@@ -59,20 +59,20 @@ export async function login() {
|
|
|
59
59
|
res.writeHead(400).end('Missing token.');
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
|
-
res.writeHead(200, { 'Content-Type': 'text/html' }).end(SUCCESS_HTML);
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(SUCCESS_HTML);
|
|
63
63
|
// Let the response flush before tearing the server down.
|
|
64
64
|
setTimeout(() => server.close(), 100);
|
|
65
65
|
resolve(received);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
server.on('error', reject);
|
|
69
|
+
// Bind to 127.0.0.1 explicitly — the web app reconstructs the callback
|
|
70
|
+
// as http://127.0.0.1:<port>/callback from callback_port.
|
|
69
71
|
server.listen(0, '127.0.0.1', () => {
|
|
70
72
|
const { port } = server.address();
|
|
71
73
|
const authUrl = new URL('/cli-auth', getWebUrl(config));
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`http://127.0.0.1:${port}/callback`
|
|
75
|
-
);
|
|
74
|
+
// Pass only the port; the full callback URL trips the WAF.
|
|
75
|
+
authUrl.searchParams.set('callback_port', String(port));
|
|
76
76
|
authUrl.searchParams.set('state', state);
|
|
77
77
|
|
|
78
78
|
console.log('Opening your browser to log in to Ari...');
|