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 +63 -5
- package/dist/cli.js +1 -1
- package/dist/projects.js +179 -0
- package/dist/setup.js +270 -47
- package/dist/ui.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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 #
|
|
86
|
-
npx getclaros
|
|
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
|
-
|
|
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
|
}
|
package/dist/projects.js
ADDED
|
@@ -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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) */
|