clario-agent 0.2.0 → 0.3.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 +34 -19
- package/clario-agent-hook.mjs +120 -36
- package/cli.mjs +143 -46
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# clario-agent: show your AI agent's activity in Clario 🧑🍳
|
|
2
2
|
|
|
3
|
-
A Claude Code
|
|
4
|
-
Clario space. Others see a pulsing badge above your avatar while
|
|
5
|
-
clicking your avatar shows your agent card: what
|
|
6
|
-
and commit (e.g. `graphite · feat/missions · a1b2c3d`). The badge
|
|
3
|
+
A hook for **Claude Code, Codex and Cursor** that shows what your coding agent is working on
|
|
4
|
+
to everyone in your current Clario space. Others see a pulsing badge above your avatar while
|
|
5
|
+
the agent works, and clicking your avatar shows your agent card: which agent, what it's doing,
|
|
6
|
+
and the project, branch and commit (e.g. `graphite · feat/missions · a1b2c3d`). The badge
|
|
7
|
+
clears when the agent finishes.
|
|
7
8
|
|
|
8
9
|
## Setup (one command)
|
|
9
10
|
|
|
10
11
|
1. Open your space on [clario.games](https://www.clario.games) and click **Agent** in the
|
|
11
|
-
left menu.
|
|
12
|
+
left menu. Pick your agent; it shows your setup command, with your key in it:
|
|
12
13
|
```bash
|
|
13
|
-
npx clario-agent setup --key YOUR_KEY
|
|
14
|
+
npx clario-agent setup --key YOUR_KEY # Claude Code
|
|
15
|
+
npx clario-agent setup --key YOUR_KEY --agent codex # Codex
|
|
16
|
+
npx clario-agent setup --key YOUR_KEY --agent cursor # Cursor
|
|
14
17
|
```
|
|
15
18
|
2. Copy it and run it in the terminal, inside the project you want to share.
|
|
19
|
+
`--agent all` sets up all three; `--agent codex,cursor` works too.
|
|
16
20
|
|
|
17
21
|
That's it: the Agent button disappears once your agent has connected. Setup:
|
|
18
22
|
|
|
@@ -21,19 +25,30 @@ That's it: the Agent button disappears once your agent has connected. Setup:
|
|
|
21
25
|
same command for you. It can only post agent activity: it can't sign in, open a space or
|
|
22
26
|
change anything else. No password is ever typed in the terminal.
|
|
23
27
|
- **Installs the hook** at `~/.clario/hook.mjs`.
|
|
24
|
-
- **Registers it with
|
|
25
|
-
Add `--global` to use it in every project (`~/.claude/settings.json`) instead.
|
|
28
|
+
- **Registers it with your agent:**
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
| Agent | Hook file | Shared from |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Claude Code | `.claude/settings.local.json` in this project (`~/.claude/settings.json` with `--global`) | this project |
|
|
33
|
+
| Codex | `~/.codex/hooks.json` (or `$CODEX_HOME/hooks.json`) | the projects you ran setup in |
|
|
34
|
+
| Cursor | `~/.cursor/hooks.json` | the projects you ran setup in |
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
Codex and Cursor read one hook file for every project, so the hook itself only reports
|
|
37
|
+
from the projects you ran setup in; add `--global` to share from all of them.
|
|
38
|
+
- **Codex** asks before it runs a new hook: open Codex, run `/hooks` and trust the Clario
|
|
39
|
+
hook. **Cursor**: restart it if it was open.
|
|
40
|
+
|
|
41
|
+
Then give your agent a task in that project and click your avatar. Teammates who do the same
|
|
42
|
+
and join your space show up on their avatars too.
|
|
43
|
+
|
|
44
|
+
To add another project later, run `npx clario-agent setup --agent …` there: it reuses your
|
|
45
|
+
saved key. Setup never registers the hook twice, leaves your other settings and hooks as they
|
|
46
|
+
are, and saves the previous file next to it as `*.clario-backup`.
|
|
33
47
|
|
|
34
48
|
```bash
|
|
35
|
-
npx clario-agent status
|
|
36
|
-
npx clario-agent uninstall
|
|
49
|
+
npx clario-agent status # server, key, which agents are set up and where
|
|
50
|
+
npx clario-agent uninstall # stop sharing from this project, for every agent
|
|
51
|
+
npx clario-agent uninstall --global # remove every Clario hook and stop sharing everywhere
|
|
37
52
|
```
|
|
38
53
|
|
|
39
54
|
It uses the live server at `https://api.clario.games`. Running Clario yourself? The Agent
|
|
@@ -48,12 +63,12 @@ from `~/.viora` and replaces the old hook. You can delete `~/.viora` afterwards.
|
|
|
48
63
|
|
|
49
64
|
## How it behaves
|
|
50
65
|
|
|
51
|
-
- While
|
|
52
|
-
|
|
53
|
-
`
|
|
66
|
+
- While your agent works your badge shows "cooking" with a short description of what it's
|
|
67
|
+
doing, e.g. `Claude is using Edit page.tsx`, `Codex is using Shell $ bun, git` or
|
|
68
|
+
`Cursor is editing page.tsx`, and the card says which agent it is
|
|
54
69
|
- Your agent card also shows the project folder, git branch and short commit the agent is
|
|
55
70
|
working in
|
|
56
|
-
- When
|
|
71
|
+
- When the agent finishes, the badge clears automatically
|
|
57
72
|
- If you're not inside a Clario space, updates are silently dropped
|
|
58
73
|
- Updates are throttled (2s) so fast tool bursts don't spam the room
|
|
59
74
|
- The hook never blocks or fails your agent session: all errors are swallowed
|
package/clario-agent-hook.mjs
CHANGED
|
@@ -1,28 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Clario agent hook for Claude Code
|
|
3
|
+
* Clario agent hook for Claude Code, Codex and Cursor
|
|
4
4
|
*
|
|
5
5
|
* Broadcasts what your AI agent is doing to everyone in your current Clario space.
|
|
6
6
|
* Setup guide: tools/agent-hook/README.md
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* node hook.mjs [--agent claude-code|codex|cursor-agent] (hook JSON on stdin)
|
|
9
|
+
*
|
|
10
|
+
* Reads the agent's hook events from stdin and POSTs activity to
|
|
9
11
|
* POST /api/v1/agent-activity on your Clario server. Exits 0 silently on any
|
|
10
|
-
* failure so it never blocks your agent session.
|
|
12
|
+
* failure so it never blocks your agent session. Without --agent it's Claude Code.
|
|
11
13
|
*
|
|
12
14
|
* Sent: a short status line (tool name, edited file name, or the programs a shell
|
|
13
|
-
* command runs, never its arguments)
|
|
14
|
-
* commit. `"shareGit": false` in ~/.clario/agent.json (or
|
|
15
|
-
* project, branch and commit on this machine.
|
|
15
|
+
* command runs, never its arguments), which agent it is, and the project folder,
|
|
16
|
+
* git branch and short commit. `"shareGit": false` in ~/.clario/agent.json (or
|
|
17
|
+
* CLARIO_SHARE_GIT=0) keeps project, branch and commit on this machine.
|
|
18
|
+
*
|
|
19
|
+
* Codex and Cursor read their hooks from one file for every project, so for them this
|
|
20
|
+
* only reports from the projects setup was run in (`projects` in the config), unless
|
|
21
|
+
* setup was run with --global (`everyProject`). Cursor reads each hook's stdout as its
|
|
22
|
+
* reply, so it always gets the neutral one: carry on, change nothing.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
25
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
19
26
|
import { homedir } from "node:os";
|
|
20
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
27
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
21
28
|
|
|
22
29
|
const CONFIG_PATH = join(homedir(), ".clario", "agent.json");
|
|
23
30
|
const THROTTLE_MS = 2000;
|
|
24
31
|
const STATE_PATH = join(homedir(), ".clario", ".agent-hook-state");
|
|
25
32
|
|
|
33
|
+
/** Agents this hook understands, by the --agent value setup registers, with the name shown in Clario. */
|
|
34
|
+
const AGENTS = { "claude-code": "Claude", codex: "Codex", "cursor-agent": "Cursor" };
|
|
35
|
+
|
|
36
|
+
function agentFromArgs(argv) {
|
|
37
|
+
const i = argv.indexOf("--agent");
|
|
38
|
+
const value = i > -1 ? argv[i + 1] : argv.find((a) => a.startsWith("--agent="))?.slice("--agent=".length);
|
|
39
|
+
return value in AGENTS ? value : "claude-code";
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
function readStdin() {
|
|
27
43
|
return new Promise((resolve) => {
|
|
28
44
|
let raw = "";
|
|
@@ -36,7 +52,7 @@ function readStdin() {
|
|
|
36
52
|
function loadConfig() {
|
|
37
53
|
try {
|
|
38
54
|
const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
39
|
-
// agentKey (setup
|
|
55
|
+
// agentKey (from setup) wins over a pasted 7-day login token.
|
|
40
56
|
const credential = config?.agentKey || config?.token;
|
|
41
57
|
if (credential && config?.apiUrl) return { ...config, credential };
|
|
42
58
|
} catch {
|
|
@@ -52,6 +68,28 @@ function lastSentAt() {
|
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
/** Working directory of a hook event. Claude Code and Codex send `cwd`; Cursor sends `workspace_roots`. */
|
|
72
|
+
function eventCwd(event) {
|
|
73
|
+
const roots = Array.isArray(event.workspace_roots) ? event.workspace_roots.filter((r) => typeof r === "string" && r) : [];
|
|
74
|
+
return (typeof event.cwd === "string" && event.cwd) || roots[0] || process.env.CURSOR_PROJECT_DIR || process.cwd();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function realPath(path) {
|
|
78
|
+
try {
|
|
79
|
+
return realpathSync(path);
|
|
80
|
+
} catch {
|
|
81
|
+
return resolve(path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Codex and Cursor: only from the projects setup was run in, or every project after setup --global. */
|
|
86
|
+
function sharedHere(config, agent, cwd) {
|
|
87
|
+
if (agent === "claude-code" || config.everyProject === true) return true; // Claude Code's hook is per project already
|
|
88
|
+
const here = realPath(cwd);
|
|
89
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
90
|
+
return projects.some((p) => typeof p === "string" && (here === p || here.startsWith(p.endsWith(sep) ? p : p + sep)));
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
// Git: project, branch and commit, read from .git directly (no git CLI).
|
|
56
94
|
|
|
57
95
|
/** Walk up from `start` to the first directory containing `.git` (a dir, or a file for worktrees). */
|
|
@@ -118,10 +156,9 @@ function readRef(gitDir, name) {
|
|
|
118
156
|
}
|
|
119
157
|
|
|
120
158
|
/** Project folder, branch and short commit for the agent card; nothing when sharing is off. */
|
|
121
|
-
function gitInfo(
|
|
159
|
+
function gitInfo(cwd, config) {
|
|
122
160
|
const env = process.env.CLARIO_SHARE_GIT;
|
|
123
161
|
if (env !== undefined ? env === "0" : config.shareGit === false) return {};
|
|
124
|
-
const cwd = typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
|
|
125
162
|
const root = findGitRoot(cwd);
|
|
126
163
|
if (!root) return { project: basename(resolve(cwd)) };
|
|
127
164
|
const { branch, commit } = readHead(root);
|
|
@@ -152,42 +189,89 @@ function programNames(command) {
|
|
|
152
189
|
return names.slice(0, 3);
|
|
153
190
|
}
|
|
154
191
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
192
|
+
/** A shell tool's command line. Codex often sends it as ["bash", "-lc", "the command"]. */
|
|
193
|
+
function commandText(input) {
|
|
194
|
+
const command = input?.command ?? input?.cmd;
|
|
195
|
+
if (typeof command === "string") return command;
|
|
196
|
+
if (!Array.isArray(command) || !command.every((part) => typeof part === "string")) return "";
|
|
197
|
+
const flag = command.findIndex((part) => part === "-c" || part === "-lc");
|
|
198
|
+
return flag > -1 && command[flag + 1] ? command[flag + 1] : command.join(" ");
|
|
199
|
+
}
|
|
159
200
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
201
|
+
/** The first file a Codex apply_patch names ("*** Update File: path"), wherever the patch sits in the input. */
|
|
202
|
+
function patchFile(value, depth = 0) {
|
|
203
|
+
if (depth > 3 || value == null) return undefined;
|
|
204
|
+
if (typeof value === "string") return /^\*\*\* (?:Update|Add|Delete) File:\s*(.+?)\s*$/m.exec(value)?.[1];
|
|
205
|
+
const values = Array.isArray(value) ? value : typeof value === "object" ? Object.values(value) : [];
|
|
206
|
+
for (const v of values) {
|
|
207
|
+
const found = patchFile(v, depth + 1);
|
|
208
|
+
if (found) return found;
|
|
168
209
|
}
|
|
169
|
-
|
|
170
|
-
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const fileName = (path) => (typeof path === "string" && path.trim() ? basename(path.trim()) : "");
|
|
214
|
+
|
|
215
|
+
const DONE_EVENTS = new Set(["Stop", "SessionEnd", "stop", "sessionEnd"]);
|
|
216
|
+
const PROMPT_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt"]);
|
|
217
|
+
const EDIT_TOOLS = new Set(["edit", "write", "multiedit", "notebookedit"]);
|
|
218
|
+
const PATCH_TOOLS = new Set(["apply_patch", "applypatch"]);
|
|
219
|
+
const SHELL_TOOLS = new Set(["bash", "shell", "local_shell", "exec_command"]);
|
|
220
|
+
|
|
221
|
+
/** What the agent is doing, as the one line others see; no text means there's nothing to send. */
|
|
222
|
+
function describe(event, agent) {
|
|
223
|
+
const name = AGENTS[agent];
|
|
224
|
+
const hookEvent = event.hook_event_name;
|
|
225
|
+
if (DONE_EVENTS.has(hookEvent)) return { state: "done", text: "" };
|
|
226
|
+
if (PROMPT_EVENTS.has(hookEvent)) return { state: "cooking", text: `${name} is thinking` };
|
|
227
|
+
// Cursor: one event per file its agent changes, with the file at the top level.
|
|
228
|
+
if (hookEvent === "afterFileEdit") {
|
|
229
|
+
const file = fileName(event.file_path);
|
|
230
|
+
return { state: "cooking", text: file ? `${name} is editing ${file}` : `${name} is editing` };
|
|
231
|
+
}
|
|
232
|
+
if (hookEvent !== "PreToolUse" && hookEvent !== "PostToolUse") return { state: "cooking", text: "" };
|
|
233
|
+
|
|
234
|
+
const tool = typeof event.tool_name === "string" ? event.tool_name : "";
|
|
235
|
+
const key = tool.toLowerCase();
|
|
236
|
+
let label = tool.replace(/^(mcp__|LambdaResponse)/, "").slice(0, 40) || "a tool";
|
|
237
|
+
let detail = "";
|
|
238
|
+
if (EDIT_TOOLS.has(key)) {
|
|
239
|
+
const file = fileName(event?.tool_input?.file_path ?? event?.tool_input?.notebook_path);
|
|
240
|
+
if (file) detail = ` ${file}`;
|
|
241
|
+
} else if (PATCH_TOOLS.has(key)) {
|
|
242
|
+
label = "Edit";
|
|
243
|
+
const file = fileName(patchFile(event?.tool_input));
|
|
244
|
+
if (file) detail = ` ${file}`;
|
|
245
|
+
} else if (SHELL_TOOLS.has(key)) {
|
|
246
|
+
if (key !== "bash") label = "Shell";
|
|
247
|
+
const names = programNames(commandText(event?.tool_input));
|
|
171
248
|
if (names.length) detail = ` $ ${names.join(", ")}`;
|
|
172
249
|
}
|
|
173
|
-
return { state: "cooking", text:
|
|
250
|
+
return { state: "cooking", text: `${name} is using ${label}${detail}` };
|
|
174
251
|
}
|
|
175
252
|
|
|
176
253
|
async function main() {
|
|
177
|
-
const
|
|
178
|
-
if (!config) process.exit(0);
|
|
179
|
-
|
|
254
|
+
const agent = agentFromArgs(process.argv.slice(2));
|
|
180
255
|
const raw = await readStdin();
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
let event;
|
|
256
|
+
let event = {};
|
|
184
257
|
try {
|
|
185
|
-
|
|
258
|
+
const parsed = JSON.parse(raw);
|
|
259
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) event = parsed;
|
|
186
260
|
} catch {
|
|
187
|
-
process.exit(0);
|
|
188
261
|
}
|
|
189
262
|
|
|
190
|
-
|
|
263
|
+
// Cursor waits for a reply on stdout before it goes on: give it first, whatever happens next.
|
|
264
|
+
if (agent === "cursor-agent") {
|
|
265
|
+
await new Promise((done) => process.stdout.write(event.hook_event_name === "beforeSubmitPrompt" ? '{"continue":true}' : "{}", done));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const config = loadConfig();
|
|
269
|
+
if (!config || !raw.trim()) process.exit(0);
|
|
270
|
+
|
|
271
|
+
const cwd = eventCwd(event);
|
|
272
|
+
if (!sharedHere(config, agent, cwd)) process.exit(0);
|
|
273
|
+
|
|
274
|
+
const { state, text } = describe(event, agent);
|
|
191
275
|
if (state === "cooking" && !text) process.exit(0);
|
|
192
276
|
|
|
193
277
|
// Client-side throttle: hook events fire per tool use and can be chatty.
|
|
@@ -204,7 +288,7 @@ async function main() {
|
|
|
204
288
|
"Content-Type": "application/json",
|
|
205
289
|
Authorization: `Bearer ${config.credential}`,
|
|
206
290
|
},
|
|
207
|
-
body: JSON.stringify(state === "cooking" ? { text, state, ...gitInfo(
|
|
291
|
+
body: JSON.stringify(state === "cooking" ? { text, state, agent, ...gitInfo(cwd, config) } : { text, state, agent }),
|
|
208
292
|
signal: controller.signal,
|
|
209
293
|
});
|
|
210
294
|
if (state === "cooking") {
|
package/cli.mjs
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* clario-agent: show what your Claude Code
|
|
3
|
+
* clario-agent: show what your coding agent (Claude Code, Codex or Cursor) is doing on
|
|
4
|
+
* your Clario avatar.
|
|
4
5
|
*
|
|
5
|
-
* npx clario-agent setup --key KEY [--api-url URL] [--global]
|
|
6
|
-
* npx clario-agent status
|
|
7
|
-
* npx clario-agent uninstall [--global]
|
|
6
|
+
* npx clario-agent setup --key KEY [--agent claude|codex|cursor|all] [--api-url URL] [--global]
|
|
7
|
+
* npx clario-agent status what is set up
|
|
8
|
+
* npx clario-agent uninstall [--global] stop sharing from this project (or everywhere)
|
|
8
9
|
*
|
|
9
10
|
* The key comes from the website: the Agent button in a Clario space shows the whole
|
|
10
11
|
* setup command. setup checks the key with the server, saves it in ~/.clario/agent.json
|
|
11
|
-
* (one key per user that stays the same; it can only post agent activity), copies the hook
|
|
12
|
-
* ~/.clario/hook.mjs and registers it
|
|
13
|
-
* or
|
|
12
|
+
* (one key per user that stays the same; it can only post agent activity), copies the hook
|
|
13
|
+
* to ~/.clario/hook.mjs and registers it with each agent:
|
|
14
|
+
* Claude Code this project's .claude/settings.local.json, or ~/.claude/settings.json with --global
|
|
15
|
+
* Codex ~/.codex/hooks.json (or $CODEX_HOME/hooks.json)
|
|
16
|
+
* Cursor ~/.cursor/hooks.json
|
|
17
|
+
* Codex and Cursor use one file for every project, so the hook only reports from the
|
|
18
|
+
* projects setup was run in (`projects` in the config), or from all with --global.
|
|
14
19
|
*/
|
|
15
20
|
|
|
16
21
|
import { execFileSync } from "node:child_process";
|
|
@@ -27,7 +32,6 @@ const HOOK_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "clario-agent-
|
|
|
27
32
|
const OLD_CONFIG_PATH = join(homedir(), ".viora", "agent.json");
|
|
28
33
|
// The live Clario server; pass --api-url http://localhost:3001 for a local one.
|
|
29
34
|
const DEFAULT_API_URL = "https://api.clario.games";
|
|
30
|
-
const HOOK_EVENTS = ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
|
|
31
35
|
const WARN_DAYS = 14; // setup reminds you to get a new key when the saved one has less time left than this
|
|
32
36
|
const GET_A_KEY = "Open your Clario space at https://www.clario.games, click Agent in the left menu, and run the setup command it shows.";
|
|
33
37
|
|
|
@@ -36,6 +40,35 @@ const fail = (message) => {
|
|
|
36
40
|
throw new CliError(message);
|
|
37
41
|
};
|
|
38
42
|
|
|
43
|
+
/** A path for a hook command line, quoted for the shell. */
|
|
44
|
+
const quote = (path) => `"${path.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
45
|
+
|
|
46
|
+
/** Where each agent reads its hooks, the events the hook listens to, and the file's layout. */
|
|
47
|
+
const AGENT_TARGETS = {
|
|
48
|
+
claude: {
|
|
49
|
+
name: "Claude Code",
|
|
50
|
+
file: (global) => (global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json")),
|
|
51
|
+
events: ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"],
|
|
52
|
+
command: () => `node ${quote(HOOK_PATH)}`,
|
|
53
|
+
},
|
|
54
|
+
codex: {
|
|
55
|
+
name: "Codex",
|
|
56
|
+
shared: true, // one file for every project
|
|
57
|
+
file: () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "hooks.json"),
|
|
58
|
+
events: ["UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"],
|
|
59
|
+
// The full path to node: Codex and Cursor can start hooks without your shell's PATH.
|
|
60
|
+
command: () => `${quote(process.execPath)} ${quote(HOOK_PATH)} --agent codex`,
|
|
61
|
+
},
|
|
62
|
+
cursor: {
|
|
63
|
+
name: "Cursor",
|
|
64
|
+
shared: true,
|
|
65
|
+
flat: true, // { version: 1, hooks: { event: [{ command }] } }
|
|
66
|
+
file: () => join(homedir(), ".cursor", "hooks.json"),
|
|
67
|
+
events: ["beforeSubmitPrompt", "afterFileEdit", "stop"],
|
|
68
|
+
command: () => `${quote(process.execPath)} ${quote(HOOK_PATH)} --agent cursor-agent`,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
39
72
|
/** { value } for a JSON file, {} when it doesn't exist; fails on invalid JSON instead of overwriting it. */
|
|
40
73
|
function readJson(file) {
|
|
41
74
|
let text;
|
|
@@ -114,10 +147,7 @@ function savedKey(config, apiUrl) {
|
|
|
114
147
|
fail(`No agent key yet. ${GET_A_KEY}`);
|
|
115
148
|
}
|
|
116
149
|
|
|
117
|
-
// ----
|
|
118
|
-
|
|
119
|
-
const settingsFile = (global) =>
|
|
120
|
-
global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
|
|
150
|
+
// ---- Hook files ---------------------------------------------------------------------
|
|
121
151
|
|
|
122
152
|
/** Our hooks from any version (also from before the rename): this package's copy, or the repo script. */
|
|
123
153
|
const isOurHook = (hook) =>
|
|
@@ -133,6 +163,7 @@ function withoutOurHooks(settings) {
|
|
|
133
163
|
continue;
|
|
134
164
|
}
|
|
135
165
|
const kept = groups
|
|
166
|
+
.filter((group) => !isOurHook(group)) // Cursor lists commands directly
|
|
136
167
|
.map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !isOurHook(h)) } : group))
|
|
137
168
|
.filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
138
169
|
if (kept.length) hooks[event] = kept;
|
|
@@ -142,17 +173,41 @@ function withoutOurHooks(settings) {
|
|
|
142
173
|
}
|
|
143
174
|
|
|
144
175
|
const countOurHooks = (settings) =>
|
|
145
|
-
Object.values(settings?.hooks ?? {})
|
|
176
|
+
Object.values(settings?.hooks ?? {})
|
|
177
|
+
.flat()
|
|
178
|
+
.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [group]))
|
|
179
|
+
.filter(isOurHook).length;
|
|
146
180
|
|
|
147
181
|
/** Add the hook for every event, replacing older entries of ours so it never fires twice. */
|
|
148
|
-
function registerHooks(
|
|
182
|
+
function registerHooks(target, global) {
|
|
183
|
+
const file = target.file(global);
|
|
149
184
|
const { value = {} } = readJson(file);
|
|
150
185
|
if (existsSync(file)) copyFileSync(file, `${file}.clario-backup`);
|
|
151
186
|
const settings = withoutOurHooks(value);
|
|
152
187
|
const hooks = { ...(settings.hooks ?? {}) };
|
|
153
|
-
const command =
|
|
154
|
-
|
|
155
|
-
|
|
188
|
+
const command = target.command();
|
|
189
|
+
const entry = target.flat ? { command, timeout: 10 } : { hooks: [{ type: "command", command, timeout: 10 }] };
|
|
190
|
+
for (const event of target.events) hooks[event] = [...(hooks[event] ?? []), entry];
|
|
191
|
+
writeJson(file, { ...(target.flat ? { version: 1 } : {}), ...settings, hooks });
|
|
192
|
+
return file;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Remove our hook from a file; true when there was one. */
|
|
196
|
+
function unregisterHooks(file) {
|
|
197
|
+
const { value } = readJson(file);
|
|
198
|
+
if (!value || !countOurHooks(value)) return false;
|
|
199
|
+
copyFileSync(file, `${file}.clario-backup`);
|
|
200
|
+
writeJson(file, withoutOurHooks(value));
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The project setup runs in: the git root around the current folder, or the folder itself. */
|
|
205
|
+
function projectRoot() {
|
|
206
|
+
const here = realpathSync(process.cwd());
|
|
207
|
+
for (let dir = here; ; dir = dirname(dir)) {
|
|
208
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
209
|
+
if (dirname(dir) === dir) return here;
|
|
210
|
+
}
|
|
156
211
|
}
|
|
157
212
|
|
|
158
213
|
function gitIgnored(file) {
|
|
@@ -164,6 +219,20 @@ function gitIgnored(file) {
|
|
|
164
219
|
}
|
|
165
220
|
}
|
|
166
221
|
|
|
222
|
+
/** --agent claude|codex|cursor|all, repeatable or comma separated; Claude Code when left out. */
|
|
223
|
+
function parseAgents(args) {
|
|
224
|
+
const values = args
|
|
225
|
+
.flatMap((arg, i) => (arg === "--agent" ? [args[i + 1] ?? ""] : arg.startsWith("--agent=") ? [arg.slice("--agent=".length)] : []))
|
|
226
|
+
.flatMap((value) => value.split(","))
|
|
227
|
+
.map((value) => value.trim().toLowerCase())
|
|
228
|
+
.filter(Boolean);
|
|
229
|
+
const aliases = { "claude-code": "claude", "cursor-agent": "cursor" };
|
|
230
|
+
const names = values.includes("all") ? Object.keys(AGENT_TARGETS) : values.map((value) => aliases[value] ?? value);
|
|
231
|
+
const unknown = names.find((name) => !(name in AGENT_TARGETS));
|
|
232
|
+
if (unknown !== undefined) fail(`Unknown agent "${unknown}". Use claude, codex, cursor or all.`);
|
|
233
|
+
return names.length ? [...new Set(names)] : ["claude"];
|
|
234
|
+
}
|
|
235
|
+
|
|
167
236
|
// ---- Commands -----------------------------------------------------------------------
|
|
168
237
|
|
|
169
238
|
async function setup(opts) {
|
|
@@ -172,22 +241,32 @@ async function setup(opts) {
|
|
|
172
241
|
const apiUrl = String(opts.apiUrl || (opts.key ? DEFAULT_API_URL : config.apiUrl) || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
173
242
|
const { key, days, reused } = opts.key ? await checkKey(opts.key, apiUrl) : savedKey(config, apiUrl);
|
|
174
243
|
|
|
175
|
-
// An old 7-day login token isn't needed any more; everything else (shareGit…) stays.
|
|
244
|
+
// An old 7-day login token isn't needed any more; everything else (shareGit, projects…) stays.
|
|
176
245
|
const { token: _loginToken, ...rest } = config;
|
|
177
|
-
|
|
246
|
+
const next = { ...rest, apiUrl, agentKey: key };
|
|
247
|
+
const root = projectRoot();
|
|
248
|
+
if (opts.agents.some((agent) => AGENT_TARGETS[agent].shared)) {
|
|
249
|
+
if (opts.global) next.everyProject = true;
|
|
250
|
+
else next.projects = [...new Set([...(Array.isArray(rest.projects) ? rest.projects : []), root])];
|
|
251
|
+
}
|
|
252
|
+
writeJson(CONFIG_PATH, next, { mode: 0o600 });
|
|
178
253
|
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
179
254
|
copyFileSync(HOOK_SOURCE, HOOK_PATH);
|
|
180
255
|
|
|
181
|
-
const file = settingsFile(opts.global);
|
|
182
|
-
registerHooks(file);
|
|
183
|
-
|
|
184
256
|
console.log(`${reused ? "Kept your agent key" : "Saved your agent key"} in ${CONFIG_PATH} (${expiryText(days)}).`);
|
|
185
257
|
if (reused && days < WARN_DAYS) console.log(`It runs out in ${Math.ceil(days)} days. ${GET_A_KEY}`);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
258
|
+
for (const agent of opts.agents) {
|
|
259
|
+
const target = AGENT_TARGETS[agent];
|
|
260
|
+
const file = registerHooks(target, opts.global);
|
|
261
|
+
const scope = opts.global ? "every project" : target.shared ? `${root} (and any other project you ran setup in)` : "this project only";
|
|
262
|
+
console.log(`${target.name}: hook registered in ${file}, sharing from ${scope}.`);
|
|
263
|
+
if (agent === "claude" && !opts.global && gitIgnored(file) === false) {
|
|
264
|
+
console.log(" Note: .claude/settings.local.json isn't git-ignored here. It holds no secrets, but you may want to ignore it.");
|
|
265
|
+
}
|
|
266
|
+
if (agent === "codex") console.log(" Codex asks before it runs a new hook: open Codex, run /hooks and trust the Clario hook.");
|
|
267
|
+
if (agent === "cursor") console.log(" If Cursor is open, restart it so it loads the hook.");
|
|
189
268
|
}
|
|
190
|
-
console.log("Open your Clario space and give
|
|
269
|
+
console.log("Open your Clario space and give your agent a task: your avatar's card shows what it's doing.");
|
|
191
270
|
}
|
|
192
271
|
|
|
193
272
|
function status() {
|
|
@@ -200,35 +279,53 @@ function status() {
|
|
|
200
279
|
: "Key: none (run setup)",
|
|
201
280
|
);
|
|
202
281
|
console.log(`Hook: ${existsSync(HOOK_PATH) ? HOOK_PATH : "not installed (run setup)"}`);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
282
|
+
const registered = (file) => (countOurHooks(readJson(file).value) ? `registered (${file})` : "not registered");
|
|
283
|
+
console.log(`Claude Code, this project: ${registered(AGENT_TARGETS.claude.file(false))}`);
|
|
284
|
+
console.log(`Claude Code, all projects: ${registered(AGENT_TARGETS.claude.file(true))}`);
|
|
285
|
+
console.log(`Codex: ${registered(AGENT_TARGETS.codex.file())}`);
|
|
286
|
+
console.log(`Cursor: ${registered(AGENT_TARGETS.cursor.file())}`);
|
|
287
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
288
|
+
console.log(`Codex and Cursor share from: ${config.everyProject ? "every project" : projects.length ? projects.join(", ") : "no project yet"}`);
|
|
208
289
|
}
|
|
209
290
|
|
|
210
291
|
function uninstall(opts) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
292
|
+
const { value: config } = readJson(CONFIG_PATH);
|
|
293
|
+
if (opts.global) {
|
|
294
|
+
// Stop everywhere: every agent's hook, and the list of shared projects.
|
|
295
|
+
for (const target of Object.values(AGENT_TARGETS)) {
|
|
296
|
+
const file = target.file(true);
|
|
297
|
+
if (unregisterHooks(file)) console.log(`Removed the ${target.name} hook from ${file}.`);
|
|
298
|
+
}
|
|
299
|
+
if (config) {
|
|
300
|
+
const { projects: _projects, everyProject: _every, ...rest } = config;
|
|
301
|
+
writeJson(CONFIG_PATH, rest, { mode: 0o600 });
|
|
302
|
+
}
|
|
303
|
+
console.log(`Nothing is shared any more. Your key stays in ${CONFIG_PATH}; delete that file to remove it too.`);
|
|
215
304
|
return;
|
|
216
305
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
console.log(`Removed the
|
|
306
|
+
// Stop in this project: its Claude Code hook, and Codex and Cursor reporting from it.
|
|
307
|
+
const file = AGENT_TARGETS.claude.file(false);
|
|
308
|
+
if (unregisterHooks(file)) console.log(`Removed the Claude Code hook from ${file}.`);
|
|
309
|
+
const root = projectRoot();
|
|
310
|
+
if (config && Array.isArray(config.projects) && config.projects.includes(root)) {
|
|
311
|
+
writeJson(CONFIG_PATH, { ...config, projects: config.projects.filter((p) => p !== root) }, { mode: 0o600 });
|
|
312
|
+
console.log(`Codex and Cursor no longer share from ${root}.`);
|
|
313
|
+
}
|
|
314
|
+
if (config?.everyProject) console.log("Codex and Cursor still share from every project (setup --global); run uninstall --global to stop that.");
|
|
315
|
+
console.log("This project isn't shared any more.");
|
|
220
316
|
}
|
|
221
317
|
|
|
222
|
-
const HELP = `clario-agent: show what your
|
|
318
|
+
const HELP = `clario-agent: show what your coding agent is doing on your Clario avatar
|
|
223
319
|
|
|
224
|
-
clario-agent setup --key KEY [--
|
|
225
|
-
|
|
226
|
-
clario-agent
|
|
320
|
+
clario-agent setup --key KEY [--agent claude|codex|cursor|all] [--global]
|
|
321
|
+
save your key and register the hook
|
|
322
|
+
clario-agent status show what is set up
|
|
323
|
+
clario-agent uninstall [--global] stop sharing from this project (or everywhere)
|
|
227
324
|
|
|
228
325
|
Get the setup command (with your key) from the Agent button in your Clario space.
|
|
229
|
-
|
|
230
|
-
Without --
|
|
231
|
-
unless you pass --api-url.`;
|
|
326
|
+
--agent picks which agents to set up (default claude; comma separated or repeated).
|
|
327
|
+
Without --global only the project you run it in is shared. Without --key, setup reuses
|
|
328
|
+
the key you saved before. The server is ${DEFAULT_API_URL} unless you pass --api-url.`;
|
|
232
329
|
|
|
233
330
|
export async function run(argv) {
|
|
234
331
|
const [command = "help", ...rest] = argv;
|
|
@@ -236,8 +333,8 @@ export async function run(argv) {
|
|
|
236
333
|
const i = rest.indexOf(flag);
|
|
237
334
|
return i > -1 ? rest[i + 1] : undefined;
|
|
238
335
|
};
|
|
239
|
-
const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key") };
|
|
240
336
|
try {
|
|
337
|
+
const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key"), agents: parseAgents(rest) };
|
|
241
338
|
if (command === "setup") await setup(opts);
|
|
242
339
|
else if (command === "status") status();
|
|
243
340
|
else if (command === "uninstall") uninstall(opts);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clario-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Show what your Claude Code agent is doing (project, branch, commit) on your Clario avatar.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Show what your Claude Code, Codex or Cursor agent is doing (project, branch, commit) on your Clario avatar.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"clario-agent": "cli.mjs"
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"keywords": [
|
|
19
19
|
"clario",
|
|
20
20
|
"claude-code",
|
|
21
|
+
"codex",
|
|
22
|
+
"cursor",
|
|
21
23
|
"hooks",
|
|
22
24
|
"agent",
|
|
23
25
|
"metaverse"
|