getclaros 0.1.0 → 0.1.1
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 +53 -3
- package/dist/setup.js +103 -56
- package/dist/ui.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,8 +12,45 @@ The package name (`getclaros`) is a placeholder and lives only in `package.json`
|
|
|
12
12
|
npx getclaros init
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
To try a local build, run `node dist/cli.js init` with `GETCLAROS_LAUNCHER="node /abs/path/to/dist/cli.js"` so the
|
|
16
|
+
configs launch your build instead of `npx -y getclaros`.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
claros” getclaros 0.1.1
|
|
20
|
+
|
|
21
|
+
Sign in opening your browser
|
|
22
|
+
› https://claros-eta.vercel.app/device?code=ZQRC-ZCTP
|
|
23
|
+
Code ZQRC-ZCTP approve only if the page shows this code
|
|
24
|
+
|
|
25
|
+
✓ Signed in to workspace "Acme" key clr_7f3a91c2_…
|
|
26
|
+
<claros server> · key saved in ~/.config/getclaros/config.json, readable only by you
|
|
27
|
+
|
|
28
|
+
MCP servers
|
|
29
|
+
|
|
30
|
+
Claude Code ~/.claude.json
|
|
31
|
+
1 ● erpnext npx -y frappe-mcp-server Work Map "Process a supplier invoice"
|
|
32
|
+
2 ○ github docker run -i …
|
|
33
|
+
3 ○ playwright (project ~/proj) npx @playwright/mcp@latest
|
|
34
|
+
|
|
35
|
+
Cursor ~/.cursor/mcp.json
|
|
36
|
+
4 ● erpnext npx frappe-mcp-server Work Map "Process a supplier invoice"
|
|
37
|
+
|
|
38
|
+
· 3 remote servers need OAuth — guard them later: linear, notion, figma
|
|
39
|
+
|
|
40
|
+
● matches a Work Map: recommended
|
|
41
|
+
Guard which? Enter = 1,4 (recommended) · numbers like 1,3 · all · none ›
|
|
42
|
+
|
|
43
|
+
✓ Guarded erpnext in Claude Code, Cursor
|
|
44
|
+
✓ Added the Claros MCP server in Claude Code, Cursor
|
|
45
|
+
every file backed up first: ~/.config/getclaros/backups/2026-10-24T01-34-53-159Z
|
|
46
|
+
|
|
47
|
+
Next restart Claude Code, Cursor, then run npx getclaros test
|
|
48
|
+
undo anytime: npx getclaros undo
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
On a terminal the output is coloured in Claros's inks (stop magenta, ask green, nudge vermilion) with a spinner while
|
|
52
|
+
it waits for the browser. Piped, it is plain text with no escape codes and no header; `NO_COLOR` turns colour off,
|
|
53
|
+
`FORCE_COLOR` turns it on. Old Windows consoles get ASCII marks.
|
|
17
54
|
|
|
18
55
|
`init` signs you in through the browser (RFC 8628 device flow): it prints a URL and a code (XXXX-XXXX), opens the
|
|
19
56
|
URL (`--no-browser` to skip), and once you approve on Claros's `/device` page and pick a workspace it receives an
|
|
@@ -62,7 +99,20 @@ rules page. Exit codes: 0 sent, 1 no such check / not valid, 2 bad usage, 3 unre
|
|
|
62
99
|
|
|
63
100
|
`test` asks Claros for a record that breaks a precise rule of a Work Map for one of your guarded servers
|
|
64
101
|
(`GET /v1/check/example?app=…`), checks it with `POST /v1/check` and prints the stop. Nothing is sent to the app.
|
|
65
|
-
With no guarded server matching a map it uses the newest map and
|
|
102
|
+
With no guarded server matching a map it uses the workspace's newest map and ends with a `Demo:` note.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
◉ Stopped in 1.3 s Work Map "Process a supplier invoice" · check chk_0b14cb5eef
|
|
106
|
+
|
|
107
|
+
◉ stop Equipment over 5,000 is capex: book it to the capital equipment account, never to expense.
|
|
108
|
+
“Equipment above five thousand is an asset, it goes to capex.” — AP lead
|
|
109
|
+
Who to ask: Controller
|
|
110
|
+
|
|
111
|
+
Test action for erpnext, made up, only checked: items.0.item_group = Machinery, items.0.amount = 7200
|
|
112
|
+
Your agent gets the rule as the tool's error. Nothing reached erpnext.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Exit codes: 0 stopped, 1 not stopped
|
|
66
116
|
or no map to test, 2 not signed in / bad usage, 3 Claros unreachable or key rejected (`status` uses 0 / 2 / 3 too).
|
|
67
117
|
|
|
68
118
|
`undo` restores a file from its backup when it is byte-for-byte what `init` wrote; if anything changed it since, it
|
package/dist/setup.js
CHANGED
|
@@ -9,8 +9,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
import { addClaros, argvOf, CLAROS, detect, launcher, load, removeEntry, sha, unwrapServer, wrapServer, writeAtomic, } from "./clients.js";
|
|
11
11
|
import { cfgDir, loadConfig, relocate, resolve, saveConfig } from "./config.js";
|
|
12
|
+
import { c, dur, header, mark, say, spinner, VERSION, wrap } from "./ui.js";
|
|
12
13
|
const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
|
|
13
|
-
const say = (s = "") => process.stdout.write(s + "\n");
|
|
14
14
|
const warn = (s) => process.stderr.write(s + "\n");
|
|
15
15
|
const tilde = (p) => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p;
|
|
16
16
|
const keyId = (k) => k.split("_").slice(0, 2).join("_") + "_…"; // clr_<prefix>_… (the prefix is the lookup id, not the secret)
|
|
@@ -89,7 +89,6 @@ async function locate(v) {
|
|
|
89
89
|
return r;
|
|
90
90
|
}
|
|
91
91
|
// ---- browser sign-in (RFC 8628 device flow) ----
|
|
92
|
-
const VERSION = "0.1.0";
|
|
93
92
|
const sleep = (s) => new Promise((ok) => setTimeout(ok, s * Number(process.env.GETCLAROS_INTERVAL_MS ?? 1000)));
|
|
94
93
|
function openBrowser(url) {
|
|
95
94
|
if (process.env.GETCLAROS_NO_BROWSER)
|
|
@@ -115,13 +114,22 @@ async function deviceSignIn(url, browser) {
|
|
|
115
114
|
catch {
|
|
116
115
|
return "unsupported";
|
|
117
116
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
${
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
const link = d.verification_uri_complete ?? d.verification_uri;
|
|
118
|
+
say(` ${c.bold("Sign in")} ${c.dim(browser ? "opening your browser" : "open this in your browser")}`);
|
|
119
|
+
say(` ${c.violet(mark.arrow)} ${c.ul(link)}`);
|
|
120
|
+
say(` Code ${c.bold(c.violet(d.user_code))} ${c.dim("approve only if the page shows this code")}`);
|
|
121
|
+
say();
|
|
123
122
|
if (browser)
|
|
124
|
-
openBrowser(
|
|
123
|
+
openBrowser(link);
|
|
124
|
+
const spin = spinner("Waiting for you to approve in the browser…");
|
|
125
|
+
try {
|
|
126
|
+
return await poll(url, d);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
spin.stop();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function poll(url, d) {
|
|
125
133
|
let interval = Number(d.interval) || 5;
|
|
126
134
|
const until = Date.now() + (Number(d.expires_in) || 600) * 1000;
|
|
127
135
|
while (Date.now() < until) {
|
|
@@ -242,6 +250,7 @@ catch {
|
|
|
242
250
|
const where = (s) => s.scope[0] === "projects" ? ` (project ${tilde(s.scope[1])})` : "";
|
|
243
251
|
// ---- init ----
|
|
244
252
|
async function init(v, project) {
|
|
253
|
+
header();
|
|
245
254
|
const at = await locate(v);
|
|
246
255
|
if (!at)
|
|
247
256
|
return 3;
|
|
@@ -274,42 +283,52 @@ async function init(v, project) {
|
|
|
274
283
|
workspace ??= await whoami(url, key);
|
|
275
284
|
const cfg = saved.url === url && saved.web === web && saved.key === key && saved.workspace?.name === workspace?.name
|
|
276
285
|
? join(cfgDir(), "config.json") : saveConfig({ url, web, key, ...(workspace ? { workspace } : {}) });
|
|
277
|
-
say(`Signed in
|
|
286
|
+
say(` ${c.ask(mark.ok)} Signed in${workspace?.name ? ` to workspace ${c.bold(`"${workspace.name}"`)}` : ""} ${c.dim(`key ${keyId(key)}`)}`);
|
|
287
|
+
say(c.dim(` ${url} ${mark.dot} key saved in ${tilde(cfg)}, readable only by you`));
|
|
278
288
|
const files = detect(project);
|
|
279
289
|
if (!files.length) {
|
|
280
|
-
say(`\
|
|
281
|
-
say("To guard an agent's tools: add an MCP server to Claude Code, Claude Desktop, Cursor, VS Code or Codex (none has one yet), then run init again.");
|
|
290
|
+
say(`\n See a stop now: ${c.bold(`${NAME} test`)}`);
|
|
291
|
+
say(c.dim(wrap(" To guard an agent's tools: add an MCP server to Claude Code, Claude Desktop, Cursor, VS Code or Codex (none has one yet), then run init again.", 2)));
|
|
282
292
|
return 1;
|
|
283
293
|
}
|
|
284
294
|
const maps = await api(url, key, "/api/workflows").then((r) => (Array.isArray(r.json) ? r.json : []), () => []);
|
|
285
295
|
const mapOf = (name) => maps.find((m) => (m.apps ?? []).some((a) => norm(a) && norm(name) && (norm(a).includes(norm(name)) || norm(name).includes(norm(a)))));
|
|
286
|
-
|
|
287
|
-
|
|
296
|
+
// per client: the servers it can guard (numbered, ● = matches a Work Map), the ones already guarded; the remote
|
|
297
|
+
// servers it can't guard yet are gathered into one line per reason under the list
|
|
298
|
+
const cands = [], gaps = new Map();
|
|
299
|
+
const all = files.flatMap((f) => f.servers);
|
|
300
|
+
const w = Math.min(28, Math.max(8, ...all.map((s) => (s.name + where(s)).length)));
|
|
301
|
+
say(`\n ${c.bold("MCP servers")}`);
|
|
288
302
|
for (const f of files) {
|
|
289
|
-
say(
|
|
303
|
+
say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}`);
|
|
290
304
|
if (f.error)
|
|
291
|
-
say(`
|
|
305
|
+
say(c.dim(` couldn't read it (${f.error}); left alone`));
|
|
292
306
|
for (const s of f.servers) {
|
|
307
|
+
const label = (s.name + where(s)).padEnd(w);
|
|
293
308
|
if (s.ours)
|
|
294
|
-
|
|
309
|
+
continue;
|
|
310
|
+
if (s.gap)
|
|
311
|
+
gaps.set(s.gap, [...(gaps.get(s.gap) ?? []), s.name]);
|
|
295
312
|
else if (s.guarded)
|
|
296
|
-
say(`
|
|
297
|
-
else if (s.gap)
|
|
298
|
-
say(` [ ] ${s.name}${where(s)} remote ${site(s.entry.url)}: ${s.gap}`);
|
|
313
|
+
say(` ${c.ask(" =")} ${label} ${c.dim("already guarded")}`);
|
|
299
314
|
else {
|
|
300
315
|
const m = mapOf(s.name);
|
|
301
316
|
cands.push(s);
|
|
302
|
-
say(`
|
|
303
|
-
(m ? `
|
|
317
|
+
say(` ${String(cands.length).padStart(2)} ${m ? c.ask(mark.on) : c.dim(mark.off)} ${m ? c.bold(label) : label} ` +
|
|
318
|
+
c.dim(s.remote ? `remote ${site(s.entry.url)}` : show(argvOf(s.entry))) + (m ? ` ${c.ask(`Work Map "${m.name}"`)}` : ""));
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
321
|
}
|
|
322
|
+
for (const [gap, names] of gaps) {
|
|
323
|
+
const u = [...new Set(names)], n = u.length, why = gap.replace(/ – not guarded yet$/, "");
|
|
324
|
+
const what = why.startsWith("needs ") ? `${n > 1 ? "need" : "needs"} ${why.slice(6)}` : `(${why})`;
|
|
325
|
+
say(c.dim(wrap(`\n ${mark.dot} ${n} remote server${n > 1 ? "s" : ""} ${what} ${mark.dash} guard ${n > 1 ? "them" : "it"} later: ${u.join(", ")}`, 4)));
|
|
326
|
+
}
|
|
307
327
|
say();
|
|
308
328
|
const byName = (names) => cands.filter((s) => names.includes(s.name));
|
|
309
329
|
let picks = cands.filter((s) => mapOf(s.name));
|
|
310
330
|
if (v.servers !== undefined) {
|
|
311
331
|
const names = v.servers.split(",").map((s) => s.trim()).filter(Boolean);
|
|
312
|
-
const all = files.flatMap((f) => f.servers);
|
|
313
332
|
for (const n of names) {
|
|
314
333
|
const hit = all.filter((s) => s.name === n);
|
|
315
334
|
if (!hit.length) {
|
|
@@ -317,9 +336,9 @@ async function init(v, project) {
|
|
|
317
336
|
return 2;
|
|
318
337
|
}
|
|
319
338
|
if (hit.every((s) => s.gap))
|
|
320
|
-
say(
|
|
339
|
+
say(c.dim(` ${mark.dot} ${n}: remote server, ${hit[0].gap}.`));
|
|
321
340
|
else if (hit.every((s) => s.guarded || s.gap))
|
|
322
|
-
say(
|
|
341
|
+
say(c.dim(` ${mark.dot} ${n}: already guarded.`));
|
|
323
342
|
}
|
|
324
343
|
picks = byName(names);
|
|
325
344
|
}
|
|
@@ -329,8 +348,11 @@ async function init(v, project) {
|
|
|
329
348
|
return 2;
|
|
330
349
|
}
|
|
331
350
|
const def = picks.map((s) => cands.indexOf(s) + 1).join(",");
|
|
351
|
+
if (def)
|
|
352
|
+
say(c.dim(` ${mark.on} matches a Work Map: recommended`));
|
|
332
353
|
for (;;) {
|
|
333
|
-
const a = (await ask(`Guard which?
|
|
354
|
+
const a = (await ask(` ${c.bold("Guard which?")} ${c.dim(`Enter = ${def ? `${def} (recommended)` : "none"} ${mark.dot} numbers like 1,3 ${mark.dot} all ${mark.dot} none`)} ${c.violet(mark.arrow)} `))
|
|
355
|
+
.trim().toLowerCase();
|
|
334
356
|
if (!a)
|
|
335
357
|
break;
|
|
336
358
|
if (a === "none") {
|
|
@@ -346,22 +368,24 @@ async function init(v, project) {
|
|
|
346
368
|
picks = ns.map((n) => cands[n - 1]);
|
|
347
369
|
break;
|
|
348
370
|
}
|
|
349
|
-
warn(`Pick numbers between 1 and ${cands.length}.`);
|
|
371
|
+
warn(` Pick numbers between 1 and ${cands.length}.`);
|
|
350
372
|
}
|
|
373
|
+
say();
|
|
351
374
|
}
|
|
352
375
|
else if (!picks.length && cands.length) {
|
|
353
|
-
say("None of these servers matches a Work Map yet, so none is guarded. Pick them with --servers a,b.");
|
|
376
|
+
say(c.dim(wrap(" None of these servers matches a Work Map yet, so none is guarded. Pick them with --servers a,b.", 2)));
|
|
354
377
|
}
|
|
355
378
|
const run = newRun();
|
|
356
|
-
const changed = [];
|
|
379
|
+
const changed = [], guardedIn = new Map(), added = [];
|
|
357
380
|
const userClients = new Set(files.filter((f) => f.user && !f.error).map((f) => f.client));
|
|
358
381
|
for (const f of files.filter((x) => !x.error)) {
|
|
359
|
-
const before = f.text, mine = picks.filter((s) => s.file === f);
|
|
382
|
+
const before = f.text, mine = picks.filter((s) => s.file === f), got = [];
|
|
383
|
+
let mcp = false;
|
|
360
384
|
try {
|
|
361
385
|
for (const s of mine) {
|
|
362
386
|
f.text = wrapServer(f, s);
|
|
363
387
|
load(f);
|
|
364
|
-
|
|
388
|
+
got.push(s.name + where(s));
|
|
365
389
|
}
|
|
366
390
|
// the Claros MCP server: in each client's user config, or its project file when the client has none
|
|
367
391
|
const top = f.servers.find((s) => s.name === CLAROS && s.scope.length === 1);
|
|
@@ -369,32 +393,43 @@ async function init(v, project) {
|
|
|
369
393
|
if (!top) {
|
|
370
394
|
f.text = addClaros(f);
|
|
371
395
|
load(f);
|
|
372
|
-
|
|
396
|
+
mcp = true;
|
|
373
397
|
}
|
|
374
398
|
else if (!top.ours)
|
|
375
|
-
say(`kept the existing "${CLAROS}" entry in ${tilde(f.path)} (not ours)`);
|
|
399
|
+
say(c.dim(` ${mark.dot} kept the existing "${CLAROS}" entry in ${tilde(f.path)} (not ours)`));
|
|
376
400
|
}
|
|
377
401
|
}
|
|
378
402
|
catch (e) {
|
|
379
|
-
warn(
|
|
403
|
+
warn(` ${tilde(f.path)}: ${e.message}; left unchanged`);
|
|
380
404
|
f.text = before;
|
|
381
405
|
continue;
|
|
382
406
|
}
|
|
383
407
|
if (f.text !== before) {
|
|
384
408
|
commit(run, f.path, before, f.text);
|
|
385
409
|
changed.push(f.client);
|
|
410
|
+
for (const n of got)
|
|
411
|
+
guardedIn.set(n, [...(guardedIn.get(n) ?? []), f.client]);
|
|
412
|
+
if (mcp)
|
|
413
|
+
added.push(f.client);
|
|
386
414
|
}
|
|
387
415
|
}
|
|
388
416
|
if (!changed.length) {
|
|
389
|
-
say("Nothing to change.");
|
|
417
|
+
say(" Nothing to change.");
|
|
390
418
|
return 0;
|
|
391
419
|
}
|
|
392
|
-
|
|
393
|
-
|
|
420
|
+
const list = (xs) => [...new Set(xs)].join(", ");
|
|
421
|
+
for (const [n, cl] of guardedIn)
|
|
422
|
+
say(` ${c.ask(mark.ok)} Guarded ${c.bold(n)} ${c.dim(`in ${list(cl)}`)}`);
|
|
423
|
+
if (added.length)
|
|
424
|
+
say(` ${c.ask(mark.ok)} Added the Claros MCP server ${c.dim(`in ${list(added)}`)}`);
|
|
425
|
+
say(c.dim(` every file backed up first: ${tilde(run.dir)}`));
|
|
426
|
+
say(`\n ${c.bold("Next")} restart ${list(changed)}, then run ${c.violet(`${NAME} test`)}`);
|
|
427
|
+
say(c.dim(` undo anytime: ${NAME} undo`));
|
|
394
428
|
return 0;
|
|
395
429
|
}
|
|
396
430
|
// ---- test ----
|
|
397
431
|
async function test(v, project) {
|
|
432
|
+
header();
|
|
398
433
|
const at = await locate(v);
|
|
399
434
|
if (!at)
|
|
400
435
|
return 3;
|
|
@@ -412,7 +447,7 @@ async function test(v, project) {
|
|
|
412
447
|
return 3;
|
|
413
448
|
}
|
|
414
449
|
if (ex.status === 404) {
|
|
415
|
-
say(`No approved Work Map with an exact rule to test yet. Paste a policy at ${at.web}/policy, approve the map, then run this again.`);
|
|
450
|
+
say(` No approved Work Map with an exact rule to test yet. Paste a policy at ${at.web}/policy, approve the map, then run this again.`);
|
|
416
451
|
return 1;
|
|
417
452
|
}
|
|
418
453
|
if (ex.status !== 200)
|
|
@@ -423,21 +458,27 @@ async function test(v, project) {
|
|
|
423
458
|
if (r.status !== 200)
|
|
424
459
|
throw new Error(`HTTP ${r.status}`);
|
|
425
460
|
const ms = Date.now() - t0;
|
|
426
|
-
if (e.demo)
|
|
427
|
-
say(`DEMO: ${guarded.length ? `none of your guarded servers (${guarded.join(", ")}) matches` : "no server is guarded yet, so nothing matches"} a Work Map, so this checks a made-up record against "${e.name}".`);
|
|
428
|
-
say(`Made-up test action for ${e.app ?? "a demo app"} (only checked, never run): ${Object.entries(e.state).map(([k, x]) => `${k} = ${x}`).join(", ")}`);
|
|
429
461
|
if (r.json.allowed) {
|
|
430
|
-
say(
|
|
462
|
+
say(` ${c.stop(mark.no)} Claros let it through (check ${r.json.check_id}). It should have been stopped by rule ${e.guardrail_id}; tell the Claros team.`);
|
|
431
463
|
return 1;
|
|
432
464
|
}
|
|
433
|
-
|
|
465
|
+
// the outcome first, then a card per broken rule, then the made-up action and what the agent sees
|
|
466
|
+
const held = r.json.result === "ask", ink = held ? c.ask : c.stop;
|
|
467
|
+
say(` ${ink(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"} in ${dur(ms)}`))} ` +
|
|
468
|
+
c.dim(`Work Map "${e.name}" ${mark.dot} check ${r.json.check_id}`));
|
|
434
469
|
for (const x of r.json.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
|
|
435
|
-
|
|
470
|
+
const ask = x.action === "ask", p = ask ? c.ask : c.stop;
|
|
471
|
+
say();
|
|
472
|
+
say(` ${p(`${ask ? mark.ask : mark.stop} ${ask ? "ask " : "stop"}`)} ${c.bold(wrap(x.rule, 12))}`);
|
|
436
473
|
if (x.quote)
|
|
437
|
-
say(`
|
|
438
|
-
say(`
|
|
474
|
+
say(` ${wrap(`“${x.quote}”`, 12)}${x.expert ? c.dim(` ${mark.dash} ${x.expert}`) : ""}`);
|
|
475
|
+
say(` ${c.dim("Who to ask:")} ${x.who_to_ask || x.owner || x.expert || "the process owner"}`);
|
|
439
476
|
}
|
|
440
|
-
say(
|
|
477
|
+
say();
|
|
478
|
+
say(c.dim(wrap(` Test action for ${e.app ?? "a demo app"}, made up, only checked: ${Object.entries(e.state).map(([k, x]) => `${k} = ${x}`).join(", ")}`, 4)));
|
|
479
|
+
say(wrap(` Your agent gets the rule as the tool's error. Nothing reached ${e.app ?? "any app"}.`, 2));
|
|
480
|
+
if (e.demo)
|
|
481
|
+
say(c.dim(wrap(` Demo: ${guarded.length ? `none of your guarded servers (${guarded.join(", ")}) matches a Work Map` : "no server is guarded yet"}, so this used your newest Work Map.`, 2)));
|
|
441
482
|
return 0;
|
|
442
483
|
}
|
|
443
484
|
catch (err) {
|
|
@@ -503,6 +544,7 @@ async function override(v, checkId) {
|
|
|
503
544
|
}
|
|
504
545
|
// ---- status ----
|
|
505
546
|
async function status(v, project) {
|
|
547
|
+
header();
|
|
506
548
|
const at = await locate(v);
|
|
507
549
|
if (!at)
|
|
508
550
|
return 3;
|
|
@@ -510,12 +552,16 @@ async function status(v, project) {
|
|
|
510
552
|
const up = await fetch(url + "/healthz", { signal: AbortSignal.timeout(10_000) }).then((r) => r.ok, () => false);
|
|
511
553
|
const ks = key ? await keyStatus(url, key) : "not set (run init)";
|
|
512
554
|
const ws = ks === "valid" ? (await whoami(url, key))?.name ?? (loadConfig().key === key ? loadConfig().workspace?.name : null) : null;
|
|
513
|
-
|
|
555
|
+
const ok = (good, s) => (good ? c.ask(s) : c.stop(s));
|
|
556
|
+
const fine = up && ks === "valid";
|
|
557
|
+
say(` ${ok(fine, fine ? mark.ok : mark.no)} ${c.bold("Claros")} ${url} ${ok(up, up ? "reachable" : "unreachable")}` +
|
|
558
|
+
` key ${key ? keyId(key) + " " : ""}${ok(ks === "valid", ks)}${ws ? ` workspace "${ws}"` : ""}`);
|
|
514
559
|
for (const f of detect(project)) {
|
|
515
|
-
say(`\n${f.client} ${tilde(f.path)}${f.error ? ` couldn't read it (${f.error})` : ""}`);
|
|
560
|
+
say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}${f.error ? ` couldn't read it (${f.error})` : ""}`);
|
|
516
561
|
for (const s of f.servers) {
|
|
517
|
-
|
|
518
|
-
: s.gap ? `remote, ${s.gap}` : s.remote ? "remote, not guarded" : "not guarded"
|
|
562
|
+
const [m, state] = s.ours ? [c.violet(mark.dot), c.dim("Claros MCP server")] : s.guarded ? [c.ask(mark.ok), c.ask("guarded")]
|
|
563
|
+
: s.gap ? [c.dim(mark.dot), c.dim(`remote, ${s.gap}`)] : [c.dim(mark.off), s.remote ? "remote, not guarded" : "not guarded"];
|
|
564
|
+
say(` ${m} ${(s.name + where(s)).padEnd(24)} ${state}`);
|
|
519
565
|
}
|
|
520
566
|
}
|
|
521
567
|
return !key ? 2 : up && ks === "valid" ? 0 : 3;
|
|
@@ -536,9 +582,10 @@ function unwrapAll(f) {
|
|
|
536
582
|
return n;
|
|
537
583
|
}
|
|
538
584
|
async function undo(v, project) {
|
|
585
|
+
header();
|
|
539
586
|
const scope = v.all ? runs() : runs().slice(-1);
|
|
540
587
|
if (!scope.length && !v.all) {
|
|
541
|
-
say(`Nothing to undo: no backups from ${NAME} init.`);
|
|
588
|
+
say(` Nothing to undo: no backups from ${NAME} init.`);
|
|
542
589
|
return 0;
|
|
543
590
|
}
|
|
544
591
|
const chains = new Map();
|
|
@@ -549,14 +596,14 @@ async function undo(v, project) {
|
|
|
549
596
|
const byPath = new Map(detect(project).map((f) => [f.path, f]));
|
|
550
597
|
for (const [path, chain] of chains) {
|
|
551
598
|
if (!existsSync(path)) {
|
|
552
|
-
say(
|
|
599
|
+
say(` ${mark.dot} ${tilde(path)}: gone; nothing to undo`);
|
|
553
600
|
continue;
|
|
554
601
|
}
|
|
555
602
|
const cur = readFileSync(path, "utf8");
|
|
556
603
|
const same = sha(cur) === chain.at(-1).after && chain.every((b, i) => !i || b.before === chain[i - 1].after);
|
|
557
604
|
if (same) {
|
|
558
605
|
commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
|
|
559
|
-
say(
|
|
606
|
+
say(` ${c.ask(mark.ok)} ${tilde(path)}: restored from the backup taken before init (unchanged since)`);
|
|
560
607
|
}
|
|
561
608
|
else {
|
|
562
609
|
const f = byPath.get(path) ?? detect(project).find((x) => x.path === path);
|
|
@@ -567,7 +614,7 @@ async function undo(v, project) {
|
|
|
567
614
|
const n = unwrapAll(f);
|
|
568
615
|
if (f.text !== cur)
|
|
569
616
|
commit(safety, path, cur, f.text);
|
|
570
|
-
say(
|
|
617
|
+
say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so unwrapped ${n} server(s) in place and kept the other edits`);
|
|
571
618
|
}
|
|
572
619
|
byPath.delete(path);
|
|
573
620
|
}
|
|
@@ -578,12 +625,12 @@ async function undo(v, project) {
|
|
|
578
625
|
const cur = f.text, n = unwrapAll(f);
|
|
579
626
|
if (f.text !== cur) {
|
|
580
627
|
commit(safety, f.path, cur, f.text);
|
|
581
|
-
say(
|
|
628
|
+
say(` ${c.ask(mark.ok)} ${tilde(f.path)}: unwrapped ${n} server(s) in place`);
|
|
582
629
|
}
|
|
583
630
|
}
|
|
584
631
|
}
|
|
585
632
|
for (const r of scope)
|
|
586
633
|
renameSync(r.dir, r.dir + ".undone");
|
|
587
|
-
say(
|
|
634
|
+
say(`\n ${c.bold("Done.")} Restart your agent clients to load the change.${safety.files.length ? ` What undo replaced is in ${tilde(safety.dir)}.` : ""}`);
|
|
588
635
|
return 0;
|
|
589
636
|
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Terminal styling for init / test / status / undo: hand-rolled ANSI, no dependency.
|
|
2
|
+
// fancy (the header, the spinner, wrapped cards) when stdout is a terminal or FORCE_COLOR is set; colour when the
|
|
3
|
+
// terminal has it, by Node's own rules (NO_COLOR, FORCE_COLOR, TERM=dumb, COLORTERM, Windows versions). Piped:
|
|
4
|
+
// plain text, no escape codes, so the output stays greppable. Marks fall back to ASCII on old Windows consoles.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { WriteStream } from "node:tty";
|
|
7
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
8
|
+
export function styleFor(s, env = process.env, platform = process.platform) {
|
|
9
|
+
const forced = env.FORCE_COLOR !== undefined && !["0", "false"].includes(env.FORCE_COLOR);
|
|
10
|
+
const fancy = !!s.isTTY || forced;
|
|
11
|
+
const depth = fancy ? WriteStream.prototype.getColorDepth.call(null, env) : 1;
|
|
12
|
+
const uni = platform !== "win32" || !!(env.WT_SESSION || env.TERM_PROGRAM || env.TERM?.startsWith("xterm"));
|
|
13
|
+
return { fancy, depth, uni };
|
|
14
|
+
}
|
|
15
|
+
export const S = styleFor(process.stdout);
|
|
16
|
+
// the brand's three answers (stop magenta, ask green, nudge vermilion) and the quote's violet, a shade brighter than
|
|
17
|
+
// the web's inks so they read on dark terminals as well as light ones: [truecolor, 256-colour, 16-colour]
|
|
18
|
+
const INK = { stop: ["#c8359a", 162, 35], ask: ["#22995f", 29, 32], nudge: ["#e0531f", 166, 31], violet: ["#8b5cf6", 98, 34] };
|
|
19
|
+
const fg = ([hex, n, basic]) => S.depth >= 24
|
|
20
|
+
? `38;2;${[1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(";")}` : S.depth >= 8 ? `38;5;${n}` : String(basic);
|
|
21
|
+
const sgr = (on, off) => (s) => (S.depth > 1 && s ? `\x1b[${on}m${s}\x1b[${off}m` : s);
|
|
22
|
+
export const c = {
|
|
23
|
+
bold: sgr("1", "22"), dim: sgr("2", "22"), ul: sgr("4", "24"),
|
|
24
|
+
stop: sgr(fg(INK.stop), "39"), ask: sgr(fg(INK.ask), "39"), nudge: sgr(fg(INK.nudge), "39"), violet: sgr(fg(INK.violet), "39"),
|
|
25
|
+
};
|
|
26
|
+
const U = S.uni;
|
|
27
|
+
/** stop = the seal, ask = the thread hooked to a person, nudge = the proofreader's pencil */
|
|
28
|
+
export const mark = {
|
|
29
|
+
stop: U ? "◉" : "x", ask: U ? "↳" : "?", nudge: U ? "✎" : "~", ok: U ? "✓" : "+", no: U ? "✗" : "x",
|
|
30
|
+
on: U ? "●" : "*", off: U ? "○" : "o", dot: U ? "·" : "-", arrow: U ? "›" : ">", dash: U ? "—" : "-",
|
|
31
|
+
};
|
|
32
|
+
export const say = (s = "") => process.stdout.write(s + "\n");
|
|
33
|
+
/** claros” and the version: only on a terminal (piped output starts with what it says) */
|
|
34
|
+
export function header() {
|
|
35
|
+
if (S.fancy)
|
|
36
|
+
say(`\n ${c.bold("claros")}${c.violet(U ? "”" : '"')} ${c.dim(`getclaros ${VERSION}`)}\n`);
|
|
37
|
+
}
|
|
38
|
+
/** wraps to the terminal's width with a hanging indent (plain output isn't wrapped) */
|
|
39
|
+
export function wrap(text, indent) {
|
|
40
|
+
const width = (process.stdout.columns || 0) - indent;
|
|
41
|
+
if (!S.fancy || width < 30)
|
|
42
|
+
return text;
|
|
43
|
+
const lead = /^\s*/.exec(text)[0], out = [];
|
|
44
|
+
let line = lead;
|
|
45
|
+
for (const w of text.slice(lead.length).split(" ")) {
|
|
46
|
+
if (line.trim() && (line + " " + w).length > width) {
|
|
47
|
+
out.push(line);
|
|
48
|
+
line = w;
|
|
49
|
+
}
|
|
50
|
+
else
|
|
51
|
+
line = line.trim() ? line + " " + w : line + w;
|
|
52
|
+
}
|
|
53
|
+
out.push(line);
|
|
54
|
+
return out.join("\n" + " ".repeat(indent));
|
|
55
|
+
}
|
|
56
|
+
/** a braille spinner on a real terminal (never when piped or forced); stop() clears its line */
|
|
57
|
+
export function spinner(text) {
|
|
58
|
+
if (!process.stdout.isTTY)
|
|
59
|
+
return { stop() { } };
|
|
60
|
+
const F = U ? "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" : "|/-\\";
|
|
61
|
+
let i = 0;
|
|
62
|
+
const draw = () => process.stdout.write(`\r\x1b[2K ${c.violet(F[i++ % F.length])} ${c.dim(text)}`);
|
|
63
|
+
draw();
|
|
64
|
+
const t = setInterval(draw, 80);
|
|
65
|
+
return { stop() { clearInterval(t); process.stdout.write("\r\x1b[2K"); } };
|
|
66
|
+
}
|
|
67
|
+
export const dur = (ms) => (ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`);
|