getclaros 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,8 +12,50 @@ 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
+ One command for every kind of agent: it guards the MCP servers your agent clients run, and, when run in a Playwright
19
+ or browser-use project, installs the browser hook with that project's own package manager (see
20
+ [Browser agents](#browser-agents-playwright-browser-use)). `npx getclaros doctor` then says, per agent type, whether it
21
+ is guarded and can reach Claros.
22
+
23
+ ```
24
+ claros” getclaros 0.2.0
25
+
26
+ Sign in opening your browser
27
+ › https://claros-eta.vercel.app/device?code=ZQRC-ZCTP
28
+ Code ZQRC-ZCTP approve only if the page shows this code
29
+
30
+ ✓ Signed in to workspace "Acme" key clr_7f3a91c2_…
31
+ <claros server> · key saved in ~/.config/getclaros/config.json, readable only by you
32
+
33
+ MCP agents
34
+
35
+ Claude Code ~/.claude.json
36
+ 1 ● erpnext npx -y frappe-mcp-server Work Map "Process a supplier invoice"
37
+ 2 ○ github docker run -i …
38
+ 3 ○ playwright (project ~/proj) npx @playwright/mcp@latest
39
+
40
+ Cursor ~/.cursor/mcp.json
41
+ 4 ● erpnext npx frappe-mcp-server Work Map "Process a supplier invoice"
42
+
43
+ · 3 remote servers need OAuth — guard them later: linear, notion, figma
44
+
45
+ ● matches a Work Map: recommended
46
+ Guard which? Enter = 1,4 (recommended) · numbers like 1,3 · all · none ›
47
+
48
+ ✓ Guarded erpnext in Claude Code, Cursor
49
+ ✓ Added the Claros MCP server in Claude Code, Cursor
50
+ every file backed up first: ~/.config/getclaros/backups/2026-10-24T01-34-53-159Z
51
+
52
+ Next restart Claude Code, Cursor, then run npx getclaros test
53
+ undo anytime: npx getclaros undo
54
+ ```
55
+
56
+ On a terminal the output is coloured in Claros's inks (stop magenta, ask green, nudge vermilion) with a spinner while
57
+ it waits for the browser. Piped, it is plain text with no escape codes and no header; `NO_COLOR` turns colour off,
58
+ `FORCE_COLOR` turns it on. Old Windows consoles get ASCII marks.
17
59
 
18
60
  `init` signs you in through the browser (RFC 8628 device flow): it prints a URL and a code (XXXX-XXXX), opens the
19
61
  URL (`--no-browser` to skip), and once you approve on Claros's `/device` page and pick a workspace it receives an
@@ -44,9 +86,39 @@ What it changes, in files that already exist (it never creates a client config):
44
86
  up front with `get_workflow`; in each client's user config (or its project config when it has no user config).
45
87
  - Every file is backed up before it changes (`~/.config/getclaros/backups/<time>/`) and written atomically.
46
88
 
89
+ ### Browser agents (Playwright, browser-use)
90
+
91
+ `init` also looks at the project directory (the current one, or `--project <dir>`):
92
+
93
+ | Found | Install (never global) | Hook |
94
+ |---|---|---|
95
+ | `@playwright/test` in package.json | `pnpm add -D` / `yarn add -D` / `bun add -d` / `npm install -D getclaros-browser`, by lockfile | writes `claros.fixture.ts` next to `playwright.config.*`; you change each spec's `from "@playwright/test"` to `from "<rel>/claros.fixture"` (init prints the exact line) |
96
+ | `playwright` (the library) in package.json | the same, as a dependency | printed: `import { guard } from "getclaros-browser"; await guard(page);` |
97
+ | `browser-use` in pyproject.toml / requirements*.txt / uv.lock / poetry.lock | `uv add` / `poetry add claros-browser`, else `pip install` into the project's virtualenv (none: printed, not run) | printed: `from claros_browser import guard_tools; guard_tools(tools)`, with the file that builds the `Agent` |
98
+
99
+ Each project gets its plan (the exact install command, the file it writes, where the key goes, the hook) and one
100
+ question, `Set up Playwright? Enter = yes`. `--yes` says yes without asking; `--no-browser-hooks` shows the plan and
101
+ changes nothing. User source files are never edited: the hook is printed.
102
+
103
+ Why a fixture file for Playwright Test: Playwright has no config-level page hook, and a fixture is its supported way to
104
+ run code for every test's `page`. The file lives in your repo (you can read it, compose it with your own fixtures), it
105
+ imports `getclaros-browser` and `@playwright/test` from your own `node_modules` (works with pnpm's strict layout), and
106
+ it re-exports everything from `@playwright/test`, so swapping the import never loses a type or helper. It also reads
107
+ `CLAROS_*` from `.env` when the shell doesn't set them, because Playwright doesn't read `.env` itself.
108
+
109
+ The browser hooks read `CLAROS_KEY` (and `CLAROS_URL` / `CLAROS_WEB_URL` when you aren't on the hosted Claros) from the
110
+ environment. `init` writes them to the project's `.env` only when it already exists, git ignores it and git doesn't
111
+ track it (no repo: the `.gitignore` next to it decides). Otherwise it prints an export line that reads the saved key
112
+ without showing it: `export CLAROS_KEY="$(node -p "require('$HOME/.config/getclaros/config.json').key")"`.
113
+ browser-use loads `.env` itself.
114
+
115
+ The fixture file is new (undo deletes it, unless you changed it) and `.env` is backed up like any config (undo restores
116
+ it, or, if you edited it since, removes only the `CLAROS_*` lines). Undo doesn't uninstall the package.
117
+
47
118
  ```
48
- npx getclaros test # a made-up record that breaks one of your rules → prints the stop (rule, expert's words, who to ask)
49
- npx getclaros status # which servers are guarded, Claros reachable, key valid
119
+ npx getclaros test # made-up records checked against your rules → prints the stop (and, in the demo, a nudge with its fix)
120
+ npx getclaros doctor # per agent type: guarded? reachable? (server, key, MCP wrapping, browser package + hook + key), with the fix
121
+ npx getclaros status # the same as doctor
50
122
  npx getclaros undo # undo the last init; --all: every init, plus any guard wrapping it finds
51
123
  npx getclaros export <workflow_id> [--format agents|design|skill] > AGENTS.md # a map's rules as markdown
52
124
  ```
@@ -62,7 +134,43 @@ rules page. Exit codes: 0 sent, 1 no such check / not valid, 2 bad usage, 3 unre
62
134
 
63
135
  `test` asks Claros for a record that breaks a precise rule of a Work Map for one of your guarded servers
64
136
  (`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
137
+
138
+ With no guarded server matching a map it runs a demo on two Starter maps (added to your workspace the first time, as
139
+ drafts that are never enforced; delete them on the web and they stay deleted): a production deploy during a release
140
+ freeze, which is stopped, and a coding agent's stylesheet with a raw hex colour, which the judge nudges with a fix.
141
+ Both are checked side by side and it ends with a `Demo:` note:
142
+
143
+ ```
144
+ ◉ Stopped · ✎ Nudged 2 checks in 827 ms
145
+
146
+ ◉ stop No production deploys during the release freeze.
147
+ “Nothing goes to prod during the freeze. Not even a typo fix.” — Release manager
148
+ Who to ask: Release manager
149
+ Test action: deploy deploy.environment = production, deploy.freeze_active = true
150
+
151
+ ✎ nudge No hand-picked hex values in UI code; use the design tokens (var(--primary) and the rest).
152
+ “If it's not a token, it's a bug. No hand-picked hex values.” — Design systems lead
153
+ → Replace #7C3AED and #FFFFFF with design tokens like var(--primary)
154
+ Test action: write_file file_path = src/components/Button.css, content = .button { background: #7C3AED; color: #FFFFFF; border-radius: 6px; }
155
+
156
+ Your agent gets a stop as the tool's error; a nudge lets the call through, with the fix. Nothing reached any app.
157
+ Demo: no server is guarded yet, so this used the Starter maps (examples, not enforced; delete them on the web).
158
+ ```
159
+
160
+ For a guarded server whose app has a Work Map:
161
+
162
+ ```
163
+ ◉ Stopped in 1.3 s Work Map "Process a supplier invoice" · check chk_0b14cb5eef
164
+
165
+ ◉ stop Equipment over 5,000 is capex: book it to the capital equipment account, never to expense.
166
+ “Equipment above five thousand is an asset, it goes to capex.” — AP lead
167
+ Who to ask: Controller
168
+
169
+ Test action for erpnext, made up, only checked: items.0.item_group = Machinery, items.0.amount = 7200
170
+ Your agent gets the rule as the tool's error. Nothing reached erpnext.
171
+ ```
172
+
173
+ Exit codes: 0 stopped, 1 not stopped
66
174
  or no map to test, 2 not signed in / bad usage, 3 Claros unreachable or key rejected (`status` uses 0 / 2 / 3 too).
67
175
 
68
176
  `undo` restores a file from its backup when it is byte-for-byte what `init` wrote; if anything changed it since, it
package/dist/cli.js CHANGED
@@ -404,7 +404,7 @@ async function main(argv) {
404
404
  }
405
405
  if (argv[0] === "mcp")
406
406
  return void import("./bridge.js").then((m) => m.bridge());
407
- if (["init", "test", "status", "undo", "export", "override"].includes(argv[0])) {
407
+ if (["init", "test", "status", "doctor", "undo", "export", "override"].includes(argv[0])) {
408
408
  process.exitCode = await (await import("./setup.js")).run(argv);
409
409
  return;
410
410
  }
@@ -0,0 +1,179 @@
1
+ // Browser agents in a project directory: Playwright (package.json) and browser-use (pyproject / requirements / locks).
2
+ // For each: the install command in the project's own package manager (never global), where the hook goes, whether
3
+ // it's there, and where CLAROS_KEY can be written safely (an existing .env that git ignores and doesn't track).
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { basename, dirname, join, relative } from "node:path";
7
+ const SKIP = new Set(["node_modules", "dist", "build", "out", "venv", "__pycache__", "site-packages", "coverage"]);
8
+ /** source files under dir (not dot dirs, deps or builds), at most `cap` of them */
9
+ function sources(dir, ext, cap = 2000) {
10
+ const out = [];
11
+ const walk = (d, depth) => {
12
+ let names;
13
+ try {
14
+ names = readdirSync(d);
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const n of names) {
20
+ if (out.length >= cap)
21
+ return;
22
+ const p = join(d, n);
23
+ if (n.startsWith(".") || SKIP.has(n))
24
+ continue;
25
+ let s;
26
+ try {
27
+ s = statSync(p);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ if (s.isDirectory()) {
33
+ if (depth < 8)
34
+ walk(p, depth + 1);
35
+ }
36
+ else if (ext.test(n) && s.size < 512_000) {
37
+ out.push({ path: p, text: readFileSync(p, "utf8") });
38
+ }
39
+ }
40
+ };
41
+ walk(dir, 0);
42
+ return out;
43
+ }
44
+ const read = (p) => { try {
45
+ return readFileSync(p, "utf8");
46
+ }
47
+ catch {
48
+ return "";
49
+ } };
50
+ export function nodePm(dir, dev, pkg) {
51
+ const has = (f) => existsSync(join(dir, f));
52
+ if (has("pnpm-lock.yaml"))
53
+ return { name: "pnpm", argv: ["pnpm", "add", ...(dev ? ["-D"] : []), pkg] };
54
+ if (has("yarn.lock"))
55
+ return { name: "yarn", argv: ["yarn", "add", ...(dev ? ["-D"] : []), pkg] };
56
+ if (has("bun.lockb") || has("bun.lock"))
57
+ return { name: "bun", argv: ["bun", "add", ...(dev ? ["-d"] : []), pkg] };
58
+ return { name: "npm", argv: ["npm", "install", ...(dev ? ["-D"] : []), pkg] };
59
+ }
60
+ export function pyPm(dir, pkg) {
61
+ if (existsSync(join(dir, "uv.lock")))
62
+ return { name: "uv", argv: ["uv", "add", pkg] };
63
+ if (existsSync(join(dir, "poetry.lock")) || /^\[tool\.poetry\]/m.test(read(join(dir, "pyproject.toml")))) {
64
+ return { name: "poetry", argv: ["poetry", "add", pkg] };
65
+ }
66
+ // pip only into a virtualenv: the active one if it lives in this project, else ./.venv or ./venv
67
+ const venvs = [process.env.VIRTUAL_ENV && !relative(dir, process.env.VIRTUAL_ENV).startsWith("..") ? process.env.VIRTUAL_ENV : "",
68
+ join(dir, ".venv"), join(dir, "venv")].filter(Boolean);
69
+ for (const v of venvs) {
70
+ const py = [join(v, "bin", "python"), join(v, "Scripts", "python.exe")].find(existsSync);
71
+ if (py)
72
+ return { name: "pip", argv: [py, "-m", "pip", "install", pkg] };
73
+ }
74
+ return { name: "pip", argv: null, note: `no virtualenv in this project: activate yours, then pip install ${pkg} (init never installs globally)` };
75
+ }
76
+ const FIXTURE = (ext) => `// Written by \`npx getclaros init\`: every test's \`page\` goes through the Claros guard (consequential clicks and
77
+ // submits are checked against your team's rules first). Import { test, expect } from here instead of
78
+ // "@playwright/test". \`npx getclaros undo\` removes this file.
79
+ import { existsSync, readFileSync } from "node:fs";
80
+ import * as util from "node:util";
81
+ import { test as base } from "@playwright/test";
82
+ import { guard } from "getclaros-browser";
83
+
84
+ // Playwright doesn't read .env: take CLAROS_KEY (and CLAROS_URL / CLAROS_WEB_URL) from it when the shell doesn't set them
85
+ const env${ext === "ts" || ext === "mts" ? ": Record<string, string | undefined>" : ""} = existsSync(".env") && util.parseEnv ? util.parseEnv(readFileSync(".env", "utf8")) : {};
86
+ for (const [k, v] of Object.entries(env)) if (k.startsWith("CLAROS_")) process.env[k] ??= v;
87
+
88
+ export * from "@playwright/test";
89
+ export const test = base.extend({
90
+ page: async ({ page }, use) => {
91
+ await guard(page);
92
+ await use(page);
93
+ },
94
+ });
95
+ `;
96
+ export const fixtureText = (path) => FIXTURE(path.split(".").pop());
97
+ // ponytail: only `dir` itself is a project (no monorepo workspace walk); run init in the package, or use --project
98
+ export function detectBrowser(dir) {
99
+ const out = [];
100
+ let pkg; // eslint-disable-line @typescript-eslint/no-explicit-any
101
+ try {
102
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
103
+ }
104
+ catch { /* not a Node project */ }
105
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
106
+ if (deps["@playwright/test"] || deps.playwright) {
107
+ const runner = !!deps["@playwright/test"], pm = nodePm(dir, runner, "getclaros-browser");
108
+ const src = sources(dir, /\.(c|m)?[jt]sx?$/);
109
+ const p = { kind: "Playwright", dir, via: `${runner ? "@playwright/test" : "playwright"}, ${pm.name}`,
110
+ install: pm.argv, installed: !!deps["getclaros-browser"] || existsSync(join(dir, "node_modules", "getclaros-browser")),
111
+ more: 0, hooked: false };
112
+ if (runner) {
113
+ const config = ["ts", "mts", "js", "mjs", "cjs", "cts"].map((e) => join(dir, `playwright.config.${e}`)).find(existsSync);
114
+ const ext = config?.split(".").pop()?.replace(/^c/, "m") ?? "ts";
115
+ p.fixture = join(config ? dirname(config) : dir, `claros.fixture.${ext}`);
116
+ const specs = src.filter((f) => f.path !== config && f.path !== p.fixture && /from\s+["']@playwright\/test["']/.test(f.text));
117
+ p.hookFile = specs[0]?.path;
118
+ p.more = Math.max(0, specs.length - 1);
119
+ p.hooked = existsSync(p.fixture) && src.some((f) => f.path !== p.fixture && /claros\.fixture/.test(f.text));
120
+ }
121
+ else {
122
+ p.hookFile = src.find((f) => /["']playwright["']/.test(f.text))?.path;
123
+ p.hooked = src.some((f) => /["']getclaros-browser["']/.test(f.text));
124
+ }
125
+ out.push(p);
126
+ }
127
+ const manifests = ["pyproject.toml", "uv.lock", "poetry.lock",
128
+ ...(existsSync(dir) ? readdirSync(dir).filter((n) => /^requirements.*\.txt$/.test(n)) : [])].map((n) => read(join(dir, n))).join("\n");
129
+ if (/browser[-_]use/i.test(manifests)) {
130
+ const pm = pyPm(dir, "claros-browser"), src = sources(dir, /\.py$/);
131
+ const venvLib = join(dir, ".venv", "lib");
132
+ const inVenv = existsSync(venvLib) && readdirSync(venvLib).some((v) => existsSync(join(venvLib, v, "site-packages", "claros_browser")));
133
+ out.push({ kind: "browser-use", dir, via: `browser-use, ${pm.name}`, install: pm.argv, installNote: pm.note,
134
+ installed: /claros[-_]browser/i.test(manifests) || inVenv,
135
+ hookFile: (src.find((f) => /browser_use/.test(f.text) && /\bAgent\(/.test(f.text)) ?? src.find((f) => /browser_use/.test(f.text)))?.path,
136
+ more: 0, hooked: src.some((f) => /\bguard_tools\(/.test(f.text)) });
137
+ }
138
+ return out;
139
+ }
140
+ /** The import line a Playwright spec needs to use the fixture. */
141
+ export function fixtureImport(p) {
142
+ const from = p.hookFile ? dirname(p.hookFile) : p.dir;
143
+ let rel = relative(from, p.fixture).split("\\").join("/");
144
+ if (/\.ts$/.test(rel))
145
+ rel = rel.slice(0, -3);
146
+ return `import { test, expect } from "${rel.startsWith(".") ? rel : "./" + rel}";`;
147
+ }
148
+ /** Where CLAROS_* may be written: an existing .env that git ignores and doesn't track. ok=false says why not. */
149
+ export function envTarget(dir) {
150
+ const path = join(dir, ".env");
151
+ if (!existsSync(path))
152
+ return { path, ok: false, why: "no .env in this project" };
153
+ const git = (...a) => spawnSync("git", ["-C", dir, ...a], { stdio: "ignore" }).status;
154
+ if (git("ls-files", "--error-unmatch", "--", ".env") === 0)
155
+ return { path, ok: false, why: ".env is tracked by git" };
156
+ const ignored = git("check-ignore", "-q", "--", ".env");
157
+ if (ignored === 0)
158
+ return { path, ok: true };
159
+ if (ignored === 1)
160
+ return { path, ok: false, why: ".env isn't in .gitignore" };
161
+ // not a git repo (or no git): the .gitignore next to it decides
162
+ const lines = read(join(dir, ".gitignore")).split(/\r?\n/).map((l) => l.trim());
163
+ return lines.some((l) => /^\/?(\.env|\.env\*|\.env\.?\*|\*\.env|\.env\/?)$/.test(l))
164
+ ? { path, ok: true } : { path, ok: false, why: ".env isn't in .gitignore" };
165
+ }
166
+ /** The .env text with each var set (an existing line replaced in place, else appended). */
167
+ export function setEnv(text, vars) {
168
+ for (const [k, v] of Object.entries(vars)) {
169
+ const line = `${k}=${v}`, re = new RegExp(`^(export\\s+)?${k}=.*$`, "m");
170
+ text = re.test(text) ? text.replace(re, line) : text + (text && !text.endsWith("\n") ? "\n" : "") + line + "\n";
171
+ }
172
+ return text;
173
+ }
174
+ /** The value of a var in a .env file ("" when absent). */
175
+ export const envValue = (path, k) => {
176
+ const m = new RegExp(`^(?:export\\s+)?${k}=["']?([^"'\\r\\n]*)`, "m").exec(read(path));
177
+ return m ? m[1].trim() : "";
178
+ };
179
+ export const short = (p, dir) => relative(dir, p) || basename(p);
package/dist/setup.js CHANGED
@@ -1,23 +1,26 @@
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
9
  import { basename, 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 { detectBrowser, envTarget, envValue, fixtureImport, fixtureText, setEnv, short } from "./projects.js";
15
+ import { c, dur, header, mark, say, spinner, VERSION, wrap } from "./ui.js";
12
16
  const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
13
- const say = (s = "") => process.stdout.write(s + "\n");
14
17
  const warn = (s) => process.stderr.write(s + "\n");
15
18
  const tilde = (p) => p.startsWith(homedir()) ? "~" + p.slice(homedir().length) : p;
16
19
  const keyId = (k) => k.split("_").slice(0, 2).join("_") + "_…"; // clr_<prefix>_… (the prefix is the lookup id, not the secret)
17
20
  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]
21
+ 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]
19
22
  ${NAME} test [--app <server>]
20
- ${NAME} status
23
+ ${NAME} doctor [--project <dir>] (status: the same)
21
24
  ${NAME} undo [--all]
22
25
  ${NAME} export <workflow_id> [--format agents|design|skill] [> AGENTS.md]
23
26
  ${NAME} override <check_id> --reason "…" [--rule <rule id>] [--verdict wrong_stop|wrong_nudge|missed|right]`;
@@ -28,7 +31,7 @@ export async function run(argv) {
28
31
  ({ values: v, positionals: pos } = parseArgs({ args: rest, allowPositionals: cmd === "export" || cmd === "override", options: {
29
32
  url: { type: "string" }, web: { type: "string" }, key: { type: "string" }, yes: { type: "boolean", short: "y" }, servers: { type: "string" },
30
33
  project: { type: "string" }, all: { type: "boolean" }, "no-mcp": { type: "boolean" }, app: { type: "string" },
31
- paste: { type: "boolean" }, "no-browser": { type: "boolean" }, format: { type: "string" },
34
+ paste: { type: "boolean" }, "no-browser": { type: "boolean" }, "no-browser-hooks": { type: "boolean" }, format: { type: "string" },
32
35
  reason: { type: "string" }, rule: { type: "string" }, verdict: { type: "string" },
33
36
  } }));
34
37
  }
@@ -41,8 +44,8 @@ export async function run(argv) {
41
44
  return init(v, project);
42
45
  if (cmd === "test")
43
46
  return test(v, project);
44
- if (cmd === "status")
45
- return status(v, project);
47
+ if (cmd === "status" || cmd === "doctor")
48
+ return doctor(v, project);
46
49
  if (cmd === "undo")
47
50
  return undo(v, project);
48
51
  if (cmd === "export" && pos.length === 1 && ["agents", "design", "skill"].includes(v.format ?? "agents")) {
@@ -89,7 +92,6 @@ async function locate(v) {
89
92
  return r;
90
93
  }
91
94
  // ---- browser sign-in (RFC 8628 device flow) ----
92
- const VERSION = "0.1.0";
93
95
  const sleep = (s) => new Promise((ok) => setTimeout(ok, s * Number(process.env.GETCLAROS_INTERVAL_MS ?? 1000)));
94
96
  function openBrowser(url) {
95
97
  if (process.env.GETCLAROS_NO_BROWSER)
@@ -115,13 +117,22 @@ async function deviceSignIn(url, browser) {
115
117
  catch {
116
118
  return "unsupported";
117
119
  }
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…`);
120
+ const link = d.verification_uri_complete ?? d.verification_uri;
121
+ say(` ${c.bold("Sign in")} ${c.dim(browser ? "opening your browser" : "open this in your browser")}`);
122
+ say(` ${c.violet(mark.arrow)} ${c.ul(link)}`);
123
+ say(` Code ${c.bold(c.violet(d.user_code))} ${c.dim("approve only if the page shows this code")}`);
124
+ say();
123
125
  if (browser)
124
- openBrowser(d.verification_uri_complete ?? d.verification_uri);
126
+ openBrowser(link);
127
+ const spin = spinner("Waiting for you to approve in the browser…");
128
+ try {
129
+ return await poll(url, d);
130
+ }
131
+ finally {
132
+ spin.stop();
133
+ }
134
+ }
135
+ async function poll(url, d) {
125
136
  let interval = Number(d.interval) || 5;
126
137
  const until = Date.now() + (Number(d.expires_in) || 600) * 1000;
127
138
  while (Date.now() < until) {
@@ -201,16 +212,20 @@ function askHidden(q) {
201
212
  }
202
213
  const backupsDir = () => join(cfgDir(), "backups");
203
214
  const newRun = (suffix = "") => ({ dir: join(backupsDir(), new Date().toISOString().replace(/[:.]/g, "-") + suffix), files: [] });
204
- /** Backs up `before`, writes `after` atomically, records both hashes. */
205
- function commit(run, path, before, after) {
215
+ /** Backs up `before` (null: a new file), writes `after` atomically, records both hashes. */
216
+ function commit(run, path, before, after, vars) {
206
217
  if (!run.files.length) {
207
218
  mkdirSync(cfgDir(), { recursive: true, mode: 0o700 });
208
219
  mkdirSync(run.dir, { recursive: true, mode: 0o700 });
209
220
  }
210
221
  const backup = join(run.dir, `${run.files.length}-${basename(path)}`);
211
- writeFileSync(backup, before, { mode: 0o600 });
212
- writeAtomic(path, after);
213
- run.files.push({ path, backup, before: sha(before), after: sha(after) });
222
+ writeFileSync(backup, before ?? "", { mode: 0o600 });
223
+ if (before === null)
224
+ writeFileSync(path, after, { flag: "wx" });
225
+ else
226
+ writeAtomic(path, after);
227
+ run.files.push({ path, backup, before: sha(before ?? ""), after: sha(after), ...(before === null ? { created: true } : {}),
228
+ ...(vars ? { vars } : {}) });
214
229
  writeFileSync(join(run.dir, "manifest.json"), JSON.stringify({ files: run.files }, null, 2), { mode: 0o600 });
215
230
  }
216
231
  function runs() {
@@ -242,6 +257,7 @@ catch {
242
257
  const where = (s) => s.scope[0] === "projects" ? ` (project ${tilde(s.scope[1])})` : "";
243
258
  // ---- init ----
244
259
  async function init(v, project) {
260
+ header();
245
261
  const at = await locate(v);
246
262
  if (!at)
247
263
  return 3;
@@ -274,42 +290,89 @@ async function init(v, project) {
274
290
  workspace ??= await whoami(url, key);
275
291
  const cfg = saved.url === url && saved.web === web && saved.key === key && saved.workspace?.name === workspace?.name
276
292
  ? 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).`);
278
- const files = detect(project);
279
- 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.");
293
+ say(` ${c.ask(mark.ok)} Signed in${workspace?.name ? ` to workspace ${c.bold(`"${workspace.name}"`)}` : ""} ${c.dim(`key ${keyId(key)}`)}`);
294
+ say(c.dim(` ${url} ${mark.dot} key saved in ${tilde(cfg)}, readable only by you`));
295
+ const files = detect(project), browsers = detectBrowser(project);
296
+ if (!files.length && !browsers.length) {
297
+ say(`\n See a stop now: ${c.bold(`${NAME} test`)}`);
298
+ 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)));
282
299
  return 1;
283
300
  }
301
+ const run = newRun();
302
+ let mcp = { changed: [], guardedIn: new Map(), added: [] };
303
+ if (files.length) {
304
+ const r = await mcpSetup(v, files, url, key, run);
305
+ if (typeof r === "number")
306
+ return r;
307
+ mcp = r;
308
+ }
309
+ else {
310
+ say(`\n ${c.bold("MCP agents")}`);
311
+ 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)));
312
+ }
313
+ const br = await browserSetup(v, browsers, project, at, key, run);
314
+ const { changed, guardedIn, added } = mcp;
315
+ if (!changed.length && !br.done.length) {
316
+ if (br.todo)
317
+ say(` ${c.bold("Next")} add the hook shown above, then run your browser agent`);
318
+ say(" Nothing to change.");
319
+ return 0;
320
+ }
321
+ const list = (xs) => [...new Set(xs)].join(", ");
322
+ for (const [n, cl] of guardedIn)
323
+ say(` ${c.ask(mark.ok)} Guarded ${c.bold(n)} ${c.dim(`in ${list(cl)}`)}`);
324
+ if (added.length)
325
+ say(` ${c.ask(mark.ok)} Added the Claros MCP server ${c.dim(`in ${list(added)}`)}`);
326
+ for (const l of br.done)
327
+ say(` ${c.ask(mark.ok)} ${l}`);
328
+ if (run.files.length)
329
+ say(c.dim(` every file backed up first: ${tilde(run.dir)}`));
330
+ say(`\n ${c.bold("Next")} ${changed.length ? `restart ${list(changed)}, then run ${c.violet(`${NAME} test`)}` : `run ${c.violet(`${NAME} test`)}`}`);
331
+ if (br.todo)
332
+ say(` add the browser hook shown above, then run your browser agent`);
333
+ say(c.dim(` check the setup: ${NAME} doctor ${mark.dot} undo anytime: ${NAME} undo`));
334
+ return 0;
335
+ }
336
+ /** MCP agents: list each client's servers, ask which to guard, wrap them. A number = exit with it. */
337
+ async function mcpSetup(v, files, url, key, run) {
284
338
  const maps = await api(url, key, "/api/workflows").then((r) => (Array.isArray(r.json) ? r.json : []), () => []);
285
339
  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();
340
+ // per client: the servers it can guard (numbered, ● = matches a Work Map), the ones already guarded; the remote
341
+ // servers it can't guard yet are gathered into one line per reason under the list
342
+ const cands = [], gaps = new Map();
343
+ const all = files.flatMap((f) => f.servers);
344
+ const w = Math.min(28, Math.max(8, ...all.map((s) => (s.name + where(s)).length)));
345
+ say(`\n ${c.bold("MCP agents")}`);
288
346
  for (const f of files) {
289
- say(`${f.client} ${tilde(f.path)}`);
347
+ say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}`);
290
348
  if (f.error)
291
- say(` couldn't read it (${f.error}); left alone`);
349
+ say(c.dim(` couldn't read it (${f.error}); left alone`));
292
350
  for (const s of f.servers) {
351
+ const label = (s.name + where(s)).padEnd(w);
293
352
  if (s.ours)
294
- say(` ${s.name}${where(s)} Claros MCP server (agents read the Work Map with get_workflow)`);
353
+ continue;
354
+ if (s.gap)
355
+ gaps.set(s.gap, [...(gaps.get(s.gap) ?? []), s.name]);
295
356
  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}`);
357
+ say(` ${c.ask(" =")} ${label} ${c.dim("already guarded")}`);
299
358
  else {
300
359
  const m = mapOf(s.name);
301
360
  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}"` : ""));
361
+ say(` ${String(cands.length).padStart(2)} ${m ? c.ask(mark.on) : c.dim(mark.off)} ${m ? c.bold(label) : label} ` +
362
+ c.dim(s.remote ? `remote ${site(s.entry.url)}` : show(argvOf(s.entry))) + (m ? ` ${c.ask(`Work Map "${m.name}"`)}` : ""));
304
363
  }
305
364
  }
306
365
  }
366
+ for (const [gap, names] of gaps) {
367
+ const u = [...new Set(names)], n = u.length, why = gap.replace(/ – not guarded yet$/, "");
368
+ const what = why.startsWith("needs ") ? `${n > 1 ? "need" : "needs"} ${why.slice(6)}` : `(${why})`;
369
+ 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)));
370
+ }
307
371
  say();
308
372
  const byName = (names) => cands.filter((s) => names.includes(s.name));
309
373
  let picks = cands.filter((s) => mapOf(s.name));
310
374
  if (v.servers !== undefined) {
311
375
  const names = v.servers.split(",").map((s) => s.trim()).filter(Boolean);
312
- const all = files.flatMap((f) => f.servers);
313
376
  for (const n of names) {
314
377
  const hit = all.filter((s) => s.name === n);
315
378
  if (!hit.length) {
@@ -317,9 +380,9 @@ async function init(v, project) {
317
380
  return 2;
318
381
  }
319
382
  if (hit.every((s) => s.gap))
320
- say(`${n}: remote server, ${hit[0].gap}.`);
383
+ say(c.dim(` ${mark.dot} ${n}: remote server, ${hit[0].gap}.`));
321
384
  else if (hit.every((s) => s.guarded || s.gap))
322
- say(`${n}: already guarded.`);
385
+ say(c.dim(` ${mark.dot} ${n}: already guarded.`));
323
386
  }
324
387
  picks = byName(names);
325
388
  }
@@ -329,8 +392,11 @@ async function init(v, project) {
329
392
  return 2;
330
393
  }
331
394
  const def = picks.map((s) => cands.indexOf(s) + 1).join(",");
395
+ if (def)
396
+ say(c.dim(` ${mark.on} matches a Work Map: recommended`));
332
397
  for (;;) {
333
- const a = (await ask(`Guard which? Numbers (1,3), "all", "none"${def ? `, Enter = ${def}` : ""}: `)).trim().toLowerCase();
398
+ 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)} `))
399
+ .trim().toLowerCase();
334
400
  if (!a)
335
401
  break;
336
402
  if (a === "none") {
@@ -346,22 +412,23 @@ async function init(v, project) {
346
412
  picks = ns.map((n) => cands[n - 1]);
347
413
  break;
348
414
  }
349
- warn(`Pick numbers between 1 and ${cands.length}.`);
415
+ warn(` Pick numbers between 1 and ${cands.length}.`);
350
416
  }
417
+ say();
351
418
  }
352
419
  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.");
420
+ 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
421
  }
355
- const run = newRun();
356
- const changed = [];
422
+ const changed = [], guardedIn = new Map(), added = [];
357
423
  const userClients = new Set(files.filter((f) => f.user && !f.error).map((f) => f.client));
358
424
  for (const f of files.filter((x) => !x.error)) {
359
- const before = f.text, mine = picks.filter((s) => s.file === f);
425
+ const before = f.text, mine = picks.filter((s) => s.file === f), got = [];
426
+ let mcp = false;
360
427
  try {
361
428
  for (const s of mine) {
362
429
  f.text = wrapServer(f, s);
363
430
  load(f);
364
- say(`guarded ${s.name}${where(s)} in ${f.client} (${tilde(f.path)})`);
431
+ got.push(s.name + where(s));
365
432
  }
366
433
  // the Claros MCP server: in each client's user config, or its project file when the client has none
367
434
  const top = f.servers.find((s) => s.name === CLAROS && s.scope.length === 1);
@@ -369,32 +436,119 @@ async function init(v, project) {
369
436
  if (!top) {
370
437
  f.text = addClaros(f);
371
438
  load(f);
372
- say(`added the Claros MCP server to ${f.client} (${tilde(f.path)})`);
439
+ mcp = true;
373
440
  }
374
441
  else if (!top.ours)
375
- say(`kept the existing "${CLAROS}" entry in ${tilde(f.path)} (not ours)`);
442
+ say(c.dim(` ${mark.dot} kept the existing "${CLAROS}" entry in ${tilde(f.path)} (not ours)`));
376
443
  }
377
444
  }
378
445
  catch (e) {
379
- warn(`${tilde(f.path)}: ${e.message}; left unchanged`);
446
+ warn(` ${tilde(f.path)}: ${e.message}; left unchanged`);
380
447
  f.text = before;
381
448
  continue;
382
449
  }
383
450
  if (f.text !== before) {
384
451
  commit(run, f.path, before, f.text);
385
452
  changed.push(f.client);
453
+ for (const n of got)
454
+ guardedIn.set(n, [...(guardedIn.get(n) ?? []), f.client]);
455
+ if (mcp)
456
+ added.push(f.client);
386
457
  }
387
458
  }
388
- if (!changed.length) {
389
- say("Nothing to change.");
390
- return 0;
459
+ return { changed, guardedIn, added };
460
+ }
461
+ // ---- browser agents (Playwright, browser-use) in this project ----
462
+ /** A shell line that exports the saved key without printing it. */
463
+ const exportKey = () => `export CLAROS_KEY="$(node -p "require('${join(cfgDir(), "config.json").replace(homedir(), "$HOME")}').key")"`;
464
+ /** The 2-line hook for a project, as lines to print (Playwright Test: the import of the fixture init writes). */
465
+ function hookLines(p) {
466
+ if (p.fixture)
467
+ return [fixtureImport(p)];
468
+ if (p.kind === "Playwright")
469
+ return ['import { guard } from "getclaros-browser";', "await guard(page); // right after you create the page"];
470
+ return ["from claros_browser import guard_tools", "guard_tools(tools) # the Tools() you pass to Agent(..., tools=tools)"];
471
+ }
472
+ /** Each detected project's plan (install, fixture, key, hook), a question per project (Enter = yes; --yes; skipped
473
+ * with --no-browser-hooks), then the install, the fixture file and CLAROS_KEY in a gitignored .env. User source
474
+ * files are never edited: the hook is printed. */
475
+ async function browserSetup(v, ps, project, at, key, run) {
476
+ const done = [];
477
+ let todo = false, wantKey = false;
478
+ if (!ps.length)
479
+ return { done, todo };
480
+ const env = envTarget(project);
481
+ const vars = { CLAROS_KEY: key,
482
+ ...(at.source === "pinned" ? { CLAROS_URL: at.url } : at.web !== DEFAULT_WEB ? { CLAROS_WEB_URL: at.web } : {}) };
483
+ const keySet = Object.entries(vars).every(([k, x]) => process.env[k] === x || (env.ok && envValue(env.path, k) === x));
484
+ const rel = (f) => short(f, project);
485
+ say(`\n ${c.bold("Browser agents in this project")}`);
486
+ for (const p of ps) {
487
+ const row = (what, s) => say(` ${c.dim(what.padEnd(8))} ${s}`);
488
+ say(`\n ${c.bold(p.kind)} ${c.dim(`${tilde(p.dir)} ${mark.dot} ${p.via}`)}`);
489
+ row("install", p.installed ? c.dim("already installed") : p.install ? c.violet(p.install.map(tilde).join(" ")) : c.dim(p.installNote));
490
+ if (p.fixture && !existsSync(p.fixture))
491
+ row("write", `${rel(p.fixture)} ${c.dim("a fixture: every test's page goes through the guard")}`);
492
+ row("key", keySet ? c.dim("CLAROS_KEY already set") : env.ok ? `CLAROS_KEY ${mark.to} .env ${c.dim("gitignored")}`
493
+ : c.dim(`CLAROS_KEY: export it (${env.why})`));
494
+ if (p.hooked)
495
+ row("hook", c.dim("already in place"));
496
+ else {
497
+ 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"
498
+ ? "the file that opens the page" : "the file that builds your Agent"}${p.more ? ` (and ${p.more} more spec${p.more > 1 ? "s" : ""})` : ""}` +
499
+ c.dim(p.fixture ? `, instead of "@playwright/test":` : ":"));
500
+ for (const l of hookLines(p))
501
+ say(` ${c.violet(l)}`);
502
+ }
503
+ if (v["no-browser-hooks"]) {
504
+ say(c.dim(" skipped (--no-browser-hooks)"));
505
+ continue;
506
+ }
507
+ if (!v.yes) {
508
+ if (!process.stdin.isTTY) {
509
+ say(c.dim(` not set up: not a terminal, pass --yes`));
510
+ continue;
511
+ }
512
+ const a = (await ask(` ${c.bold(`Set up ${p.kind}?`)} ${c.dim(`Enter = yes ${mark.dot} n = skip`)} ${c.violet(mark.arrow)} `)).trim().toLowerCase();
513
+ if (a && !a.startsWith("y"))
514
+ continue;
515
+ }
516
+ if (!p.installed && p.install) {
517
+ say(c.dim(` running ${p.install.map(tilde).join(" ")}`));
518
+ const r = spawnSync(p.install[0], p.install.slice(1), { cwd: p.dir, stdio: ["ignore", 2, 2], shell: process.platform === "win32" });
519
+ if (r.status === 0)
520
+ done.push(`Installed ${p.kind === "Playwright" ? "getclaros-browser" : "claros-browser"} ${c.dim(`with ${p.install[0].includes("python") ? "pip" : p.install[0]}`)}`);
521
+ else
522
+ warn(` ${p.install.map(tilde).join(" ")} failed (${r.error?.message ?? `exit ${r.status}`}); run it yourself`);
523
+ }
524
+ if (p.fixture && !existsSync(p.fixture)) {
525
+ commit(run, p.fixture, null, fixtureText(p.fixture));
526
+ done.push(`Wrote ${rel(p.fixture)}`);
527
+ }
528
+ wantKey = true;
529
+ todo ||= !p.hooked;
391
530
  }
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`);
394
- return 0;
531
+ say();
532
+ if (wantKey && !keySet) {
533
+ if (env.ok) {
534
+ const before = readFileSync(env.path, "utf8");
535
+ commit(run, env.path, before, setEnv(before, vars), Object.keys(vars));
536
+ done.push(`Set ${Object.keys(vars).join(", ")} in .env ${c.dim(`key ${keyId(key)} ${mark.dot} gitignored`)}`);
537
+ }
538
+ else {
539
+ say(` ${c.bold("CLAROS_KEY")} ${c.dim(`not written to a file (${env.why}); in the shell that runs the agent:`)}`);
540
+ say(` ${c.violet(exportKey())} ${c.dim(`# key ${keyId(key)}`)}`);
541
+ for (const [k, x] of Object.entries(vars))
542
+ if (k !== "CLAROS_KEY")
543
+ say(` ${c.violet(`export ${k}=${x}`)}`);
544
+ say();
545
+ }
546
+ }
547
+ return { done, todo };
395
548
  }
396
549
  // ---- test ----
397
550
  async function test(v, project) {
551
+ header();
398
552
  const at = await locate(v);
399
553
  if (!at)
400
554
  return 3;
@@ -412,32 +566,34 @@ async function test(v, project) {
412
566
  return 3;
413
567
  }
414
568
  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.`);
569
+ 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
570
  return 1;
417
571
  }
418
572
  if (ex.status !== 200)
419
573
  throw new Error(`HTTP ${ex.status}`);
420
- const e = ex.json, t0 = Date.now();
574
+ const e = ex.json;
575
+ if (e.demo && e.examples?.length > 1)
576
+ return await demo(e, url, key, guarded);
577
+ const t0 = Date.now();
421
578
  const r = await api(url, key, "/v1/check", { workflow_id: e.workflow_id, app: e.app ?? guarded[0] ?? undefined,
422
579
  tool: "getclaros_test", actor: "getclaros test", state: e.state });
423
580
  if (r.status !== 200)
424
581
  throw new Error(`HTTP ${r.status}`);
425
582
  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
583
  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.`);
584
+ 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
585
  return 1;
432
586
  }
433
- say(`\nClaros stopped it in ${ms} ms (Work Map "${e.name}", check ${r.json.check_id}):`);
434
- for (const x of r.json.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
435
- say(`\n Rule: ${x.rule}`);
436
- 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"}`);
439
- }
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"}.`);
587
+ // the outcome first, then a card per broken rule, then the made-up action and what the agent sees
588
+ const held = r.json.result === "ask", ink = held ? c.ask : c.stop;
589
+ say(` ${ink(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"} in ${dur(ms)}`))} ` +
590
+ c.dim(`Work Map "${e.name}" ${mark.dot} check ${r.json.check_id}`));
591
+ stopCards(r.json);
592
+ say();
593
+ 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)));
594
+ say(wrap(` Your agent gets the rule as the tool's error. Nothing reached ${e.app ?? "any app"}.`, 2));
595
+ if (e.demo)
596
+ 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
597
  return 0;
442
598
  }
443
599
  catch (err) {
@@ -445,6 +601,57 @@ async function test(v, project) {
445
601
  return 3;
446
602
  }
447
603
  }
604
+ /** a card per stop / ask rule the check broke: the rule, the expert's words, who to ask */
605
+ function stopCards(r) {
606
+ for (const x of r.violations.filter((x) => x.action === "stop" || x.action === "ask")) {
607
+ const ask = x.action === "ask", p = ask ? c.ask : c.stop;
608
+ say();
609
+ say(` ${p(`${ask ? mark.ask : mark.stop} ${ask ? "ask " : "stop"}`)} ${c.bold(wrap(x.rule, 12))}`);
610
+ if (x.quote)
611
+ say(` ${wrap(`“${x.quote}”`, 12)}${x.expert ? c.dim(` ${mark.dash} ${x.expert}`) : ""}`);
612
+ say(` ${c.dim("Who to ask:")} ${x.who_to_ask || x.owner || x.expert || "the process owner"}`);
613
+ }
614
+ }
615
+ /** a made-up record on one line: whitespace in strings collapsed, long values cut */
616
+ const record = (tool, state) => `${tool ? `${tool} ` : ""}${Object.entries(state).map(([k, x]) => {
617
+ const v = typeof x === "string" ? x.replace(/\s+/g, " ").trim() : String(x);
618
+ return `${k} = ${v.length > 80 ? v.slice(0, 79) + "…" : v}`;
619
+ }).join(", ")}`;
620
+ /** demo (no guarded server matches a map): the starter maps' examples, a stop then a nudge, checked side by side */
621
+ async function demo(e, url, key, guarded) {
622
+ const t0 = Date.now();
623
+ 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 })));
624
+ const bad = rs.find((r) => r.status !== 200);
625
+ if (bad)
626
+ throw new Error(`HTTP ${bad.status}`);
627
+ const ms = Date.now() - t0, [stop, ...rest] = rs.map((r) => r.json), [se, ...re] = e.examples;
628
+ if (stop.allowed) {
629
+ 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.`);
630
+ return 1;
631
+ }
632
+ const held = stop.result === "ask", nudged = rest.some((r) => r.result === "nudge");
633
+ const outcome = [(held ? c.ask : c.stop)(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"}`)),
634
+ ...(nudged ? [c.nudge(c.bold(`${mark.nudge} Nudged`))] : [])].join(` ${mark.dot} `);
635
+ say(` ${outcome} ${c.dim(`${rs.length} checks in ${dur(ms)}`)}`);
636
+ stopCards(stop);
637
+ say(c.dim(` ${wrap(`Test action: ${record(se.tool, se.state)}`, 12)}`));
638
+ rest.forEach((r, i) => {
639
+ const x = r.violations.find((v) => v.action === "nudge"), ex = re[i];
640
+ say();
641
+ say(` ${c.nudge(`${mark.nudge} nudge`)} ${c.bold(wrap(x?.rule ?? ex.rule ?? `Work Map "${ex.name}"`, 12))}`);
642
+ if (x?.quote)
643
+ say(` ${wrap(`“${x.quote}”`, 12)}${x.expert ? c.dim(` ${mark.dash} ${x.expert}`) : ""}`);
644
+ if (x?.fix)
645
+ say(` ${c.nudge(mark.to)} ${wrap(x.fix, 14)}`);
646
+ else if (!x)
647
+ say(c.dim(` Not judged this time (${r.judge?.error ?? r.judge?.status ?? "the server's judge is off"}).`));
648
+ say(c.dim(` ${wrap(`Test action: ${record(ex.tool, ex.state)}`, 12)}`));
649
+ });
650
+ say();
651
+ 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));
652
+ 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)));
653
+ return 0;
654
+ }
448
655
  // ---- export ----
449
656
  /** A Work Map's rules as markdown on stdout (GET /v1/maps/<id>/export): redirect it into AGENTS.md, DESIGN.md or a
450
657
  * skill's SKILL.md. An advisory copy: Claros still enforces the rules when an agent acts. */
@@ -501,8 +708,11 @@ async function override(v, checkId) {
501
708
  return 3;
502
709
  }
503
710
  }
504
- // ---- status ----
505
- async function status(v, project) {
711
+ // ---- doctor (status is the same command) ----
712
+ /** Per agent type: is it guarded and can it reach Claros, with the command that fixes what isn't.
713
+ * Exit: 0 all good, 1 a browser agent here isn't set up, 2 no key, 3 Claros unreachable or the key rejected. */
714
+ async function doctor(v, project) {
715
+ header();
506
716
  const at = await locate(v);
507
717
  if (!at)
508
718
  return 3;
@@ -510,15 +720,59 @@ async function status(v, project) {
510
720
  const up = await fetch(url + "/healthz", { signal: AbortSignal.timeout(10_000) }).then((r) => r.ok, () => false);
511
721
  const ks = key ? await keyStatus(url, key) : "not set (run init)";
512
722
  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}"` : ""}`);
514
- for (const f of detect(project)) {
515
- say(`\n${f.client} ${tilde(f.path)}${f.error ? ` couldn't read it (${f.error})` : ""}`);
723
+ const ok = (good, s) => (good ? c.ask(s) : c.stop(s));
724
+ const fine = up && ks === "valid";
725
+ say(` ${ok(fine, fine ? mark.ok : mark.no)} ${c.bold("Claros")} ${url} ${ok(up, up ? "reachable" : "unreachable")}` +
726
+ ` key ${key ? keyId(key) + " " : ""}${ok(ks === "valid", ks)}${ws ? ` workspace "${ws}"` : ""}`);
727
+ const fix = (s) => say(` ${c.violet(mark.to)} ${s}`);
728
+ if (!key || /rejected|scope/.test(ks))
729
+ fix(`${NAME} init ${c.dim("signs in and saves an agent key")}`);
730
+ else if (!up)
731
+ fix(c.dim(`check your network; a moved server is followed on the next run (or pass --url)`));
732
+ const files = detect(project);
733
+ say(`\n ${c.bold("MCP agents")}`);
734
+ if (!files.length)
735
+ say(c.dim(" none found (Claude Code, Claude Desktop, Cursor, VS Code, Codex)"));
736
+ for (const f of files) {
737
+ say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}${f.error ? ` couldn't read it (${f.error})` : ""}`);
516
738
  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"}`);
739
+ const [m, state] = s.ours ? [c.violet(mark.dot), c.dim("Claros MCP server")] : s.guarded ? [c.ask(mark.ok), c.ask("guarded")]
740
+ : s.gap ? [c.dim(mark.dot), c.dim(`remote, ${s.gap}`)] : [c.dim(mark.off), s.remote ? "remote, not guarded" : "not guarded"];
741
+ say(` ${m} ${(s.name + where(s)).padEnd(24)} ${state}`);
519
742
  }
520
743
  }
521
- return !key ? 2 : up && ks === "valid" ? 0 : 3;
744
+ const servers = files.flatMap((f) => f.servers.filter((s) => !s.ours));
745
+ if (servers.some((s) => !s.guarded && !s.gap))
746
+ fix(`${NAME} init ${c.dim(`guards the servers you pick${servers.some((s) => s.guarded) ? "" : " (none is guarded yet)"}`)}`);
747
+ // browser agents: package installed, hook present, a key the agent can read (env or a safe .env)
748
+ let broken = false;
749
+ const ps = detectBrowser(project);
750
+ if (ps.length)
751
+ say(`\n ${c.bold("Browser agents in this project")}`);
752
+ const env = envTarget(project), fromFile = envValue(env.path, "CLAROS_KEY");
753
+ const agentKey = process.env.CLAROS_KEY || fromFile;
754
+ const aks = !ps.length || !agentKey ? "" : agentKey === key ? ks : await keyStatus(url, agentKey);
755
+ for (const p of ps) {
756
+ const row = (good, s, hint) => {
757
+ broken ||= !good;
758
+ say(` ${ok(good, good ? mark.ok : mark.no)} ${s}`);
759
+ if (!good && hint)
760
+ fix(hint);
761
+ };
762
+ say(`\n ${c.bold(p.kind)} ${c.dim(`${tilde(p.dir)} ${mark.dot} ${p.via}`)}`);
763
+ const pkg = p.kind === "Playwright" ? "getclaros-browser" : "claros-browser";
764
+ row(p.installed, `${pkg} ${p.installed ? "installed" : "not installed"}`, p.install ? `${p.install.map(tilde).join(" ")} ${c.dim(`(or ${NAME} init)`)}` : p.installNote);
765
+ 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`)}`
766
+ : `${p.hookFile ? `in ${short(p.hookFile, project)}` : "add"}:\n${hookLines(p).map((l) => ` ${l}`).join("\n")}`);
767
+ if (!agentKey)
768
+ row(false, "CLAROS_KEY not set", env.ok ? `${NAME} init ${c.dim("writes it to .env (gitignored)")}` : `${exportKey()} ${c.dim(`(${env.why})`)}`);
769
+ else if (fromFile && !process.env.CLAROS_KEY && !env.ok) {
770
+ 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`);
771
+ }
772
+ else
773
+ row(aks === "valid", `CLAROS_KEY ${keyId(agentKey)} from ${process.env.CLAROS_KEY ? "the environment" : ".env"} ${aks}`, `${NAME} init ${c.dim("writes a valid key")}`);
774
+ }
775
+ return !key ? 2 : !(up && ks === "valid") ? 3 : broken ? 1 : 0;
522
776
  }
523
777
  // ---- undo ----
524
778
  /** Unwraps every guarded server and removes our Claros entry in place; the rest of the file stays. */
@@ -536,9 +790,10 @@ function unwrapAll(f) {
536
790
  return n;
537
791
  }
538
792
  async function undo(v, project) {
793
+ header();
539
794
  const scope = v.all ? runs() : runs().slice(-1);
540
795
  if (!scope.length && !v.all) {
541
- say(`Nothing to undo: no backups from ${NAME} init.`);
796
+ say(` Nothing to undo: no backups from ${NAME} init.`);
542
797
  return 0;
543
798
  }
544
799
  const chains = new Map();
@@ -549,14 +804,29 @@ async function undo(v, project) {
549
804
  const byPath = new Map(detect(project).map((f) => [f.path, f]));
550
805
  for (const [path, chain] of chains) {
551
806
  if (!existsSync(path)) {
552
- say(`${tilde(path)}: gone; nothing to undo`);
807
+ say(` ${mark.dot} ${tilde(path)}: gone; nothing to undo`);
553
808
  continue;
554
809
  }
555
810
  const cur = readFileSync(path, "utf8");
556
811
  const same = sha(cur) === chain.at(-1).after && chain.every((b, i) => !i || b.before === chain[i - 1].after);
557
- if (same) {
812
+ const vars = chain.flatMap((b) => b.vars ?? []);
813
+ if (chain[0].created) { // a file init wrote (the Playwright fixture): deleted only while it's exactly what init wrote
814
+ if (same) {
815
+ unlinkSync(path);
816
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: removed (init wrote it)`);
817
+ }
818
+ else
819
+ warn(`${tilde(path)}: changed since init wrote it; left in place (delete it yourself)`);
820
+ }
821
+ else if (!same && vars.length) { // a .env edited since: drop only the lines init set
822
+ const t = cur.replace(new RegExp(`^(export\\s+)?(${vars.join("|")})=.*(\\r?\\n|$)`, "gm"), "");
823
+ if (t !== cur)
824
+ commit(safety, path, cur, t);
825
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so removed ${vars.join(", ")} and kept the other lines`);
826
+ }
827
+ else if (same) {
558
828
  commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
559
- say(`${tilde(path)}: restored from the backup taken before init (unchanged since)`);
829
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: restored from the backup taken before init (unchanged since)`);
560
830
  }
561
831
  else {
562
832
  const f = byPath.get(path) ?? detect(project).find((x) => x.path === path);
@@ -567,7 +837,7 @@ async function undo(v, project) {
567
837
  const n = unwrapAll(f);
568
838
  if (f.text !== cur)
569
839
  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`);
840
+ say(` ${c.ask(mark.ok)} ${tilde(path)}: changed since init, so unwrapped ${n} server(s) in place and kept the other edits`);
571
841
  }
572
842
  byPath.delete(path);
573
843
  }
@@ -578,12 +848,12 @@ async function undo(v, project) {
578
848
  const cur = f.text, n = unwrapAll(f);
579
849
  if (f.text !== cur) {
580
850
  commit(safety, f.path, cur, f.text);
581
- say(`${tilde(f.path)}: unwrapped ${n} server(s) in place`);
851
+ say(` ${c.ask(mark.ok)} ${tilde(f.path)}: unwrapped ${n} server(s) in place`);
582
852
  }
583
853
  }
584
854
  }
585
855
  for (const r of scope)
586
856
  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)}.` : ""}`);
857
+ 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
858
  return 0;
589
859
  }
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 ? "—" : "-", to: 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.2.0",
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",