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 CHANGED
@@ -1,18 +1,21 @@
1
1
  # clario-agent: show your AI agent's activity in Clario 🧑‍🍳
2
2
 
3
- A Claude Code hook that shows what your agent is working on to everyone in your current
4
- Clario space. Others see a pulsing badge above your avatar while the agent works, and
5
- clicking your avatar shows your agent card: what Claude is doing plus the project, branch
6
- and commit (e.g. `graphite · feat/missions · a1b2c3d`). The badge clears when Claude finishes.
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 a key made for you:
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 Claude Code** for this project only, in `.claude/settings.local.json`.
25
- Add `--global` to use it in every project (`~/.claude/settings.json`) instead.
27
+ - **Registers it with your agent:**
26
28
 
27
- Then give Claude a task in that project and click your avatar. Teammates who do the same and
28
- join your space show up on their avatars too.
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
- To add another project later, run `npx clario-agent setup` there: it reuses your saved key.
31
- Setup never registers the hook twice, leaves your other Claude Code settings and hooks as
32
- they are, and saves the previous file next to it as `*.clario-backup`.
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 # server, key expiry, where the hook is registered
36
- npx clario-agent uninstall # remove the hook from this project (add --global for all)
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 Claude runs tools (edits, bash commands…) your badge shows "cooking" with a short
52
- description of the current tool (e.g. `Claude is using Edit page.tsx` or
53
- `Claude is using Bash $ bun, git`)
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 Claude finishes, the badge clears automatically
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
@@ -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
- * Reads Claude Code hook events from stdin and POSTs activity to
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) plus the project folder, git branch and short
14
- * commit. `"shareGit": false` in ~/.clario/agent.json (or CLARIO_SHARE_GIT=0) keeps
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.mjs, lasts 180 days) wins over a pasted 7-day login token.
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(event, config) {
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
- function describe(event) {
156
- const tool = typeof event.tool_name === "string" ? event.tool_name : "";
157
- if (event.hook_event_name === "Stop") return { state: "done", text: "" };
158
- if (event.hook_event_name === "SessionEnd") return { state: "done", text: "" };
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
- const toolLabel = tool.replace(/^(mcp__|LambdaResponse)/, "").slice(0, 40);
161
- let detail = "";
162
- if (tool === "Edit" || tool === "Write" || tool === "MultiEdit") {
163
- const filePath = event?.tool_input?.file_path;
164
- if (typeof filePath === "string") {
165
- const parts = filePath.split("/");
166
- detail = ` ${parts[parts.length - 1]}`;
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
- if (tool === "Bash" && typeof event?.tool_input?.command === "string") {
170
- const names = programNames(event.tool_input.command);
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: `Claude is using ${toolLabel || "a tool"}${detail}` };
250
+ return { state: "cooking", text: `${name} is using ${label}${detail}` };
174
251
  }
175
252
 
176
253
  async function main() {
177
- const config = loadConfig();
178
- if (!config) process.exit(0);
179
-
254
+ const agent = agentFromArgs(process.argv.slice(2));
180
255
  const raw = await readStdin();
181
- if (!raw.trim()) process.exit(0);
182
-
183
- let event;
256
+ let event = {};
184
257
  try {
185
- event = JSON.parse(raw);
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
- const { state, text } = describe(event);
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(event, config) } : { text, state }),
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 agent is doing on your Clario avatar.
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] key + hook, once
6
- * npx clario-agent status what is set up
7
- * npx clario-agent uninstall [--global] remove the hook
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 to
12
- * ~/.clario/hook.mjs and registers it in this project's .claude/settings.local.json,
13
- * or in ~/.claude/settings.json with --global.
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
- // ---- Claude Code settings -------------------------------------------------------
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 ?? {}).flat().flatMap((group) => group?.hooks ?? []).filter(isOurHook).length;
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(file) {
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 = `node "${HOOK_PATH}"`;
154
- for (const event of HOOK_EVENTS) hooks[event] = [...(hooks[event] ?? []), { hooks: [{ type: "command", command, timeout: 10 }] }];
155
- writeJson(file, { ...settings, hooks });
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
- writeJson(CONFIG_PATH, { ...rest, apiUrl, agentKey: key }, { mode: 0o600 });
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
- console.log(`Hook registered in ${file}${opts.global ? " for every project" : " for this project only"}.`);
187
- if (!opts.global && gitIgnored(file) === false) {
188
- console.log("Note: .claude/settings.local.json isn't git-ignored here. It holds no secrets, but you may want to ignore it.");
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 Claude a task: your avatar's card shows what it's doing.");
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
- for (const global of [false, true]) {
204
- const file = settingsFile(global);
205
- const n = countOurHooks(readJson(file).value);
206
- console.log(`${global ? "All projects" : "This project"}: ${n ? `registered (${file})` : "not registered"}`);
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 file = settingsFile(opts.global);
212
- const { value } = readJson(file);
213
- if (!value || !countOurHooks(value)) {
214
- console.log(`No Clario hook in ${file}.`);
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
- copyFileSync(file, `${file}.clario-backup`);
218
- writeJson(file, withoutOurHooks(value));
219
- console.log(`Removed the Clario hook from ${file}. Your key stays in ${CONFIG_PATH}; delete that file to remove it too.`);
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 Claude Code agent is doing on your Clario avatar
332
+ const HELP = `clario-agent: show what your coding agent is doing on your Clario avatar
223
333
 
224
- clario-agent setup --key KEY [--api-url URL] [--global] save your key and register the hook
225
- clario-agent status show what is set up
226
- clario-agent uninstall [--global] remove the hook
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
- Without --global the hook is registered only for the project you run it in.
230
- Without --key, setup reuses the key you saved before. The server is ${DEFAULT_API_URL}
231
- unless you pass --api-url.`;
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.2.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"