getclaros 0.1.1 → 0.3.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/dist/setup.js CHANGED
@@ -1,23 +1,28 @@
1
- // getclaros init | test | undo | status (| export: a map's rules as markdown): puts the guard in front of the MCP servers an agent client already runs.
2
- // Edits only existing config files, backs each one up first (~/.config/getclaros/backups/<time>/, 0600), writes
3
- // atomically, and never prints the key.
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
- import { spawn } from "node:child_process";
1
+ // getclaros init | test | undo | doctor (= status) (| export: a map's rules as markdown): puts the guard in front of
2
+ // the MCP servers an agent client already runs, and in front of the browser agents (Playwright, browser-use) in this
3
+ // project. Edits only existing config files (plus, for Playwright Test, one new fixture file and CLAROS_KEY in a
4
+ // gitignored .env), backs each one up first (~/.config/getclaros/backups/<time>/, 0600), writes atomically, and never
5
+ // prints the key.
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { spawn, spawnSync } from "node:child_process";
6
8
  import { homedir, hostname } from "node:os";
7
- import { basename, join } from "node:path";
9
+ import { basename, dirname, join } from "node:path";
8
10
  import { createInterface } from "node:readline/promises";
9
11
  import { parseArgs } from "node:util";
10
12
  import { addClaros, argvOf, CLAROS, detect, launcher, load, removeEntry, sha, unwrapServer, wrapServer, writeAtomic, } from "./clients.js";
11
- import { cfgDir, loadConfig, relocate, resolve, saveConfig } from "./config.js";
13
+ import { cfgDir, DEFAULT_WEB, loadConfig, relocate, resolve, saveConfig } from "./config.js";
14
+ import { addHooks, hasHooks, hookAgents, hookFiles, removeHooks } from "./hooks.js";
15
+ import { detectBrowser, envTarget, envValue, fixtureImport, fixtureText, setEnv, short } from "./projects.js";
12
16
  import { c, dur, header, mark, say, spinner, VERSION, wrap } from "./ui.js";
13
17
  const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
14
18
  const warn = (s) => process.stderr.write(s + "\n");
15
19
  const tilde = (p) => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p;
16
20
  const keyId = (k) => k.split("_").slice(0, 2).join("_") + "_…"; // clr_<prefix>_… (the prefix is the lookup id, not the secret)
17
21
  const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
18
- export const USAGE = `usage: ${NAME} init [--key clr_… | env CLAROS_KEY | --paste] [--no-browser] [--url <claros server> | --web <claros web app>] [--yes] [--servers a,b] [--project <dir>] [--no-mcp]
22
+ export const USAGE = `usage: ${NAME} init [--key clr_… | env CLAROS_KEY | --paste] [--no-browser] [--url <claros server> | --web <claros web app>] [--yes] [--servers a,b] [--project <dir>] [--no-mcp] [--no-browser-hooks]
23
+ [--no-hooks] [--user] [--fail-closed] (coding-agent hooks: project scope, --user for all projects)
19
24
  ${NAME} test [--app <server>]
20
- ${NAME} status
25
+ ${NAME} doctor [--project <dir>] (status: the same)
21
26
  ${NAME} undo [--all]
22
27
  ${NAME} export <workflow_id> [--format agents|design|skill] [> AGENTS.md]
23
28
  ${NAME} override <check_id> --reason "…" [--rule <rule id>] [--verdict wrong_stop|wrong_nudge|missed|right]`;
@@ -28,8 +33,9 @@ export async function run(argv) {
28
33
  ({ values: v, positionals: pos } = parseArgs({ args: rest, allowPositionals: cmd === "export" || cmd === "override", options: {
29
34
  url: { type: "string" }, web: { type: "string" }, key: { type: "string" }, yes: { type: "boolean", short: "y" }, servers: { type: "string" },
30
35
  project: { type: "string" }, all: { type: "boolean" }, "no-mcp": { type: "boolean" }, app: { type: "string" },
31
- paste: { type: "boolean" }, "no-browser": { type: "boolean" }, format: { type: "string" },
36
+ paste: { type: "boolean" }, "no-browser": { type: "boolean" }, "no-browser-hooks": { type: "boolean" }, format: { type: "string" },
32
37
  reason: { type: "string" }, rule: { type: "string" }, verdict: { type: "string" },
38
+ user: { type: "boolean" }, "no-hooks": { type: "boolean" }, "fail-closed": { type: "boolean" },
33
39
  } }));
34
40
  }
35
41
  catch (e) {
@@ -41,8 +47,8 @@ export async function run(argv) {
41
47
  return init(v, project);
42
48
  if (cmd === "test")
43
49
  return test(v, project);
44
- if (cmd === "status")
45
- return status(v, project);
50
+ if (cmd === "status" || cmd === "doctor")
51
+ return doctor(v, project);
46
52
  if (cmd === "undo")
47
53
  return undo(v, project);
48
54
  if (cmd === "export" && pos.length === 1 && ["agents", "design", "skill"].includes(v.format ?? "agents")) {
@@ -209,16 +215,20 @@ function askHidden(q) {
209
215
  }
210
216
  const backupsDir = () => join(cfgDir(), "backups");
211
217
  const newRun = (suffix = "") => ({ dir: join(backupsDir(), new Date().toISOString().replace(/[:.]/g, "-") + suffix), files: [] });
212
- /** Backs up `before`, writes `after` atomically, records both hashes. */
213
- function commit(run, path, before, after) {
218
+ /** Backs up `before` (null: a new file), writes `after` atomically, records both hashes. */
219
+ function commit(run, path, before, after, vars) {
214
220
  if (!run.files.length) {
215
221
  mkdirSync(cfgDir(), { recursive: true, mode: 0o700 });
216
222
  mkdirSync(run.dir, { recursive: true, mode: 0o700 });
217
223
  }
218
224
  const backup = join(run.dir, `${run.files.length}-${basename(path)}`);
219
- writeFileSync(backup, before, { mode: 0o600 });
220
- writeAtomic(path, after);
221
- run.files.push({ path, backup, before: sha(before), after: sha(after) });
225
+ writeFileSync(backup, before ?? "", { mode: 0o600 });
226
+ if (before === null)
227
+ writeFileSync(path, after, { flag: "wx" });
228
+ else
229
+ writeAtomic(path, after);
230
+ run.files.push({ path, backup, before: sha(before ?? ""), after: sha(after), ...(before === null ? { created: true } : {}),
231
+ ...(vars ? { vars } : {}) });
222
232
  writeFileSync(join(run.dir, "manifest.json"), JSON.stringify({ files: run.files }, null, 2), { mode: 0o600 });
223
233
  }
224
234
  function runs() {
@@ -285,12 +295,52 @@ async function init(v, project) {
285
295
  ? join(cfgDir(), "config.json") : saveConfig({ url, web, key, ...(workspace ? { workspace } : {}) });
286
296
  say(` ${c.ask(mark.ok)} Signed in${workspace?.name ? ` to workspace ${c.bold(`"${workspace.name}"`)}` : ""} ${c.dim(`key ${keyId(key)}`)}`);
287
297
  say(c.dim(` ${url} ${mark.dot} key saved in ${tilde(cfg)}, readable only by you`));
288
- const files = detect(project);
289
- if (!files.length) {
298
+ const files = detect(project), browsers = detectBrowser(project), agents = v["no-hooks"] ? [] : hookAgents(project);
299
+ if (!files.length && !browsers.length && !agents.length) {
290
300
  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)));
301
+ say(c.dim(wrap(" To guard an agent: add an MCP server to Claude Code, Claude Desktop, Cursor, VS Code or Codex (none has one yet), or run init in a Playwright or browser-use project, then run init again.", 2)));
292
302
  return 1;
293
303
  }
304
+ const run = newRun();
305
+ let mcp = { changed: [], guardedIn: new Map(), added: [] };
306
+ if (files.length) {
307
+ const r = await mcpSetup(v, files, url, key, run);
308
+ if (typeof r === "number")
309
+ return r;
310
+ mcp = r;
311
+ }
312
+ else {
313
+ say(`\n ${c.bold("MCP agents")}`);
314
+ say(c.dim(wrap(" none found (Claude Code, Claude Desktop, Cursor, VS Code, Codex); add an MCP server to one, then run init again", 4)));
315
+ }
316
+ const hk = await hookSetup(v, agents, project, run);
317
+ const br = await browserSetup(v, browsers, project, at, key, run);
318
+ const { guardedIn, added } = mcp, changed = [...mcp.changed, ...hk.clients];
319
+ if (!changed.length && !br.done.length) {
320
+ if (br.todo)
321
+ say(` ${c.bold("Next")} add the hook shown above, then run your browser agent`);
322
+ say(" Nothing to change.");
323
+ return 0;
324
+ }
325
+ const list = (xs) => [...new Set(xs)].join(", ");
326
+ for (const [n, cl] of guardedIn)
327
+ say(` ${c.ask(mark.ok)} Guarded ${c.bold(n)} ${c.dim(`in ${list(cl)}`)}`);
328
+ if (added.length)
329
+ say(` ${c.ask(mark.ok)} Added the Claros MCP server ${c.dim(`in ${list(added)}`)}`);
330
+ for (const l of hk.done)
331
+ say(` ${c.ask(mark.ok)} ${l}`);
332
+ for (const l of br.done)
333
+ say(` ${c.ask(mark.ok)} ${l}`);
334
+ if (run.files.length)
335
+ say(c.dim(` every file backed up first: ${tilde(run.dir)}`));
336
+ say(`\n ${c.bold("Next")} ${changed.length ? `restart ${list(changed)}, then run ${c.violet(`${NAME} test`)}` : `run ${c.violet(`${NAME} test`)}`}`);
337
+ if (br.todo)
338
+ say(` add the browser hook shown above, then run your browser agent`);
339
+ say(c.dim(` check the setup: ${NAME} doctor ${mark.dot} undo anytime: ${NAME} undo`));
340
+ return 0;
341
+ }
342
+ /** MCP agents: list each client's servers, ask which to guard, wrap them. A number = exit with it. */
343
+ async function mcpSetup(v, files, url, key, run) {
294
344
  const maps = await api(url, key, "/api/workflows").then((r) => (Array.isArray(r.json) ? r.json : []), () => []);
295
345
  const mapOf = (name) => maps.find((m) => (m.apps ?? []).some((a) => norm(a) && norm(name) && (norm(a).includes(norm(name)) || norm(name).includes(norm(a)))));
296
346
  // per client: the servers it can guard (numbered, ● = matches a Work Map), the ones already guarded; the remote
@@ -298,7 +348,7 @@ async function init(v, project) {
298
348
  const cands = [], gaps = new Map();
299
349
  const all = files.flatMap((f) => f.servers);
300
350
  const w = Math.min(28, Math.max(8, ...all.map((s) => (s.name + where(s)).length)));
301
- say(`\n ${c.bold("MCP servers")}`);
351
+ say(`\n ${c.bold("MCP agents")}`);
302
352
  for (const f of files) {
303
353
  say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}`);
304
354
  if (f.error)
@@ -375,7 +425,6 @@ async function init(v, project) {
375
425
  else if (!picks.length && cands.length) {
376
426
  say(c.dim(wrap(" None of these servers matches a Work Map yet, so none is guarded. Pick them with --servers a,b.", 2)));
377
427
  }
378
- const run = newRun();
379
428
  const changed = [], guardedIn = new Map(), added = [];
380
429
  const userClients = new Set(files.filter((f) => f.user && !f.error).map((f) => f.client));
381
430
  for (const f of files.filter((x) => !x.error)) {
@@ -413,19 +462,131 @@ async function init(v, project) {
413
462
  added.push(f.client);
414
463
  }
415
464
  }
416
- if (!changed.length) {
417
- say(" Nothing to change.");
418
- return 0;
465
+ return { changed, guardedIn, added };
466
+ }
467
+ // ---- coding-agent hooks (Claude Code, Codex, Cursor): shell commands and file edits checked before they run ----
468
+ /** Adds `getclaros hook <agent>` to each detected agent's hooks file (project scope; --user: the user's), merged
469
+ * into what is there. Never asks: like the Claros MCP entry, it is part of init (--no-hooks skips it). */
470
+ async function hookSetup(v, agents, project, run) {
471
+ const done = [], clients = [];
472
+ if (!agents.length)
473
+ return { done, clients };
474
+ say(`\n ${c.bold("Coding-agent hooks")} ${c.dim("shell commands and file edits checked before they run")}`);
475
+ const cmd = [...launcher(), "hook"].join(" ");
476
+ for (const a of agents) {
477
+ const f = hookFiles(project).find((x) => x.agent === a && x.user === !!v.user);
478
+ const before = existsSync(f.path) ? readFileSync(f.path, "utf8") : null;
479
+ let after;
480
+ try {
481
+ after = await addHooks(before ?? "", a, `${cmd} ${a}${v["fail-closed"] ? " --fail-closed" : ""}`);
482
+ }
483
+ catch (e) {
484
+ warn(` ${tilde(f.path)}: ${e.message}; left unchanged`);
485
+ continue;
486
+ }
487
+ if (after === before) {
488
+ say(c.dim(` ${f.client}: already hooked in ${tilde(f.path)}`));
489
+ continue;
490
+ }
491
+ say(` ${f.client.padEnd(12)} ${c.dim(tilde(f.path))}`);
492
+ if (before === null)
493
+ mkdirSync(dirname(f.path), { recursive: true });
494
+ commit(run, f.path, before, after);
495
+ done.push(`Hooked ${f.client} ${c.dim(`in ${tilde(f.path)}`)}`);
496
+ clients.push(f.client);
497
+ }
498
+ if (clients.includes("Codex"))
499
+ say(c.dim(wrap(" Codex runs a new hook only after you trust it: open /hooks in Codex once (project hooks also need a trusted project).", 4)));
500
+ return { done, clients };
501
+ }
502
+ const isHookPath = (p, project) => /[\\/]\.(claude[\\/]settings|codex[\\/]hooks|cursor[\\/]hooks)\.json$/.test(p) || hookFiles(project).some((f) => f.path === p);
503
+ // ---- browser agents (Playwright, browser-use) in this project ----
504
+ /** A shell line that exports the saved key without printing it. */
505
+ const exportKey = () => `export CLAROS_KEY="$(node -p "require('${join(cfgDir(), "config.json").replace(homedir(), "$HOME")}').key")"`;
506
+ /** The 2-line hook for a project, as lines to print (Playwright Test: the import of the fixture init writes). */
507
+ function hookLines(p) {
508
+ if (p.fixture)
509
+ return [fixtureImport(p)];
510
+ if (p.kind === "Playwright")
511
+ return ['import { guard } from "getclaros-browser";', "await guard(page); // right after you create the page"];
512
+ return ["from claros_browser import guard_tools", "guard_tools(tools) # the Tools() you pass to Agent(..., tools=tools)"];
513
+ }
514
+ /** Each detected project's plan (install, fixture, key, hook), a question per project (Enter = yes; --yes; skipped
515
+ * with --no-browser-hooks), then the install, the fixture file and CLAROS_KEY in a gitignored .env. User source
516
+ * files are never edited: the hook is printed. */
517
+ async function browserSetup(v, ps, project, at, key, run) {
518
+ const done = [];
519
+ let todo = false, wantKey = false;
520
+ if (!ps.length)
521
+ return { done, todo };
522
+ const env = envTarget(project);
523
+ const vars = { CLAROS_KEY: key,
524
+ ...(at.source === "pinned" ? { CLAROS_URL: at.url } : at.web !== DEFAULT_WEB ? { CLAROS_WEB_URL: at.web } : {}) };
525
+ const keySet = Object.entries(vars).every(([k, x]) => process.env[k] === x || (env.ok && envValue(env.path, k) === x));
526
+ const rel = (f) => short(f, project);
527
+ say(`\n ${c.bold("Browser agents in this project")}`);
528
+ for (const p of ps) {
529
+ const row = (what, s) => say(` ${c.dim(what.padEnd(8))} ${s}`);
530
+ say(`\n ${c.bold(p.kind)} ${c.dim(`${tilde(p.dir)} ${mark.dot} ${p.via}`)}`);
531
+ row("install", p.installed ? c.dim("already installed") : p.install ? c.violet(p.install.map(tilde).join(" ")) : c.dim(p.installNote));
532
+ if (p.fixture && !existsSync(p.fixture))
533
+ row("write", `${rel(p.fixture)} ${c.dim("a fixture: every test's page goes through the guard")}`);
534
+ row("key", keySet ? c.dim("CLAROS_KEY already set") : env.ok ? `CLAROS_KEY ${mark.to} .env ${c.dim("gitignored")}`
535
+ : c.dim(`CLAROS_KEY: export it (${env.why})`));
536
+ if (p.hooked)
537
+ row("hook", c.dim("already in place"));
538
+ else {
539
+ say(` ${c.dim("hook".padEnd(8))} ${p.fixture ? "in" : "add to"} ${p.hookFile ? c.bold(rel(p.hookFile)) : p.fixture ? "each spec" : p.kind === "Playwright"
540
+ ? "the file that opens the page" : "the file that builds your Agent"}${p.more ? ` (and ${p.more} more spec${p.more > 1 ? "s" : ""})` : ""}` +
541
+ c.dim(p.fixture ? `, instead of "@playwright/test":` : ":"));
542
+ for (const l of hookLines(p))
543
+ say(` ${c.violet(l)}`);
544
+ }
545
+ if (v["no-browser-hooks"]) {
546
+ say(c.dim(" skipped (--no-browser-hooks)"));
547
+ continue;
548
+ }
549
+ if (!v.yes) {
550
+ if (!process.stdin.isTTY) {
551
+ say(c.dim(` not set up: not a terminal, pass --yes`));
552
+ continue;
553
+ }
554
+ const a = (await ask(` ${c.bold(`Set up ${p.kind}?`)} ${c.dim(`Enter = yes ${mark.dot} n = skip`)} ${c.violet(mark.arrow)} `)).trim().toLowerCase();
555
+ if (a && !a.startsWith("y"))
556
+ continue;
557
+ }
558
+ if (!p.installed && p.install) {
559
+ say(c.dim(` running ${p.install.map(tilde).join(" ")}`));
560
+ const r = spawnSync(p.install[0], p.install.slice(1), { cwd: p.dir, stdio: ["ignore", 2, 2], shell: process.platform === "win32" });
561
+ if (r.status === 0)
562
+ done.push(`Installed ${p.kind === "Playwright" ? "getclaros-browser" : "claros-browser"} ${c.dim(`with ${p.install[0].includes("python") ? "pip" : p.install[0]}`)}`);
563
+ else
564
+ warn(` ${p.install.map(tilde).join(" ")} failed (${r.error?.message ?? `exit ${r.status}`}); run it yourself`);
565
+ }
566
+ if (p.fixture && !existsSync(p.fixture)) {
567
+ commit(run, p.fixture, null, fixtureText(p.fixture));
568
+ done.push(`Wrote ${rel(p.fixture)}`);
569
+ }
570
+ wantKey = true;
571
+ todo ||= !p.hooked;
419
572
  }
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`));
428
- return 0;
573
+ say();
574
+ if (wantKey && !keySet) {
575
+ if (env.ok) {
576
+ const before = readFileSync(env.path, "utf8");
577
+ commit(run, env.path, before, setEnv(before, vars), Object.keys(vars));
578
+ done.push(`Set ${Object.keys(vars).join(", ")} in .env ${c.dim(`key ${keyId(key)} ${mark.dot} gitignored`)}`);
579
+ }
580
+ else {
581
+ say(` ${c.bold("CLAROS_KEY")} ${c.dim(`not written to a file (${env.why}); in the shell that runs the agent:`)}`);
582
+ say(` ${c.violet(exportKey())} ${c.dim(`# key ${keyId(key)}`)}`);
583
+ for (const [k, x] of Object.entries(vars))
584
+ if (k !== "CLAROS_KEY")
585
+ say(` ${c.violet(`export ${k}=${x}`)}`);
586
+ say();
587
+ }
588
+ }
589
+ return { done, todo };
429
590
  }
430
591
  // ---- test ----
431
592
  async function test(v, project) {
@@ -452,7 +613,10 @@ async function test(v, project) {
452
613
  }
453
614
  if (ex.status !== 200)
454
615
  throw new Error(`HTTP ${ex.status}`);
455
- const e = ex.json, t0 = Date.now();
616
+ const e = ex.json;
617
+ if (e.demo && e.examples?.length > 1)
618
+ return await demo(e, url, key, guarded);
619
+ const t0 = Date.now();
456
620
  const r = await api(url, key, "/v1/check", { workflow_id: e.workflow_id, app: e.app ?? guarded[0] ?? undefined,
457
621
  tool: "getclaros_test", actor: "getclaros test", state: e.state });
458
622
  if (r.status !== 200)
@@ -466,14 +630,7 @@ async function test(v, project) {
466
630
  const held = r.json.result === "ask", ink = held ? c.ask : c.stop;
467
631
  say(` ${ink(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"} in ${dur(ms)}`))} ` +
468
632
  c.dim(`Work Map "${e.name}" ${mark.dot} check ${r.json.check_id}`));
469
- for (const x of r.json.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
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))}`);
473
- if (x.quote)
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"}`);
476
- }
633
+ stopCards(r.json);
477
634
  say();
478
635
  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
636
  say(wrap(` Your agent gets the rule as the tool's error. Nothing reached ${e.app ?? "any app"}.`, 2));
@@ -486,6 +643,57 @@ async function test(v, project) {
486
643
  return 3;
487
644
  }
488
645
  }
646
+ /** a card per stop / ask rule the check broke: the rule, the expert's words, who to ask */
647
+ function stopCards(r) {
648
+ for (const x of r.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
649
+ const ask = x.action === "ask", p = ask ? c.ask : c.stop;
650
+ say();
651
+ say(` ${p(`${ask ? mark.ask : mark.stop} ${ask ? "ask " : "stop"}`)} ${c.bold(wrap(x.rule, 12))}`);
652
+ if (x.quote)
653
+ say(` ${wrap(`“${x.quote}”`, 12)}${x.expert ? c.dim(` ${mark.dash} ${x.expert}`) : ""}`);
654
+ say(` ${c.dim("Who to ask:")} ${x.who_to_ask || x.owner || x.expert || "the process owner"}`);
655
+ }
656
+ }
657
+ /** a made-up record on one line: whitespace in strings collapsed, long values cut */
658
+ const record = (tool, state) => `${tool ? `${tool} ` : ""}${Object.entries(state).map(([k, x]) => {
659
+ const v = typeof x === "string" ? x.replace(/\s+/g, " ").trim() : String(x);
660
+ return `${k} = ${v.length > 80 ? v.slice(0, 79) + "…" : v}`;
661
+ }).join(", ")}`;
662
+ /** demo (no guarded server matches a map): the starter maps' examples, a stop then a nudge, checked side by side */
663
+ async function demo(e, url, key, guarded) {
664
+ const t0 = Date.now();
665
+ const rs = await Promise.all(e.examples.map((x) => api(url, key, "/v1/check", { workflow_id: x.workflow_id, tool: x.tool, actor: "getclaros test", state: x.state })));
666
+ const bad = rs.find((r) => r.status !== 200);
667
+ if (bad)
668
+ throw new Error(`HTTP ${bad.status}`);
669
+ const ms = Date.now() - t0, [stop, ...rest] = rs.map((r) => r.json), [se, ...re] = e.examples;
670
+ if (stop.allowed) {
671
+ say(` ${c.stop(mark.no)} Claros let it through (check ${stop.check_id}). It should have been stopped by rule ${se.guardrail_id}; tell the Claros team.`);
672
+ return 1;
673
+ }
674
+ const held = stop.result === "ask", nudged = rest.some((r) => r.result === "nudge");
675
+ const outcome = [(held ? c.ask : c.stop)(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"}`)),
676
+ ...(nudged ? [c.nudge(c.bold(`${mark.nudge} Nudged`))] : [])].join(` ${mark.dot} `);
677
+ say(` ${outcome} ${c.dim(`${rs.length} checks in ${dur(ms)}`)}`);
678
+ stopCards(stop);
679
+ say(c.dim(` ${wrap(`Test action: ${record(se.tool, se.state)}`, 12)}`));
680
+ rest.forEach((r, i) => {
681
+ const x = r.violations.find((v) => v.action === "nudge"), ex = re[i];
682
+ say();
683
+ say(` ${c.nudge(`${mark.nudge} nudge`)} ${c.bold(wrap(x?.rule ?? ex.rule ?? `Work Map "${ex.name}"`, 12))}`);
684
+ if (x?.quote)
685
+ say(` ${wrap(`“${x.quote}”`, 12)}${x.expert ? c.dim(` ${mark.dash} ${x.expert}`) : ""}`);
686
+ if (x?.fix)
687
+ say(` ${c.nudge(mark.to)} ${wrap(x.fix, 14)}`);
688
+ else if (!x)
689
+ say(c.dim(` Not judged this time (${r.judge?.error ?? r.judge?.status ?? "the server's judge is off"}).`));
690
+ say(c.dim(` ${wrap(`Test action: ${record(ex.tool, ex.state)}`, 12)}`));
691
+ });
692
+ say();
693
+ say(wrap(" Your agent gets a stop as the tool's error; a nudge lets the call through, with the fix. Nothing reached any app.", 2));
694
+ 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 the Starter maps (examples, not enforced; delete them on the web).`, 2)));
695
+ return 0;
696
+ }
489
697
  // ---- export ----
490
698
  /** A Work Map's rules as markdown on stdout (GET /v1/maps/<id>/export): redirect it into AGENTS.md, DESIGN.md or a
491
699
  * skill's SKILL.md. An advisory copy: Claros still enforces the rules when an agent acts. */
@@ -542,8 +750,10 @@ async function override(v, checkId) {
542
750
  return 3;
543
751
  }
544
752
  }
545
- // ---- status ----
546
- async function status(v, project) {
753
+ // ---- doctor (status is the same command) ----
754
+ /** Per agent type: is it guarded and can it reach Claros, with the command that fixes what isn't.
755
+ * Exit: 0 all good, 1 a browser agent here isn't set up, 2 no key, 3 Claros unreachable or the key rejected. */
756
+ async function doctor(v, project) {
547
757
  header();
548
758
  const at = await locate(v);
549
759
  if (!at)
@@ -556,7 +766,16 @@ async function status(v, project) {
556
766
  const fine = up && ks === "valid";
557
767
  say(` ${ok(fine, fine ? mark.ok : mark.no)} ${c.bold("Claros")} ${url} ${ok(up, up ? "reachable" : "unreachable")}` +
558
768
  ` key ${key ? keyId(key) + " " : ""}${ok(ks === "valid", ks)}${ws ? ` workspace "${ws}"` : ""}`);
559
- for (const f of detect(project)) {
769
+ const fix = (s) => say(` ${c.violet(mark.to)} ${s}`);
770
+ if (!key || /rejected|scope/.test(ks))
771
+ fix(`${NAME} init ${c.dim("signs in and saves an agent key")}`);
772
+ else if (!up)
773
+ fix(c.dim(`check your network; a moved server is followed on the next run (or pass --url)`));
774
+ const files = detect(project);
775
+ say(`\n ${c.bold("MCP agents")}`);
776
+ if (!files.length)
777
+ say(c.dim(" none found (Claude Code, Claude Desktop, Cursor, VS Code, Codex)"));
778
+ for (const f of files) {
560
779
  say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}${f.error ? ` couldn't read it (${f.error})` : ""}`);
561
780
  for (const s of f.servers) {
562
781
  const [m, state] = s.ours ? [c.violet(mark.dot), c.dim("Claros MCP server")] : s.guarded ? [c.ask(mark.ok), c.ask("guarded")]
@@ -564,9 +783,67 @@ async function status(v, project) {
564
783
  say(` ${m} ${(s.name + where(s)).padEnd(24)} ${state}`);
565
784
  }
566
785
  }
567
- return !key ? 2 : up && ks === "valid" ? 0 : 3;
786
+ const servers = files.flatMap((f) => f.servers.filter((s) => !s.ours));
787
+ if (servers.some((s) => !s.guarded && !s.gap))
788
+ fix(`${NAME} init ${c.dim(`guards the servers you pick${servers.some((s) => s.guarded) ? "" : " (none is guarded yet)"}`)}`);
789
+ // coding-agent hooks: per agent, the files that hold ours
790
+ const agents = hookAgents(project), hooked = new Map();
791
+ for (const f of hookFiles(project))
792
+ if (await hasHooks(f.path))
793
+ hooked.set(f.client, [...(hooked.get(f.client) ?? []), tilde(f.path)]);
794
+ const named = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
795
+ const clientsHere = [...new Set([...agents.map((a) => named[a]), ...hooked.keys()])];
796
+ if (clientsHere.length)
797
+ say(`\n ${c.bold("Coding-agent hooks")} ${c.dim("shell commands and file edits")}`);
798
+ for (const cl of clientsHere) {
799
+ const at = hooked.get(cl);
800
+ say(` ${at ? c.ask(mark.ok) : c.dim(mark.off)} ${cl.padEnd(24)} ${at ? `${c.ask("hooked")} ${c.dim(at.join(", "))}` : "not hooked"}`);
801
+ }
802
+ if (clientsHere.some((cl) => !hooked.has(cl)))
803
+ fix(`${NAME} init ${c.dim("adds the hooks (project scope; --user for all projects)")}`);
804
+ // browser agents: package installed, hook present, a key the agent can read (env or a safe .env)
805
+ let broken = false;
806
+ const ps = detectBrowser(project);
807
+ if (ps.length)
808
+ say(`\n ${c.bold("Browser agents in this project")}`);
809
+ const env = envTarget(project), fromFile = envValue(env.path, "CLAROS_KEY");
810
+ const agentKey = process.env.CLAROS_KEY || fromFile;
811
+ const aks = !ps.length || !agentKey ? "" : agentKey === key ? ks : await keyStatus(url, agentKey);
812
+ for (const p of ps) {
813
+ const row = (good, s, hint) => {
814
+ broken ||= !good;
815
+ say(` ${ok(good, good ? mark.ok : mark.no)} ${s}`);
816
+ if (!good && hint)
817
+ fix(hint);
818
+ };
819
+ say(`\n ${c.bold(p.kind)} ${c.dim(`${tilde(p.dir)} ${mark.dot} ${p.via}`)}`);
820
+ const pkg = p.kind === "Playwright" ? "getclaros-browser" : "claros-browser";
821
+ row(p.installed, `${pkg} ${p.installed ? "installed" : "not installed"}`, p.install ? `${p.install.map(tilde).join(" ")} ${c.dim(`(or ${NAME} init)`)}` : p.installNote);
822
+ row(p.hooked, p.hooked ? `hook in place${p.fixture ? ` (${short(p.fixture, project)})` : ""}` : "no hook yet", p.fixture && !existsSync(p.fixture) ? `${NAME} init ${c.dim(`writes ${short(p.fixture, project)}; then import from it in your specs`)}`
823
+ : `${p.hookFile ? `in ${short(p.hookFile, project)}` : "add"}:\n${hookLines(p).map((l) => ` ${l}`).join("\n")}`);
824
+ if (!agentKey)
825
+ row(false, "CLAROS_KEY not set", env.ok ? `${NAME} init ${c.dim("writes it to .env (gitignored)")}` : `${exportKey()} ${c.dim(`(${env.why})`)}`);
826
+ else if (fromFile && !process.env.CLAROS_KEY && !env.ok) {
827
+ row(false, `CLAROS_KEY is in .env, but ${env.why}`, `move it out of .env, and make a new key if it was ever committed`);
828
+ }
829
+ else
830
+ row(aks === "valid", `CLAROS_KEY ${keyId(agentKey)} from ${process.env.CLAROS_KEY ? "the environment" : ".env"} ${aks}`, `${NAME} init ${c.dim("writes a valid key")}`);
831
+ }
832
+ return !key ? 2 : !(up && ks === "valid") ? 3 : broken ? 1 : 0;
568
833
  }
569
834
  // ---- undo ----
835
+ /** Our hook entries out of a hooks file that changed since init; the rest stays. */
836
+ async function unhook(safety, path, cur) {
837
+ try {
838
+ const t = await removeHooks(cur);
839
+ if (t !== cur)
840
+ commit(safety, path, cur, t);
841
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so removed the Claros hooks in place and kept the other edits`);
842
+ }
843
+ catch (e) {
844
+ warn(`${tilde(path)}: couldn't read it (${e.message}); remove the getclaros hook entries yourself`);
845
+ }
846
+ }
570
847
  /** Unwraps every guarded server and removes our Claros entry in place; the rest of the file stays. */
571
848
  function unwrapAll(f) {
572
849
  let n = 0;
@@ -601,10 +878,29 @@ async function undo(v, project) {
601
878
  }
602
879
  const cur = readFileSync(path, "utf8");
603
880
  const same = sha(cur) === chain.at(-1).after && chain.every((b, i) => !i || b.before === chain[i - 1].after);
604
- if (same) {
881
+ const vars = chain.flatMap((b) => b.vars ?? []);
882
+ if (chain[0].created) { // a file init wrote (the Playwright fixture): deleted only while it's exactly what init wrote
883
+ if (same) {
884
+ unlinkSync(path);
885
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: removed (init wrote it)`);
886
+ }
887
+ else if (isHookPath(path, project))
888
+ await unhook(safety, path, cur);
889
+ else
890
+ warn(`${tilde(path)}: changed since init wrote it; left in place (delete it yourself)`);
891
+ }
892
+ else if (!same && vars.length) { // a .env edited since: drop only the lines init set
893
+ const t = cur.replace(new RegExp(`^(export\\s+)?(${vars.join("|")})=.*(\\r?\\n|$)`, "gm"), "");
894
+ if (t !== cur)
895
+ commit(safety, path, cur, t);
896
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so removed ${vars.join(", ")} and kept the other lines`);
897
+ }
898
+ else if (same) {
605
899
  commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
606
900
  say(` ${c.ask(mark.ok)} ${tilde(path)}: restored from the backup taken before init (unchanged since)`);
607
901
  }
902
+ else if (isHookPath(path, project))
903
+ await unhook(safety, path, cur);
608
904
  else {
609
905
  const f = byPath.get(path) ?? detect(project).find((x) => x.path === path);
610
906
  if (!f || f.error) {
@@ -619,6 +915,9 @@ async function undo(v, project) {
619
915
  byPath.delete(path);
620
916
  }
621
917
  if (v.all) {
918
+ for (const f of hookFiles(project))
919
+ if (!chains.has(f.path) && await hasHooks(f.path))
920
+ await unhook(safety, f.path, readFileSync(f.path, "utf8"));
622
921
  for (const f of byPath.values()) {
623
922
  if (f.error)
624
923
  continue;
package/dist/ui.js CHANGED
@@ -27,7 +27,7 @@ const U = S.uni;
27
27
  /** stop = the seal, ask = the thread hooked to a person, nudge = the proofreader's pencil */
28
28
  export const mark = {
29
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 ? "—" : "-",
30
+ on: U ? "●" : "*", off: U ? "○" : "o", dot: U ? "·" : "-", arrow: U ? "›" : ">", dash: U ? "—" : "-", to: U ? "→" : "->",
31
31
  };
32
32
  export const say = (s = "") => process.stdout.write(s + "\n");
33
33
  /** claros” and the version: only on a terminal (piped output starts with what it says) */
@@ -0,0 +1,54 @@
1
+ // What an agent reads when Claros stops, holds or nudges an action: shared by the MCP guard (cli.ts) and the coding-agent
2
+ // hooks (hooks.ts).
3
+ import { readFileSync } from "node:fs";
4
+ const NAME = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).name;
5
+ /** `head` replaces the first line (a decided ask: who said no, or that nobody answered). */
6
+ export function stopText(r, head) {
7
+ const hits = (r.violations || []).filter((v) => v.action === "stop" || v.action === "ask");
8
+ const maps = [...new Set(hits.map((v) => v.workflow_id ?? r.workflow_id))];
9
+ const from = `expert-approved Work Map${maps.length > 1 ? "s" : ""} ${maps.map((m) => `"${m}"`).join(", ")}`;
10
+ const out = [head ?? (r.result === "ask" // ask only: not refused for good, waiting on a person
11
+ ? `Claros held this action: ${hits.length === 1 ? "a rule" : "rules"} from the ${from} ${hits.length === 1 ? "says" : "say"} to ask first. Do not run it without that answer.`
12
+ : `Claros stopped this action: it breaks ${hits.length === 1 ? "a rule" : "rules"} from the ${from}. Do not retry it as is.`)];
13
+ for (const v of hits) {
14
+ const who = v.who_to_ask || v.owner || v.expert || "the process owner";
15
+ out.push("", `Rule: ${v.rule}`);
16
+ if (v.reason)
17
+ out.push(`Why Claros stopped it: ${v.reason}`); // fuzzy rules: the judge's one-line reason
18
+ if (v.quote)
19
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
20
+ out.push(`Who to ask: ${who}`);
21
+ out.push(v.action === "ask"
22
+ ? `Next: stop and ask ${who} before doing this; continue only with their answer.`
23
+ : `Next: change the record so the rule holds, or ask ${who} to approve an exception. Tell the user why.`);
24
+ }
25
+ // an ask opened a question to the rule's owner (server: knowledge/approvals.py); its answer is at the decision URL
26
+ if (r.question?.decision_url && !head)
27
+ out.push("", `Claros asked the rule's owner (check ${r.check_id}); their answer: GET ${r.question.decision_url}`);
28
+ // an override goes to the rule's expert as a report; the rule stays as it is until they confirm a change
29
+ if (r.check_id) {
30
+ out.push("", "If this stop is wrong, tell the rule's expert why (the rule stays until they change it):");
31
+ for (const v of hits)
32
+ out.push(`npx ${NAME} override ${r.check_id} --rule ${v.guardrail_id} --reason "<why it was fine>"`);
33
+ }
34
+ return out.join("\n");
35
+ }
36
+ // a nudge lets the call through; "warn" is its old name (older Claros servers)
37
+ export const nudgesOf = (r) => (r.violations || []).filter((v) => v.action === "nudge" || v.action === "warn");
38
+ /** The extra text item on a nudged call's result: the rule, the expert's words and the fix, so the agent can
39
+ * correct this record (if it still can) and the next ones. */
40
+ export function nudgeText(r) {
41
+ const hits = nudgesOf(r);
42
+ const out = [`Claros nudge: the call went through, but it doesn't follow ${hits.length === 1 ? "a rule" : "rules"} ` +
43
+ `your team holds. Fix it if you still can, and follow ${hits.length === 1 ? "it" : "them"} from now on.`];
44
+ for (const v of hits) {
45
+ out.push("", `Rule: ${v.rule}`);
46
+ if (v.reason)
47
+ out.push(`Why: ${v.reason}`);
48
+ if (v.quote)
49
+ out.push(`${v.source === "doc" ? "In the written policy" : "In the expert's words"}${v.expert ? ` (${v.expert})` : ""}: "${v.quote}"`);
50
+ if (v.fix)
51
+ out.push(`Suggested fix: ${v.fix}`);
52
+ }
53
+ return out.join("\n");
54
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "getclaros",
3
- "version": "0.1.1",
4
- "description": "Claros guard: checks an agent's MCP tool calls against your experts' rules before they run",
3
+ "version": "0.3.0",
4
+ "description": "Claros guard: checks an agent's MCP tool calls, shell commands and file edits against your experts' rules before they run",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {