clario-agent 0.2.0 → 0.4.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 -20
- package/clario-agent-hook.mjs +120 -36
- package/cli.mjs +158 -46
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
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. It shows your setup command, with
|
|
12
|
+
left menu. 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@latest setup --key YOUR_KEY
|
|
14
15
|
```
|
|
15
|
-
2. Copy it and run it in the terminal, inside the project you want to share.
|
|
16
|
+
2. Copy it and run it in the terminal, inside the project you want to share. It sets up
|
|
17
|
+
every agent it finds on your computer (Claude Code, Codex, Cursor). To pick them
|
|
18
|
+
yourself, add `--agent codex`, `--agent claude,cursor` or `--agent all`.
|
|
16
19
|
|
|
17
20
|
That's it: the Agent button disappears once your agent has connected. Setup:
|
|
18
21
|
|
|
@@ -21,19 +24,30 @@ That's it: the Agent button disappears once your agent has connected. Setup:
|
|
|
21
24
|
same command for you. It can only post agent activity: it can't sign in, open a space or
|
|
22
25
|
change anything else. No password is ever typed in the terminal.
|
|
23
26
|
- **Installs the hook** at `~/.clario/hook.mjs`.
|
|
24
|
-
- **Registers it with
|
|
25
|
-
Add `--global` to use it in every project (`~/.claude/settings.json`) instead.
|
|
27
|
+
- **Registers it with your agent:**
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
| Agent | Hook file | Shared from |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| Claude Code | `.claude/settings.local.json` in this project (`~/.claude/settings.json` with `--global`) | this project |
|
|
32
|
+
| Codex | `~/.codex/hooks.json` (or `$CODEX_HOME/hooks.json`) | the projects you ran setup in |
|
|
33
|
+
| Cursor | `~/.cursor/hooks.json` | the projects you ran setup in |
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
Codex and Cursor read one hook file for every project, so the hook itself only reports
|
|
36
|
+
from the projects you ran setup in; add `--global` to share from all of them.
|
|
37
|
+
- **Codex** asks before it runs a new hook: open Codex, run `/hooks` and trust the Clario
|
|
38
|
+
hook. **Cursor**: restart it if it was open.
|
|
39
|
+
|
|
40
|
+
Then give your agent a task in that project and click your avatar. Teammates who do the same
|
|
41
|
+
and join your space show up on their avatars too.
|
|
42
|
+
|
|
43
|
+
To add another project or a newly installed agent later, run `npx clario-agent@latest setup` there: it reuses your
|
|
44
|
+
saved key. Setup never registers the hook twice, leaves your other settings and hooks as they
|
|
45
|
+
are, and saves the previous file next to it as `*.clario-backup`.
|
|
33
46
|
|
|
34
47
|
```bash
|
|
35
|
-
npx clario-agent status
|
|
36
|
-
npx clario-agent uninstall
|
|
48
|
+
npx clario-agent status # server, key, which agents are set up and where
|
|
49
|
+
npx clario-agent uninstall # stop sharing from this project, for every agent
|
|
50
|
+
npx clario-agent uninstall --global # remove every Clario hook and stop sharing everywhere
|
|
37
51
|
```
|
|
38
52
|
|
|
39
53
|
It uses the live server at `https://api.clario.games`. Running Clario yourself? The Agent
|
|
@@ -48,12 +62,12 @@ from `~/.viora` and replaces the old hook. You can delete `~/.viora` afterwards.
|
|
|
48
62
|
|
|
49
63
|
## How it behaves
|
|
50
64
|
|
|
51
|
-
- While
|
|
52
|
-
|
|
53
|
-
`
|
|
65
|
+
- While your agent works your badge shows "cooking" with a short description of what it's
|
|
66
|
+
doing, e.g. `Claude is using Edit page.tsx`, `Codex is using Shell $ bun, git` or
|
|
67
|
+
`Cursor is editing page.tsx`, and the card says which agent it is
|
|
54
68
|
- Your agent card also shows the project folder, git branch and short commit the agent is
|
|
55
69
|
working in
|
|
56
|
-
- When
|
|
70
|
+
- When the agent finishes, the badge clears automatically
|
|
57
71
|
- If you're not inside a Clario space, updates are silently dropped
|
|
58
72
|
- Updates are throttled (2s) so fast tool bursts don't spam the room
|
|
59
73
|
- 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,22 @@
|
|
|
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
|
-
*
|
|
7
|
-
* npx clario-agent
|
|
6
|
+
* npx clario-agent setup --key KEY [--agent claude|codex|cursor|all] [--api-url URL] [--global]
|
|
7
|
+
* every agent on this computer unless --agent picks
|
|
8
|
+
* npx clario-agent status what is set up
|
|
9
|
+
* npx clario-agent uninstall [--global] stop sharing from this project (or everywhere)
|
|
8
10
|
*
|
|
9
11
|
* The key comes from the website: the Agent button in a Clario space shows the whole
|
|
10
12
|
* 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
|
|
13
|
+
* (one key per user that stays the same; it can only post agent activity), copies the hook
|
|
14
|
+
* to ~/.clario/hook.mjs and registers it with each agent:
|
|
15
|
+
* Claude Code this project's .claude/settings.local.json, or ~/.claude/settings.json with --global
|
|
16
|
+
* Codex ~/.codex/hooks.json (or $CODEX_HOME/hooks.json)
|
|
17
|
+
* Cursor ~/.cursor/hooks.json
|
|
18
|
+
* Codex and Cursor use one file for every project, so the hook only reports from the
|
|
19
|
+
* projects setup was run in (`projects` in the config), or from all with --global.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
import { execFileSync } from "node:child_process";
|
|
@@ -27,7 +33,6 @@ const HOOK_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "clario-agent-
|
|
|
27
33
|
const OLD_CONFIG_PATH = join(homedir(), ".viora", "agent.json");
|
|
28
34
|
// The live Clario server; pass --api-url http://localhost:3001 for a local one.
|
|
29
35
|
const DEFAULT_API_URL = "https://api.clario.games";
|
|
30
|
-
const HOOK_EVENTS = ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
|
|
31
36
|
const WARN_DAYS = 14; // setup reminds you to get a new key when the saved one has less time left than this
|
|
32
37
|
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
38
|
|
|
@@ -36,6 +41,39 @@ const fail = (message) => {
|
|
|
36
41
|
throw new CliError(message);
|
|
37
42
|
};
|
|
38
43
|
|
|
44
|
+
/** A path for a hook command line, quoted for the shell. */
|
|
45
|
+
const quote = (path) => `"${path.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
46
|
+
|
|
47
|
+
/** Where each agent reads its hooks, the events the hook listens to, and the file's layout. */
|
|
48
|
+
const AGENT_TARGETS = {
|
|
49
|
+
claude: {
|
|
50
|
+
name: "Claude Code",
|
|
51
|
+
// Each agent makes its home folder the first time it runs: that's how setup finds it.
|
|
52
|
+
installed: () => existsSync(join(homedir(), ".claude")),
|
|
53
|
+
file: (global) => (global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json")),
|
|
54
|
+
events: ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"],
|
|
55
|
+
command: () => `node ${quote(HOOK_PATH)}`,
|
|
56
|
+
},
|
|
57
|
+
codex: {
|
|
58
|
+
name: "Codex",
|
|
59
|
+
shared: true, // one file for every project
|
|
60
|
+
installed: () => existsSync(process.env.CODEX_HOME || join(homedir(), ".codex")),
|
|
61
|
+
file: () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "hooks.json"),
|
|
62
|
+
events: ["UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd"],
|
|
63
|
+
// The full path to node: Codex and Cursor can start hooks without your shell's PATH.
|
|
64
|
+
command: () => `${quote(process.execPath)} ${quote(HOOK_PATH)} --agent codex`,
|
|
65
|
+
},
|
|
66
|
+
cursor: {
|
|
67
|
+
name: "Cursor",
|
|
68
|
+
shared: true,
|
|
69
|
+
flat: true, // { version: 1, hooks: { event: [{ command }] } }
|
|
70
|
+
installed: () => existsSync(join(homedir(), ".cursor")),
|
|
71
|
+
file: () => join(homedir(), ".cursor", "hooks.json"),
|
|
72
|
+
events: ["beforeSubmitPrompt", "afterFileEdit", "stop"],
|
|
73
|
+
command: () => `${quote(process.execPath)} ${quote(HOOK_PATH)} --agent cursor-agent`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
39
77
|
/** { value } for a JSON file, {} when it doesn't exist; fails on invalid JSON instead of overwriting it. */
|
|
40
78
|
function readJson(file) {
|
|
41
79
|
let text;
|
|
@@ -114,10 +152,7 @@ function savedKey(config, apiUrl) {
|
|
|
114
152
|
fail(`No agent key yet. ${GET_A_KEY}`);
|
|
115
153
|
}
|
|
116
154
|
|
|
117
|
-
// ----
|
|
118
|
-
|
|
119
|
-
const settingsFile = (global) =>
|
|
120
|
-
global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
|
|
155
|
+
// ---- Hook files ---------------------------------------------------------------------
|
|
121
156
|
|
|
122
157
|
/** Our hooks from any version (also from before the rename): this package's copy, or the repo script. */
|
|
123
158
|
const isOurHook = (hook) =>
|
|
@@ -133,6 +168,7 @@ function withoutOurHooks(settings) {
|
|
|
133
168
|
continue;
|
|
134
169
|
}
|
|
135
170
|
const kept = groups
|
|
171
|
+
.filter((group) => !isOurHook(group)) // Cursor lists commands directly
|
|
136
172
|
.map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !isOurHook(h)) } : group))
|
|
137
173
|
.filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
138
174
|
if (kept.length) hooks[event] = kept;
|
|
@@ -142,17 +178,41 @@ function withoutOurHooks(settings) {
|
|
|
142
178
|
}
|
|
143
179
|
|
|
144
180
|
const countOurHooks = (settings) =>
|
|
145
|
-
Object.values(settings?.hooks ?? {})
|
|
181
|
+
Object.values(settings?.hooks ?? {})
|
|
182
|
+
.flat()
|
|
183
|
+
.flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [group]))
|
|
184
|
+
.filter(isOurHook).length;
|
|
146
185
|
|
|
147
186
|
/** Add the hook for every event, replacing older entries of ours so it never fires twice. */
|
|
148
|
-
function registerHooks(
|
|
187
|
+
function registerHooks(target, global) {
|
|
188
|
+
const file = target.file(global);
|
|
149
189
|
const { value = {} } = readJson(file);
|
|
150
190
|
if (existsSync(file)) copyFileSync(file, `${file}.clario-backup`);
|
|
151
191
|
const settings = withoutOurHooks(value);
|
|
152
192
|
const hooks = { ...(settings.hooks ?? {}) };
|
|
153
|
-
const command =
|
|
154
|
-
|
|
155
|
-
|
|
193
|
+
const command = target.command();
|
|
194
|
+
const entry = target.flat ? { command, timeout: 10 } : { hooks: [{ type: "command", command, timeout: 10 }] };
|
|
195
|
+
for (const event of target.events) hooks[event] = [...(hooks[event] ?? []), entry];
|
|
196
|
+
writeJson(file, { ...(target.flat ? { version: 1 } : {}), ...settings, hooks });
|
|
197
|
+
return file;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Remove our hook from a file; true when there was one. */
|
|
201
|
+
function unregisterHooks(file) {
|
|
202
|
+
const { value } = readJson(file);
|
|
203
|
+
if (!value || !countOurHooks(value)) return false;
|
|
204
|
+
copyFileSync(file, `${file}.clario-backup`);
|
|
205
|
+
writeJson(file, withoutOurHooks(value));
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The project setup runs in: the git root around the current folder, or the folder itself. */
|
|
210
|
+
function projectRoot() {
|
|
211
|
+
const here = realpathSync(process.cwd());
|
|
212
|
+
for (let dir = here; ; dir = dirname(dir)) {
|
|
213
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
214
|
+
if (dirname(dir) === dir) return here;
|
|
215
|
+
}
|
|
156
216
|
}
|
|
157
217
|
|
|
158
218
|
function gitIgnored(file) {
|
|
@@ -164,6 +224,25 @@ function gitIgnored(file) {
|
|
|
164
224
|
}
|
|
165
225
|
}
|
|
166
226
|
|
|
227
|
+
/**
|
|
228
|
+
* --agent claude|codex|cursor|all, repeatable or comma separated. Left out: every agent on
|
|
229
|
+
* this computer (Claude Code when none is found), so one command sets up all of them.
|
|
230
|
+
*/
|
|
231
|
+
function parseAgents(args) {
|
|
232
|
+
const values = args
|
|
233
|
+
.flatMap((arg, i) => (arg === "--agent" ? [args[i + 1] ?? ""] : arg.startsWith("--agent=") ? [arg.slice("--agent=".length)] : []))
|
|
234
|
+
.flatMap((value) => value.split(","))
|
|
235
|
+
.map((value) => value.trim().toLowerCase())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
const aliases = { "claude-code": "claude", "cursor-agent": "cursor" };
|
|
238
|
+
const names = values.includes("all") ? Object.keys(AGENT_TARGETS) : values.map((value) => aliases[value] ?? value);
|
|
239
|
+
const unknown = names.find((name) => !(name in AGENT_TARGETS));
|
|
240
|
+
if (unknown !== undefined) fail(`Unknown agent "${unknown}". Use claude, codex, cursor or all.`);
|
|
241
|
+
if (names.length) return [...new Set(names)];
|
|
242
|
+
const found = Object.keys(AGENT_TARGETS).filter((name) => AGENT_TARGETS[name].installed());
|
|
243
|
+
return found.length ? found : ["claude"];
|
|
244
|
+
}
|
|
245
|
+
|
|
167
246
|
// ---- Commands -----------------------------------------------------------------------
|
|
168
247
|
|
|
169
248
|
async function setup(opts) {
|
|
@@ -172,22 +251,36 @@ async function setup(opts) {
|
|
|
172
251
|
const apiUrl = String(opts.apiUrl || (opts.key ? DEFAULT_API_URL : config.apiUrl) || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
173
252
|
const { key, days, reused } = opts.key ? await checkKey(opts.key, apiUrl) : savedKey(config, apiUrl);
|
|
174
253
|
|
|
175
|
-
// An old 7-day login token isn't needed any more; everything else (shareGit…) stays.
|
|
254
|
+
// An old 7-day login token isn't needed any more; everything else (shareGit, projects…) stays.
|
|
176
255
|
const { token: _loginToken, ...rest } = config;
|
|
177
|
-
|
|
256
|
+
const next = { ...rest, apiUrl, agentKey: key };
|
|
257
|
+
const root = projectRoot();
|
|
258
|
+
if (opts.agents.some((agent) => AGENT_TARGETS[agent].shared)) {
|
|
259
|
+
if (opts.global) next.everyProject = true;
|
|
260
|
+
else next.projects = [...new Set([...(Array.isArray(rest.projects) ? rest.projects : []), root])];
|
|
261
|
+
}
|
|
262
|
+
writeJson(CONFIG_PATH, next, { mode: 0o600 });
|
|
178
263
|
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
179
264
|
copyFileSync(HOOK_SOURCE, HOOK_PATH);
|
|
180
265
|
|
|
181
|
-
const file = settingsFile(opts.global);
|
|
182
|
-
registerHooks(file);
|
|
183
|
-
|
|
184
266
|
console.log(`${reused ? "Kept your agent key" : "Saved your agent key"} in ${CONFIG_PATH} (${expiryText(days)}).`);
|
|
185
267
|
if (reused && days < WARN_DAYS) console.log(`It runs out in ${Math.ceil(days)} days. ${GET_A_KEY}`);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
268
|
+
for (const agent of opts.agents) {
|
|
269
|
+
const target = AGENT_TARGETS[agent];
|
|
270
|
+
const file = registerHooks(target, opts.global);
|
|
271
|
+
const scope = opts.global ? "every project" : target.shared ? `${root} (and any other project you ran setup in)` : "this project only";
|
|
272
|
+
console.log(`${target.name}: hook registered in ${file}, sharing from ${scope}.`);
|
|
273
|
+
if (agent === "claude" && !opts.global && gitIgnored(file) === false) {
|
|
274
|
+
console.log(" Note: .claude/settings.local.json isn't git-ignored here. It holds no secrets, but you may want to ignore it.");
|
|
275
|
+
}
|
|
276
|
+
if (agent === "codex") console.log(" Codex asks before it runs a new hook: open Codex, run /hooks and trust the Clario hook.");
|
|
277
|
+
if (agent === "cursor") console.log(" If Cursor is open, restart it so it loads the hook.");
|
|
278
|
+
}
|
|
279
|
+
const missing = Object.keys(AGENT_TARGETS).filter((agent) => !opts.agents.includes(agent));
|
|
280
|
+
if (!opts.picked && missing.length) {
|
|
281
|
+
console.log(`Not found on this computer: ${missing.map((agent) => AGENT_TARGETS[agent].name).join(", ")}. Run setup again after installing one.`);
|
|
189
282
|
}
|
|
190
|
-
console.log("Open your Clario space and give
|
|
283
|
+
console.log("Open your Clario space and give your agent a task: your avatar's card shows what it's doing.");
|
|
191
284
|
}
|
|
192
285
|
|
|
193
286
|
function status() {
|
|
@@ -200,35 +293,54 @@ function status() {
|
|
|
200
293
|
: "Key: none (run setup)",
|
|
201
294
|
);
|
|
202
295
|
console.log(`Hook: ${existsSync(HOOK_PATH) ? HOOK_PATH : "not installed (run setup)"}`);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
296
|
+
const registered = (file) => (countOurHooks(readJson(file).value) ? `registered (${file})` : "not registered");
|
|
297
|
+
console.log(`Claude Code, this project: ${registered(AGENT_TARGETS.claude.file(false))}`);
|
|
298
|
+
console.log(`Claude Code, all projects: ${registered(AGENT_TARGETS.claude.file(true))}`);
|
|
299
|
+
console.log(`Codex: ${registered(AGENT_TARGETS.codex.file())}`);
|
|
300
|
+
console.log(`Cursor: ${registered(AGENT_TARGETS.cursor.file())}`);
|
|
301
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
302
|
+
console.log(`Codex and Cursor share from: ${config.everyProject ? "every project" : projects.length ? projects.join(", ") : "no project yet"}`);
|
|
208
303
|
}
|
|
209
304
|
|
|
210
305
|
function uninstall(opts) {
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
306
|
+
const { value: config } = readJson(CONFIG_PATH);
|
|
307
|
+
if (opts.global) {
|
|
308
|
+
// Stop everywhere: every agent's hook, and the list of shared projects.
|
|
309
|
+
for (const target of Object.values(AGENT_TARGETS)) {
|
|
310
|
+
const file = target.file(true);
|
|
311
|
+
if (unregisterHooks(file)) console.log(`Removed the ${target.name} hook from ${file}.`);
|
|
312
|
+
}
|
|
313
|
+
if (config) {
|
|
314
|
+
const { projects: _projects, everyProject: _every, ...rest } = config;
|
|
315
|
+
writeJson(CONFIG_PATH, rest, { mode: 0o600 });
|
|
316
|
+
}
|
|
317
|
+
console.log(`Nothing is shared any more. Your key stays in ${CONFIG_PATH}; delete that file to remove it too.`);
|
|
215
318
|
return;
|
|
216
319
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
console.log(`Removed the
|
|
320
|
+
// Stop in this project: its Claude Code hook, and Codex and Cursor reporting from it.
|
|
321
|
+
const file = AGENT_TARGETS.claude.file(false);
|
|
322
|
+
if (unregisterHooks(file)) console.log(`Removed the Claude Code hook from ${file}.`);
|
|
323
|
+
const root = projectRoot();
|
|
324
|
+
if (config && Array.isArray(config.projects) && config.projects.includes(root)) {
|
|
325
|
+
writeJson(CONFIG_PATH, { ...config, projects: config.projects.filter((p) => p !== root) }, { mode: 0o600 });
|
|
326
|
+
console.log(`Codex and Cursor no longer share from ${root}.`);
|
|
327
|
+
}
|
|
328
|
+
if (config?.everyProject) console.log("Codex and Cursor still share from every project (setup --global); run uninstall --global to stop that.");
|
|
329
|
+
console.log("This project isn't shared any more.");
|
|
220
330
|
}
|
|
221
331
|
|
|
222
|
-
const HELP = `clario-agent: show what your
|
|
332
|
+
const HELP = `clario-agent: show what your coding agent is doing on your Clario avatar
|
|
223
333
|
|
|
224
|
-
clario-agent setup --key KEY [--
|
|
225
|
-
|
|
226
|
-
clario-agent
|
|
334
|
+
clario-agent setup --key KEY [--agent claude|codex|cursor|all] [--global]
|
|
335
|
+
save your key and register the hook
|
|
336
|
+
clario-agent status show what is set up
|
|
337
|
+
clario-agent uninstall [--global] stop sharing from this project (or everywhere)
|
|
227
338
|
|
|
228
339
|
Get the setup command (with your key) from the Agent button in your Clario space.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
340
|
+
Setup registers every agent it finds on this computer; --agent picks them yourself
|
|
341
|
+
(comma separated or repeated).
|
|
342
|
+
Without --global only the project you run it in is shared. Without --key, setup reuses
|
|
343
|
+
the key you saved before. The server is ${DEFAULT_API_URL} unless you pass --api-url.`;
|
|
232
344
|
|
|
233
345
|
export async function run(argv) {
|
|
234
346
|
const [command = "help", ...rest] = argv;
|
|
@@ -236,8 +348,8 @@ export async function run(argv) {
|
|
|
236
348
|
const i = rest.indexOf(flag);
|
|
237
349
|
return i > -1 ? rest[i + 1] : undefined;
|
|
238
350
|
};
|
|
239
|
-
const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key") };
|
|
240
351
|
try {
|
|
352
|
+
const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key"), agents: parseAgents(rest), picked: rest.some((arg) => arg.startsWith("--agent")) };
|
|
241
353
|
if (command === "setup") await setup(opts);
|
|
242
354
|
else if (command === "status") status();
|
|
243
355
|
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.4.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"
|