@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ariso-ai/ari-hooks",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
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,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 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;
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
- entry = JSON.parse(lines[i]);
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
- 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;
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
- const outcome = extractOutcome(input.transcript_path);
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 URL, and wait for the
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://127.0.0.1');
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
- authUrl.searchParams.set(
73
- 'callback',
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...');