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 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
- Not published yet (`"private": true`); until then run `node dist/cli.js init` with
16
- `GETCLAROS_LAUNCHER="node /abs/path/to/dist/cli.js"` so the configs launch your build instead of `npx -y getclaros`.
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 says `DEMO`. Exit codes: 0 stopped, 1 not stopped
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
- say(`Sign in to Claros in your browser:
119
-
120
- ${d.verification_uri_complete ?? d.verification_uri}
121
-
122
- and check it shows the code ${d.user_code}. Waiting…`);
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(d.verification_uri_complete ?? d.verification_uri);
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 to ${url}${workspace?.name ? ` (workspace "${workspace.name}")` : ""} with key ${keyId(key)} (saved in ${tilde(cfg)}, readable only by you).`);
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(`\nSee a stop now: ${NAME} test`);
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
- const cands = [];
287
- say();
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(`${f.client} ${tilde(f.path)}`);
303
+ say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}`);
290
304
  if (f.error)
291
- say(` couldn't read it (${f.error}); left alone`);
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
- say(` ${s.name}${where(s)} Claros MCP server (agents read the Work Map with get_workflow)`);
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(` [=] ${s.name}${where(s)} already guarded`);
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(` ${String(cands.length).padStart(2)}. ${s.name}${where(s)} ${s.remote ? `remote ${site(s.entry.url)}` : show(argvOf(s.entry))}` +
303
- (m ? ` ← Work Map "${m.name}"` : ""));
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(`${n}: remote server, ${hit[0].gap}.`);
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(`${n}: already guarded.`);
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? Numbers (1,3), "all", "none"${def ? `, Enter = ${def}` : ""}: `)).trim().toLowerCase();
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
- say(`guarded ${s.name}${where(s)} in ${f.client} (${tilde(f.path)})`);
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
- say(`added the Claros MCP server to ${f.client} (${tilde(f.path)})`);
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(`${tilde(f.path)}: ${e.message}; left unchanged`);
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
- say(`\nBacked up every file before changing it (${tilde(run.dir)}).`);
393
- say(`Restart ${[...new Set(changed)].join(", ")} to load the change. Then: ${NAME} test Undo: ${NAME} undo`);
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(`\nClaros let it through (check ${r.json.check_id}). It should have been stopped by rule ${e.guardrail_id}; tell the Claros team.`);
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
- say(`\nClaros stopped it in ${ms} ms (Work Map "${e.name}", check ${r.json.check_id}):`);
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
- say(`\n Rule: ${x.rule}`);
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(` In the expert's words${x.expert ? ` (${x.expert})` : ""}: "${x.quote}"`);
438
- say(` Who to ask: ${x.who_to_ask || x.owner || x.expert || "the process owner"}`);
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(`\nYour agent gets this as the tool's error instead of the call going through. Nothing was sent to ${e.app ?? "any app"}.`);
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
- say(`Claros ${url} ${up ? "reachable" : "unreachable"} key ${key ? keyId(key) + " " : ""}${ks}${ws ? ` workspace "${ws}"` : ""}`);
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
- say(` ${(s.name + where(s)).padEnd(24)} ${s.ours ? "Claros MCP server" : s.guarded ? "guarded"
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(`${tilde(path)}: gone; nothing to undo`);
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(`${tilde(path)}: restored from the backup taken before init (unchanged since)`);
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(`${tilde(path)}: changed since init, so unwrapped ${n} server(s) in place and kept the other edits`);
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(`${tilde(f.path)}: unwrapped ${n} server(s) in place`);
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(`Done. Restart your agent clients to load the change.${safety.files.length ? ` What undo replaced is in ${tilde(safety.dir)}.` : ""}`);
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`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getclaros",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Claros guard: checks an agent's MCP tool calls against your experts' rules before they run",
5
5
  "license": "MIT",
6
6
  "type": "module",