pursr 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -154,7 +154,16 @@ pursr baseline approve myapp ./new.png home --url https://example.com
154
154
  pursr validate ./plan.json
155
155
  ```
156
156
 
157
- ### Subcommands
157
+ ### Subcommands
158
+
159
+ Flags are order-independent. Both commands below are valid, and explicit output paths override Pursr's default output directory:
160
+
161
+ ```bash
162
+ pursr shot --preset desktop-1280 https://example.com --out ./captures/home.png
163
+ pursr full https://example.com --preset desktop-1280 --out-dir ./captures
164
+ ```
165
+
166
+ `--out` is a complete file path. `--out-dir` is a directory where Pursr writes the command's standard filename.
158
167
 
159
168
  | Subcommand | Purpose |
160
169
  | --- | --- |
@@ -175,7 +184,11 @@ pursr validate ./plan.json
175
184
  | `every-viewport` | Capture once per preset in parallel (3-wide pool) |
176
185
  | `baseline` | save / list / approve / show visual baselines |
177
186
  | `auth` | save / load / list / delete Playwright storageState |
178
- | `validate` | Validate a sweep plan JSON without running it |
187
+ | `validate` | Validate a sweep plan JSON without running it |
188
+
189
+ ### Agent Skill
190
+
191
+ The npm package includes [`SKILL.md`](./SKILL.md), a compact operating guide for coding agents. Point an agent at `node_modules/pursr/SKILL.md`, or copy it into the skill directory used by your agent host. It explains when to use CLI versus MCP, correct argument order, action plans, visual verification, and safety boundaries.
179
192
 
180
193
  ## MCP Server
181
194
 
package/SKILL.md ADDED
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: pursr
3
+ description: Use Pursr for browser screenshots, scripted visual operation, visual regression, accessibility audits, DOM inspection, and MCP-driven browser sessions.
4
+ ---
5
+
6
+ # Pursr
7
+
8
+ Use this skill when a user asks an agent to inspect, operate, record, test, or compare a browser interface.
9
+
10
+ ## Choose The Right Surface
11
+
12
+ - Use the CLI for repeatable commands and prewritten action plans.
13
+ - Use MCP when the agent must inspect the current page, choose the next action, verify it, or pause for human approval.
14
+ - Use `pursr operator` for a visible scripted walkthrough or silent WebM recording.
15
+ - Use `pursr shoot` for a rich screenshot with viewport, layer, camera, grid, or animation controls.
16
+ - Use `pursr check` for CI regression against an approved baseline.
17
+ - Use `pursr sweep` only with a local JSON plan path. A URL is not a sweep plan.
18
+
19
+ ## CLI Argument Contract
20
+
21
+ Flags may appear before or after positional arguments.
22
+
23
+ ```bash
24
+ pursr shot --preset desktop-1280 https://example.com --out ./out/page.png
25
+ pursr full https://example.com --out-dir ./out
26
+ pursr eval --preset desktop-1280 https://example.com "document.title" --out ./out/eval.png
27
+ pursr click https://example.com "role=button|Continue" --out ./out/click.png
28
+ pursr type https://example.com "#email" "hello@example.com" --out ./out/type.png
29
+ pursr hover https://example.com "text=Pricing" --out ./out/hover.png
30
+ pursr seq https://example.com ./actions.json --out ./out/final.png
31
+ pursr sweep ./sweep-plan.json --out-dir ./out/sweep
32
+ ```
33
+
34
+ `--out` is a complete file path. `--out-dir` is a directory; Pursr chooses the command's standard filename inside it.
35
+
36
+ For `seq` and `operator`, actions may be inline JSON, a plain `.json` path, or an `@file.json` reference.
37
+
38
+ ## Visual Operator
39
+
40
+ ```bash
41
+ pursr operator https://example.com ./actions.json \
42
+ --visible --start-delay 1500 --slow-mo 80 \
43
+ --video ./recordings --out ./recordings/final.png
44
+ ```
45
+
46
+ The result includes the action trace, final screenshot, diagnostics, and WebM path. Browser video is silent.
47
+
48
+ Common actions:
49
+
50
+ ```json
51
+ [
52
+ { "type": "annotate", "selector": "role=button|Continue", "label": "Continue" },
53
+ { "type": "click", "selector": "role=button|Continue" },
54
+ { "type": "fill", "selector": "#email", "text": "hello@example.com" },
55
+ { "type": "drag", "fromX": 200, "fromY": 300, "toX": 600, "toY": 300 },
56
+ { "type": "press", "key": "Escape" }
57
+ ]
58
+ ```
59
+
60
+ ## MCP Agent Loop
61
+
62
+ 1. Open one stable session with `pursr_session_open`.
63
+ 2. Read rendered state with `pursr_snapshot`.
64
+ 3. Perform a small action sequence with `pursr_act`.
65
+ 4. Use `pursr_screenshot` when visual judgment is needed.
66
+ 5. Use `pursr_inspect` for geometry, clipping, style, or stacking issues.
67
+ 6. Read `pursr_diagnostics` after failures.
68
+ 7. Close with `pursr_session_close`; this finalizes any video recording.
69
+
70
+ ## Safety
71
+
72
+ - Inspect before acting on unfamiliar pages.
73
+ - Ask for human confirmation immediately before publishing, sending, purchasing, deleting, or changing permissions.
74
+ - Keep CDP endpoints on localhost. CDP preserves the browser profile but cannot record video.
75
+ - Do not claim a visual result passed until the produced screenshot or video has been checked.
76
+
77
+ ## Avoid
78
+
79
+ - Do not pass a URL to `sweep`; pass a JSON plan file.
80
+ - Do not treat `viewports` as a capture command; it only lists presets.
81
+ - Do not use `probe` as visual evidence; it only returns HTTP and page metadata.
82
+ - Do not invent selectors. Snapshot or inspect the page first when using MCP.
package/bin/pursr.mjs CHANGED
@@ -16,9 +16,10 @@ import { runEveryViewport } from "../src/every-viewport.js";
16
16
  import { runAudit } from "../src/plugin-audit.js";
17
17
  import { captureDomSnapshot } from "../src/dom-snapshot.js";
18
18
  import { listViewports } from "../src/viewport.js";
19
- import { parseFlags, asNum, readArg, makeOut, pickOutPath, __PURSR_GET } from "../src/util.js";
19
+ import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
20
+ import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
20
21
  import { writeFileSync, existsSync, mkdirSync } from "node:fs";
21
- import { dirname } from "node:path";
22
+ import { dirname, join } from "node:path";
22
23
  import { readFileSync as _readFileSync } from "node:fs";
23
24
  const readFile = _readFileSync;
24
25
  import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
@@ -44,19 +45,24 @@ function die(msg, code = 2) {
44
45
  process.exit(code);
45
46
  }
46
47
 
47
- const argv = process.argv;
48
- const [, , cmd, a, b, c, d] = argv;
49
- const url = __PURSR_GET("PURSR_URL") || a;
50
- // Top-level --plan / --out parsing for subcommands that need it before dispatch
51
- function _topOpts() {
52
- const o = {};
53
- for (let i = 2; i < argv.length; i++) {
54
- if (argv[i] === "--plan" && i + 1 < argv.length) o.plan = argv[++i];
55
- if (argv[i] === "--out" && i + 1 < argv.length) o.out = argv[++i];
56
- }
57
- return o;
58
- }
59
- const opts = _topOpts();
48
+ const argv = process.argv;
49
+ const [, , cmd] = argv;
50
+ const { flags: cliFlags, positionals } = parseCommandArgs(argv.slice(3));
51
+ const [a, b, c, d] = positionals;
52
+ const url = __PURSR_GET("PURSR_URL") || a;
53
+ const opts = { plan: cliFlags.plan, out: cliFlags.out };
54
+
55
+ function outputPath(positional, filename) {
56
+ if (cliFlags.out) return String(cliFlags.out);
57
+ if (positional) return positional;
58
+ if (cliFlags["out-dir"]) return join(String(cliFlags["out-dir"]), filename);
59
+ return makeOut(filename);
60
+ }
61
+
62
+ function dataArg(value) {
63
+ if (value && !value.startsWith("@") && existsSync(value)) return readFile(value, "utf8").replace(/\r?\n$/, "");
64
+ return readArg(value);
65
+ }
60
66
 
61
67
  // Plugin loading: scan for --plugin <path> and built-in plugins/
62
68
  const pluginPaths = [];
@@ -64,40 +70,41 @@ for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv
64
70
  await loadPlugins(pluginPaths);
65
71
 
66
72
  (async () => {
67
- try {
68
- switch (cmd) {
73
+ try {
74
+ if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
75
+ switch (cmd) {
69
76
  case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
70
77
  case "version": case "--version": case "-v": {
71
78
  console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
72
79
  break;
73
80
  }
74
81
  case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
75
- case "shot": { if (!url) die("missing url"); const out = b || makeOut("shot.png"); const r = await runShot(url, out, { fullPage: false }); console.log(JSON.stringify(r, null, 2)); break; }
76
- case "full": { if (!url) die("missing url"); const out = b || makeOut("full.png"); const r = await runShot(url, out, { fullPage: true }); console.log(JSON.stringify(r, null, 2)); break; }
77
- case "eval": { if (!url) die("missing url"); const js = readArg(b); if (!js) die("eval: missing <js> (or @file)"); const out = c || makeOut("eval.png"); const r = await runEval(url, js, out); console.log(JSON.stringify(r, null, 2)); break; }
78
- case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = c || makeOut(`click-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runClick(url, sel, out); console.log(JSON.stringify(r, null, 2)); break; }
79
- case "type": { if (!url) die("missing url"); const sel = b; const text = readArg(c); if (!sel || text === undefined) die("type: missing <selector> or <text> (text can be @file)"); const out = d || makeOut(`type-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runType(url, sel, text, out); console.log(JSON.stringify(r, null, 2)); break; }
80
- case "wait": { if (!url) die("missing url"); const sel = b; if (!sel) die("wait: missing <selector>"); const t = c !== undefined ? asNum(c, 30000) : 30000; const r = await runWait(url, sel, t); console.log(JSON.stringify(r, null, 2)); break; }
82
+ case "shot": { if (!url) die("missing url"); const out = outputPath(b, "shot.png"); const r = await runShot(url, out, { ...cliFlags, fullPage: false }); console.log(JSON.stringify(r, null, 2)); break; }
83
+ case "full": { if (!url) die("missing url"); const out = outputPath(b, "full.png"); const r = await runShot(url, out, { ...cliFlags, fullPage: true }); console.log(JSON.stringify(r, null, 2)); break; }
84
+ case "eval": { if (!url) die("missing url"); const js = dataArg(b); if (!js) die("eval: missing <js> (or @file)"); const out = outputPath(c, "eval.png"); const r = await runEval(url, js, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
85
+ case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = outputPath(c, `click-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runClick(url, sel, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
86
+ case "type": { if (!url) die("missing url"); const sel = b; const text = dataArg(c); if (!sel || text === undefined) die("type: missing <selector> or <text> (text can be @file)"); const out = outputPath(d, `type-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runType(url, sel, text, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
87
+ case "wait": { if (!url) die("missing url"); const sel = b; if (!sel) die("wait: missing <selector>"); const t = c !== undefined ? asNum(c, 30000) : 30000; const r = await runWait(url, sel, t, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
81
88
  case "diff": {
82
89
  if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
83
- const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1;
84
- const flags = parseFlags(argv.slice(5));
90
+ const out = outputPath(c, "diff.png"); const threshold = cliFlags.threshold !== undefined ? Number(cliFlags.threshold) : d !== undefined ? Number(d) : 0.1;
91
+ const flags = { ...cliFlags };
85
92
  // --ai / --ai-model / --ai-base-url / --ai-api-key
86
93
  const useAi = argv.includes("--ai");
87
- const aiModel = (() => { const i = argv.indexOf("--ai-model"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
88
- const aiBaseUrl = (() => { const i = argv.indexOf("--ai-base-url"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
89
- const aiApiKey = (() => { const i = argv.indexOf("--ai-api-key"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
94
+ const aiModel = cliFlags["ai-model"];
95
+ const aiBaseUrl = cliFlags["ai-base-url"];
96
+ const aiApiKey = cliFlags["ai-api-key"];
90
97
  const r = useAi
91
98
  ? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
92
99
  : await runDiff(url, ref, out, threshold, flags);
93
100
  console.log(JSON.stringify(r, null, 2)); break;
94
101
  }
95
- case "seq": { if (!url) die("missing url"); const actions = readArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = c || makeOut("seq.png"); const r = await runSeq(url, actions, out); console.log(JSON.stringify(r, null, 2)); break; }
102
+ case "seq": { if (!url) die("missing url"); const actions = dataArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = outputPath(c, "seq.png"); const r = await runSeq(url, actions, out, cliFlags); console.log(JSON.stringify(r, null, 2)); break; }
96
103
  case "operator": {
97
104
  if (!url) die("operator: missing <url>");
98
- const actions = readArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
99
- const flags = parseFlags(argv.slice(5));
100
- const out = flags.out || makeOut("operator.png");
105
+ const actions = dataArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
106
+ const flags = { ...cliFlags };
107
+ const out = outputPath(null, "operator.png");
101
108
  const videoValue = flags.video ?? flags["record-video"];
102
109
  const recordVideoDir = videoValue
103
110
  ? (videoValue === true ? dirname(out) : String(videoValue))
@@ -130,16 +137,16 @@ await loadPlugins(pluginPaths);
130
137
  case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
131
138
  case "shoot": {
132
139
  if (!url) die("missing url");
133
- const out = (b && !b.startsWith("--")) ? b : pickOutPath(argv.slice(5)) || makeOut("shoot.png");
134
- const r = await runShootWithSidecar({ url, out, flags: parseFlags(argv.slice(5)) });
140
+ const out = outputPath(b, "shoot.png");
141
+ const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
135
142
  console.log(JSON.stringify(r, null, 2));
136
143
  break;
137
144
  }
138
145
  case "layer": {
139
146
  if (!url) die("missing url");
140
147
  const layerName = b; if (!layerName) die("layer: missing <name>");
141
- const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`layer-${layerName}.png`);
142
- const flags = parseFlags(argv.slice(7)); flags.layer = layerName;
148
+ const out = outputPath(c, `layer-${layerName}.png`);
149
+ const flags = { ...cliFlags, layer: layerName };
143
150
  const r = await runShootWithSidecar({ url, out, flags });
144
151
  console.log(JSON.stringify(r, null, 2));
145
152
  break;
@@ -148,71 +155,70 @@ await loadPlugins(pluginPaths);
148
155
  if (!url) die("missing url");
149
156
  const count = asNum(b, 8);
150
157
  const stepMs = asNum(c, 250);
151
- const outDir = (d && !d.startsWith("--")) ? d : makeOut(`frames-${count}x${stepMs}ms`);
152
- const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: parseFlags(argv.slice(7)) });
158
+ const outDir = cliFlags["out-dir"] || cliFlags.out || d || makeOut(`frames-${count}x${stepMs}ms`);
159
+ const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: { ...cliFlags } });
153
160
  console.log(JSON.stringify(r, null, 2));
154
161
  break;
155
162
  }
156
163
  case "hover": {
157
164
  if (!url) die("missing url");
158
165
  const sel = b; if (!sel) die("hover: missing <selector>");
159
- const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
160
- const r = await runHover({ url, selector: sel, out, flags: parseFlags(argv.slice(6)) });
166
+ const out = outputPath(c, `hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
167
+ const r = await runHover({ url, selector: sel, out, flags: { ...cliFlags } });
161
168
  console.log(JSON.stringify(r, null, 2));
162
169
  break;
163
170
  }
164
171
  case "sweep": {
165
- const planPath = readArg(a);
166
- if (!planPath) die("sweep: missing <plan.json> (or @file)");
167
- const outDirArg = (b && !b.startsWith("--")) ? b : undefined;
172
+ const planPath = filePathArg(a);
173
+ if (!planPath) die("sweep: missing <plan.json>");
174
+ if (/^https?:\/\//i.test(planPath)) die("sweep: expected a local JSON plan path, not a URL");
175
+ const outDirArg = cliFlags["out-dir"] || cliFlags.out || b;
168
176
  const r = await runSweep(planPath, outDirArg);
169
177
  console.log(JSON.stringify(r, null, 2));
170
178
  break;
171
179
  }
172
180
  case "report": {
173
181
  // pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
174
- const sweepIdx = argv.indexOf("--sweep");
175
- const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
182
+ const sweepPath = cliFlags.sweep || a;
176
183
  if (!sweepPath) die("report: missing --sweep <sweep.json>");
177
184
  if (!existsSync(sweepPath)) die("report: sweep not found: " + sweepPath);
178
- const outIdx = argv.indexOf("--out");
179
- const outPath = outIdx >= 0 && outIdx + 1 < argv.length ? argv[outIdx + 1] : (opts.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf"));
185
+ const outPath = cliFlags.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf");
180
186
  if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
181
- const titleIdx = argv.indexOf("--title");
182
- const title = titleIdx >= 0 && titleIdx + 1 < argv.length ? argv[titleIdx + 1] : undefined;
183
- const noEmbed = argv.includes("--no-embed");
187
+ const title = cliFlags.title;
188
+ const noEmbed = !!cliFlags["no-embed"];
184
189
  const summary = JSON.parse(readFile(sweepPath, "utf8"));
185
190
  const { renderSweepPdf } = await import("../src/report.js");
186
191
  const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
187
192
  console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
188
193
  break;
189
194
  }
190
- case "every-viewport": {
191
- if (!url) die("missing url");
192
- const outDir = (b && !b.startsWith("--")) ? b : undefined;
193
- const viewports = c?.startsWith("--") ? undefined : c?.split(",");
195
+ case "every-viewport": {
196
+ if (!url) die("missing url");
197
+ const outDir = cliFlags["out-dir"] || cliFlags.out || b;
198
+ const viewports = c?.split(",");
194
199
  const r = await runEveryViewport({ url, outDir, viewports });
195
200
  console.log(JSON.stringify(r, null, 2));
196
201
  break;
197
202
  }
198
- case "audit": {
199
- if (!url) die("missing url");
200
- const tags = (b && !b.startsWith("--")) ? b : undefined;
201
- const outDir = (c && !c.startsWith("--")) ? c : undefined;
203
+ case "audit": {
204
+ if (!url) die("missing url");
205
+ const tags = cliFlags.tags || b;
206
+ const outDir = cliFlags["out-dir"] || cliFlags.out || c;
202
207
  const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
203
208
  console.log(JSON.stringify(r, null, 2));
204
209
  break;
205
210
  }
206
- case "dom-snapshot": case "dom": {
207
- if (!url) die("missing url");
208
- const out = (b && !b.startsWith("--")) ? b : undefined;
211
+ case "dom-snapshot": case "dom": {
212
+ if (!url) die("missing url");
213
+ const out = cliFlags.out || b;
209
214
  const r = await captureDomSnapshot({ url, out });
210
215
  console.log(JSON.stringify({ url: r.url, title: r.title, elements: r.selectorMap?.length, domSize: r.dom?.length, out: r.url?.replace(/[^/]+$/, "") + "dom.json" }, null, 2));
211
216
  break;
212
217
  }
213
- case "validate": {
214
- const planPath = readArg(a);
215
- if (!planPath) die("validate: missing <plan.json> (or @file)");
218
+ case "validate": {
219
+ const planPath = filePathArg(a);
220
+ if (!planPath) die("validate: missing <plan.json>");
221
+ if (/^https?:\/\//i.test(planPath)) die("validate: expected a local JSON plan path, not a URL");
216
222
  let plan;
217
223
  try { plan = JSON.parse(readFile(planPath, "utf8")); }
218
224
  catch (e) { die("validate: " + e.message); }
@@ -237,7 +243,7 @@ await loadPlugins(pluginPaths);
237
243
  } else if (sub === "save") {
238
244
  if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
239
245
  const project = b, png = c, step = d;
240
- const flags = parseFlags(argv.slice(7));
246
+ const flags = { ...cliFlags };
241
247
  let meta = null;
242
248
  if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
243
249
  else if (flags.url) meta = { url: flags.url };
@@ -253,14 +259,14 @@ await loadPlugins(pluginPaths);
253
259
  } else if (sub === "approve") {
254
260
  if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
255
261
  const project = b, png = c, step = d;
256
- const flags = parseFlags(argv.slice(7));
262
+ const flags = { ...cliFlags };
257
263
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
258
264
  const result = approveBaseline({ project, id, step, fromPng: png });
259
265
  console.log(JSON.stringify({ approved: true, ...result }, null, 2));
260
266
  } else if (sub === "show") {
261
267
  if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
262
268
  const project = b, step = c;
263
- const flags = parseFlags(argv.slice(5));
269
+ const flags = { ...cliFlags };
264
270
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
265
271
  const r = loadBaseline({ project, id, step });
266
272
  console.log(JSON.stringify(r, null, 2));
@@ -282,14 +288,14 @@ await loadPlugins(pluginPaths);
282
288
  console.log(JSON.stringify(listAuthStates(project), null, 2));
283
289
  } else if (sub === "save") {
284
290
  if (!b || !c) die("auth save: <project> <name> --from <state.json>");
285
- const fromFile = argv[argv.indexOf("--from") + 1];
291
+ const fromFile = cliFlags.from;
286
292
  if (!fromFile) die("auth save: missing --from <state.json>");
287
293
  const state = JSON.parse(readFile(fromFile, "utf8"));
288
294
  const r = saveAuthState({ project: b, name: c, state });
289
295
  console.log(JSON.stringify({ saved: true, ...r }, null, 2));
290
296
  } else if (sub === "load") {
291
297
  if (!b || !c) die("auth load: <project> <name> --out <state.json>");
292
- const outFile = argv[argv.indexOf("--out") + 1];
298
+ const outFile = cliFlags.out;
293
299
  if (!outFile) die("auth load: missing --out <state.json>");
294
300
  const state = loadAuthState({ project: b, name: c });
295
301
  if (!state) { console.error("not found"); process.exit(2); }
@@ -312,9 +318,9 @@ await loadPlugins(pluginPaths);
312
318
  die("watch: missing <url> (or use --plan <plan.json>)");
313
319
  }
314
320
  const { startWatch } = await import("../src/watch.js");
315
- const out = (b && !b.startsWith("--")) ? b : (opts.out || makeOut("watch.png"));
321
+ const out = opts.out || b || makeOut("watch.png");
316
322
  if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
317
- const flags = parseFlags(argv.slice(3));
323
+ const flags = { ...cliFlags };
318
324
  const onGlobs = [];
319
325
  for (let i = 0; i < argv.length; i++) {
320
326
  if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
@@ -341,9 +347,9 @@ await loadPlugins(pluginPaths);
341
347
  // pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
342
348
  if (!url) die("snap: missing <url>");
343
349
  const sel = b; if (!sel) die("snap: missing <selector>");
344
- const flags = parseFlags(argv.slice(4));
350
+ const flags = { ...cliFlags };
345
351
  const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
346
- const outDir = flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
352
+ const outDir = flags["out-dir"] || flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
347
353
  const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
348
354
  console.log(JSON.stringify({
349
355
  url: snap.url,
@@ -363,7 +369,7 @@ await loadPlugins(pluginPaths);
363
369
  case "check": {
364
370
  // pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
365
371
  if (!url) die("check: missing <url>");
366
- const flags = parseFlags(argv.slice(4));
372
+ const flags = { ...cliFlags };
367
373
  const update = !!flags.update;
368
374
  const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
369
375
  const { runCheck } = await import("../src/check.js");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "description": "pursr — Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
6
6
  "homepage": "https://github.com/0xheycat/pursr",
@@ -49,6 +49,7 @@
49
49
  "plugins",
50
50
  "plans",
51
51
  "assets",
52
+ "SKILL.md",
52
53
  "README.md",
53
54
  "LICENSE"
54
55
  ],
@@ -0,0 +1,33 @@
1
+ // Order-independent CLI argument parsing for pursr subcommands.
2
+
3
+ const DEFAULT_BOOLEAN_FLAGS = new Set([
4
+ "ai", "full", "grid", "help", "json", "no-animation", "no-embed",
5
+ "no-hud", "no-visual", "update", "visible",
6
+ ]);
7
+
8
+ export function parseCommandArgs(args = [], { booleanFlags = DEFAULT_BOOLEAN_FLAGS } = {}) {
9
+ const flags = {};
10
+ const positionals = [];
11
+ for (let i = 0; i < args.length; i++) {
12
+ const token = args[i];
13
+ if (!token?.startsWith("--")) {
14
+ positionals.push(token);
15
+ continue;
16
+ }
17
+
18
+ const eq = token.indexOf("=");
19
+ const key = token.slice(2, eq >= 0 ? eq : undefined);
20
+ let value;
21
+ if (eq >= 0) value = token.slice(eq + 1);
22
+ else if (booleanFlags.has(key)) value = true;
23
+ else if (i + 1 < args.length && !args[i + 1].startsWith("--")) value = args[++i];
24
+ else value = true;
25
+ flags[key] = value;
26
+ }
27
+ return { flags, positionals };
28
+ }
29
+
30
+ export function filePathArg(value) {
31
+ if (typeof value !== "string") return value;
32
+ return value.startsWith("@") ? value.slice(1) : value;
33
+ }
package/src/diff.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Pixelmatch diff against a reference PNG.
2
2
 
3
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { launch, newPage } from "./runway.js";
6
6
  import { resolveViewport } from "./viewport.js";
@@ -24,7 +24,8 @@ export async function runDiff(url, refPath, out, threshold, flags = {}, browser)
24
24
  requireArg("url", url, "string");
25
25
  requireArg("refPath", refPath, "string");
26
26
  const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
27
- if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
27
+ if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
28
+ if (out) mkdirSync(dirname(out), { recursive: true });
28
29
  const PNG = await loadPngjs();
29
30
  const pixelmatch = await loadPixelmatch();
30
31
  const ownBrowser = !browser;
package/src/eval.js CHANGED
@@ -1,18 +1,24 @@
1
1
  // Evaluate a JS string in the page, optionally screenshot after.
2
2
 
3
3
  import { launch, newPage } from "./runway.js";
4
- import { DEFAULT_VIEWPORT } from "./viewport.js";
4
+ import { resolveViewport } from "./viewport.js";
5
5
  import { gotoOrThrow } from "./overlays.js";
6
- import { requireArg } from "./util.js";
6
+ import { requireArg } from "./util.js";
7
+ import { mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
7
9
 
8
- export async function runEval(url, js, out) {
9
- requireArg("url", url, "string");
10
- const browser = await launch();
11
- try {
12
- const page = await newPage(browser, DEFAULT_VIEWPORT);
10
+ export async function runEval(url, js, out, flags = {}) {
11
+ requireArg("url", url, "string");
12
+ const viewport = resolveViewport(flags);
13
+ const browser = await launch();
14
+ try {
15
+ const page = await newPage(browser, viewport);
13
16
  const r = await gotoOrThrow(page, url);
14
17
  const result = await page.evaluate(js);
15
- if (out) await page.screenshot({ path: out, fullPage: false });
16
- return { ...r, url, out, result };
18
+ if (out) {
19
+ mkdirSync(dirname(out), { recursive: true });
20
+ await page.screenshot({ path: out, fullPage: false });
21
+ }
22
+ return { ...r, url, out, viewport, result };
17
23
  } finally { try { await browser.close(); } catch {} }
18
- }
24
+ }
package/src/hover.js CHANGED
@@ -4,7 +4,9 @@ import { launch, newPage } from "./runway.js";
4
4
  import { resolveViewport } from "./viewport.js";
5
5
  import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
6
6
  import { resolveLocator } from "./selector.js";
7
- import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
7
+ import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
8
10
 
9
11
  export async function runHover({ url, selector, out, flags = {} }) {
10
12
  requireArg("url", url, "string");
@@ -18,9 +20,12 @@ export async function runHover({ url, selector, out, flags = {} }) {
18
20
  await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
19
21
  await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
20
22
  await page.waitForTimeout(asNum(flags["hover-ms"], 250));
21
- if (out) await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
23
+ if (out) {
24
+ mkdirSync(dirname(out), { recursive: true });
25
+ await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
26
+ }
22
27
  const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
23
28
  if (out) await writeSidecar(meta);
24
29
  return meta;
25
30
  } finally { try { await browser.close(); } catch {} }
26
- }
31
+ }
package/src/interact.js CHANGED
@@ -1,50 +1,56 @@
1
1
  // click, type, wait, seq — interaction primitives.
2
2
 
3
3
  import { launch, newPage } from "./runway.js";
4
- import { DEFAULT_VIEWPORT } from "./viewport.js";
4
+ import { resolveViewport } from "./viewport.js";
5
5
  import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
6
6
  import { resolveLocator } from "./selector.js";
7
- import { requireArg } from "./util.js";
7
+ import { requireArg } from "./util.js";
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ function ensureScreenshotDir(out) {
12
+ if (out) mkdirSync(dirname(out), { recursive: true });
13
+ }
8
14
 
9
- export async function runClick(url, selector, out) {
15
+ export async function runClick(url, selector, out, flags = {}) {
10
16
  requireArg("url", url, "string");
11
17
  requireArg("selector", selector, "string");
12
18
  const browser = await launch();
13
19
  try {
14
- const page = await newPage(browser, DEFAULT_VIEWPORT);
20
+ const page = await newPage(browser, resolveViewport(flags));
15
21
  const r = await gotoOrThrow(page, url); await settle(page);
16
22
  const loc = await resolveLocator(page, selector);
17
23
  await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
18
24
  await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
19
25
  await settle(page);
20
- if (out) await page.screenshot({ path: out, fullPage: false });
26
+ if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
21
27
  return { ...r, url, out, selector, clicked: true };
22
28
  } finally { try { await browser.close(); } catch {} }
23
29
  }
24
30
 
25
- export async function runType(url, selector, text, out) {
31
+ export async function runType(url, selector, text, out, flags = {}) {
26
32
  requireArg("url", url, "string");
27
33
  requireArg("selector", selector, "string");
28
34
  const browser = await launch();
29
35
  try {
30
- const page = await newPage(browser, DEFAULT_VIEWPORT);
36
+ const page = await newPage(browser, resolveViewport(flags));
31
37
  const r = await gotoOrThrow(page, url); await settle(page);
32
38
  const loc = await resolveLocator(page, selector);
33
39
  await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
34
40
  await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
35
41
  await page.keyboard.type(String(text ?? ""), { delay: 10 });
36
42
  await settle(page);
37
- if (out) await page.screenshot({ path: out, fullPage: false });
43
+ if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
38
44
  return { ...r, url, out, selector, text, typed: true };
39
45
  } finally { try { await browser.close(); } catch {} }
40
46
  }
41
47
 
42
- export async function runWait(url, selector, timeoutMs) {
48
+ export async function runWait(url, selector, timeoutMs, flags = {}) {
43
49
  requireArg("url", url, "string");
44
50
  requireArg("selector", selector, "string");
45
51
  const browser = await launch();
46
52
  try {
47
- const page = await newPage(browser, DEFAULT_VIEWPORT);
53
+ const page = await newPage(browser, resolveViewport(flags));
48
54
  const r = await gotoOrThrow(page, url);
49
55
  const loc = await resolveLocator(page, selector);
50
56
  const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
@@ -57,7 +63,7 @@ export async function runWait(url, selector, timeoutMs) {
57
63
  } finally { try { await browser.close(); } catch {} }
58
64
  }
59
65
 
60
- export async function runSeq(url, actionsJson, out) {
66
+ export async function runSeq(url, actionsJson, out, flags = {}) {
61
67
  requireArg("url", url, "string");
62
68
  let actions;
63
69
  try { actions = JSON.parse(actionsJson); }
@@ -66,7 +72,7 @@ export async function runSeq(url, actionsJson, out) {
66
72
  if (!actions.length) throw new Error("actions array is empty");
67
73
  const browser = await launch();
68
74
  try {
69
- const page = await newPage(browser, DEFAULT_VIEWPORT);
75
+ const page = await newPage(browser, resolveViewport(flags));
70
76
  const r = await gotoOrThrow(page, url); await settle(page);
71
77
  const trace = [];
72
78
  let failed = false;
@@ -132,7 +138,7 @@ export async function runSeq(url, actionsJson, out) {
132
138
  trace.push(step);
133
139
  if (failed) break;
134
140
  }
135
- if (out) await page.screenshot({ path: out, fullPage: false });
141
+ if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
136
142
  return { ...r, url, out, steps: trace, failed };
137
143
  } finally { try { await browser.close(); } catch {} }
138
- }
144
+ }
package/src/shoot.js CHANGED
@@ -10,10 +10,13 @@ import {
10
10
  import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
11
11
  import { runBeforeShoot, runAfterShoot } from "./plugin.js";
12
12
  import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
13
- import { loadAuthState } from "./auth.js";
13
+ import { loadAuthState } from "./auth.js";
14
+ import { mkdirSync } from "node:fs";
15
+ import { dirname } from "node:path";
14
16
 
15
- export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
16
- requireArg("url", url, "string");
17
+ export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
18
+ requireArg("url", url, "string");
19
+ if (out) mkdirSync(dirname(out), { recursive: true });
17
20
  const viewport = resolveViewport(flags);
18
21
  const ownBrowser = !extBrowser;
19
22
  const browser = extBrowser || await launch();
@@ -71,4 +74,4 @@ export async function runShootWithSidecar(args) {
71
74
  const meta = await runShoot(args);
72
75
  await writeSidecar(meta);
73
76
  return meta;
74
- }
77
+ }
package/src/shot.js CHANGED
@@ -1,18 +1,22 @@
1
1
  // Simple screenshot (no flags / overlays).
2
2
 
3
3
  import { launch, newPage } from "./runway.js";
4
- import { DEFAULT_VIEWPORT } from "./viewport.js";
4
+ import { resolveViewport } from "./viewport.js";
5
5
  import { gotoOrThrow, settle } from "./overlays.js";
6
- import { requireArg } from "./util.js";
6
+ import { requireArg } from "./util.js";
7
+ import { mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
7
9
 
8
- export async function runShot(url, out, opts = {}) {
9
- requireArg("url", url, "string");
10
- const browser = await launch();
11
- try {
12
- const page = await newPage(browser, DEFAULT_VIEWPORT);
10
+ export async function runShot(url, out, opts = {}) {
11
+ requireArg("url", url, "string");
12
+ const viewport = resolveViewport(opts);
13
+ const browser = await launch();
14
+ try {
15
+ const page = await newPage(browser, viewport);
13
16
  const r = await gotoOrThrow(page, url);
14
17
  await settle(page);
15
- await page.screenshot({ path: out, fullPage: !!opts.fullPage });
16
- return { ...r, url, out, fullPage: !!opts.fullPage };
18
+ if (out) mkdirSync(dirname(out), { recursive: true });
19
+ await page.screenshot({ path: out, fullPage: !!opts.fullPage });
20
+ return { ...r, url, out, viewport, fullPage: !!opts.fullPage };
17
21
  } finally { try { await browser.close(); } catch {} }
18
- }
22
+ }