agentglow 0.2.1 → 0.2.2

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
@@ -8,6 +8,23 @@ Python server, so it works with LangGraph, deepagents, LangChain and anything el
8
8
 
9
9
  ![neural theme](https://raw.githubusercontent.com/Nideesh1/agentglow/main/docs/media/hero.gif)
10
10
 
11
+ ## Watch Claude Code (CLI)
12
+
13
+ ```bash
14
+ npx agentglow claude
15
+ ```
16
+ Starts the AgentGlow server (fetching uv + Python on first run, so only Node 18+ is needed), opens the 3D view
17
+ and runs `claude` with AgentGlow hooks + traces. Your agents and subagents appear live as Claude works.
18
+
19
+ | Command | What it does |
20
+ |---|---|
21
+ | `npx agentglow claude [--port 8100] [--no-open] [-- <claude args>]` | start/reuse the server, open `/neural`, run Claude Code with AgentGlow attached |
22
+ | `npx agentglow claude --install` / `--uninstall` | add/remove the AgentGlow hooks + env in `~/.claude/settings.json` (backup first) |
23
+ | `npx agentglow serve` / `stop` / `open` `[--port 8100]` | run the server in the foreground / stop a background one / open the view |
24
+
25
+ `AGENTGLOW_URL` points everything at a remote server, `AGENTGLOW_API_KEY` sends an ingest key. Details:
26
+ [examples/claude-code](https://github.com/Nideesh1/agentglow/tree/main/examples/claude-code#cli).
27
+
11
28
  ## Install
12
29
 
13
30
  ```bash
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ // agentglow CLI: watch Claude Code in 3D with one command. Needs only Node >= 18 (uv + Python are fetched on demand).
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { probe } from "./lib/net.mjs";
9
+ import { baseUrl, claudeSettings, installSettingsFile, uninstallSettingsFile } from "./lib/settings.mjs";
10
+ import { localBase, readPid, serveForeground, startBackground, stopServer } from "./lib/server.mjs";
11
+ import { which } from "./lib/uv.mjs";
12
+
13
+ const here = path.dirname(fileURLToPath(import.meta.url));
14
+ const VERSION = (() => {
15
+ try { return JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8")).version; } catch { return ""; }
16
+ })();
17
+
18
+ const HELP = `agentglow ${VERSION}: live 3D views of agent systems
19
+
20
+ Usage:
21
+ npx agentglow claude [--port 8100] [--no-open] [-- <claude args...>]
22
+ Start (or reuse) the AgentGlow server, open /neural and run Claude Code with AgentGlow hooks + traces.
23
+ npx agentglow claude --install [--port 8100]
24
+ Add the AgentGlow hooks + traces env to ~/.claude/settings.json (backup first). Plain \`claude\` then reports.
25
+ npx agentglow claude --uninstall
26
+ Remove exactly what --install added.
27
+ npx agentglow serve [--port 8100] Run the server in the foreground.
28
+ npx agentglow stop [--port 8100] Stop a server this CLI started in the background.
29
+ npx agentglow open [--port 8100] Open the 3D view in your browser.
30
+
31
+ Environment:
32
+ AGENTGLOW_URL use this (remote) server instead of starting one, e.g. https://glow.example.com
33
+ AGENTGLOW_API_KEY ingest key, sent as x-api-key by the hooks and the OTel traces
34
+ AGENTGLOW_CACHE_DIR where uv, the pidfile and server logs live
35
+ `;
36
+
37
+ function fail(msg, code = 1) {
38
+ console.error(`agentglow: ${msg}`);
39
+ process.exit(code);
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const o = { cmd: argv[0], port: Number(process.env.AGENTGLOW_PORT) || 8100, open: true, install: false, uninstall: false, rest: [] };
44
+ const args = argv.slice(1);
45
+ for (let i = 0; i < args.length; i++) {
46
+ const a = args[i];
47
+ if (a === "--") { o.rest.push(...args.slice(i + 1)); break; }
48
+ else if (a === "--port") o.port = Number(args[++i]);
49
+ else if (a.startsWith("--port=")) o.port = Number(a.slice(7));
50
+ else if (a === "--no-open") o.open = false;
51
+ else if (a === "--install") o.install = true;
52
+ else if (a === "--uninstall") o.uninstall = true;
53
+ else if (a === "-h" || a === "--help") o.help = true;
54
+ else o.rest.push(a); // anything else goes to claude
55
+ }
56
+ if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535) fail("--port must be a number between 1 and 65535");
57
+ return o;
58
+ }
59
+
60
+ function openBrowser(url) {
61
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
62
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
63
+ : ["xdg-open", [url]];
64
+ try {
65
+ const c = spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true });
66
+ c.on("error", () => console.error(`Open ${url} in your browser.`));
67
+ c.unref();
68
+ } catch { console.error(`Open ${url} in your browser.`); }
69
+ }
70
+
71
+ const remoteUrl = () => (process.env.AGENTGLOW_URL || "").replace(/\/+$/, "") || null;
72
+
73
+ /** Make sure a server answers; returns { base (for hooks + browser), started }. */
74
+ async function ensureServer(port) {
75
+ const remote = remoteUrl();
76
+ if (remote) {
77
+ const st = await probe(remote, 4000);
78
+ if (st !== "agentglow") console.error(`agentglow: warning: ${remote}/live/health did not answer like AgentGlow; Claude Code will run anyway.`);
79
+ return { base: remote, started: false };
80
+ }
81
+ const st = await probe(localBase(port));
82
+ if (st === "agentglow") return { base: baseUrl({ port }), started: false };
83
+ if (st === "other") fail(`port ${port} is in use by something that is not AgentGlow. Pick another one: --port ${port + 1}`);
84
+ await startBackground({ port, version: VERSION });
85
+ return { base: baseUrl({ port }), started: true };
86
+ }
87
+
88
+ async function cmdClaude(o) {
89
+ if (o.install || o.uninstall) {
90
+ const file = path.join(os.homedir(), ".claude", "settings.json");
91
+ if (o.uninstall) {
92
+ const r = uninstallSettingsFile(file);
93
+ console.log(r.changed ? `Removed the AgentGlow hooks + env from ${file} (backup: ${r.backup})` : `No AgentGlow entries in ${file}.`);
94
+ return 0;
95
+ }
96
+ const base = remoteUrl() || baseUrl({ port: o.port });
97
+ const r = installSettingsFile(file, base);
98
+ if (!r.changed) console.log(`AgentGlow is already installed in ${file} (${base}).`);
99
+ else console.log(`Installed AgentGlow hooks + traces env into ${file}${r.backup ? ` (backup: ${r.backup})` : ""}.`);
100
+ if (r.skipped.length) console.log(`Left your own values for: ${r.skipped.join(", ")}`);
101
+ if (process.env.AGENTGLOW_API_KEY) console.log('For traces with an ingest key also export OTEL_EXPORTER_OTLP_HEADERS="x-api-key=$AGENTGLOW_API_KEY".');
102
+ console.log(`Every \`claude\` session now reports to ${base}. Start the server with: npx agentglow serve${o.port !== 8100 ? ` --port ${o.port}` : ""}`);
103
+ console.log("Undo with: npx agentglow claude --uninstall");
104
+ return 0;
105
+ }
106
+
107
+ const claude = which("claude");
108
+ if (!claude) fail("`claude` is not on your PATH. Install Claude Code first: npm i -g @anthropic-ai/claude-code");
109
+
110
+ const { base, started } = await ensureServer(o.port);
111
+ const view = `${base}/neural`;
112
+ console.error(`AgentGlow: ${view}${started ? " (server started in the background)" : ""}`);
113
+ if (o.open) openBrowser(view);
114
+
115
+ const tmp = path.join(os.tmpdir(), `agentglow-claude-${process.pid}.json`);
116
+ fs.writeFileSync(tmp, JSON.stringify(claudeSettings(base), null, 2));
117
+ const env = { ...process.env };
118
+ if (env.AGENTGLOW_API_KEY && !env.OTEL_EXPORTER_OTLP_HEADERS) env.OTEL_EXPORTER_OTLP_HEADERS = `x-api-key=${env.AGENTGLOW_API_KEY}`;
119
+
120
+ const code = await new Promise((resolve) => {
121
+ const win = process.platform === "win32";
122
+ const args = ["--settings", tmp, ...o.rest];
123
+ const child = win
124
+ ? spawn([claude, ...args].map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)).join(" "), { stdio: "inherit", env, shell: true })
125
+ : spawn(claude, args, { stdio: "inherit", env });
126
+ // Claude Code handles Ctrl-C itself; don't let it kill us before we can clean up
127
+ const ignore = () => {};
128
+ process.on("SIGINT", ignore);
129
+ child.on("error", (e) => { console.error(`agentglow: could not start claude: ${e.message}`); resolve(1); });
130
+ child.on("exit", (c, sig) => {
131
+ process.off("SIGINT", ignore);
132
+ resolve(c ?? (sig === "SIGINT" ? 130 : sig === "SIGTERM" ? 143 : 1));
133
+ });
134
+ });
135
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
136
+ if (!remoteUrl() && readPid(o.port)) {
137
+ console.error(`AgentGlow is still running at ${view}. Stop it with: npx agentglow stop${o.port !== 8100 ? ` --port ${o.port}` : ""}`);
138
+ }
139
+ return code;
140
+ }
141
+
142
+ async function main() {
143
+ const argv = process.argv.slice(2);
144
+ if (!argv.length || ["-h", "--help", "help"].includes(argv[0])) { console.log(HELP); return 0; }
145
+ if (["-v", "--version", "version"].includes(argv[0])) { console.log(VERSION); return 0; }
146
+ const o = parseArgs(argv);
147
+ if (o.help) { console.log(HELP); return 0; }
148
+ switch (o.cmd) {
149
+ case "claude":
150
+ return cmdClaude(o);
151
+ case "serve": {
152
+ const st = await probe(localBase(o.port));
153
+ if (st === "agentglow") { console.log(`AgentGlow is already running at ${baseUrl({ port: o.port })}/neural`); return 0; }
154
+ if (st === "other") fail(`port ${o.port} is in use by something that is not AgentGlow. Pick another one: --port ${o.port + 1}`);
155
+ return serveForeground({ port: o.port, version: VERSION });
156
+ }
157
+ case "stop": {
158
+ const r = await stopServer(o.port);
159
+ if (r === "stopped") console.log(`Stopped the AgentGlow server on port ${o.port}.`);
160
+ else if (r === "not-ours") console.log(`The AgentGlow server on port ${o.port} was not started by this CLI; stop it where it runs.`);
161
+ else console.log(`No AgentGlow server on port ${o.port}.`);
162
+ return 0;
163
+ }
164
+ case "open": {
165
+ const url = `${remoteUrl() || baseUrl({ port: o.port })}/neural`;
166
+ openBrowser(url);
167
+ console.log(url);
168
+ return 0;
169
+ }
170
+ default:
171
+ console.error(`agentglow: unknown command "${o.cmd}"\n`);
172
+ console.log(HELP);
173
+ return 2;
174
+ }
175
+ }
176
+
177
+ main().then((code) => process.exit(code ?? 0), (e) => fail(e.message || String(e)));
@@ -0,0 +1,73 @@
1
+ // Tiny HTTP helpers on node:http/https (no runtime deps).
2
+ import fs from "node:fs";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import net from "node:net";
6
+
7
+ function get(url, { timeout = 30000 } = {}) {
8
+ return new Promise((resolve, reject) => {
9
+ const mod = url.startsWith("https:") ? https : http;
10
+ const req = mod.get(url, { headers: { "user-agent": "agentglow-cli" } }, resolve);
11
+ req.setTimeout(timeout, () => req.destroy(new Error(`timeout fetching ${url}`)));
12
+ req.on("error", reject);
13
+ });
14
+ }
15
+
16
+ /** GET with redirects, streamed to `dest`. */
17
+ export async function download(url, dest, redirects = 8) {
18
+ const res = await get(url, { timeout: 120000 });
19
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
20
+ res.resume();
21
+ if (!redirects) throw new Error(`too many redirects for ${url}`);
22
+ return download(new URL(res.headers.location, url).toString(), dest, redirects - 1);
23
+ }
24
+ if (res.statusCode !== 200) {
25
+ res.resume();
26
+ throw new Error(`GET ${url} -> HTTP ${res.statusCode}`);
27
+ }
28
+ await new Promise((resolve, reject) => {
29
+ const out = fs.createWriteStream(dest);
30
+ res.pipe(out);
31
+ res.on("error", reject);
32
+ out.on("error", reject);
33
+ out.on("finish", resolve);
34
+ });
35
+ }
36
+
37
+ /** GET a URL, return { status, body } or null on connection error / timeout. */
38
+ export async function fetchText(url, timeout = 2000) {
39
+ try {
40
+ const res = await get(url, { timeout });
41
+ let body = "";
42
+ res.setEncoding("utf8");
43
+ for await (const chunk of res) body += chunk;
44
+ return { status: res.statusCode, body };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** "agentglow" when an AgentGlow server answers /live/health, "other" when something else holds it, else "none". */
51
+ export async function probe(base, timeout = 2000) {
52
+ const r = await fetchText(base + "/live/health", timeout);
53
+ if (r) {
54
+ try { if (r.status === 200 && JSON.parse(r.body).ok === true) return "agentglow"; } catch { /* not ours */ }
55
+ return "other";
56
+ }
57
+ const u = new URL(base);
58
+ if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(u.hostname)) return "none";
59
+ // something may hold the port on only one loopback family (e.g. a dev server on [::1] that `localhost` reaches)
60
+ const port = Number(u.port || (u.protocol === "https:" ? 443 : 80));
61
+ const open = await Promise.all([portOpen(port, "127.0.0.1"), portOpen(port, "::1")]);
62
+ return open.some(Boolean) ? "other" : "none";
63
+ }
64
+
65
+ export function portOpen(port, host = "127.0.0.1", timeout = 1000) {
66
+ return new Promise((resolve) => {
67
+ const s = net.connect({ port, host });
68
+ const done = (v) => { s.destroy(); resolve(v); };
69
+ s.setTimeout(timeout, () => done(false));
70
+ s.on("connect", () => done(true));
71
+ s.on("error", () => done(false));
72
+ });
73
+ }
@@ -0,0 +1,128 @@
1
+ // Start / stop the Python `agentglow serve` through uv.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { probe } from "./net.mjs";
6
+ import { cacheDir, findRunner } from "./uv.mjs";
7
+
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+
10
+ export const localBase = (port) => `http://127.0.0.1:${port}`;
11
+ export const pidFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.pid`);
12
+ export const logFile = (port, dir = cacheDir()) => path.join(dir, `server-${port}.log`);
13
+
14
+ /** PyPI requirement for the server: same version as this npm package (or AGENTGLOW_PY_SPEC). */
15
+ export function pySpecs(version, env = process.env) {
16
+ if (env.AGENTGLOW_PY_SPEC) return [env.AGENTGLOW_PY_SPEC];
17
+ return /^\d+\.\d+\.\d+$/.test(version || "") ? [`agentglow==${version}`, "agentglow"] : ["agentglow"];
18
+ }
19
+
20
+ export function serveArgv(runner, spec, port) {
21
+ return [...runner.args, "--from", spec, "agentglow", "serve", "--host", "127.0.0.1", "--port", String(port)];
22
+ }
23
+
24
+ function alive(pid) {
25
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
26
+ }
27
+
28
+ export function readPid(port) {
29
+ try {
30
+ const j = JSON.parse(fs.readFileSync(pidFile(port), "utf8"));
31
+ return j && alive(j.pid) ? j : null;
32
+ } catch { return null; }
33
+ }
34
+
35
+ function tail(file, n = 15) {
36
+ try { return fs.readFileSync(file, "utf8").trimEnd().split("\n").slice(-n).join("\n"); } catch { return ""; }
37
+ }
38
+
39
+ /** Start the server in the background and wait for /live/health. Returns { pid, started: true }. */
40
+ export async function startBackground({ port, version, log = console.error, timeoutMs = 120000 }) {
41
+ const dir = cacheDir();
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const runner = await findRunner({ log });
44
+ log(`Starting AgentGlow server on port ${port} (via ${runner.source === "uvx" ? "uvx" : "uv"}). ` +
45
+ "First run downloads Python + agentglow, about 30-60s ...");
46
+ const logPath = logFile(port);
47
+ let lastErr = "";
48
+ for (const spec of pySpecs(version)) {
49
+ const fd = fs.openSync(logPath, "w");
50
+ const child = spawn(runner.cmd, serveArgv(runner, spec, port), {
51
+ detached: true, stdio: ["ignore", fd, fd], windowsHide: true,
52
+ env: { ...process.env, PYTHONUNBUFFERED: "1" },
53
+ });
54
+ fs.closeSync(fd);
55
+ let exited = null;
56
+ child.on("exit", (code) => { exited = code ?? -1; });
57
+ child.on("error", (e) => { exited = -1; lastErr = e.message; });
58
+ child.unref();
59
+ fs.writeFileSync(pidFile(port), JSON.stringify({ pid: child.pid, port, spec, startedAt: new Date().toISOString() }));
60
+ const t0 = Date.now();
61
+ let noted = false;
62
+ while (Date.now() - t0 < timeoutMs) {
63
+ if ((await probe(localBase(port), 1500)) === "agentglow") return { pid: child.pid, started: true };
64
+ if (exited !== null) break;
65
+ if (!noted && Date.now() - t0 > 15000) { log(" still preparing the Python environment ..."); noted = true; }
66
+ await sleep(500);
67
+ }
68
+ if (exited === null) {
69
+ killTree(child.pid);
70
+ fs.rmSync(pidFile(port), { force: true });
71
+ throw new Error(`AgentGlow server did not become healthy within ${timeoutMs / 1000}s. Log: ${logPath}\n${tail(logPath)}`);
72
+ }
73
+ fs.rmSync(pidFile(port), { force: true });
74
+ lastErr = tail(logPath) || lastErr;
75
+ log(` ${spec} failed to start, ${spec === "agentglow" ? "giving up" : "retrying with the latest agentglow"} ...`);
76
+ }
77
+ throw new Error(`AgentGlow server failed to start. Log: ${logPath}\n${lastErr}`);
78
+ }
79
+
80
+ export function killTree(pid) {
81
+ if (process.platform === "win32") {
82
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
83
+ return;
84
+ }
85
+ try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } }
86
+ }
87
+
88
+ /** Stop a server this CLI started. Returns "stopped" | "not-ours" | "none". */
89
+ export async function stopServer(port) {
90
+ const info = readPid(port);
91
+ if (!info) {
92
+ fs.rmSync(pidFile(port), { force: true });
93
+ return (await probe(localBase(port), 1500)) === "agentglow" ? "not-ours" : "none";
94
+ }
95
+ killTree(info.pid);
96
+ for (let i = 0; i < 20 && alive(info.pid); i++) await sleep(250);
97
+ if (alive(info.pid) && process.platform !== "win32") { try { process.kill(-info.pid, "SIGKILL"); } catch { /* gone */ } }
98
+ fs.rmSync(pidFile(port), { force: true });
99
+ return "stopped";
100
+ }
101
+
102
+ /** Run the server in the foreground (stdio inherited). Resolves with its exit code. */
103
+ export async function serveForeground({ port, version, log = console.error }) {
104
+ const runner = await findRunner({ log });
105
+ const specs = pySpecs(version);
106
+ for (const [i, spec] of specs.entries()) {
107
+ const t0 = Date.now();
108
+ const code = await new Promise((resolve) => {
109
+ const child = spawn(runner.cmd, serveArgv(runner, spec, port), { stdio: "inherit" });
110
+ const fwd = (sig) => () => child.kill(sig);
111
+ const onInt = fwd("SIGINT"), onTerm = fwd("SIGTERM");
112
+ process.on("SIGINT", onInt);
113
+ process.on("SIGTERM", onTerm);
114
+ child.on("exit", (c, sig) => {
115
+ process.off("SIGINT", onInt);
116
+ process.off("SIGTERM", onTerm);
117
+ resolve(c ?? (sig ? 130 : 1));
118
+ });
119
+ });
120
+ // a pinned version missing on PyPI fails fast; fall back to latest once
121
+ if (code !== 0 && i < specs.length - 1 && Date.now() - t0 < 30000) {
122
+ log(`${spec} failed, retrying with the latest agentglow ...`);
123
+ continue;
124
+ }
125
+ return code;
126
+ }
127
+ return 1;
128
+ }
@@ -0,0 +1,192 @@
1
+ // Claude Code settings for AgentGlow: HTTP hooks -> POST /v1/claude-code, OTel traces -> POST /v1/traces.
2
+ // Mirrors examples/claude-code/settings.json (a unit test keeps the two in sync).
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ export const HOOK_PATH = "/v1/claude-code";
7
+ export const TRACES_PATH = "/v1/traces";
8
+
9
+ // [event, needs "matcher": "*"] in the order of examples/claude-code/settings.json
10
+ export const HOOK_EVENTS = [
11
+ ["SessionStart", false],
12
+ ["UserPromptSubmit", false],
13
+ ["PreToolUse", true],
14
+ ["PostToolUse", true],
15
+ ["PostToolUseFailure", true],
16
+ ["SubagentStart", true],
17
+ ["SubagentStop", true],
18
+ ["Stop", false],
19
+ ["SessionEnd", false],
20
+ ];
21
+
22
+ // Static env values; OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is added per base URL.
23
+ export const STATIC_ENV = {
24
+ CLAUDE_CODE_ENABLE_TELEMETRY: "1",
25
+ CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1",
26
+ OTEL_TRACES_EXPORTER: "otlp",
27
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
28
+ };
29
+ const ENDPOINT_KEY = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT";
30
+ const TAIL_ENV = { OTEL_TRACES_EXPORT_INTERVAL: "1000", OTEL_LOG_USER_PROMPTS: "0" };
31
+
32
+ export function baseUrl({ port = 8100, url } = {}) {
33
+ return String(url || `http://localhost:${port}`).replace(/\/+$/, "");
34
+ }
35
+
36
+ export function envBlock(base) {
37
+ return { ...STATIC_ENV, [ENDPOINT_KEY]: base + TRACES_PATH, ...TAIL_ENV };
38
+ }
39
+
40
+ function hookGroup(base, withMatcher) {
41
+ const hook = {
42
+ type: "http",
43
+ url: base + HOOK_PATH,
44
+ timeout: 2,
45
+ async: true,
46
+ headers: { "x-api-key": "$AGENTGLOW_API_KEY" },
47
+ allowedEnvVars: ["AGENTGLOW_API_KEY"],
48
+ };
49
+ return withMatcher ? { matcher: "*", hooks: [hook] } : { hooks: [hook] };
50
+ }
51
+
52
+ /** The full settings object (what `claude --settings <file>` gets). */
53
+ export function claudeSettings(base) {
54
+ const hooks = {};
55
+ for (const [event, m] of HOOK_EVENTS) hooks[event] = [hookGroup(base, m)];
56
+ return { env: envBlock(base), hooks };
57
+ }
58
+
59
+ export function isOurHook(h) {
60
+ return !!h && h.type === "http" && typeof h.url === "string" && h.url.includes(HOOK_PATH);
61
+ }
62
+
63
+ /** Base URLs of our hooks found in a settings object (used to recognise our traces endpoint). */
64
+ export function ourBases(settings) {
65
+ const out = new Set();
66
+ for (const groups of Object.values(settings?.hooks || {})) {
67
+ if (!Array.isArray(groups)) continue;
68
+ for (const g of groups) for (const h of g?.hooks || []) {
69
+ if (isOurHook(h)) out.add(h.url.slice(0, h.url.indexOf(HOOK_PATH)));
70
+ }
71
+ }
72
+ return [...out];
73
+ }
74
+
75
+ const clone = (o) => JSON.parse(JSON.stringify(o ?? {}));
76
+
77
+ /**
78
+ * Remove exactly our hook entries and our env keys. `state.addedEnv` (from the install sidecar) lists the env keys
79
+ * we added; without it, keys are removed only when their value is the one we would have set.
80
+ */
81
+ export function unmergeSettings(settings, state = null) {
82
+ const s = clone(settings);
83
+ const bases = ourBases(s);
84
+ if (s.hooks && typeof s.hooks === "object") {
85
+ for (const [event, groups] of Object.entries(s.hooks)) {
86
+ if (!Array.isArray(groups)) continue;
87
+ const kept = [];
88
+ for (const g of groups) {
89
+ if (!g || !Array.isArray(g.hooks)) { kept.push(g); continue; }
90
+ const hooks = g.hooks.filter((h) => !isOurHook(h));
91
+ if (hooks.length === g.hooks.length) kept.push(g);
92
+ else if (hooks.length) kept.push({ ...g, hooks });
93
+ }
94
+ if (kept.length) s.hooks[event] = kept;
95
+ else delete s.hooks[event];
96
+ }
97
+ if (!Object.keys(s.hooks).length) delete s.hooks;
98
+ }
99
+ if (s.env && typeof s.env === "object") {
100
+ const ours = { ...STATIC_ENV, ...TAIL_ENV };
101
+ const endpoints = new Set(bases.map((b) => b + TRACES_PATH));
102
+ const candidates = state?.addedEnv ?? [...Object.keys(ours), ENDPOINT_KEY];
103
+ for (const k of candidates) {
104
+ if (!(k in s.env)) continue;
105
+ const v = s.env[k];
106
+ const mine = k === ENDPOINT_KEY ? (state?.addedEnv ? true : endpoints.has(v)) : (state?.addedEnv ? true : v === ours[k]);
107
+ if (mine) delete s.env[k];
108
+ }
109
+ if (!Object.keys(s.env).length) delete s.env;
110
+ }
111
+ return s;
112
+ }
113
+
114
+ /**
115
+ * Add our hooks + env to `settings` (idempotent: our previous entries are replaced, foreign ones kept).
116
+ * Env keys the user already set to a different value are left alone and reported in `skipped`.
117
+ * Returns { settings, state: { addedEnv, base }, skipped }.
118
+ */
119
+ export function mergeSettings(settings, base, state = null) {
120
+ const s = unmergeSettings(settings, state);
121
+ const ours = claudeSettings(base);
122
+ s.hooks = s.hooks && typeof s.hooks === "object" ? s.hooks : {};
123
+ for (const [event, groups] of Object.entries(ours.hooks)) {
124
+ s.hooks[event] = [...(Array.isArray(s.hooks[event]) ? s.hooks[event] : []), ...groups];
125
+ }
126
+ s.env = s.env && typeof s.env === "object" ? s.env : {};
127
+ const addedEnv = [];
128
+ const skipped = [];
129
+ for (const [k, v] of Object.entries(ours.env)) {
130
+ if (!(k in s.env)) { s.env[k] = v; addedEnv.push(k); }
131
+ else if (s.env[k] !== v) skipped.push(k);
132
+ }
133
+ if (!Object.keys(s.env).length) delete s.env;
134
+ return { settings: s, state: { base, addedEnv }, skipped };
135
+ }
136
+
137
+ // ---------- files ----------
138
+
139
+ export function statePath(settingsFile) {
140
+ return path.join(path.dirname(settingsFile), "agentglow-install.json");
141
+ }
142
+
143
+ function readJson(file, fallback) {
144
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); }
145
+ catch (e) {
146
+ if (e.code === "ENOENT") return fallback;
147
+ throw new Error(`could not parse ${file}: ${e.message}`);
148
+ }
149
+ }
150
+
151
+ function backup(file, now) {
152
+ if (!fs.existsSync(file)) return null;
153
+ const ts = new Date(now).toISOString().replace(/[:.]/g, "-");
154
+ const dest = `${file}.agentglow-backup-${ts}`;
155
+ fs.copyFileSync(file, dest);
156
+ return dest;
157
+ }
158
+
159
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
160
+
161
+ /** Merge into a settings file. Returns { changed, backup, skipped, file }. */
162
+ export function installSettingsFile(file, base, { now = Date.now() } = {}) {
163
+ const before = readJson(file, {});
164
+ const st = readJson(statePath(file), null);
165
+ const { settings, state, skipped } = mergeSettings(before, base, st);
166
+ if (same(before, settings) && st && same(st.addedEnv, state.addedEnv)) {
167
+ return { changed: false, backup: null, skipped, file };
168
+ }
169
+ fs.mkdirSync(path.dirname(file), { recursive: true });
170
+ const bak = same(before, settings) ? null : backup(file, now);
171
+ if (!same(before, settings)) fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
172
+ fs.writeFileSync(statePath(file), JSON.stringify(state, null, 2) + "\n");
173
+ return { changed: true, backup: bak, skipped, file };
174
+ }
175
+
176
+ /** Remove our entries from a settings file. Returns { changed, backup, file }. */
177
+ export function uninstallSettingsFile(file, { now = Date.now() } = {}) {
178
+ const before = readJson(file, null);
179
+ const st = readJson(statePath(file), null);
180
+ if (before === null) {
181
+ if (st) fs.rmSync(statePath(file), { force: true });
182
+ return { changed: false, backup: null, file };
183
+ }
184
+ const after = unmergeSettings(before, st);
185
+ let bak = null;
186
+ if (!same(before, after)) {
187
+ bak = backup(file, now);
188
+ fs.writeFileSync(file, JSON.stringify(after, null, 2) + "\n");
189
+ }
190
+ fs.rmSync(statePath(file), { force: true });
191
+ return { changed: !same(before, after), backup: bak, file };
192
+ }
package/cli/lib/uv.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // Find (or fetch) uv, the runner that starts the Python `agentglow` server without a Python install.
2
+ // Order: uvx on PATH -> uv on PATH -> cached uv -> download the pinned standalone uv from GitHub (sha256-verified).
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { download } from "./net.mjs";
9
+
10
+ export const UV_VERSION = "0.12.21";
11
+
12
+ /** Release asset for a platform, or null when uv publishes none. `libc` is "gnu" | "musl" (Linux only). */
13
+ export function uvAsset(platform = process.platform, arch = process.arch, libc = detectLibc()) {
14
+ const cpu = { x64: "x86_64", arm64: "aarch64" }[arch];
15
+ if (!cpu) return null;
16
+ if (platform === "darwin") return { name: `uv-${cpu}-apple-darwin`, ext: ".tar.gz" };
17
+ if (platform === "linux") return { name: `uv-${cpu}-unknown-linux-${libc === "musl" ? "musl" : "gnu"}`, ext: ".tar.gz" };
18
+ if (platform === "win32") return { name: `uv-${cpu}-pc-windows-msvc`, ext: ".zip" };
19
+ return null;
20
+ }
21
+
22
+ export function uvAssetUrl(asset, version = UV_VERSION) {
23
+ return `https://github.com/astral-sh/uv/releases/download/${version}/${asset.name}${asset.ext}`;
24
+ }
25
+
26
+ export function detectLibc() {
27
+ if (process.platform !== "linux") return null;
28
+ try {
29
+ const r = process.report?.getReport?.();
30
+ if (r?.header?.glibcVersionRuntime) return "gnu";
31
+ } catch { /* ignore */ }
32
+ try { if (fs.readdirSync("/lib").some((f) => f.startsWith("ld-musl-"))) return "musl"; } catch { /* ignore */ }
33
+ return "gnu";
34
+ }
35
+
36
+ /** Per-user cache dir (AGENTGLOW_CACHE_DIR overrides). */
37
+ export function cacheDir(env = process.env, platform = process.platform, home = os.homedir()) {
38
+ if (env.AGENTGLOW_CACHE_DIR) return env.AGENTGLOW_CACHE_DIR;
39
+ if (platform === "darwin") return path.join(home, "Library", "Caches", "agentglow");
40
+ if (platform === "win32") return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "agentglow", "Cache");
41
+ return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "agentglow");
42
+ }
43
+
44
+ const exe = (name) => (process.platform === "win32" ? `${name}.exe` : name);
45
+
46
+ /** First executable `name` on PATH (honours PATHEXT on Windows), or null. */
47
+ export function which(name, env = process.env) {
48
+ const exts = process.platform === "win32" ? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
49
+ for (const dir of (env.PATH || env.Path || "").split(path.delimiter)) {
50
+ if (!dir) continue;
51
+ for (const ext of exts) {
52
+ const p = path.join(dir, name + ext.toLowerCase());
53
+ const P = path.join(dir, name + ext);
54
+ for (const c of new Set([P, p])) {
55
+ try {
56
+ const st = fs.statSync(c);
57
+ if (st.isFile() && (process.platform === "win32" || st.mode & 0o111)) return c;
58
+ } catch { /* next */ }
59
+ }
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ export function cachedUvPath(dir = cacheDir()) {
66
+ return path.join(dir, "uv", UV_VERSION, exe("uv"));
67
+ }
68
+
69
+ function sha256(file) {
70
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
71
+ }
72
+
73
+ function findFile(dir, name) {
74
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
75
+ const p = path.join(dir, e.name);
76
+ if (e.isFile() && e.name === name) return p;
77
+ if (e.isDirectory()) { const f = findFile(p, name); if (f) return f; }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function extract(archive, dest) {
83
+ // bsdtar/GNU tar on macOS/Linux; tar.exe (bsdtar, reads zip) on Windows 10+, else PowerShell
84
+ const r = spawnSync("tar", archive.endsWith(".zip") ? ["-xf", archive, "-C", dest] : ["-xzf", archive, "-C", dest], { stdio: "ignore" });
85
+ if (r.status === 0) return;
86
+ if (process.platform === "win32") {
87
+ const ps = spawnSync("powershell.exe", ["-NoProfile", "-Command",
88
+ `Expand-Archive -Force -LiteralPath '${archive}' -DestinationPath '${dest}'`], { stdio: "ignore" });
89
+ if (ps.status === 0) return;
90
+ }
91
+ throw new Error(`could not extract ${archive} (is \`tar\` installed?)`);
92
+ }
93
+
94
+ /** Download + verify + extract the pinned uv into the cache. Returns its path. */
95
+ export async function downloadUv(dir = cacheDir(), log = () => {}) {
96
+ const asset = uvAsset();
97
+ if (!asset) throw new Error(`no prebuilt uv for ${process.platform}/${process.arch}; install uv: https://docs.astral.sh/uv/`);
98
+ const url = uvAssetUrl(asset);
99
+ const target = cachedUvPath(dir);
100
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "agentglow-uv-"));
101
+ try {
102
+ log(`Downloading uv ${UV_VERSION} (${asset.name}) ...`);
103
+ const archive = path.join(work, asset.name + asset.ext);
104
+ await download(url, archive);
105
+ const shaFile = path.join(work, "sha256");
106
+ await download(url + ".sha256", shaFile);
107
+ const want = fs.readFileSync(shaFile, "utf8").trim().split(/\s+/)[0].toLowerCase();
108
+ const got = sha256(archive);
109
+ if (!/^[0-9a-f]{64}$/.test(want) || want !== got) throw new Error(`uv checksum mismatch for ${asset.name}: ${got} != ${want}`);
110
+ const out = path.join(work, "x");
111
+ fs.mkdirSync(out);
112
+ extract(archive, out);
113
+ const bin = findFile(out, exe("uv"));
114
+ if (!bin) throw new Error(`uv binary not found in ${asset.name}${asset.ext}`);
115
+ fs.mkdirSync(path.dirname(target), { recursive: true });
116
+ fs.copyFileSync(bin, target + ".tmp");
117
+ fs.chmodSync(target + ".tmp", 0o755);
118
+ fs.renameSync(target + ".tmp", target);
119
+ return target;
120
+ } finally {
121
+ fs.rmSync(work, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The command prefix that runs a PyPI tool: ["uvx"] or [uv, "tool", "run"].
127
+ * Returns { cmd, args, source } where source is "uvx" | "uv" | "cache" | "download".
128
+ */
129
+ export async function findRunner({ env = process.env, log = () => {} } = {}) {
130
+ const uvx = which("uvx", env);
131
+ if (uvx) return { cmd: uvx, args: [], source: "uvx" };
132
+ const uv = which("uv", env);
133
+ if (uv) return { cmd: uv, args: ["tool", "run"], source: "uv" };
134
+ const dir = cacheDir(env);
135
+ const cached = cachedUvPath(dir);
136
+ if (fs.existsSync(cached)) return { cmd: cached, args: ["tool", "run"], source: "cache" };
137
+ const got = await downloadUv(dir, log);
138
+ return { cmd: got, args: ["tool", "run"], source: "download" };
139
+ }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "agentglow",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Live 3D views of agent systems (LangGraph, deepagents, any OpenTelemetry-instrumented agents) as a React component.",
5
5
  "keywords": [
6
+ "claude-code",
6
7
  "opentelemetry",
7
8
  "agents",
8
9
  "3d",
@@ -36,9 +37,17 @@
36
37
  "./style.css": "./dist/style.css",
37
38
  "./package.json": "./package.json"
38
39
  },
40
+ "bin": {
41
+ "agentglow": "cli/agentglow.mjs"
42
+ },
39
43
  "files": [
40
- "dist"
44
+ "dist",
45
+ "cli",
46
+ "!cli/test"
41
47
  ],
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
42
51
  "sideEffects": [
43
52
  "**/*.css",
44
53
  "./dist/index.js"
@@ -50,6 +59,7 @@
50
59
  "build:app": "tsc --noEmit && vite build",
51
60
  "build:lib": "vite build -c vite.lib.config.ts && tsc -p tsconfig.lib.json && node scripts/postbuild-lib.mjs",
52
61
  "preview": "vite preview",
62
+ "test": "node --test cli/test/*.test.mjs",
53
63
  "prepublishOnly": "npm run build:lib"
54
64
  },
55
65
  "peerDependencies": {