getclaros 0.1.1 → 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
@@ -15,8 +15,13 @@ npx getclaros init
15
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
16
  configs launch your build instead of `npx -y getclaros`.
17
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
+
18
23
  ```
19
- claros” getclaros 0.1.1
24
+ claros” getclaros 0.2.0
20
25
 
21
26
  Sign in opening your browser
22
27
  › https://claros-eta.vercel.app/device?code=ZQRC-ZCTP
@@ -25,7 +30,7 @@ configs launch your build instead of `npx -y getclaros`.
25
30
  ✓ Signed in to workspace "Acme" key clr_7f3a91c2_…
26
31
  <claros server> · key saved in ~/.config/getclaros/config.json, readable only by you
27
32
 
28
- MCP servers
33
+ MCP agents
29
34
 
30
35
  Claude Code ~/.claude.json
31
36
  1 ● erpnext npx -y frappe-mcp-server Work Map "Process a supplier invoice"
@@ -81,9 +86,39 @@ What it changes, in files that already exist (it never creates a client config):
81
86
  up front with `get_workflow`; in each client's user config (or its project config when it has no user config).
82
87
  - Every file is backed up before it changes (`~/.config/getclaros/backups/<time>/`) and written atomically.
83
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
+
84
118
  ```
85
- npx getclaros test # a made-up record that breaks one of your rules → prints the stop (rule, expert's words, who to ask)
86
- 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
87
122
  npx getclaros undo # undo the last init; --all: every init, plus any guard wrapping it finds
88
123
  npx getclaros export <workflow_id> [--format agents|design|skill] > AGENTS.md # a map's rules as markdown
89
124
  ```
@@ -99,7 +134,30 @@ rules page. Exit codes: 0 sent, 1 no such check / not valid, 2 bad usage, 3 unre
99
134
 
100
135
  `test` asks Claros for a record that breaks a precise rule of a Work Map for one of your guarded servers
101
136
  (`GET /v1/check/example?app=…`), checks it with `POST /v1/check` and prints the stop. Nothing is sent to the app.
102
- With no guarded server matching a map it uses the workspace's newest map and ends with a `Demo:` note.
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:
103
161
 
104
162
  ```
105
163
  ◉ Stopped in 1.3 s Work Map "Process a supplier invoice" · check chk_0b14cb5eef
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";
12
15
  import { c, dur, header, mark, say, spinner, VERSION, wrap } from "./ui.js";
13
16
  const NAME = launcher().at(-1) === "getclaros" ? "npx getclaros" : "getclaros";
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")) {
@@ -209,16 +212,20 @@ function askHidden(q) {
209
212
  }
210
213
  const backupsDir = () => join(cfgDir(), "backups");
211
214
  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) {
215
+ /** Backs up `before` (null: a new file), writes `after` atomically, records both hashes. */
216
+ function commit(run, path, before, after, vars) {
214
217
  if (!run.files.length) {
215
218
  mkdirSync(cfgDir(), { recursive: true, mode: 0o700 });
216
219
  mkdirSync(run.dir, { recursive: true, mode: 0o700 });
217
220
  }
218
221
  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) });
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 } : {}) });
222
229
  writeFileSync(join(run.dir, "manifest.json"), JSON.stringify({ files: run.files }, null, 2), { mode: 0o600 });
223
230
  }
224
231
  function runs() {
@@ -285,12 +292,49 @@ async function init(v, project) {
285
292
  ? join(cfgDir(), "config.json") : saveConfig({ url, web, key, ...(workspace ? { workspace } : {}) });
286
293
  say(` ${c.ask(mark.ok)} Signed in${workspace?.name ? ` to workspace ${c.bold(`"${workspace.name}"`)}` : ""} ${c.dim(`key ${keyId(key)}`)}`);
287
294
  say(c.dim(` ${url} ${mark.dot} key saved in ${tilde(cfg)}, readable only by you`));
288
- const files = detect(project);
289
- if (!files.length) {
295
+ const files = detect(project), browsers = detectBrowser(project);
296
+ if (!files.length && !browsers.length) {
290
297
  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)));
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)));
292
299
  return 1;
293
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) {
294
338
  const maps = await api(url, key, "/api/workflows").then((r) => (Array.isArray(r.json) ? r.json : []), () => []);
295
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)))));
296
340
  // per client: the servers it can guard (numbered, ● = matches a Work Map), the ones already guarded; the remote
@@ -298,7 +342,7 @@ async function init(v, project) {
298
342
  const cands = [], gaps = new Map();
299
343
  const all = files.flatMap((f) => f.servers);
300
344
  const w = Math.min(28, Math.max(8, ...all.map((s) => (s.name + where(s)).length)));
301
- say(`\n ${c.bold("MCP servers")}`);
345
+ say(`\n ${c.bold("MCP agents")}`);
302
346
  for (const f of files) {
303
347
  say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}`);
304
348
  if (f.error)
@@ -375,7 +419,6 @@ async function init(v, project) {
375
419
  else if (!picks.length && cands.length) {
376
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)));
377
421
  }
378
- const run = newRun();
379
422
  const changed = [], guardedIn = new Map(), added = [];
380
423
  const userClients = new Set(files.filter((f) => f.user && !f.error).map((f) => f.client));
381
424
  for (const f of files.filter((x) => !x.error)) {
@@ -413,19 +456,95 @@ async function init(v, project) {
413
456
  added.push(f.client);
414
457
  }
415
458
  }
416
- if (!changed.length) {
417
- say(" Nothing to change.");
418
- 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;
419
530
  }
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;
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 };
429
548
  }
430
549
  // ---- test ----
431
550
  async function test(v, project) {
@@ -452,7 +571,10 @@ async function test(v, project) {
452
571
  }
453
572
  if (ex.status !== 200)
454
573
  throw new Error(`HTTP ${ex.status}`);
455
- 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();
456
578
  const r = await api(url, key, "/v1/check", { workflow_id: e.workflow_id, app: e.app ?? guarded[0] ?? undefined,
457
579
  tool: "getclaros_test", actor: "getclaros test", state: e.state });
458
580
  if (r.status !== 200)
@@ -466,14 +588,7 @@ async function test(v, project) {
466
588
  const held = r.json.result === "ask", ink = held ? c.ask : c.stop;
467
589
  say(` ${ink(c.bold(`${held ? mark.ask : mark.stop} ${held ? "Held to ask first" : "Stopped"} in ${dur(ms)}`))} ` +
468
590
  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
- }
591
+ stopCards(r.json);
477
592
  say();
478
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)));
479
594
  say(wrap(` Your agent gets the rule as the tool's error. Nothing reached ${e.app ?? "any app"}.`, 2));
@@ -486,6 +601,57 @@ async function test(v, project) {
486
601
  return 3;
487
602
  }
488
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
+ }
489
655
  // ---- export ----
490
656
  /** A Work Map's rules as markdown on stdout (GET /v1/maps/<id>/export): redirect it into AGENTS.md, DESIGN.md or a
491
657
  * skill's SKILL.md. An advisory copy: Claros still enforces the rules when an agent acts. */
@@ -542,8 +708,10 @@ async function override(v, checkId) {
542
708
  return 3;
543
709
  }
544
710
  }
545
- // ---- status ----
546
- 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) {
547
715
  header();
548
716
  const at = await locate(v);
549
717
  if (!at)
@@ -556,7 +724,16 @@ async function status(v, project) {
556
724
  const fine = up && ks === "valid";
557
725
  say(` ${ok(fine, fine ? mark.ok : mark.no)} ${c.bold("Claros")} ${url} ${ok(up, up ? "reachable" : "unreachable")}` +
558
726
  ` key ${key ? keyId(key) + " " : ""}${ok(ks === "valid", ks)}${ws ? ` workspace "${ws}"` : ""}`);
559
- for (const f of detect(project)) {
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) {
560
737
  say(`\n ${c.bold(f.client)} ${c.dim(tilde(f.path))}${f.error ? ` couldn't read it (${f.error})` : ""}`);
561
738
  for (const s of f.servers) {
562
739
  const [m, state] = s.ours ? [c.violet(mark.dot), c.dim("Claros MCP server")] : s.guarded ? [c.ask(mark.ok), c.ask("guarded")]
@@ -564,7 +741,38 @@ async function status(v, project) {
564
741
  say(` ${m} ${(s.name + where(s)).padEnd(24)} ${state}`);
565
742
  }
566
743
  }
567
- 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;
568
776
  }
569
777
  // ---- undo ----
570
778
  /** Unwraps every guarded server and removes our Claros entry in place; the rest of the file stays. */
@@ -601,7 +809,22 @@ async function undo(v, project) {
601
809
  }
602
810
  const cur = readFileSync(path, "utf8");
603
811
  const same = sha(cur) === chain.at(-1).after && chain.every((b, i) => !i || b.before === chain[i - 1].after);
604
- 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) {
605
828
  commit(safety, path, cur, readFileSync(chain[0].backup, "utf8"));
606
829
  say(` ${c.ask(mark.ok)} ${tilde(path)}: restored from the backup taken before init (unchanged since)`);
607
830
  }
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) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "getclaros",
3
- "version": "0.1.1",
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",