getclaros 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -7
- package/dist/cli.js +60 -52
- package/dist/hooks.js +348 -0
- package/dist/projects.js +179 -0
- package/dist/setup.js +347 -48
- package/dist/ui.js +1 -1
- package/dist/verdict.js +54 -0
- package/package.json +2 -2
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Coding-agent hooks: `getclaros hook <agent>` runs once per tool call, reads the agent's hook payload on stdin, maps a
|
|
2
|
+
// shell command or a file edit to a Claros action, asks POST /v1/check and answers in the agent's own protocol.
|
|
3
|
+
// MCP tool calls are left to the MCP guard (cli.ts), so nothing is checked twice.
|
|
4
|
+
// Protocols (official docs, read 2026-10-24):
|
|
5
|
+
// - Claude Code https://code.claude.com/docs/en/hooks settings.json {hooks: {PreToolUse: [{matcher, hooks: [{type:
|
|
6
|
+
// "command", command, timeout}]}]}}; stdin {cwd, tool_name, tool_input}; stdout hookSpecificOutput {permissionDecision
|
|
7
|
+
// allow|deny|ask, permissionDecisionReason (deny: to Claude, ask: to the user), additionalContext}, systemMessage.
|
|
8
|
+
// - Codex https://developers.openai.com/codex/hooks hooks.json, same shape; shell = Bash, edits = apply_patch (the
|
|
9
|
+
// patch in tool_input.command). "ask" is not supported on PreToolUse (the call would run): ask blocks instead.
|
|
10
|
+
// Hooks run only after the user trusts them in /hooks; project hooks only in a trusted project.
|
|
11
|
+
// - Cursor https://cursor.com/docs/hooks hooks.json {version: 1, hooks: {beforeShellExecution: [{command}],
|
|
12
|
+
// preToolUse: [{command, matcher}]}}; stdout {permission allow|deny|ask, user_message, agent_message}. There is no
|
|
13
|
+
// before-edit event: preToolUse on Write/Delete gates edits; its "ask" isn't enforced yet, so ask blocks there.
|
|
14
|
+
// Kept light: node builtins, config.js and verdict.js at run time; jsonc-parser only when installing.
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { loadConfig, relocate, resolve, saveConfig } from "./config.js";
|
|
20
|
+
import { nudgesOf, nudgeText, stopText } from "./verdict.js";
|
|
21
|
+
export const AGENTS = ["claude-code", "codex", "cursor"];
|
|
22
|
+
const MAX = 20_000; // ponytail: a huge write is checked on its first 20k chars; send a summary if rules need the rest
|
|
23
|
+
const cut = (s) => { const t = String(s ?? ""); return t.length > MAX ? t.slice(0, MAX) + "\n…(truncated)" : t; };
|
|
24
|
+
/** The project root: the agent's own variable, else the nearest git root above cwd, else cwd. */
|
|
25
|
+
export function rootOf(p, cwd, env = process.env) {
|
|
26
|
+
const set = env.CLAUDE_PROJECT_DIR || env.CURSOR_PROJECT_DIR || p.workspace_roots?.[0];
|
|
27
|
+
if (set)
|
|
28
|
+
return set;
|
|
29
|
+
for (let d = cwd;; d = dirname(d)) {
|
|
30
|
+
if (existsSync(join(d, ".git")))
|
|
31
|
+
return d;
|
|
32
|
+
if (dirname(d) === d)
|
|
33
|
+
return cwd;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const rel = (f, root) => {
|
|
37
|
+
const r = isAbsolute(f) ? relative(root, f) : f;
|
|
38
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(sep).join("/") : f;
|
|
39
|
+
};
|
|
40
|
+
const diffOf = (edits) => edits.map((e) => [...String(e.old_string ?? "").split("\n").map((l) => `-${l}`), ...String(e.new_string ?? "").split("\n").map((l) => `+${l}`)].join("\n"))
|
|
41
|
+
.join("\n");
|
|
42
|
+
/** Files an apply_patch envelope touches (*** Add / Update / Delete File: …, *** Move to: …). */
|
|
43
|
+
export const patchFiles = (patch) => [...patch.matchAll(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm)].map((m) => m[1].trim());
|
|
44
|
+
/** A hook payload → the action Claros checks, or null (not a shell command or an edit: let it run unchecked). */
|
|
45
|
+
export function actionOf(agent, p, env = process.env) {
|
|
46
|
+
const cwd = String(p.cwd ?? p.tool_input?.working_directory ?? process.cwd()), root = rootOf(p, cwd, env);
|
|
47
|
+
const base = { project: basename(root), cwd: cwd === root ? "." : rel(cwd, root) };
|
|
48
|
+
const t = p.tool_name, i = p.tool_input ?? {};
|
|
49
|
+
const shell = (command) => ({ tool: "shell", state: { command: cut(command), ...base }, arguments: { command } });
|
|
50
|
+
const file = (tool, f, more) => ({ tool, state: { file_path: rel(f, root), ...more, project: base.project }, arguments: i });
|
|
51
|
+
if (agent === "cursor") {
|
|
52
|
+
if (p.hook_event_name === "beforeShellExecution")
|
|
53
|
+
return shell(String(p.command ?? ""));
|
|
54
|
+
if (t === "Shell")
|
|
55
|
+
return shell(String(i.command ?? ""));
|
|
56
|
+
// ponytail: Cursor documents no tool_input for Write / Delete; these are the field names its other events use
|
|
57
|
+
if (t === "Write") {
|
|
58
|
+
const f = String(i.file_path ?? i.path ?? "");
|
|
59
|
+
return file("write_file", f, i.edits ? { diff: cut(diffOf(i.edits)) } : { content: cut(i.content ?? i.contents ?? "") });
|
|
60
|
+
}
|
|
61
|
+
if (t === "Delete")
|
|
62
|
+
return file("delete_file", String(i.file_path ?? i.path ?? ""), {});
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (t === "Bash" || t === "PowerShell")
|
|
66
|
+
return shell(String(i.command ?? ""));
|
|
67
|
+
if (agent === "codex") {
|
|
68
|
+
if (t !== "apply_patch")
|
|
69
|
+
return null;
|
|
70
|
+
const patch = String(i.command ?? i.patch ?? ""), files = patchFiles(patch);
|
|
71
|
+
return file("edit_file", files[0] ?? "", { diff: cut(patch), ...(files.length > 1 ? { files: files.map((f) => rel(f, root)).join(", ") } : {}) });
|
|
72
|
+
}
|
|
73
|
+
if (t === "Write")
|
|
74
|
+
return file("write_file", String(i.file_path ?? ""), { content: cut(i.content) });
|
|
75
|
+
if (t === "Edit")
|
|
76
|
+
return file("edit_file", String(i.file_path ?? ""), { diff: cut(diffOf([i])) });
|
|
77
|
+
if (t === "MultiEdit")
|
|
78
|
+
return file("edit_file", String(i.file_path ?? ""), { diff: cut(diffOf(i.edits ?? [])) });
|
|
79
|
+
if (t === "NotebookEdit")
|
|
80
|
+
return file("edit_file", String(i.notebook_path ?? ""), { content: cut(i.new_source) });
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const who = (r) => {
|
|
84
|
+
const v = (r.violations ?? []).find((x) => x.action === "ask" || x.action === "stop") ?? {};
|
|
85
|
+
return v.who_to_ask || v.owner || v.expert || "the rule's owner";
|
|
86
|
+
};
|
|
87
|
+
const firstRule = (r) => (r.violations ?? []).find((x) => x.action === "ask" || x.action === "stop")?.rule ?? "a team rule";
|
|
88
|
+
/** A /v1/check answer (or why the check failed) → the agent's hook output. `cursorPre`: Cursor's preToolUse (no ask). */
|
|
89
|
+
export function outputOf(agent, r, err, o = {}) {
|
|
90
|
+
const what = o.tool ?? "this action";
|
|
91
|
+
let kind, text = "", short = "";
|
|
92
|
+
if (err) {
|
|
93
|
+
if (o.failClosed) {
|
|
94
|
+
kind = "stop";
|
|
95
|
+
text = `Claros couldn't check this action (${err}), so it was not run. Ask a person to confirm it is allowed before trying again.`;
|
|
96
|
+
short = `Claros couldn't check ${what} (${err}); blocked (fail-closed).`;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
kind = "warn";
|
|
100
|
+
short = `Claros couldn't check ${what} (${err}); it ran unchecked. Run: npx getclaros doctor`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (r.allowed) {
|
|
104
|
+
kind = nudgesOf(r).length ? "nudge" : "silent";
|
|
105
|
+
text = kind === "nudge" ? nudgeText(r) : "";
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
kind = r.result === "ask" ? "ask" : "stop";
|
|
109
|
+
text = stopText(r) + (kind === "ask" && r.check_id && !r.question ? `\nCheck: ${r.check_id} (give it to ${who(r)} with the question)` : "");
|
|
110
|
+
short = kind === "ask" ? `Claros: ask ${who(r)} before ${what}: ${firstRule(r)}${r.check_id ? ` (check ${r.check_id})` : ""}`
|
|
111
|
+
: `Claros stopped ${what}: ${firstRule(r)}`;
|
|
112
|
+
}
|
|
113
|
+
const json = (x) => ({ stdout: JSON.stringify(x), stderr: "", code: 0 });
|
|
114
|
+
if (agent === "cursor") {
|
|
115
|
+
const noAsk = o.event === "preToolUse"; // Cursor: ask accepted but not enforced on preToolUse
|
|
116
|
+
if (kind === "silent")
|
|
117
|
+
return json({ permission: "allow" });
|
|
118
|
+
if (kind === "warn")
|
|
119
|
+
return json({ permission: "allow", user_message: short });
|
|
120
|
+
if (kind === "nudge")
|
|
121
|
+
return json({ permission: "allow", agent_message: text });
|
|
122
|
+
if (kind === "ask" && !noAsk)
|
|
123
|
+
return json({ permission: "ask", user_message: short, agent_message: text });
|
|
124
|
+
return json({ permission: "deny", user_message: short, agent_message: text });
|
|
125
|
+
}
|
|
126
|
+
const hso = (x) => ({ hookSpecificOutput: { hookEventName: "PreToolUse", ...x } });
|
|
127
|
+
if (kind === "silent")
|
|
128
|
+
return { stdout: "", stderr: "", code: 0 };
|
|
129
|
+
if (kind === "warn")
|
|
130
|
+
return json({ systemMessage: short });
|
|
131
|
+
if (kind === "nudge")
|
|
132
|
+
return json(hso({ additionalContext: text.slice(0, 10_000) }));
|
|
133
|
+
if (kind === "ask" && agent === "claude-code") { // native ask: the person at the keyboard decides, with the reason
|
|
134
|
+
return json({ ...hso({ permissionDecision: "ask", permissionDecisionReason: short, additionalContext: text.slice(0, 10_000) }) });
|
|
135
|
+
}
|
|
136
|
+
return json({ ...hso({ permissionDecision: "deny", permissionDecisionReason: text }), systemMessage: short });
|
|
137
|
+
}
|
|
138
|
+
/** The key: flag → CLAROS_KEY → saved config → the project's .env. */
|
|
139
|
+
function keyFor(flag, root) {
|
|
140
|
+
if (flag || process.env.CLAROS_KEY)
|
|
141
|
+
return (flag || process.env.CLAROS_KEY);
|
|
142
|
+
const saved = loadConfig().key;
|
|
143
|
+
if (saved)
|
|
144
|
+
return saved;
|
|
145
|
+
try {
|
|
146
|
+
return /^(?:export\s+)?CLAROS_KEY=["']?([^"'\r\n]*)/m.exec(readFileSync(join(root, ".env"), "utf8"))?.[1].trim() ?? "";
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function check(at, a, actor, timeout) {
|
|
153
|
+
const go = async () => {
|
|
154
|
+
const res = await fetch(at.url + "/v1/check", { method: "POST", signal: AbortSignal.timeout(timeout),
|
|
155
|
+
headers: { "content-type": "application/json", ...(at.key ? { authorization: `Bearer ${at.key}` } : {}) },
|
|
156
|
+
body: JSON.stringify({ tool: a.tool, action: a.tool, actor, state: a.state, arguments: a.arguments }) });
|
|
157
|
+
if (!res.ok)
|
|
158
|
+
throw new Error(`HTTP ${res.status}`);
|
|
159
|
+
return res.json();
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
return await go();
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (e.name === "TimeoutError" || !(await relocate(at)))
|
|
166
|
+
throw e;
|
|
167
|
+
return go(); // the server moved: the saved address is updated for the next call
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** `getclaros hook <agent> [--fail-closed] [--timeout ms] [--url …] [--web …] [--key …]` → exit code. */
|
|
171
|
+
export async function main(argv) {
|
|
172
|
+
let v, agent;
|
|
173
|
+
try {
|
|
174
|
+
const p = parseArgs({ args: argv, allowPositionals: true, options: { "fail-closed": { type: "boolean" },
|
|
175
|
+
timeout: { type: "string" }, url: { type: "string" }, web: { type: "string" }, key: { type: "string" } } });
|
|
176
|
+
v = p.values;
|
|
177
|
+
agent = p.positionals[0];
|
|
178
|
+
if (!AGENTS.includes(agent))
|
|
179
|
+
throw new Error(`usage: getclaros hook <${AGENTS.join("|")}> [--fail-closed] [--timeout ms]`);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
process.stderr.write(`${e.message}\n`);
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
let payload;
|
|
186
|
+
try {
|
|
187
|
+
payload = JSON.parse(readFileSync(0, "utf8"));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
payload = null;
|
|
191
|
+
}
|
|
192
|
+
const emit = (o) => {
|
|
193
|
+
if (o.stdout)
|
|
194
|
+
process.stdout.write(o.stdout + "\n");
|
|
195
|
+
if (o.stderr)
|
|
196
|
+
process.stderr.write(o.stderr + "\n");
|
|
197
|
+
return o.code;
|
|
198
|
+
};
|
|
199
|
+
// Cursor also runs Claude Code's hooks (.claude/settings.json); its own hooks.json entry checks those calls
|
|
200
|
+
if (agent !== "cursor" && payload?.cursor_version)
|
|
201
|
+
return 0;
|
|
202
|
+
const a = payload && actionOf(agent, payload);
|
|
203
|
+
if (!payload)
|
|
204
|
+
return emit(outputOf(agent, null, "unreadable hook payload", { failClosed: v["fail-closed"] }));
|
|
205
|
+
if (!a)
|
|
206
|
+
return emit(outputOf(agent, { allowed: true }, null, { event: payload.hook_event_name }));
|
|
207
|
+
const o = { failClosed: !!v["fail-closed"], tool: a.tool === "shell" ? `\`${String(a.state.command).slice(0, 80)}\``
|
|
208
|
+
: `${a.tool} ${a.state.file_path}`, event: payload.hook_event_name };
|
|
209
|
+
let r = null, err = null;
|
|
210
|
+
try {
|
|
211
|
+
const root = rootOf(payload, String(payload.cwd ?? process.cwd()));
|
|
212
|
+
const at = await resolve({ url: v.url, web: v.web, key: keyFor(v.key, root) });
|
|
213
|
+
const s = loadConfig();
|
|
214
|
+
if (at.source === "discovered" && !s.url)
|
|
215
|
+
saveConfig({ ...s, url: at.url, web: at.web }); // cache discovery
|
|
216
|
+
if (!at.key)
|
|
217
|
+
throw new Error("no Claros key: run npx getclaros init");
|
|
218
|
+
r = await check(at, a, agent, Number(v.timeout ?? 5000));
|
|
219
|
+
if (r?.need?.length)
|
|
220
|
+
throw new Error("the check needs records from an app the hook can't read");
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
err = e.name === "TimeoutError" ? `timed out after ${v.timeout ?? 5000}ms` : e.message;
|
|
224
|
+
}
|
|
225
|
+
return emit(outputOf(agent, r, err, o));
|
|
226
|
+
}
|
|
227
|
+
const isOurs = (cmd) => /(getclaros|[\\/]dist[\\/]cli\.js)\s+hook\s/.test(String(cmd ?? "") + " ");
|
|
228
|
+
/** Where each agent keeps its hooks, project and user scope. */
|
|
229
|
+
export function hookFiles(project) {
|
|
230
|
+
const h = homedir(), codex = process.env.CODEX_HOME || join(h, ".codex");
|
|
231
|
+
return [
|
|
232
|
+
{ agent: "claude-code", client: "Claude Code", path: join(project, ".claude", "settings.json"), user: false },
|
|
233
|
+
{ agent: "claude-code", client: "Claude Code", path: join(h, ".claude", "settings.json"), user: true },
|
|
234
|
+
{ agent: "codex", client: "Codex", path: join(project, ".codex", "hooks.json"), user: false },
|
|
235
|
+
{ agent: "codex", client: "Codex", path: join(codex, "hooks.json"), user: true },
|
|
236
|
+
{ agent: "cursor", client: "Cursor", path: join(project, ".cursor", "hooks.json"), user: false },
|
|
237
|
+
{ agent: "cursor", client: "Cursor", path: join(h, ".cursor", "hooks.json"), user: true },
|
|
238
|
+
].filter((f, i, all) => all.findIndex((x) => x.path === f.path) === i);
|
|
239
|
+
}
|
|
240
|
+
/** Agents installed on this machine (their home directory exists) or configured in this project. */
|
|
241
|
+
export function hookAgents(project) {
|
|
242
|
+
const h = homedir(), codex = process.env.CODEX_HOME || join(h, ".codex");
|
|
243
|
+
const dirs = { "claude-code": [join(h, ".claude"), join(project, ".claude")],
|
|
244
|
+
codex: [codex, join(project, ".codex")], cursor: [join(h, ".cursor"), join(project, ".cursor")] };
|
|
245
|
+
return AGENTS.filter((a) => dirs[a].some((d) => existsSync(d)));
|
|
246
|
+
}
|
|
247
|
+
/** [event, entry] pairs init adds for an agent. */
|
|
248
|
+
export function entriesFor(agent, command) {
|
|
249
|
+
if (agent === "claude-code")
|
|
250
|
+
return [["PreToolUse", { matcher: "Bash|PowerShell|Edit|Write|NotebookEdit",
|
|
251
|
+
hooks: [{ type: "command", command, timeout: 30 }] }]];
|
|
252
|
+
if (agent === "codex")
|
|
253
|
+
return [["PreToolUse", { matcher: "^Bash$|^apply_patch$",
|
|
254
|
+
hooks: [{ type: "command", command, timeout: 30, statusMessage: "Checking with Claros" }] }]];
|
|
255
|
+
return [["beforeShellExecution", { command, timeout: 30 }], ["preToolUse", { command, matcher: "Write|Delete", timeout: 30 }]];
|
|
256
|
+
}
|
|
257
|
+
const entryOurs = (e) => isOurs(e?.command) || (Array.isArray(e?.hooks) && e.hooks.some((h) => isOurs(h?.command)));
|
|
258
|
+
async function jsonc() {
|
|
259
|
+
const j = await import("jsonc-parser");
|
|
260
|
+
const fmt = (text) => {
|
|
261
|
+
const ind = text.match(/^([ \t]+)\S/m)?.[1] ?? " ";
|
|
262
|
+
return { insertSpaces: !ind.startsWith("\t"), tabSize: ind.startsWith("\t") ? 1 : ind.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
|
|
263
|
+
};
|
|
264
|
+
const set = (t, path, value) => j.applyEdits(t, j.modify(t, path, value, { formattingOptions: fmt(t) }));
|
|
265
|
+
const items = (t, path) => j.findNodeAtLocation(j.parseTree(t), path)?.children ?? [];
|
|
266
|
+
const cut = (t, a, b, r = "") => t.slice(0, a) + r + t.slice(b);
|
|
267
|
+
// by hand, like clients.ts addClaros / removeEntry: jsonc's modify would reformat the neighbouring entries
|
|
268
|
+
/** value appended to the array at path, or as `key` to the object at path */
|
|
269
|
+
const put = (t, path, value, key) => {
|
|
270
|
+
const last = items(t, path).at(-1);
|
|
271
|
+
if (!last)
|
|
272
|
+
return set(t, key === undefined ? path : [...path, key], key === undefined ? [value] : value);
|
|
273
|
+
const at = last.offset + last.length, indent = /^[ \t]*/.exec(t.slice(t.lastIndexOf("\n", last.offset) + 1))[0];
|
|
274
|
+
return cut(t, at, at, `,${fmt(t).eol}${indent}${key === undefined ? "" : `${JSON.stringify(key)}: `}${JSON.stringify(value)}`);
|
|
275
|
+
};
|
|
276
|
+
/** the k-th element (array) or property (object) at path out, with its comma */
|
|
277
|
+
const drop = (t, path, k) => {
|
|
278
|
+
const xs = items(t, path), p = xs[k];
|
|
279
|
+
if (k > 0)
|
|
280
|
+
return cut(t, xs[k - 1].offset + xs[k - 1].length, p.offset + p.length);
|
|
281
|
+
if (xs.length > 1)
|
|
282
|
+
return cut(t, p.offset, xs[1].offset);
|
|
283
|
+
return set(t, [...path, p.type === "property" ? p.children[0].value : k], undefined);
|
|
284
|
+
};
|
|
285
|
+
const read = (t) => {
|
|
286
|
+
const errors = [], doc = j.parse(t, errors, { allowTrailingComma: true });
|
|
287
|
+
if (errors.length || (doc !== undefined && (typeof doc !== "object" || Array.isArray(doc))))
|
|
288
|
+
throw new Error("not valid JSON");
|
|
289
|
+
return doc ?? {};
|
|
290
|
+
};
|
|
291
|
+
return { set, read, put, drop };
|
|
292
|
+
}
|
|
293
|
+
/** The file's text with our hooks in it (added once; a changed launcher is updated in place). Throws on bad JSON. */
|
|
294
|
+
export async function addHooks(text, agent, command) {
|
|
295
|
+
const { set, read, put } = await jsonc();
|
|
296
|
+
let t = text.trim() ? text : "{}\n";
|
|
297
|
+
if (agent === "cursor" && read(t).version === undefined)
|
|
298
|
+
t = put(t, [], 1, "version");
|
|
299
|
+
for (const [event, entry] of entriesFor(agent, command)) {
|
|
300
|
+
const hooks = read(t).hooks, list = hooks?.[event];
|
|
301
|
+
if (!hooks || typeof hooks !== "object") {
|
|
302
|
+
t = put(t, [], { [event]: [entry] }, "hooks");
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!Array.isArray(list)) {
|
|
306
|
+
t = list === undefined ? put(t, ["hooks"], [entry], event) : set(t, ["hooks", event], [entry]);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const at = list.findIndex(entryOurs);
|
|
310
|
+
if (at < 0)
|
|
311
|
+
t = put(t, ["hooks", event], entry);
|
|
312
|
+
else if (JSON.stringify(list[at]) !== JSON.stringify(entry))
|
|
313
|
+
t = set(t, ["hooks", event, at], entry);
|
|
314
|
+
}
|
|
315
|
+
return t;
|
|
316
|
+
}
|
|
317
|
+
/** The inverse: our entries out; an event list or `hooks` object left empty by that goes too. */
|
|
318
|
+
export async function removeHooks(text) {
|
|
319
|
+
const { read, drop } = await jsonc();
|
|
320
|
+
let t = text;
|
|
321
|
+
for (const [event, list] of Object.entries(read(t).hooks ?? {})) {
|
|
322
|
+
if (!Array.isArray(list) || !list.some(entryOurs))
|
|
323
|
+
continue;
|
|
324
|
+
if (list.every(entryOurs))
|
|
325
|
+
t = drop(t, ["hooks"], Object.keys(read(t).hooks).indexOf(event));
|
|
326
|
+
else
|
|
327
|
+
for (let i = list.length - 1; i >= 0; i--)
|
|
328
|
+
if (entryOurs(list[i]))
|
|
329
|
+
t = drop(t, ["hooks", event], i);
|
|
330
|
+
}
|
|
331
|
+
const doc = read(t);
|
|
332
|
+
if (doc.hooks && typeof doc.hooks === "object" && !Object.keys(doc.hooks).length && text !== t) {
|
|
333
|
+
t = drop(t, [], Object.keys(doc).indexOf("hooks"));
|
|
334
|
+
}
|
|
335
|
+
return t;
|
|
336
|
+
}
|
|
337
|
+
/** Does this file hold our hooks? (false when absent or unreadable) */
|
|
338
|
+
export async function hasHooks(path) {
|
|
339
|
+
if (!existsSync(path))
|
|
340
|
+
return false;
|
|
341
|
+
try {
|
|
342
|
+
const { read } = await jsonc();
|
|
343
|
+
return Object.values(read(readFileSync(path, "utf8")).hooks ?? {}).some((l) => Array.isArray(l) && l.some(entryOurs));
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
}
|
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);
|