clario-agent 0.1.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 CHANGED
@@ -1,70 +1,74 @@
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
- Make an account at [clario.games](https://www.clario.games) first, then run this inside the
11
- project you want to share:
11
+ 1. Open your space on [clario.games](https://www.clario.games) and click **Agent** in the
12
+ left menu. Pick your agent; it shows your setup command, with your key in it:
13
+ ```bash
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
17
+ ```
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.
20
+
21
+ That's it: the Agent button disappears once your agent has connected. Setup:
22
+
23
+ - **Checks the key** with the Clario server and saves it in `~/.clario/agent.json` (readable
24
+ only by you). It's your own key and it stays the same: the Agent button always shows the
25
+ same command for you. It can only post agent activity: it can't sign in, open a space or
26
+ change anything else. No password is ever typed in the terminal.
27
+ - **Installs the hook** at `~/.clario/hook.mjs`.
28
+ - **Registers it with your agent:**
29
+
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 |
35
+
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`.
12
47
 
13
48
  ```bash
14
- npx clario-agent setup
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
15
52
  ```
16
53
 
17
- It does three things:
54
+ It uses the live server at `https://api.clario.games`. Running Clario yourself? The Agent
55
+ button on your own site adds `--api-url` to the command for you.
18
56
 
19
- 1. **Saves an agent key** in `~/.clario/agent.json` (readable only by you). It asks for your
20
- Clario email and password once (sent only to the Clario server). The key lasts 180 days and
21
- can only post agent activity: it can't sign in, open a space or change anything else.
22
- 2. **Installs the hook** at `~/.clario/hook.mjs`.
23
- 3. **Registers it with Claude Code** for this project only, in `.claude/settings.local.json`.
24
- Add `--global` to use it in every project (`~/.claude/settings.json`) instead.
57
+ Used the older `viora-agent` package? Run `npx clario-agent setup` once: it reuses the key
58
+ from `~/.viora` and replaces the old hook. You can delete `~/.viora` afterwards.
25
59
 
26
- Then open your Clario space, give Claude a task in that project, and click your avatar.
27
- Teammates who do the same and join your space show up on their avatars too.
28
-
29
- It uses the live server at `https://api.clario.games`. Running Clario yourself? Add
30
- `--api-url http://localhost:3001` (or your own server's address); setup remembers it.
31
-
32
- Running `setup` again keeps a key that still has more than 14 days left, updates the hook
33
- and never registers it twice. Your other Claude Code settings and hooks are left as they
34
- are, and the previous file is saved next to it as `*.clario-backup`.
35
-
36
- ```bash
37
- npx clario-agent status # server, key expiry, where the hook is registered
38
- npx clario-agent uninstall # remove the hook from this project (add --global for all)
39
- ```
40
-
41
- Used the older `viora-agent` package? Run `setup` once: it reuses the key from `~/.viora` and
42
- replaces the old hook, so it never runs twice. You can delete `~/.viora` afterwards.
43
-
44
- From a checkout of this repo, `node tools/agent-hook/setup.mjs` does the same as `setup`.
45
- For scripts, `CLARIO_EMAIL` and `CLARIO_PASSWORD` are read instead of prompting.
46
-
47
- <details>
48
- <summary>Manual alternative: a 7-day login token</summary>
49
-
50
- Sign in, open DevTools → Console on your Clario site and run
51
- `copy(localStorage.getItem("metaverse:token"))`, then save it as `"token"` in
52
- `~/.clario/agent.json` next to `"apiUrl"`. It expires after 7 days. If you then run
53
- `setup`, it uses that token to create the agent key without asking for your password.
54
- </details>
55
-
56
- > An agent key can't be cancelled on its own before it expires. If one leaks, delete it
57
- > from `~/.clario/agent.json` and change the server's `JWT_SECRET` (this signs everyone out).
58
- > The server needs the `/api/v1/agent-key` endpoint from this repo's `apps/server`.
60
+ > Keep the setup command private: anyone with your key can post activity as your agent (and
61
+ > nothing else). If it leaks, delete `~/.clario/agent.json`; the server's owner can cancel
62
+ > every key by changing `JWT_SECRET` (this signs everyone out).
59
63
 
60
64
  ## How it behaves
61
65
 
62
- - While Claude runs tools (edits, bash commands…) your badge shows "cooking" with a short
63
- description of the current tool (e.g. `Claude is using Edit page.tsx` or
64
- `Claude is using Bash $ bun, git`)
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
65
69
  - Your agent card also shows the project folder, git branch and short commit the agent is
66
70
  working in
67
- - When Claude finishes, the badge clears automatically
71
+ - When the agent finishes, the badge clears automatically
68
72
  - If you're not inside a Clario space, updates are silently dropped
69
73
  - Updates are throttled (2s) so fast tool bursts don't spam the room
70
74
  - 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,21 +1,27 @@
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 [--api-url URL] [--global] agent 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
+ * npx clario-agent status what is set up
8
+ * npx clario-agent uninstall [--global] stop sharing from this project (or everywhere)
8
9
  *
9
- * setup saves a 180-day agent key in ~/.clario/agent.json (it can only post agent
10
- * activity), copies the hook to ~/.clario/hook.mjs and registers it in this project's
11
- * .claude/settings.local.json, or in ~/.claude/settings.json with --global.
10
+ * The key comes from the website: the Agent button in a Clario space shows the whole
11
+ * setup command. setup checks the key with the server, saves it in ~/.clario/agent.json
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.
12
19
  */
13
20
 
14
21
  import { execFileSync } from "node:child_process";
15
22
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
16
23
  import { homedir } from "node:os";
17
24
  import { dirname, join } from "node:path";
18
- import { createInterface } from "node:readline";
19
25
  import { fileURLToPath } from "node:url";
20
26
 
21
27
  const DIR = join(homedir(), ".clario");
@@ -26,14 +32,43 @@ const HOOK_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "clario-agent-
26
32
  const OLD_CONFIG_PATH = join(homedir(), ".viora", "agent.json");
27
33
  // The live Clario server; pass --api-url http://localhost:3001 for a local one.
28
34
  const DEFAULT_API_URL = "https://api.clario.games";
29
- const HOOK_EVENTS = ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
30
- const RENEW_DAYS = 14; // setup makes a new key when the saved one has less time left than this
35
+ const WARN_DAYS = 14; // setup reminds you to get a new key when the saved one has less time left than this
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.";
31
37
 
32
38
  class CliError extends Error {}
33
39
  const fail = (message) => {
34
40
  throw new CliError(message);
35
41
  };
36
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
+
37
72
  /** { value } for a JSON file, {} when it doesn't exist; fails on invalid JSON instead of overwriting it. */
38
73
  function readJson(file) {
39
74
  let text;
@@ -57,16 +92,26 @@ function writeJson(file, value, { mode } = {}) {
57
92
  if (mode) chmodSync(file, mode);
58
93
  }
59
94
 
60
- /** Days until a JWT expires, or null when it can't be read. */
61
- function daysLeft(token) {
95
+ /** The readable part of a key (a JWT), or null. The server checks the signature. */
96
+ function keyPayload(token) {
62
97
  try {
63
- const { exp } = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
64
- return typeof exp === "number" ? (exp * 1000 - Date.now()) / 86_400_000 : null;
98
+ const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
99
+ return payload && typeof payload === "object" ? payload : null;
65
100
  } catch {
66
101
  return null;
67
102
  }
68
103
  }
69
104
 
105
+ /** Days until a key expires: Infinity for one that doesn't (today's keys), null when it can't be read. */
106
+ function daysLeft(token) {
107
+ const payload = keyPayload(token);
108
+ if (!payload) return null;
109
+ return typeof payload.exp === "number" ? (payload.exp * 1000 - Date.now()) / 86_400_000 : Infinity;
110
+ }
111
+
112
+ const expiryText = (days) =>
113
+ Number.isFinite(days) ? `works until ${new Date(Date.now() + days * 86_400_000).toDateString()}` : "it doesn't expire";
114
+
70
115
  async function post(apiUrl, path, body, token) {
71
116
  let res;
72
117
  try {
@@ -81,50 +126,28 @@ async function post(apiUrl, path, body, token) {
81
126
  return { status: res.status, json: await res.json().catch(() => null) };
82
127
  }
83
128
 
84
- /** Email and password from the terminal (the password isn't echoed), or CLARIO_EMAIL / CLARIO_PASSWORD. */
85
- async function askCredentials() {
86
- if (process.env.CLARIO_EMAIL && process.env.CLARIO_PASSWORD) {
87
- return { email: process.env.CLARIO_EMAIL, password: process.env.CLARIO_PASSWORD };
88
- }
89
- if (!process.stdin.isTTY) fail("No terminal to ask for your email and password; set CLARIO_EMAIL and CLARIO_PASSWORD.");
90
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
91
- const ask = (text) => new Promise((resolve) => rl.question(text, resolve));
92
- const email = (await ask("Clario email: ")).trim();
93
- const answer = ask("Password: ");
94
- const write = rl._writeToOutput;
95
- rl._writeToOutput = () => {}; // hide the password as it's typed
96
- const password = await answer;
97
- rl._writeToOutput = write;
98
- rl.close();
99
- process.stdout.write("\n");
100
- return { email, password };
129
+ /** A key from the website, checked with the server before it's saved. */
130
+ async function checkKey(key, apiUrl) {
131
+ const payload = keyPayload(key);
132
+ if (!payload || typeof payload.userId !== "string") fail(`That isn't a Clario agent key. ${GET_A_KEY}`);
133
+ if (payload.scope !== "agent") fail(`That's a login token, not an agent key. ${GET_A_KEY}`);
134
+ const days = daysLeft(key);
135
+ if (days === null || days <= 0) fail(`That key has expired. ${GET_A_KEY}`);
136
+ // A "done" update changes nothing anyone sees, and tells the server the hook is set up.
137
+ const res = await post(apiUrl, "/agent-activity", { text: "", state: "done" }, key);
138
+ if (res.status === 403) fail(`The server at ${apiUrl} doesn't accept this key. ${GET_A_KEY}`);
139
+ if (res.status !== 200) fail(`Could not check the key with ${apiUrl} (HTTP ${res.status}).`);
140
+ return { key, days, reused: false };
101
141
  }
102
142
 
103
- /** A usable agent key: the saved one if it has time left, else a new one from the server. */
104
- async function ensureAgentKey(config, apiUrl) {
105
- const left = daysLeft(config.agentKey);
106
- if (config.agentKey && config.apiUrl === apiUrl && left !== null && left > RENEW_DAYS) {
107
- return { key: config.agentKey, days: left, reused: true };
108
- }
109
- // A login token from the manual setup (README) is enough to create the key.
110
- let res = config.token ? await post(apiUrl, "/agent-key", {}, config.token) : null;
111
- if (!res || res.status !== 200) {
112
- if (res?.status !== 404) {
113
- const { email, password } = await askCredentials();
114
- const signin = await post(apiUrl, "/signin", { email, password });
115
- if (!signin.json?.token) fail("Sign-in failed. Check your email and password.");
116
- res = await post(apiUrl, "/agent-key", {}, signin.json.token);
117
- }
118
- }
119
- if (res.status === 404) fail(`The server at ${apiUrl} has no /api/v1/agent-key yet. Restart it so it runs the latest code.`);
120
- if (res.status !== 200 || !res.json?.key) fail(`Could not create an agent key (HTTP ${res.status}).`);
121
- return { key: res.json.key, days: daysLeft(res.json.key), reused: false };
143
+ /** The key from an earlier setup, when it still works for this server (e.g. to add another project). */
144
+ function savedKey(config, apiUrl) {
145
+ const days = daysLeft(config.agentKey);
146
+ if (config.agentKey && config.apiUrl === apiUrl && days !== null && days > 0) return { key: config.agentKey, days, reused: true };
147
+ fail(`No agent key yet. ${GET_A_KEY}`);
122
148
  }
123
149
 
124
- // ---- Claude Code settings -------------------------------------------------------
125
-
126
- const settingsFile = (global) =>
127
- global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
150
+ // ---- Hook files ---------------------------------------------------------------------
128
151
 
129
152
  /** Our hooks from any version (also from before the rename): this package's copy, or the repo script. */
130
153
  const isOurHook = (hook) =>
@@ -140,6 +163,7 @@ function withoutOurHooks(settings) {
140
163
  continue;
141
164
  }
142
165
  const kept = groups
166
+ .filter((group) => !isOurHook(group)) // Cursor lists commands directly
143
167
  .map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !isOurHook(h)) } : group))
144
168
  .filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
145
169
  if (kept.length) hooks[event] = kept;
@@ -149,17 +173,41 @@ function withoutOurHooks(settings) {
149
173
  }
150
174
 
151
175
  const countOurHooks = (settings) =>
152
- Object.values(settings?.hooks ?? {}).flat().flatMap((group) => group?.hooks ?? []).filter(isOurHook).length;
176
+ Object.values(settings?.hooks ?? {})
177
+ .flat()
178
+ .flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [group]))
179
+ .filter(isOurHook).length;
153
180
 
154
181
  /** Add the hook for every event, replacing older entries of ours so it never fires twice. */
155
- function registerHooks(file) {
182
+ function registerHooks(target, global) {
183
+ const file = target.file(global);
156
184
  const { value = {} } = readJson(file);
157
185
  if (existsSync(file)) copyFileSync(file, `${file}.clario-backup`);
158
186
  const settings = withoutOurHooks(value);
159
187
  const hooks = { ...(settings.hooks ?? {}) };
160
- const command = `node "${HOOK_PATH}"`;
161
- for (const event of HOOK_EVENTS) hooks[event] = [...(hooks[event] ?? []), { hooks: [{ type: "command", command, timeout: 10 }] }];
162
- writeJson(file, { ...settings, hooks });
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
+ }
163
211
  }
164
212
 
165
213
  function gitIgnored(file) {
@@ -171,29 +219,54 @@ function gitIgnored(file) {
171
219
  }
172
220
  }
173
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
+
174
236
  // ---- Commands -----------------------------------------------------------------------
175
237
 
176
238
  async function setup(opts) {
177
239
  const { value: config = readJson(OLD_CONFIG_PATH).value ?? {} } = readJson(CONFIG_PATH);
178
- const apiUrl = String(opts.apiUrl || config.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "");
179
- const { key, days, reused } = await ensureAgentKey(config, apiUrl);
240
+ // A new key comes from the live site unless --api-url says otherwise; re-running setup keeps the saved server.
241
+ const apiUrl = String(opts.apiUrl || (opts.key ? DEFAULT_API_URL : config.apiUrl) || DEFAULT_API_URL).replace(/\/+$/, "");
242
+ const { key, days, reused } = opts.key ? await checkKey(opts.key, apiUrl) : savedKey(config, apiUrl);
180
243
 
181
- // The 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.
182
245
  const { token: _loginToken, ...rest } = config;
183
- writeJson(CONFIG_PATH, { ...rest, apiUrl, agentKey: key }, { mode: 0o600 });
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 });
184
253
  mkdirSync(DIR, { recursive: true, mode: 0o700 });
185
254
  copyFileSync(HOOK_SOURCE, HOOK_PATH);
186
255
 
187
- const file = settingsFile(opts.global);
188
- registerHooks(file);
189
-
190
- const until = new Date(Date.now() + days * 86_400_000).toDateString();
191
- console.log(`${reused ? "Kept your agent key" : "Saved a new agent key"} in ${CONFIG_PATH} (works until ${until}).`);
192
- console.log(`Hook registered in ${file}${opts.global ? " for every project" : " for this project only"}.`);
193
- if (!opts.global && gitIgnored(file) === false) {
194
- console.log("Note: .claude/settings.local.json isn't git-ignored here. It holds no secrets, but you may want to ignore it.");
256
+ console.log(`${reused ? "Kept your agent key" : "Saved your agent key"} in ${CONFIG_PATH} (${expiryText(days)}).`);
257
+ if (reused && days < WARN_DAYS) console.log(`It runs out in ${Math.ceil(days)} days. ${GET_A_KEY}`);
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.");
195
268
  }
196
- console.log("Open your Clario space and give Claude a task: your avatar's card shows what it's doing.");
269
+ console.log("Open your Clario space and give your agent a task: your avatar's card shows what it's doing.");
197
270
  }
198
271
 
199
272
  function status() {
@@ -202,43 +275,66 @@ function status() {
202
275
  console.log(`Server: ${config.apiUrl ?? "not set"}`);
203
276
  console.log(
204
277
  config.agentKey || config.token
205
- ? `Key: ${config.agentKey ? "agent key" : "7-day login token"}, ${left === null ? "unreadable" : left > 0 ? `${Math.floor(left)} days left` : "EXPIRED (run setup)"}`
278
+ ? `Key: ${config.agentKey ? "agent key" : "7-day login token"}, ${left === null ? "unreadable" : left === Infinity ? "doesn't expire" : left > 0 ? `${Math.floor(left)} days left` : "EXPIRED (run setup)"}`
206
279
  : "Key: none (run setup)",
207
280
  );
208
281
  console.log(`Hook: ${existsSync(HOOK_PATH) ? HOOK_PATH : "not installed (run setup)"}`);
209
- for (const global of [false, true]) {
210
- const file = settingsFile(global);
211
- const n = countOurHooks(readJson(file).value);
212
- console.log(`${global ? "All projects" : "This project"}: ${n ? `registered (${file})` : "not registered"}`);
213
- }
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"}`);
214
289
  }
215
290
 
216
291
  function uninstall(opts) {
217
- const file = settingsFile(opts.global);
218
- const { value } = readJson(file);
219
- if (!value || !countOurHooks(value)) {
220
- console.log(`No Clario hook in ${file}.`);
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.`);
221
304
  return;
222
305
  }
223
- copyFileSync(file, `${file}.clario-backup`);
224
- writeJson(file, withoutOurHooks(value));
225
- console.log(`Removed the Clario hook from ${file}. Your key stays in ${CONFIG_PATH}; delete that file to remove it too.`);
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.");
226
316
  }
227
317
 
228
- const HELP = `clario-agent: show what your Claude Code agent is doing on your Clario avatar
318
+ const HELP = `clario-agent: show what your coding agent is doing on your Clario avatar
229
319
 
230
- clario-agent setup [--api-url URL] [--global] save an agent key and register the hook
231
- clario-agent status show what is set up
232
- clario-agent uninstall [--global] remove the hook
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)
233
324
 
234
- Without --global the hook is registered only for the project you run it in.
235
- --api-url defaults to the saved server, then ${DEFAULT_API_URL}.`;
325
+ Get the setup command (with your key) from the Agent button in your Clario space.
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.`;
236
329
 
237
330
  export async function run(argv) {
238
331
  const [command = "help", ...rest] = argv;
239
- const i = rest.indexOf("--api-url");
240
- const opts = { global: rest.includes("--global"), apiUrl: i > -1 ? rest[i + 1] : undefined };
332
+ const value = (flag) => {
333
+ const i = rest.indexOf(flag);
334
+ return i > -1 ? rest[i + 1] : undefined;
335
+ };
241
336
  try {
337
+ const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key"), agents: parseAgents(rest) };
242
338
  if (command === "setup") await setup(opts);
243
339
  else if (command === "status") status();
244
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.1.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"