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