clario-agent 0.0.0-stage → 0.1.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 +82 -2
- package/clario-agent-hook.mjs +227 -0
- package/cli.mjs +256 -0
- package/package.json +29 -4
- package/setup.mjs +6 -0
package/README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
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
|
+
Make an account at [clario.games](https://www.clario.games) first, then run this inside the
|
|
11
|
+
project you want to share:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx clario-agent setup
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It does three things:
|
|
18
|
+
|
|
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.
|
|
25
|
+
|
|
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`.
|
|
59
|
+
|
|
60
|
+
## How it behaves
|
|
61
|
+
|
|
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`)
|
|
65
|
+
- Your agent card also shows the project folder, git branch and short commit the agent is
|
|
66
|
+
working in
|
|
67
|
+
- When Claude finishes, the badge clears automatically
|
|
68
|
+
- If you're not inside a Clario space, updates are silently dropped
|
|
69
|
+
- Updates are throttled (2s) so fast tool bursts don't spam the room
|
|
70
|
+
- The hook never blocks or fails your agent session: all errors are swallowed
|
|
71
|
+
|
|
72
|
+
## Privacy notes
|
|
73
|
+
|
|
74
|
+
Everyone in your space sees what is sent. That is the tool name, the edited file's name,
|
|
75
|
+
the names of the programs a shell command runs (`bun`, `git`; never its arguments, which
|
|
76
|
+
can hold tokens or paths), and the project folder, branch and short commit. Never file
|
|
77
|
+
contents, prompts or code.
|
|
78
|
+
|
|
79
|
+
To keep project, branch and commit on your machine, add `"shareGit": false` to
|
|
80
|
+
`~/.clario/agent.json` (or set `CLARIO_SHARE_GIT=0`). Run `npx clario-agent uninstall` to stop
|
|
81
|
+
broadcasting from a project.
|
|
82
|
+
|
|
83
|
+
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,256 @@
|
|
|
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 [--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
|
|
8
|
+
*
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const DIR = join(homedir(), ".clario");
|
|
22
|
+
const CONFIG_PATH = join(DIR, "agent.json");
|
|
23
|
+
const HOOK_PATH = join(DIR, "hook.mjs");
|
|
24
|
+
const HOOK_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "clario-agent-hook.mjs");
|
|
25
|
+
// Where this package kept its key before it was renamed: setup reuses that key instead of asking again.
|
|
26
|
+
const OLD_CONFIG_PATH = join(homedir(), ".viora", "agent.json");
|
|
27
|
+
// The live Clario server; pass --api-url http://localhost:3001 for a local one.
|
|
28
|
+
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
|
|
31
|
+
|
|
32
|
+
class CliError extends Error {}
|
|
33
|
+
const fail = (message) => {
|
|
34
|
+
throw new CliError(message);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** { value } for a JSON file, {} when it doesn't exist; fails on invalid JSON instead of overwriting it. */
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = readFileSync(file, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return { value: JSON.parse(text) };
|
|
47
|
+
} catch {
|
|
48
|
+
fail(`${file} is not valid JSON. Fix or remove it, then run this again.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeJson(file, value, { mode } = {}) {
|
|
53
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
54
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
55
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode ? { mode } : {});
|
|
56
|
+
renameSync(tmp, file);
|
|
57
|
+
if (mode) chmodSync(file, mode);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Days until a JWT expires, or null when it can't be read. */
|
|
61
|
+
function daysLeft(token) {
|
|
62
|
+
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;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function post(apiUrl, path, body, token) {
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(`${apiUrl}/api/v1${path}`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
});
|
|
78
|
+
} catch {
|
|
79
|
+
fail(`Can't reach the Clario server at ${apiUrl}. Start it, or pass --api-url, then run this again.`);
|
|
80
|
+
}
|
|
81
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
82
|
+
}
|
|
83
|
+
|
|
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 };
|
|
101
|
+
}
|
|
102
|
+
|
|
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 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Claude Code settings -------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const settingsFile = (global) =>
|
|
127
|
+
global ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
|
|
128
|
+
|
|
129
|
+
/** Our hooks from any version (also from before the rename): this package's copy, or the repo script. */
|
|
130
|
+
const isOurHook = (hook) =>
|
|
131
|
+
typeof hook?.command === "string" && /(clario|viora)-agent-hook\.mjs|[\\/]\.(clario|viora)[\\/]hook\.mjs/.test(hook.command);
|
|
132
|
+
|
|
133
|
+
/** Settings with every one of our hooks removed; other hooks and settings are left exactly as they were. */
|
|
134
|
+
function withoutOurHooks(settings) {
|
|
135
|
+
if (!settings.hooks || typeof settings.hooks !== "object") return settings;
|
|
136
|
+
const hooks = {};
|
|
137
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
138
|
+
if (!Array.isArray(groups)) {
|
|
139
|
+
hooks[event] = groups;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const kept = groups
|
|
143
|
+
.map((group) => (Array.isArray(group?.hooks) ? { ...group, hooks: group.hooks.filter((h) => !isOurHook(h)) } : group))
|
|
144
|
+
.filter((group) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
|
|
145
|
+
if (kept.length) hooks[event] = kept;
|
|
146
|
+
}
|
|
147
|
+
const { hooks: _old, ...rest } = settings;
|
|
148
|
+
return Object.keys(hooks).length ? { ...rest, hooks } : rest;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const countOurHooks = (settings) =>
|
|
152
|
+
Object.values(settings?.hooks ?? {}).flat().flatMap((group) => group?.hooks ?? []).filter(isOurHook).length;
|
|
153
|
+
|
|
154
|
+
/** Add the hook for every event, replacing older entries of ours so it never fires twice. */
|
|
155
|
+
function registerHooks(file) {
|
|
156
|
+
const { value = {} } = readJson(file);
|
|
157
|
+
if (existsSync(file)) copyFileSync(file, `${file}.clario-backup`);
|
|
158
|
+
const settings = withoutOurHooks(value);
|
|
159
|
+
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 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function gitIgnored(file) {
|
|
166
|
+
try {
|
|
167
|
+
execFileSync("git", ["check-ignore", "-q", file], { cwd: dirname(file), stdio: "ignore" });
|
|
168
|
+
return true;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return e.status === 1 ? false : null; // 1: tracked-able; anything else: not a git repo
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Commands -----------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async function setup(opts) {
|
|
177
|
+
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);
|
|
180
|
+
|
|
181
|
+
// The 7-day login token isn't needed any more; everything else (shareGit…) stays.
|
|
182
|
+
const { token: _loginToken, ...rest } = config;
|
|
183
|
+
writeJson(CONFIG_PATH, { ...rest, apiUrl, agentKey: key }, { mode: 0o600 });
|
|
184
|
+
mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
185
|
+
copyFileSync(HOOK_SOURCE, HOOK_PATH);
|
|
186
|
+
|
|
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.");
|
|
195
|
+
}
|
|
196
|
+
console.log("Open your Clario space and give Claude a task: your avatar's card shows what it's doing.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function status() {
|
|
200
|
+
const { value: config = {} } = readJson(CONFIG_PATH);
|
|
201
|
+
const left = daysLeft(config.agentKey ?? config.token);
|
|
202
|
+
console.log(`Server: ${config.apiUrl ?? "not set"}`);
|
|
203
|
+
console.log(
|
|
204
|
+
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)"}`
|
|
206
|
+
: "Key: none (run setup)",
|
|
207
|
+
);
|
|
208
|
+
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
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
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}.`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
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.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const HELP = `clario-agent: show what your Claude Code agent is doing on your Clario avatar
|
|
229
|
+
|
|
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
|
|
233
|
+
|
|
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}.`;
|
|
236
|
+
|
|
237
|
+
export async function run(argv) {
|
|
238
|
+
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 };
|
|
241
|
+
try {
|
|
242
|
+
if (command === "setup") await setup(opts);
|
|
243
|
+
else if (command === "status") status();
|
|
244
|
+
else if (command === "uninstall") uninstall(opts);
|
|
245
|
+
else console.log(HELP);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
if (!(e instanceof CliError)) throw e;
|
|
248
|
+
console.error(e.message);
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Run when called as a command (npm links the bin, so compare real paths).
|
|
254
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
255
|
+
run(process.argv.slice(2));
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clario-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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