@ariso-ai/ari-hooks 0.1.0

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 ADDED
@@ -0,0 +1,69 @@
1
+ # ari-hooks
2
+
3
+ Shares your Claude Code activity with [Ari](https://ariso.ai): after every
4
+ Claude Code turn, the hooks send your request and the final outcome (not the
5
+ intermediate steps) to Ari.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g ari-hooks
11
+ ```
12
+
13
+ ## Use
14
+
15
+ In any project folder where you use Claude Code:
16
+
17
+ ```bash
18
+ ari-hooks
19
+ ```
20
+
21
+ That single command:
22
+
23
+ 1. Opens your browser to log in to Ari and mint an API token (stored in
24
+ `~/.ari-hooks/config.json`, `0600`). Only needed once per machine.
25
+ 2. Adds two hooks to `./.claude/settings.json`:
26
+ - `UserPromptSubmit` — records what you asked for
27
+ - `Stop` — reads the final assistant message from the transcript and sends
28
+ the request/outcome pair to the Ari API
29
+
30
+ Existing settings and hooks are preserved; running it again is a no-op.
31
+
32
+ ### Commands
33
+
34
+ | Command | What it does |
35
+ |---|---|
36
+ | `ari-hooks` | Login (if needed) + set up hooks in the current folder |
37
+ | `ari-hooks login` | Browser login, stores the API token |
38
+ | `ari-hooks init` | Just add the hooks to `./.claude/settings.json` |
39
+ | `ari-hooks config` | Show configured URLs and login state |
40
+ | `ari-hooks status` | Show login state |
41
+ | `ari-hooks logout` | Delete the stored token |
42
+
43
+ ### Configuration
44
+
45
+ By default the CLI talks to production (`https://web.ari.ariso.ai` /
46
+ `https://api.ari.ariso.ai`). To test against a local Ari stack, persist
47
+ overrides with the URL flags (they work on any command, including bare
48
+ `ari-hooks`):
49
+
50
+ ```bash
51
+ ari-hooks config --web-url http://localhost:5173 --api-url http://localhost:4000
52
+ ari-hooks login # browser flow now goes through localhost
53
+ ari-hooks config --reset-urls # back to production
54
+ ```
55
+
56
+ The overrides are stored in `~/.ari-hooks/config.json`, so the hooks
57
+ themselves also report to the configured API. Environment variables take
58
+ precedence over the stored config:
59
+
60
+ - `ARI_HOOKS_API_URL` — override the API base URL
61
+ - `ARI_HOOKS_WEB_URL` — override the web app URL used for login
62
+ - `ARI_HOOKS_HOME` — override the config directory (default `~/.ari-hooks`)
63
+
64
+ ## Notes
65
+
66
+ - Hooks never break your Claude Code session: every failure is swallowed and
67
+ logged to `~/.ari-hooks/error.log`.
68
+ - Only the request text and the final assistant message are sent — no tool
69
+ calls, diffs, or intermediate steps.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2)).catch((err) => {
5
+ console.error(err?.message ?? err);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ariso-ai/ari-hooks",
3
+ "version": "0.1.0",
4
+ "description": "Set up Claude Code hooks that share your requests and their outcomes with Ari",
5
+ "type": "module",
6
+ "bin": {
7
+ "ari-hooks": "bin/ari-hooks.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "test": "node --test"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "claude-code",
24
+ "hooks",
25
+ "ari",
26
+ "ariso"
27
+ ],
28
+ "license": "UNLICENSED"
29
+ }
package/src/cli.js ADDED
@@ -0,0 +1,87 @@
1
+ import { login, logout, status } from './login.js';
2
+ import { init } from './init.js';
3
+ import { runHook } from './hooks.js';
4
+ import { loadConfig, setUrls, showConfig } from './config.js';
5
+
6
+ const USAGE = `ari-hooks — share your Claude Code activity with Ari
7
+
8
+ Usage:
9
+ ari-hooks Log in (if needed) and set up hooks in the current folder
10
+ ari-hooks login Log in via the browser and store an API token
11
+ ari-hooks init Add the hooks to ./.claude/settings.json
12
+ ari-hooks config Show the configured URLs and login state
13
+ ari-hooks status Show login state
14
+ ari-hooks logout Remove the stored token
15
+
16
+ Options (persisted to ~/.ari-hooks/config.json, work with any command):
17
+ --web-url <url> Set the Ari web app URL, e.g. http://localhost:5173
18
+ --api-url <url> Set the Ari API URL, e.g. http://localhost:4000
19
+ --reset-urls Go back to the default production URLs
20
+ -h, --help Show this help
21
+
22
+ The ARI_HOOKS_WEB_URL / ARI_HOOKS_API_URL environment variables take
23
+ precedence over the persisted config.
24
+ `;
25
+
26
+ function parseFlags(args) {
27
+ const flags = {};
28
+ const rest = [];
29
+ for (let i = 0; i < args.length; i++) {
30
+ if (args[i] === '--web-url') flags.webUrl = args[++i];
31
+ else if (args[i] === '--api-url') flags.apiUrl = args[++i];
32
+ else if (args[i] === '--reset-urls') flags.resetUrls = true;
33
+ else rest.push(args[i]);
34
+ }
35
+ return { flags, rest };
36
+ }
37
+
38
+ export async function main(argv) {
39
+ const { flags, rest } = parseFlags(argv);
40
+ const command = rest[0];
41
+
42
+ if (command === '-h' || command === '--help' || command === 'help') {
43
+ console.log(USAGE);
44
+ return;
45
+ }
46
+
47
+ // Persist URL overrides before dispatching so they apply to this run and
48
+ // every later hook invocation, regardless of which command they rode in on.
49
+ if (flags.webUrl || flags.apiUrl || flags.resetUrls) {
50
+ setUrls(flags);
51
+ }
52
+
53
+ switch (command) {
54
+ case 'login':
55
+ await login();
56
+ return;
57
+ case 'logout':
58
+ logout();
59
+ return;
60
+ case 'status':
61
+ status();
62
+ return;
63
+ case 'config':
64
+ showConfig();
65
+ return;
66
+ case 'init':
67
+ init();
68
+ return;
69
+ case 'hook':
70
+ await runHook(rest[1]);
71
+ return;
72
+ case undefined: {
73
+ // Bare invocation: make "npx/global install → run once in a folder"
74
+ // the whole setup story. URL-only invocations (e.g. `ari-hooks
75
+ // --web-url ...`) still run the full setup with the new URLs.
76
+ if (!loadConfig().token) {
77
+ await login();
78
+ }
79
+ init();
80
+ return;
81
+ }
82
+ default:
83
+ console.error(`Unknown command: ${command}\n`);
84
+ console.log(USAGE);
85
+ process.exitCode = 1;
86
+ }
87
+ }
package/src/config.js ADDED
@@ -0,0 +1,78 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
4
+
5
+ export const DEFAULT_WEB_URL = 'https://web.ari.ariso.ai';
6
+ export const DEFAULT_API_URL = 'https://api.ari.ariso.ai';
7
+
8
+ export const configDir = () =>
9
+ process.env.ARI_HOOKS_HOME || join(homedir(), '.ari-hooks');
10
+
11
+ const configPath = () => join(configDir(), 'config.json');
12
+
13
+ export function loadConfig() {
14
+ try {
15
+ return JSON.parse(readFileSync(configPath(), 'utf8'));
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function saveConfig(config) {
22
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
23
+ writeFileSync(configPath(), JSON.stringify(config, null, 2) + '\n', {
24
+ mode: 0o600,
25
+ });
26
+ }
27
+
28
+ export function clearConfig() {
29
+ rmSync(configPath(), { force: true });
30
+ }
31
+
32
+ export function getApiUrl(config = loadConfig()) {
33
+ return process.env.ARI_HOOKS_API_URL || config.apiUrl || DEFAULT_API_URL;
34
+ }
35
+
36
+ export function getWebUrl(config = loadConfig()) {
37
+ return process.env.ARI_HOOKS_WEB_URL || config.webUrl || DEFAULT_WEB_URL;
38
+ }
39
+
40
+ function normalizeUrl(raw, label) {
41
+ let url;
42
+ try {
43
+ url = new URL(raw);
44
+ } catch {
45
+ throw new Error(`${label} is not a valid URL: ${raw}`);
46
+ }
47
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
48
+ throw new Error(`${label} must be http(s): ${raw}`);
49
+ }
50
+ return url.toString().replace(/\/$/, '');
51
+ }
52
+
53
+ /** Persist base-URL overrides (e.g. point at localhost for testing). */
54
+ export function setUrls({ webUrl, apiUrl, resetUrls }) {
55
+ const config = loadConfig();
56
+ if (resetUrls) {
57
+ delete config.webUrl;
58
+ delete config.apiUrl;
59
+ }
60
+ if (webUrl) config.webUrl = normalizeUrl(webUrl, '--web-url');
61
+ if (apiUrl) config.apiUrl = normalizeUrl(apiUrl, '--api-url');
62
+ saveConfig(config);
63
+ console.log(`Web URL: ${getWebUrl(config)}`);
64
+ console.log(`API URL: ${getApiUrl(config)}`);
65
+ }
66
+
67
+ export function showConfig() {
68
+ const config = loadConfig();
69
+ const note = (key, envVar) =>
70
+ process.env[envVar]
71
+ ? ` (from ${envVar})`
72
+ : config[key]
73
+ ? ' (configured)'
74
+ : ' (default)';
75
+ console.log(`Web URL: ${getWebUrl(config)}${note('webUrl', 'ARI_HOOKS_WEB_URL')}`);
76
+ console.log(`API URL: ${getApiUrl(config)}${note('apiUrl', 'ARI_HOOKS_API_URL')}`);
77
+ console.log(config.token ? `Logged in (since ${config.loggedInAt ?? 'unknown'})` : 'Not logged in.');
78
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,149 @@
1
+ import { join } from 'node:path';
2
+ import {
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ rmSync,
7
+ appendFileSync,
8
+ } from 'node:fs';
9
+ import { configDir, loadConfig, getApiUrl } from './config.js';
10
+
11
+ const MAX_TEXT_LENGTH = 100_000;
12
+ const SEND_TIMEOUT_MS = 15_000;
13
+
14
+ const sessionsDir = () => join(configDir(), 'sessions');
15
+ const sessionPath = (sessionId) =>
16
+ join(sessionsDir(), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
17
+
18
+ function readStdin() {
19
+ return new Promise((resolve) => {
20
+ let data = '';
21
+ process.stdin.setEncoding('utf8');
22
+ process.stdin.on('data', (chunk) => (data += chunk));
23
+ process.stdin.on('end', () => resolve(data));
24
+ });
25
+ }
26
+
27
+ function logError(err) {
28
+ try {
29
+ mkdirSync(configDir(), { recursive: true });
30
+ appendFileSync(
31
+ join(configDir(), 'error.log'),
32
+ `${new Date().toISOString()} ${err?.stack ?? err}\n`
33
+ );
34
+ } catch {
35
+ // Never let diagnostics break a hook.
36
+ }
37
+ }
38
+
39
+ function loadSession(sessionId) {
40
+ try {
41
+ return JSON.parse(readFileSync(sessionPath(sessionId), 'utf8'));
42
+ } catch {
43
+ return { prompts: [] };
44
+ }
45
+ }
46
+
47
+ function saveSession(sessionId, session) {
48
+ mkdirSync(sessionsDir(), { recursive: true });
49
+ writeFileSync(sessionPath(sessionId), JSON.stringify(session));
50
+ }
51
+
52
+ /**
53
+ * UserPromptSubmit: remember the prompt so the Stop hook can pair it with
54
+ * the turn's outcome.
55
+ */
56
+ async function onUserPromptSubmit(input) {
57
+ if (!input.session_id || typeof input.prompt !== 'string') return;
58
+ const session = loadSession(input.session_id);
59
+ session.prompts.push(input.prompt);
60
+ saveSession(input.session_id, session);
61
+ }
62
+
63
+ /**
64
+ * Pull the final assistant text out of the transcript (JSONL). This is the
65
+ * "outcome" — we deliberately skip the intermediate steps/tool calls.
66
+ */
67
+ function extractOutcome(transcriptPath) {
68
+ const lines = readFileSync(transcriptPath, 'utf8').split('\n');
69
+ for (let i = lines.length - 1; i >= 0; i--) {
70
+ if (!lines[i].trim()) continue;
71
+ let entry;
72
+ try {
73
+ entry = JSON.parse(lines[i]);
74
+ } catch {
75
+ continue;
76
+ }
77
+ if (entry.type !== 'assistant' || !entry.message?.content) continue;
78
+ const text = entry.message.content
79
+ .filter((block) => block.type === 'text' && block.text)
80
+ .map((block) => block.text)
81
+ .join('\n')
82
+ .trim();
83
+ if (text) return text;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ const clamp = (text) =>
89
+ text.length > MAX_TEXT_LENGTH ? text.slice(0, MAX_TEXT_LENGTH) : text;
90
+
91
+ /**
92
+ * Stop: the turn is over — send the accumulated request(s) plus the final
93
+ * assistant message to Ari, then clear the per-session state.
94
+ */
95
+ async function onStop(input) {
96
+ // stop_hook_active means a stop hook already forced Claude to continue;
97
+ // the real end of the turn will fire another Stop event.
98
+ if (input.stop_hook_active) return;
99
+ if (!input.session_id || !input.transcript_path) return;
100
+
101
+ const session = loadSession(input.session_id);
102
+ if (session.prompts.length === 0) return;
103
+
104
+ const outcome = extractOutcome(input.transcript_path);
105
+ if (!outcome) return;
106
+
107
+ const config = loadConfig();
108
+ if (!config.token) return;
109
+
110
+ const response = await fetch(new URL('/agent-activities', getApiUrl(config)), {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ Authorization: `Bearer ${config.token}`,
115
+ },
116
+ body: JSON.stringify({
117
+ request: clamp(session.prompts.join('\n\n')),
118
+ outcome: clamp(outcome),
119
+ session_id: input.session_id,
120
+ cwd: input.cwd ?? process.cwd(),
121
+ }),
122
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
123
+ });
124
+ if (!response.ok) {
125
+ throw new Error(`POST /agent-activities failed: ${response.status}`);
126
+ }
127
+
128
+ rmSync(sessionPath(input.session_id), { force: true });
129
+ }
130
+
131
+ /**
132
+ * Entry point for `ari-hooks hook <event>`. Hooks must never break the
133
+ * user's Claude Code session: all failures are swallowed (logged to
134
+ * ~/.ari-hooks/error.log) and we always exit 0.
135
+ */
136
+ export async function runHook(event) {
137
+ try {
138
+ const raw = await readStdin();
139
+ const input = raw ? JSON.parse(raw) : {};
140
+ if (event === 'user-prompt-submit') {
141
+ await onUserPromptSubmit(input);
142
+ } else if (event === 'stop') {
143
+ await onStop(input);
144
+ }
145
+ } catch (err) {
146
+ logError(err);
147
+ }
148
+ process.exit(0);
149
+ }
package/src/init.js ADDED
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+
4
+ const HOOK_EVENTS = {
5
+ UserPromptSubmit: 'ari-hooks hook user-prompt-submit',
6
+ Stop: 'ari-hooks hook stop',
7
+ };
8
+
9
+ /**
10
+ * Merge the ari-hooks hook commands into the project's Claude Code
11
+ * settings (.claude/settings.json in cwd). Idempotent: existing ari-hooks
12
+ * entries are left alone, and unrelated hooks/settings are preserved.
13
+ */
14
+ export function init(cwd = process.cwd()) {
15
+ const claudeDir = join(cwd, '.claude');
16
+ const settingsPath = join(claudeDir, 'settings.json');
17
+
18
+ let settings = {};
19
+ try {
20
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
21
+ } catch (err) {
22
+ if (err.code !== 'ENOENT') {
23
+ throw new Error(
24
+ `${settingsPath} exists but is not valid JSON — fix or remove it, then re-run.`
25
+ );
26
+ }
27
+ }
28
+
29
+ settings.hooks ??= {};
30
+ let changed = false;
31
+
32
+ for (const [event, command] of Object.entries(HOOK_EVENTS)) {
33
+ settings.hooks[event] ??= [];
34
+ const already = settings.hooks[event].some((matcher) =>
35
+ (matcher.hooks ?? []).some((h) => h.command?.includes('ari-hooks hook'))
36
+ );
37
+ if (already) continue;
38
+ settings.hooks[event].push({
39
+ hooks: [{ type: 'command', command, timeout: 30 }],
40
+ });
41
+ changed = true;
42
+ }
43
+
44
+ if (!changed) {
45
+ console.log(`Ari hooks already configured in ${settingsPath}`);
46
+ return;
47
+ }
48
+
49
+ mkdirSync(claudeDir, { recursive: true });
50
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
51
+ console.log(`✓ Ari hooks added to ${settingsPath}`);
52
+ console.log(
53
+ 'Claude Code sessions in this folder will now share each request and its outcome with Ari.'
54
+ );
55
+ }
package/src/login.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import {
5
+ loadConfig,
6
+ saveConfig,
7
+ getWebUrl,
8
+ getApiUrl,
9
+ clearConfig,
10
+ } from './config.js';
11
+
12
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
13
+
14
+ const SUCCESS_HTML = `<!doctype html>
15
+ <html><head><title>Ari Hooks</title></head>
16
+ <body style="font-family: sans-serif; text-align: center; padding-top: 4rem;">
17
+ <h2>✓ Logged in</h2>
18
+ <p>The Ari Hooks CLI received your token. You can close this window.</p>
19
+ </body></html>`;
20
+
21
+ function openBrowser(url) {
22
+ const cmd =
23
+ process.platform === 'darwin'
24
+ ? 'open'
25
+ : process.platform === 'win32'
26
+ ? 'start'
27
+ : 'xdg-open';
28
+ const child = spawn(cmd, [url], {
29
+ stdio: 'ignore',
30
+ detached: true,
31
+ shell: process.platform === 'win32',
32
+ });
33
+ child.on('error', () => {});
34
+ child.unref();
35
+ }
36
+
37
+ /**
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
40
+ * page to redirect back with a freshly minted API token.
41
+ */
42
+ export async function login() {
43
+ const config = loadConfig();
44
+ const state = randomBytes(16).toString('hex');
45
+
46
+ const token = await new Promise((resolve, reject) => {
47
+ const server = createServer((req, res) => {
48
+ const url = new URL(req.url, 'http://127.0.0.1');
49
+ if (url.pathname !== '/callback') {
50
+ res.writeHead(404).end();
51
+ return;
52
+ }
53
+ if (url.searchParams.get('state') !== state) {
54
+ res.writeHead(400).end('State mismatch — please retry `ari-hooks login`.');
55
+ return;
56
+ }
57
+ const received = url.searchParams.get('token');
58
+ if (!received) {
59
+ res.writeHead(400).end('Missing token.');
60
+ return;
61
+ }
62
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end(SUCCESS_HTML);
63
+ // Let the response flush before tearing the server down.
64
+ setTimeout(() => server.close(), 100);
65
+ resolve(received);
66
+ });
67
+
68
+ server.on('error', reject);
69
+ server.listen(0, '127.0.0.1', () => {
70
+ const { port } = server.address();
71
+ const authUrl = new URL('/cli-auth', getWebUrl(config));
72
+ authUrl.searchParams.set(
73
+ 'callback',
74
+ `http://127.0.0.1:${port}/callback`
75
+ );
76
+ authUrl.searchParams.set('state', state);
77
+
78
+ console.log('Opening your browser to log in to Ari...');
79
+ console.log(`If it does not open, visit:\n\n ${authUrl}\n`);
80
+ openBrowser(authUrl.toString());
81
+ });
82
+
83
+ setTimeout(() => {
84
+ server.close();
85
+ reject(new Error('Login timed out after 5 minutes. Please retry `ari-hooks login`.'));
86
+ }, LOGIN_TIMEOUT_MS).unref();
87
+ });
88
+
89
+ config.token = token;
90
+ config.loggedInAt = new Date().toISOString();
91
+ saveConfig(config);
92
+ console.log('✓ Logged in. Token saved to ~/.ari-hooks/config.json');
93
+ return token;
94
+ }
95
+
96
+ export function logout() {
97
+ clearConfig();
98
+ console.log('Logged out — local token removed.');
99
+ }
100
+
101
+ export function status() {
102
+ const config = loadConfig();
103
+ if (!config.token) {
104
+ console.log('Not logged in. Run `ari-hooks login`.');
105
+ return;
106
+ }
107
+ console.log(`Logged in (since ${config.loggedInAt ?? 'unknown'})`);
108
+ console.log(`API: ${getApiUrl(config)}`);
109
+ }