clario-agent 0.0.0-stage → 0.2.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 +71 -2
- package/clario-agent-hook.mjs +227 -0
- package/cli.mjs +255 -0
- package/package.json +29 -4
- package/setup.mjs +6 -0
package/README.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
-
#
|
|
1
|
+
# clario-agent: show your AI agent's activity in Clario 🧑🍳
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
7
|
+
|
|
8
|
+
## Setup (one command)
|
|
9
|
+
|
|
10
|
+
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
|
+
```bash
|
|
13
|
+
npx clario-agent setup --key YOUR_KEY
|
|
14
|
+
```
|
|
15
|
+
2. Copy it and run it in the terminal, inside the project you want to share.
|
|
16
|
+
|
|
17
|
+
That's it: the Agent button disappears once your agent has connected. Setup:
|
|
18
|
+
|
|
19
|
+
- **Checks the key** with the Clario server and saves it in `~/.clario/agent.json` (readable
|
|
20
|
+
only by you). It's your own key and it stays the same: the Agent button always shows the
|
|
21
|
+
same command for you. It can only post agent activity: it can't sign in, open a space or
|
|
22
|
+
change anything else. No password is ever typed in the terminal.
|
|
23
|
+
- **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.
|
|
26
|
+
|
|
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
|
+
|
|
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`.
|
|
33
|
+
|
|
34
|
+
```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)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It uses the live server at `https://api.clario.games`. Running Clario yourself? The Agent
|
|
40
|
+
button on your own site adds `--api-url` to the command for you.
|
|
41
|
+
|
|
42
|
+
Used the older `viora-agent` package? Run `npx clario-agent setup` once: it reuses the key
|
|
43
|
+
from `~/.viora` and replaces the old hook. You can delete `~/.viora` afterwards.
|
|
44
|
+
|
|
45
|
+
> Keep the setup command private: anyone with your key can post activity as your agent (and
|
|
46
|
+
> nothing else). If it leaks, delete `~/.clario/agent.json`; the server's owner can cancel
|
|
47
|
+
> every key by changing `JWT_SECRET` (this signs everyone out).
|
|
48
|
+
|
|
49
|
+
## How it behaves
|
|
50
|
+
|
|
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`)
|
|
54
|
+
- Your agent card also shows the project folder, git branch and short commit the agent is
|
|
55
|
+
working in
|
|
56
|
+
- When Claude finishes, the badge clears automatically
|
|
57
|
+
- If you're not inside a Clario space, updates are silently dropped
|
|
58
|
+
- Updates are throttled (2s) so fast tool bursts don't spam the room
|
|
59
|
+
- The hook never blocks or fails your agent session: all errors are swallowed
|
|
60
|
+
|
|
61
|
+
## Privacy notes
|
|
62
|
+
|
|
63
|
+
Everyone in your space sees what is sent. That is the tool name, the edited file's name,
|
|
64
|
+
the names of the programs a shell command runs (`bun`, `git`; never its arguments, which
|
|
65
|
+
can hold tokens or paths), and the project folder, branch and short commit. Never file
|
|
66
|
+
contents, prompts or code.
|
|
67
|
+
|
|
68
|
+
To keep project, branch and commit on your machine, add `"shareGit": false` to
|
|
69
|
+
`~/.clario/agent.json` (or set `CLARIO_SHARE_GIT=0`). Run `npx clario-agent uninstall` to stop
|
|
70
|
+
broadcasting from a project.
|
|
71
|
+
|
|
72
|
+
Requires Node.js 18 or newer. MIT licensed.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Clario agent hook for Claude Code
|
|
4
|
+
*
|
|
5
|
+
* Broadcasts what your AI agent is doing to everyone in your current Clario space.
|
|
6
|
+
* Setup guide: tools/agent-hook/README.md
|
|
7
|
+
*
|
|
8
|
+
* Reads Claude Code hook events from stdin and POSTs activity to
|
|
9
|
+
* POST /api/v1/agent-activity on your Clario server. Exits 0 silently on any
|
|
10
|
+
* failure so it never blocks your agent session.
|
|
11
|
+
*
|
|
12
|
+
* 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.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
21
|
+
|
|
22
|
+
const CONFIG_PATH = join(homedir(), ".clario", "agent.json");
|
|
23
|
+
const THROTTLE_MS = 2000;
|
|
24
|
+
const STATE_PATH = join(homedir(), ".clario", ".agent-hook-state");
|
|
25
|
+
|
|
26
|
+
function readStdin() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let raw = "";
|
|
29
|
+
process.stdin.setEncoding("utf8");
|
|
30
|
+
process.stdin.on("data", (chunk) => (raw += chunk));
|
|
31
|
+
process.stdin.on("end", () => resolve(raw));
|
|
32
|
+
setTimeout(() => resolve(raw), 1500);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadConfig() {
|
|
37
|
+
try {
|
|
38
|
+
const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
39
|
+
// agentKey (setup.mjs, lasts 180 days) wins over a pasted 7-day login token.
|
|
40
|
+
const credential = config?.agentKey || config?.token;
|
|
41
|
+
if (credential && config?.apiUrl) return { ...config, credential };
|
|
42
|
+
} catch {
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lastSentAt() {
|
|
48
|
+
try {
|
|
49
|
+
return Number(readFileSync(STATE_PATH, "utf8")) || 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Git: project, branch and commit, read from .git directly (no git CLI).
|
|
56
|
+
|
|
57
|
+
/** Walk up from `start` to the first directory containing `.git` (a dir, or a file for worktrees). */
|
|
58
|
+
function findGitRoot(start) {
|
|
59
|
+
let dir = resolve(start);
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
62
|
+
const parent = dirname(dir);
|
|
63
|
+
if (parent === dir) return null;
|
|
64
|
+
dir = parent;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The repository's git dir. Throws if `.git` is missing or unreadable. */
|
|
69
|
+
function gitDirOf(gitRoot) {
|
|
70
|
+
const gitDir = join(gitRoot, ".git");
|
|
71
|
+
if (!statSync(gitDir).isFile()) return gitDir;
|
|
72
|
+
// Worktrees and submodules: ".git" is a file containing "gitdir: <path>".
|
|
73
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(gitDir, "utf8"));
|
|
74
|
+
return m ? resolve(gitRoot, m[1].trim()) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A full commit id: 40 hex characters, or 64 in SHA-256 repositories. */
|
|
78
|
+
const COMMIT_RE = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/;
|
|
79
|
+
|
|
80
|
+
/** Branch and commit from HEAD. A detached HEAD has no branch; a repo with no commits has no commit. */
|
|
81
|
+
function readHead(gitRoot) {
|
|
82
|
+
let gitDir;
|
|
83
|
+
let head;
|
|
84
|
+
try {
|
|
85
|
+
gitDir = gitDirOf(gitRoot);
|
|
86
|
+
head = gitDir && readFileSync(join(gitDir, "HEAD"), "utf8").trim();
|
|
87
|
+
} catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
if (!head) return {};
|
|
91
|
+
if (COMMIT_RE.test(head)) return { commit: head };
|
|
92
|
+
const ref = /^ref:\s*(refs\/heads\/(.+))$/.exec(head);
|
|
93
|
+
return ref ? { branch: ref[2], commit: readRef(gitDir, ref[1]) } : {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A branch's commit: loose ref first, then packed-refs. Linked worktrees keep both in the main git dir. */
|
|
97
|
+
function readRef(gitDir, name) {
|
|
98
|
+
let commonDir = gitDir;
|
|
99
|
+
try {
|
|
100
|
+
commonDir = resolve(gitDir, readFileSync(join(gitDir, "commondir"), "utf8").trim());
|
|
101
|
+
} catch {
|
|
102
|
+
// Not a linked worktree.
|
|
103
|
+
}
|
|
104
|
+
let id;
|
|
105
|
+
try {
|
|
106
|
+
id = readFileSync(join(commonDir, name), "utf8").trim();
|
|
107
|
+
} catch {
|
|
108
|
+
try {
|
|
109
|
+
for (const line of readFileSync(join(commonDir, "packed-refs"), "utf8").split("\n")) {
|
|
110
|
+
const [sha, ref] = line.trim().split(" ");
|
|
111
|
+
if (ref === name) id = sha;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// No packed-refs either: a repo with no commits yet.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return id && COMMIT_RE.test(id) ? id : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Project folder, branch and short commit for the agent card; nothing when sharing is off. */
|
|
121
|
+
function gitInfo(event, config) {
|
|
122
|
+
const env = process.env.CLARIO_SHARE_GIT;
|
|
123
|
+
if (env !== undefined ? env === "0" : config.shareGit === false) return {};
|
|
124
|
+
const cwd = typeof event.cwd === "string" && event.cwd ? event.cwd : process.cwd();
|
|
125
|
+
const root = findGitRoot(cwd);
|
|
126
|
+
if (!root) return { project: basename(resolve(cwd)) };
|
|
127
|
+
const { branch, commit } = readHead(root);
|
|
128
|
+
return { project: basename(root), branch, commit: commit?.slice(0, 7) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const WRAPPERS = new Set(["sudo", "env", "time", "nohup", "exec", "command"]);
|
|
132
|
+
const NOT_PROGRAMS = new Set(["cd", "export", "source", ".", "set", "unset"]);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The programs a shell command runs ("bun", "git"), never its arguments: those can
|
|
136
|
+
* hold tokens, passwords or private paths, and the status is shown to the whole space.
|
|
137
|
+
*/
|
|
138
|
+
function programNames(command) {
|
|
139
|
+
const names = [];
|
|
140
|
+
// First line only (skips heredoc bodies), and quoted strings removed, so a `;` or `|`
|
|
141
|
+
// inside quotes can't turn quoted text into a "program".
|
|
142
|
+
const line = command.split("\n")[0].replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, "''");
|
|
143
|
+
for (const segment of line.split(/&&|\|\||[;|]/)) {
|
|
144
|
+
const words = segment.trim().split(/\s+/);
|
|
145
|
+
let i = 0;
|
|
146
|
+
// Skip leading VAR=value, wrappers like sudo/env and their flags.
|
|
147
|
+
while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || WRAPPERS.has(words[i]) || words[i].startsWith("-"))) i++;
|
|
148
|
+
if (!words[i] || NOT_PROGRAMS.has(words[i])) continue;
|
|
149
|
+
const name = basename(words[i]);
|
|
150
|
+
if (/^[A-Za-z0-9_.][A-Za-z0-9._+-]{0,23}$/.test(name) && !names.includes(name)) names.push(name);
|
|
151
|
+
}
|
|
152
|
+
return names.slice(0, 3);
|
|
153
|
+
}
|
|
154
|
+
|
|
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: "" };
|
|
159
|
+
|
|
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
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (tool === "Bash" && typeof event?.tool_input?.command === "string") {
|
|
170
|
+
const names = programNames(event.tool_input.command);
|
|
171
|
+
if (names.length) detail = ` $ ${names.join(", ")}`;
|
|
172
|
+
}
|
|
173
|
+
return { state: "cooking", text: `Claude is using ${toolLabel || "a tool"}${detail}` };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function main() {
|
|
177
|
+
const config = loadConfig();
|
|
178
|
+
if (!config) process.exit(0);
|
|
179
|
+
|
|
180
|
+
const raw = await readStdin();
|
|
181
|
+
if (!raw.trim()) process.exit(0);
|
|
182
|
+
|
|
183
|
+
let event;
|
|
184
|
+
try {
|
|
185
|
+
event = JSON.parse(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
process.exit(0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { state, text } = describe(event);
|
|
191
|
+
if (state === "cooking" && !text) process.exit(0);
|
|
192
|
+
|
|
193
|
+
// Client-side throttle: hook events fire per tool use and can be chatty.
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
if (state === "cooking" && now - lastSentAt() < THROTTLE_MS) process.exit(0);
|
|
196
|
+
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timer = setTimeout(() => controller.abort(), 2500);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await fetch(`${config.apiUrl.replace(/\/$/, "")}/api/v1/agent-activity`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: {
|
|
204
|
+
"Content-Type": "application/json",
|
|
205
|
+
Authorization: `Bearer ${config.credential}`,
|
|
206
|
+
},
|
|
207
|
+
body: JSON.stringify(state === "cooking" ? { text, state, ...gitInfo(event, config) } : { text, state }),
|
|
208
|
+
signal: controller.signal,
|
|
209
|
+
});
|
|
210
|
+
if (state === "cooking") {
|
|
211
|
+
const { writeFileSync, mkdirSync } = await import("node:fs");
|
|
212
|
+
try {
|
|
213
|
+
mkdirSync(join(homedir(), ".clario"), { recursive: true });
|
|
214
|
+
writeFileSync(STATE_PATH, String(now));
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Never let analytics failures disturb the agent session.
|
|
220
|
+
} finally {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main();
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* clario-agent: show what your Claude Code agent is doing on your Clario avatar.
|
|
4
|
+
*
|
|
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
|
|
8
|
+
*
|
|
9
|
+
* The key comes from the website: the Agent button in a Clario space shows the whole
|
|
10
|
+
* 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.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const DIR = join(homedir(), ".clario");
|
|
23
|
+
const CONFIG_PATH = join(DIR, "agent.json");
|
|
24
|
+
const HOOK_PATH = join(DIR, "hook.mjs");
|
|
25
|
+
const HOOK_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "clario-agent-hook.mjs");
|
|
26
|
+
// Where this package kept its key before it was renamed: setup reuses that key instead of asking again.
|
|
27
|
+
const OLD_CONFIG_PATH = join(homedir(), ".viora", "agent.json");
|
|
28
|
+
// The live Clario server; pass --api-url http://localhost:3001 for a local one.
|
|
29
|
+
const DEFAULT_API_URL = "https://api.clario.games";
|
|
30
|
+
const HOOK_EVENTS = ["PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
|
|
31
|
+
const WARN_DAYS = 14; // setup reminds you to get a new key when the saved one has less time left than this
|
|
32
|
+
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
|
+
|
|
34
|
+
class CliError extends Error {}
|
|
35
|
+
const fail = (message) => {
|
|
36
|
+
throw new CliError(message);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** { value } for a JSON file, {} when it doesn't exist; fails on invalid JSON instead of overwriting it. */
|
|
40
|
+
function readJson(file) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(file, "utf8");
|
|
44
|
+
} catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return { value: JSON.parse(text) };
|
|
49
|
+
} catch {
|
|
50
|
+
fail(`${file} is not valid JSON. Fix or remove it, then run this again.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeJson(file, value, { mode } = {}) {
|
|
55
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
56
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
57
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode ? { mode } : {});
|
|
58
|
+
renameSync(tmp, file);
|
|
59
|
+
if (mode) chmodSync(file, mode);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The readable part of a key (a JWT), or null. The server checks the signature. */
|
|
63
|
+
function keyPayload(token) {
|
|
64
|
+
try {
|
|
65
|
+
const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
|
|
66
|
+
return payload && typeof payload === "object" ? payload : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Days until a key expires: Infinity for one that doesn't (today's keys), null when it can't be read. */
|
|
73
|
+
function daysLeft(token) {
|
|
74
|
+
const payload = keyPayload(token);
|
|
75
|
+
if (!payload) return null;
|
|
76
|
+
return typeof payload.exp === "number" ? (payload.exp * 1000 - Date.now()) / 86_400_000 : Infinity;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const expiryText = (days) =>
|
|
80
|
+
Number.isFinite(days) ? `works until ${new Date(Date.now() + days * 86_400_000).toDateString()}` : "it doesn't expire";
|
|
81
|
+
|
|
82
|
+
async function post(apiUrl, path, body, token) {
|
|
83
|
+
let res;
|
|
84
|
+
try {
|
|
85
|
+
res = await fetch(`${apiUrl}/api/v1${path}`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
fail(`Can't reach the Clario server at ${apiUrl}. Start it, or pass --api-url, then run this again.`);
|
|
92
|
+
}
|
|
93
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A key from the website, checked with the server before it's saved. */
|
|
97
|
+
async function checkKey(key, apiUrl) {
|
|
98
|
+
const payload = keyPayload(key);
|
|
99
|
+
if (!payload || typeof payload.userId !== "string") fail(`That isn't a Clario agent key. ${GET_A_KEY}`);
|
|
100
|
+
if (payload.scope !== "agent") fail(`That's a login token, not an agent key. ${GET_A_KEY}`);
|
|
101
|
+
const days = daysLeft(key);
|
|
102
|
+
if (days === null || days <= 0) fail(`That key has expired. ${GET_A_KEY}`);
|
|
103
|
+
// A "done" update changes nothing anyone sees, and tells the server the hook is set up.
|
|
104
|
+
const res = await post(apiUrl, "/agent-activity", { text: "", state: "done" }, key);
|
|
105
|
+
if (res.status === 403) fail(`The server at ${apiUrl} doesn't accept this key. ${GET_A_KEY}`);
|
|
106
|
+
if (res.status !== 200) fail(`Could not check the key with ${apiUrl} (HTTP ${res.status}).`);
|
|
107
|
+
return { key, days, reused: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The key from an earlier setup, when it still works for this server (e.g. to add another project). */
|
|
111
|
+
function savedKey(config, apiUrl) {
|
|
112
|
+
const days = daysLeft(config.agentKey);
|
|
113
|
+
if (config.agentKey && config.apiUrl === apiUrl && days !== null && days > 0) return { key: config.agentKey, days, reused: true };
|
|
114
|
+
fail(`No agent key yet. ${GET_A_KEY}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Claude Code settings -------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const settingsFile = (global) =>
|
|
120
|
+
global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
|
|
121
|
+
|
|
122
|
+
/** Our hooks from any version (also from before the rename): this package's copy, or the repo script. */
|
|
123
|
+
const isOurHook = (hook) =>
|
|
124
|
+
typeof hook?.command === "string" && /(clario|viora)-agent-hook\.mjs|[\\/]\.(clario|viora)[\\/]hook\.mjs/.test(hook.command);
|
|
125
|
+
|
|
126
|
+
/** Settings with every one of our hooks removed; other hooks and settings are left exactly as they were. */
|
|
127
|
+
function withoutOurHooks(settings) {
|
|
128
|
+
if (!settings.hooks || typeof settings.hooks !== "object") return settings;
|
|
129
|
+
const hooks = {};
|
|
130
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
131
|
+
if (!Array.isArray(groups)) {
|
|
132
|
+
hooks[event] = groups;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const kept = groups
|
|
136
|
+
.map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !isOurHook(h)) } : group))
|
|
137
|
+
.filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
138
|
+
if (kept.length) hooks[event] = kept;
|
|
139
|
+
}
|
|
140
|
+
const { hooks: _old, ...rest } = settings;
|
|
141
|
+
return Object.keys(hooks).length ? { ...rest, hooks } : rest;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const countOurHooks = (settings) =>
|
|
145
|
+
Object.values(settings?.hooks ?? {}).flat().flatMap((group) => group?.hooks ?? []).filter(isOurHook).length;
|
|
146
|
+
|
|
147
|
+
/** Add the hook for every event, replacing older entries of ours so it never fires twice. */
|
|
148
|
+
function registerHooks(file) {
|
|
149
|
+
const { value = {} } = readJson(file);
|
|
150
|
+
if (existsSync(file)) copyFileSync(file, `${file}.clario-backup`);
|
|
151
|
+
const settings = withoutOurHooks(value);
|
|
152
|
+
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 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function gitIgnored(file) {
|
|
159
|
+
try {
|
|
160
|
+
execFileSync("git", ["check-ignore", "-q", file], { cwd: dirname(file), stdio: "ignore" });
|
|
161
|
+
return true;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return e.status === 1 ? false : null; // 1: tracked-able; anything else: not a git repo
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- Commands -----------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
async function setup(opts) {
|
|
170
|
+
const { value: config = readJson(OLD_CONFIG_PATH).value ?? {} } = readJson(CONFIG_PATH);
|
|
171
|
+
// A new key comes from the live site unless --api-url says otherwise; re-running setup keeps the saved server.
|
|
172
|
+
const apiUrl = String(opts.apiUrl || (opts.key ? DEFAULT_API_URL : config.apiUrl) || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
173
|
+
const { key, days, reused } = opts.key ? await checkKey(opts.key, apiUrl) : savedKey(config, apiUrl);
|
|
174
|
+
|
|
175
|
+
// An old 7-day login token isn't needed any more; everything else (shareGit…) stays.
|
|
176
|
+
const { token: _loginToken, ...rest } = config;
|
|
177
|
+
writeJson(CONFIG_PATH, { ...rest, apiUrl, agentKey: key }, { mode: 0o600 });
|
|
178
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
179
|
+
copyFileSync(HOOK_SOURCE, HOOK_PATH);
|
|
180
|
+
|
|
181
|
+
const file = settingsFile(opts.global);
|
|
182
|
+
registerHooks(file);
|
|
183
|
+
|
|
184
|
+
console.log(`${reused ? "Kept your agent key" : "Saved your agent key"} in ${CONFIG_PATH} (${expiryText(days)}).`);
|
|
185
|
+
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.");
|
|
189
|
+
}
|
|
190
|
+
console.log("Open your Clario space and give Claude a task: your avatar's card shows what it's doing.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function status() {
|
|
194
|
+
const { value: config = {} } = readJson(CONFIG_PATH);
|
|
195
|
+
const left = daysLeft(config.agentKey ?? config.token);
|
|
196
|
+
console.log(`Server: ${config.apiUrl ?? "not set"}`);
|
|
197
|
+
console.log(
|
|
198
|
+
config.agentKey || config.token
|
|
199
|
+
? `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)"}`
|
|
200
|
+
: "Key: none (run setup)",
|
|
201
|
+
);
|
|
202
|
+
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
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
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}.`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
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.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const HELP = `clario-agent: show what your Claude Code agent is doing on your Clario avatar
|
|
223
|
+
|
|
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
|
|
227
|
+
|
|
228
|
+
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.`;
|
|
232
|
+
|
|
233
|
+
export async function run(argv) {
|
|
234
|
+
const [command = "help", ...rest] = argv;
|
|
235
|
+
const value = (flag) => {
|
|
236
|
+
const i = rest.indexOf(flag);
|
|
237
|
+
return i > -1 ? rest[i + 1] : undefined;
|
|
238
|
+
};
|
|
239
|
+
const opts = { global: rest.includes("--global"), apiUrl: value("--api-url"), key: value("--key") };
|
|
240
|
+
try {
|
|
241
|
+
if (command === "setup") await setup(opts);
|
|
242
|
+
else if (command === "status") status();
|
|
243
|
+
else if (command === "uninstall") uninstall(opts);
|
|
244
|
+
else console.log(HELP);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
if (!(e instanceof CliError)) throw e;
|
|
247
|
+
console.error(e.message);
|
|
248
|
+
process.exitCode = 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Run when called as a command (npm links the bin, so compare real paths).
|
|
253
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
254
|
+
run(process.argv.slice(2));
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clario-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Show what your Claude Code agent is doing (project, branch, commit) on your Clario avatar.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"clario-agent": "cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"cli.mjs",
|
|
11
|
+
"setup.mjs",
|
|
12
|
+
"clario-agent-hook.mjs",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"clario",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"hooks",
|
|
22
|
+
"agent",
|
|
23
|
+
"metaverse"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Deepak-negi11/graphite-hacks.git",
|
|
28
|
+
"directory": "tools/agent-hook"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|
package/setup.mjs
ADDED