pursr 0.10.0 → 0.10.2

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
@@ -36,23 +36,40 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
36
36
  - **An agent-grade MCP stdio server** (`pursr-mcp`) built on the official Model Context Protocol SDK, with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
37
37
  - **Visual Operator** sessions with a rendered cursor, target labels, click markers, visible Chrome windows, and authenticated Chrome attachment over CDP.
38
38
  - **A library API** with 25 subpath modules, so you can embed the browser and QA primitives in your own tooling.
39
- - **A plugin system** for custom viewports, sweep ops, and capture hooks.
40
- - **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
41
- - **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
39
+ - **A plugin system** for custom viewports, sweep ops, and capture hooks.
40
+ - **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
41
+ - **Zero browser bundled** - drives your system Chrome, Edge, Brave, or Chromium via Playwright. No surprise browser download during install.
42
+ - **First-run doctor + update notices** - diagnose missing browser/runtime pieces and get lightweight release notifications without breaking CI or JSON output.
42
43
 
43
44
  ## Install
44
45
 
45
46
  ```bash
46
- npm install pursr
47
- npm install --save-dev playwright-core # peer dep - bring your own Chrome
48
- ```
49
-
50
- Then verify:
51
-
52
- ```bash
53
- pursr viewports # list 10+ registered viewport presets
54
- pursr probe https://example.com # health check
55
- ```
47
+ npm install pursr
48
+ npm install --save-dev playwright-core # peer dep - bring your own Chrome
49
+ ```
50
+
51
+ Then verify:
52
+
53
+ ```bash
54
+ pursr doctor # check Node, playwright-core, browser, and SKILL.md
55
+ pursr setup # print next steps if anything is missing
56
+ pursr viewports # list 10+ registered viewport presets
57
+ pursr probe https://example.com # health check
58
+ ```
59
+
60
+ Pursr intentionally does **not** download browsers during `npm install`. Install Chrome, Edge, Brave, or Chromium yourself, or point Pursr at a Chrome-compatible executable:
61
+
62
+ ```bash
63
+ # Windows
64
+ setx PURSR_BROWSER_PATH "C:\Program Files\Google\Chrome\Application\chrome.exe"
65
+
66
+ # macOS/Linux
67
+ export PURSR_BROWSER_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
68
+ ```
69
+
70
+ Browser discovery checks standard global installs, user installs, Dev/Beta/Canary/Nightly channels, and executable names found in `PATH` across Windows, macOS, and Linux. Current target is Chromium-family browsers; Firefox/WebKit are planned, but not claimed as stable targets yet.
71
+
72
+ CLI update notifications are cached for 24 hours, written to stderr, and disabled automatically in CI/non-interactive runs. Disable them manually with `PURSR_NO_UPDATE_NOTIFIER=1`.
56
73
 
57
74
  ## 30 seconds
58
75
 
@@ -154,12 +171,23 @@ pursr baseline approve myapp ./new.png home --url https://example.com
154
171
  pursr validate ./plan.json
155
172
  ```
156
173
 
157
- ### Subcommands
174
+ ### Subcommands
175
+
176
+ Flags are order-independent. Both commands below are valid, and explicit output paths override Pursr's default output directory:
177
+
178
+ ```bash
179
+ pursr shot --preset desktop-1280 https://example.com --out ./captures/home.png
180
+ pursr full https://example.com --preset desktop-1280 --out-dir ./captures
181
+ ```
182
+
183
+ `--out` is a complete file path. `--out-dir` is a directory where Pursr writes the command's standard filename.
158
184
 
159
- | Subcommand | Purpose |
160
- | --- | --- |
161
- | `probe` | Health check (HTTP status, page title) |
162
- | `shot` / `full` | Viewport / full-page screenshot |
185
+ | Subcommand | Purpose |
186
+ | --- | --- |
187
+ | `doctor` | Diagnose Node, `playwright-core`, browser discovery, and packaged skill availability |
188
+ | `setup` | Print first-run setup guidance without auto-installing browsers |
189
+ | `probe` | Health check (HTTP status, page title) |
190
+ | `shot` / `full` | Viewport / full-page screenshot |
163
191
  | `eval` | Execute JS in the page, return result |
164
192
  | `click` / `type` / `wait` / `seq` | Interaction primitives |
165
193
  | `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
@@ -175,7 +203,11 @@ pursr validate ./plan.json
175
203
  | `every-viewport` | Capture once per preset in parallel (3-wide pool) |
176
204
  | `baseline` | save / list / approve / show visual baselines |
177
205
  | `auth` | save / load / list / delete Playwright storageState |
178
- | `validate` | Validate a sweep plan JSON without running it |
206
+ | `validate` | Validate a sweep plan JSON without running it |
207
+
208
+ ### Agent Skill
209
+
210
+ 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
211
 
180
212
  ## MCP Server
181
213
 
package/SKILL.md ADDED
@@ -0,0 +1,169 @@
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. Use when a user asks an agent to inspect a site, operate an existing browser session, fill or draft UI content, record a tutorial, compare visuals, debug layout, or behave like a careful browser operator that observes, acts, verifies, and pauses before external side effects.
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
+ ## Operator Mindset
11
+
12
+ Act like a careful visual browser operator, not a command runner.
13
+
14
+ - Infer the user's practical goal from the page and request, then state the first step briefly.
15
+ - Prefer the smallest useful action: observe, act once, verify, then continue.
16
+ - Use the current authenticated browser session when the task depends on login state, open tabs, cookies, or a dashboard the user already opened.
17
+ - Use a fresh session for repeatable tests, visual regression, tutorial recordings, or unauthenticated pages.
18
+ - Draft or prepare external communication, but pause before publishing, sending, buying, deleting, or changing permissions.
19
+ - Report what changed, what was verified, and what still needs user approval.
20
+
21
+ Examples:
22
+
23
+ - If the user says "help me make a first post on the Twitter tab I opened", find the open tab, inspect the composer, write a polished draft, then ask before clicking `Post`.
24
+ - If the user says "check why this dashboard looks wrong", open a session, capture a screenshot, inspect geometry/styles for the suspect element, make a small diagnosis, and propose or apply the fix if the repo is available.
25
+ - If the user says "make a tutorial video", use `pursr operator` or MCP actions with visible cursor, labels, slow motion, screenshots, and WebM recording.
26
+
27
+ ## Choose The Right Surface
28
+
29
+ - Use the CLI for repeatable commands and prewritten action plans.
30
+ - Use MCP when the agent must inspect the current page, choose the next action, verify it, or pause for human approval.
31
+ - Use `pursr doctor` before first-run debugging or when browser/runtime setup is uncertain.
32
+ - Use `pursr setup` to give the user safe install guidance without downloading browsers automatically.
33
+ - Use `pursr operator` for a visible scripted walkthrough or silent WebM recording.
34
+ - Use `pursr shoot` for a rich screenshot with viewport, layer, camera, grid, or animation controls.
35
+ - Use `pursr check` for CI regression against an approved baseline.
36
+ - Use `pursr sweep` only with a local JSON plan path. A URL is not a sweep plan.
37
+
38
+ ## Capability Check
39
+
40
+ Before operating a browser, pick the strongest available path:
41
+
42
+ 1. Existing browser-control plugin or app: use it for logged-in tabs and user-opened pages.
43
+ 2. Pursr MCP session: use it for inspect-act-verify loops, visual debugging, and agent-driven browser work.
44
+ 3. Pursr CLI: use it for screenshots, scripted operator plans, regression checks, and recordings.
45
+ 4. No browser tools: provide the draft, action plan JSON, or exact command for the user to run.
46
+
47
+ Do not pretend to control the user's live browser if only the CLI is available. Use CDP attach only when the user intentionally starts Chrome with a local debugging port.
48
+
49
+ ## First Run And Setup
50
+
51
+ Do not auto-install browsers during package installation. Pursr is intentionally lightweight at install time.
52
+
53
+ Run:
54
+
55
+ ```bash
56
+ pursr doctor
57
+ pursr setup
58
+ ```
59
+
60
+ Expected support target:
61
+
62
+ - Chrome-compatible browsers first: Google Chrome, Microsoft Edge, Brave, and Chromium.
63
+ - Discovery should cover global installs, user installs, Dev/Beta/Canary/Nightly channels, and executables found in `PATH` across Windows, macOS, and Linux.
64
+ - Use `PURSR_BROWSER_PATH` when discovery misses the executable.
65
+ - Firefox and WebKit are not the primary supported target yet; do not claim full all-browser support unless the implementation has been added and verified.
66
+ - Update notifications are informational only, cached, written to stderr, and disabled by `PURSR_NO_UPDATE_NOTIFIER=1`.
67
+
68
+ ## Autonomous Browser Workflow
69
+
70
+ For open-ended browser tasks:
71
+
72
+ 1. Discover context: list or open the relevant tab, read URL/title, and snapshot the visible page.
73
+ 2. Build intent: identify the user's real goal, the current page state, and the next safe action.
74
+ 3. Act narrowly: click, type, scroll, or run an action plan only after the target is visible or identified from a snapshot.
75
+ 4. Verify immediately: take a new snapshot, screenshot, URL check, or diagnostics read after meaningful actions.
76
+ 5. Stop at side effects: ask for confirmation immediately before final publish/send/delete/purchase/permission actions.
77
+ 6. Leave a handoff: keep important tabs or artifacts available and summarize the exact state.
78
+
79
+ Use this loop for social posts, dashboard setup, design QA, local app testing, checkout-like flows, account settings, and tutorial recording.
80
+
81
+ Default short progress pattern:
82
+
83
+ - "I will inspect the current page first."
84
+ - "I found the relevant tab/page and I am preparing the smallest safe action."
85
+ - "Draft/action is ready; I am stopping before the final external side effect."
86
+ - "Verified: [state]. Remaining approval/action: [item]."
87
+
88
+ ## CLI Argument Contract
89
+
90
+ Flags may appear before or after positional arguments.
91
+
92
+ ```bash
93
+ pursr shot --preset desktop-1280 https://example.com --out ./out/page.png
94
+ pursr full https://example.com --out-dir ./out
95
+ pursr eval --preset desktop-1280 https://example.com "document.title" --out ./out/eval.png
96
+ pursr click https://example.com "role=button|Continue" --out ./out/click.png
97
+ pursr type https://example.com "#email" "hello@example.com" --out ./out/type.png
98
+ pursr hover https://example.com "text=Pricing" --out ./out/hover.png
99
+ pursr seq https://example.com ./actions.json --out ./out/final.png
100
+ pursr sweep ./sweep-plan.json --out-dir ./out/sweep
101
+ ```
102
+
103
+ `--out` is a complete file path. `--out-dir` is a directory; Pursr chooses the command's standard filename inside it.
104
+
105
+ For `seq` and `operator`, actions may be inline JSON, a plain `.json` path, or an `@file.json` reference.
106
+
107
+ ## Visual Operator
108
+
109
+ ```bash
110
+ pursr operator https://example.com ./actions.json \
111
+ --visible --start-delay 1500 --slow-mo 80 \
112
+ --video ./recordings --out ./recordings/final.png
113
+ ```
114
+
115
+ The result includes the action trace, final screenshot, diagnostics, and WebM path. Browser video is silent.
116
+
117
+ Common actions:
118
+
119
+ ```json
120
+ [
121
+ { "type": "annotate", "selector": "role=button|Continue", "label": "Continue" },
122
+ { "type": "click", "selector": "role=button|Continue" },
123
+ { "type": "fill", "selector": "#email", "text": "hello@example.com" },
124
+ { "type": "drag", "fromX": 200, "fromY": 300, "toX": 600, "toY": 300 },
125
+ { "type": "press", "key": "Escape" }
126
+ ]
127
+ ```
128
+
129
+ ## MCP Agent Loop
130
+
131
+ 1. Open one stable session with `pursr_session_open`.
132
+ 2. Read rendered state with `pursr_snapshot`.
133
+ 3. Perform a small action sequence with `pursr_act`.
134
+ 4. Use `pursr_screenshot` when visual judgment is needed.
135
+ 5. Use `pursr_inspect` for geometry, clipping, style, or stacking issues.
136
+ 6. Read `pursr_diagnostics` after failures.
137
+ 7. Close with `pursr_session_close`; this finalizes any video recording.
138
+
139
+ Recommended MCP behavior:
140
+
141
+ - Keep one persistent session per task.
142
+ - Prefer snapshots for target discovery and screenshots for visual judgment.
143
+ - Use `pursr_inspect` when the issue is position, z-index, clipping, opacity, transform, or layout.
144
+ - Use `pursr_act` for small verified action batches, not long blind scripts.
145
+ - When recording, add visible cursor movement, target labels, and short pauses so the output is understandable.
146
+
147
+ ## Communication Drafting
148
+
149
+ When operating social media, email, forms, comments, or public dashboards:
150
+
151
+ 1. Draft text in the UI or provide it to the user.
152
+ 2. Verify the account/page context before filling anything.
153
+ 3. Keep tone natural and specific to the product; avoid generic AI-marketing language.
154
+ 4. Ask for final approval before clicking the button that transmits the content.
155
+ 5. After approval and posting, verify the success state or resulting URL.
156
+
157
+ ## Safety
158
+
159
+ - Inspect before acting on unfamiliar pages.
160
+ - Ask for human confirmation immediately before publishing, sending, purchasing, deleting, or changing permissions.
161
+ - Keep CDP endpoints on localhost. CDP preserves the browser profile but cannot record video.
162
+ - Do not claim a visual result passed until the produced screenshot or video has been checked.
163
+
164
+ ## Avoid
165
+
166
+ - Do not pass a URL to `sweep`; pass a JSON plan file.
167
+ - Do not treat `viewports` as a capture command; it only lists presets.
168
+ - Do not use `probe` as visual evidence; it only returns HTTP and page metadata.
169
+ - Do not invent selectors. Snapshot or inspect the page first when using MCP.
package/bin/pursr.mjs CHANGED
@@ -13,12 +13,14 @@ import { runFrames } from "../src/frames.js";
13
13
  import { runDiff, runDiffWithAi } from "../src/diff.js";
14
14
  import { runSweep } from "../src/sweep.js";
15
15
  import { runEveryViewport } from "../src/every-viewport.js";
16
- import { runAudit } from "../src/plugin-audit.js";
17
- import { captureDomSnapshot } from "../src/dom-snapshot.js";
18
- import { listViewports } from "../src/viewport.js";
19
- import { parseFlags, asNum, readArg, makeOut, pickOutPath, __PURSR_GET } from "../src/util.js";
20
- import { writeFileSync, existsSync, mkdirSync } from "node:fs";
21
- import { dirname } from "node:path";
16
+ import { runAudit } from "../src/plugin-audit.js";
17
+ import { captureDomSnapshot } from "../src/dom-snapshot.js";
18
+ import { listViewports } from "../src/viewport.js";
19
+ import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
20
+ import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
21
+ import { notifyUpdate } from "../src/update-notifier.js";
22
+ import { writeFileSync, existsSync, mkdirSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
22
24
  import { readFileSync as _readFileSync } from "node:fs";
23
25
  const readFile = _readFileSync;
24
26
  import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
@@ -27,15 +29,16 @@ const USAGE = `usage:
27
29
  v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
28
30
  v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
29
31
  operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
30
- flags: --preset <name> --width N --height N --dpr N
32
+ flags: --preset <name> --width N --height N --dpr N
31
33
  --zoom 1.5 --panX 200 --panY -100
32
34
  --cursor pointer|grab|grabbing|crosshair|none
33
35
  --layer entity|terrain|hud|ui
34
36
  --grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
35
37
  --no-animation --wait-frame 600 --full
36
38
  @file prefix reads argv contents from file (UTF-8, newline trimmed).
37
- report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
38
- diff extras: --ai [--ai-model M] [--ai-base-url U] [--ai-api-key K]
39
+ report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
40
+ setup: pursr doctor [--json] | pursr setup [--json]
41
+ diff extras: --ai [--ai-model M] [--ai-base-url U] [--ai-api-key K]
39
42
  plugins: pursr automatically loads built-in plugins from plugins/.
40
43
  You can also pass --plugin <path> to load custom plugins (repeatable).`;
41
44
 
@@ -44,60 +47,67 @@ function die(msg, code = 2) {
44
47
  process.exit(code);
45
48
  }
46
49
 
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();
50
+ const argv = process.argv;
51
+ const [, , cmd] = argv;
52
+ const { flags: cliFlags, positionals } = parseCommandArgs(argv.slice(3));
53
+ const [a, b, c, d] = positionals;
54
+ const url = __PURSR_GET("PURSR_URL") || a;
55
+ const opts = { plan: cliFlags.plan, out: cliFlags.out };
56
+
57
+ function outputPath(positional, filename) {
58
+ if (cliFlags.out) return String(cliFlags.out);
59
+ if (positional) return positional;
60
+ if (cliFlags["out-dir"]) return join(String(cliFlags["out-dir"]), filename);
61
+ return makeOut(filename);
62
+ }
63
+
64
+ function dataArg(value) {
65
+ if (value && !value.startsWith("@") && existsSync(value)) return readFile(value, "utf8").replace(/\r?\n$/, "");
66
+ return readArg(value);
67
+ }
60
68
 
61
69
  // Plugin loading: scan for --plugin <path> and built-in plugins/
62
70
  const pluginPaths = [];
63
71
  for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv.length) pluginPaths.push(argv[++i]);
64
- await loadPlugins(pluginPaths);
65
-
66
- (async () => {
67
- try {
68
- switch (cmd) {
72
+ await loadPlugins(pluginPaths);
73
+
74
+ (async () => {
75
+ try {
76
+ await notifyUpdate({ currentVersion: VERSION, stdout: process.stdout, stderr: process.stderr });
77
+ if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
78
+ switch (cmd) {
69
79
  case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
70
80
  case "version": case "--version": case "-v": {
71
81
  console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
72
82
  break;
73
83
  }
74
84
  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; }
85
+ 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; }
86
+ 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; }
87
+ 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; }
88
+ 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; }
89
+ 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; }
90
+ 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
91
  case "diff": {
82
92
  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));
93
+ const out = outputPath(c, "diff.png"); const threshold = cliFlags.threshold !== undefined ? Number(cliFlags.threshold) : d !== undefined ? Number(d) : 0.1;
94
+ const flags = { ...cliFlags };
85
95
  // --ai / --ai-model / --ai-base-url / --ai-api-key
86
96
  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; })();
97
+ const aiModel = cliFlags["ai-model"];
98
+ const aiBaseUrl = cliFlags["ai-base-url"];
99
+ const aiApiKey = cliFlags["ai-api-key"];
90
100
  const r = useAi
91
101
  ? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
92
102
  : await runDiff(url, ref, out, threshold, flags);
93
103
  console.log(JSON.stringify(r, null, 2)); break;
94
104
  }
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; }
105
+ 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
106
  case "operator": {
97
107
  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");
108
+ const actions = dataArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
109
+ const flags = { ...cliFlags };
110
+ const out = outputPath(null, "operator.png");
101
111
  const videoValue = flags.video ?? flags["record-video"];
102
112
  const recordVideoDir = videoValue
103
113
  ? (videoValue === true ? dirname(out) : String(videoValue))
@@ -127,19 +137,35 @@ await loadPlugins(pluginPaths);
127
137
  if (!r.ok) process.exitCode = 1;
128
138
  break;
129
139
  }
130
- case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
131
- case "shoot": {
140
+ case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
141
+ case "doctor": {
142
+ const { runDoctor, renderDoctorText } = await import("../src/doctor.js");
143
+ const r = await runDoctor();
144
+ if (cliFlags.json) console.log(JSON.stringify(r, null, 2));
145
+ else console.log(renderDoctorText(r));
146
+ process.exitCode = r.ok ? 0 : 1;
147
+ break;
148
+ }
149
+ case "setup": {
150
+ const { runSetup, renderSetupText } = await import("../src/doctor.js");
151
+ const r = await runSetup();
152
+ if (cliFlags.json) console.log(JSON.stringify(r, null, 2));
153
+ else console.log(renderSetupText(r));
154
+ process.exitCode = r.ok ? 0 : 1;
155
+ break;
156
+ }
157
+ case "shoot": {
132
158
  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)) });
159
+ const out = outputPath(b, "shoot.png");
160
+ const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
135
161
  console.log(JSON.stringify(r, null, 2));
136
162
  break;
137
163
  }
138
164
  case "layer": {
139
165
  if (!url) die("missing url");
140
166
  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;
167
+ const out = outputPath(c, `layer-${layerName}.png`);
168
+ const flags = { ...cliFlags, layer: layerName };
143
169
  const r = await runShootWithSidecar({ url, out, flags });
144
170
  console.log(JSON.stringify(r, null, 2));
145
171
  break;
@@ -148,71 +174,70 @@ await loadPlugins(pluginPaths);
148
174
  if (!url) die("missing url");
149
175
  const count = asNum(b, 8);
150
176
  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)) });
177
+ const outDir = cliFlags["out-dir"] || cliFlags.out || d || makeOut(`frames-${count}x${stepMs}ms`);
178
+ const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: { ...cliFlags } });
153
179
  console.log(JSON.stringify(r, null, 2));
154
180
  break;
155
181
  }
156
182
  case "hover": {
157
183
  if (!url) die("missing url");
158
184
  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)) });
185
+ const out = outputPath(c, `hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
186
+ const r = await runHover({ url, selector: sel, out, flags: { ...cliFlags } });
161
187
  console.log(JSON.stringify(r, null, 2));
162
188
  break;
163
189
  }
164
190
  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;
191
+ const planPath = filePathArg(a);
192
+ if (!planPath) die("sweep: missing <plan.json>");
193
+ if (/^https?:\/\//i.test(planPath)) die("sweep: expected a local JSON plan path, not a URL");
194
+ const outDirArg = cliFlags["out-dir"] || cliFlags.out || b;
168
195
  const r = await runSweep(planPath, outDirArg);
169
196
  console.log(JSON.stringify(r, null, 2));
170
197
  break;
171
198
  }
172
199
  case "report": {
173
200
  // 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;
201
+ const sweepPath = cliFlags.sweep || a;
176
202
  if (!sweepPath) die("report: missing --sweep <sweep.json>");
177
203
  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"));
204
+ const outPath = cliFlags.out || makeOut("report.pdf").replace(/pursr-[^-]+-shot.png$/, "report.pdf");
180
205
  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");
206
+ const title = cliFlags.title;
207
+ const noEmbed = !!cliFlags["no-embed"];
184
208
  const summary = JSON.parse(readFile(sweepPath, "utf8"));
185
209
  const { renderSweepPdf } = await import("../src/report.js");
186
210
  const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
187
211
  console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
188
212
  break;
189
213
  }
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(",");
214
+ case "every-viewport": {
215
+ if (!url) die("missing url");
216
+ const outDir = cliFlags["out-dir"] || cliFlags.out || b;
217
+ const viewports = c?.split(",");
194
218
  const r = await runEveryViewport({ url, outDir, viewports });
195
219
  console.log(JSON.stringify(r, null, 2));
196
220
  break;
197
221
  }
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;
222
+ case "audit": {
223
+ if (!url) die("missing url");
224
+ const tags = cliFlags.tags || b;
225
+ const outDir = cliFlags["out-dir"] || cliFlags.out || c;
202
226
  const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
203
227
  console.log(JSON.stringify(r, null, 2));
204
228
  break;
205
229
  }
206
- case "dom-snapshot": case "dom": {
207
- if (!url) die("missing url");
208
- const out = (b && !b.startsWith("--")) ? b : undefined;
230
+ case "dom-snapshot": case "dom": {
231
+ if (!url) die("missing url");
232
+ const out = cliFlags.out || b;
209
233
  const r = await captureDomSnapshot({ url, out });
210
234
  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
235
  break;
212
236
  }
213
- case "validate": {
214
- const planPath = readArg(a);
215
- if (!planPath) die("validate: missing <plan.json> (or @file)");
237
+ case "validate": {
238
+ const planPath = filePathArg(a);
239
+ if (!planPath) die("validate: missing <plan.json>");
240
+ if (/^https?:\/\//i.test(planPath)) die("validate: expected a local JSON plan path, not a URL");
216
241
  let plan;
217
242
  try { plan = JSON.parse(readFile(planPath, "utf8")); }
218
243
  catch (e) { die("validate: " + e.message); }
@@ -237,7 +262,7 @@ await loadPlugins(pluginPaths);
237
262
  } else if (sub === "save") {
238
263
  if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
239
264
  const project = b, png = c, step = d;
240
- const flags = parseFlags(argv.slice(7));
265
+ const flags = { ...cliFlags };
241
266
  let meta = null;
242
267
  if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
243
268
  else if (flags.url) meta = { url: flags.url };
@@ -253,14 +278,14 @@ await loadPlugins(pluginPaths);
253
278
  } else if (sub === "approve") {
254
279
  if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
255
280
  const project = b, png = c, step = d;
256
- const flags = parseFlags(argv.slice(7));
281
+ const flags = { ...cliFlags };
257
282
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
258
283
  const result = approveBaseline({ project, id, step, fromPng: png });
259
284
  console.log(JSON.stringify({ approved: true, ...result }, null, 2));
260
285
  } else if (sub === "show") {
261
286
  if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
262
287
  const project = b, step = c;
263
- const flags = parseFlags(argv.slice(5));
288
+ const flags = { ...cliFlags };
264
289
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
265
290
  const r = loadBaseline({ project, id, step });
266
291
  console.log(JSON.stringify(r, null, 2));
@@ -282,14 +307,14 @@ await loadPlugins(pluginPaths);
282
307
  console.log(JSON.stringify(listAuthStates(project), null, 2));
283
308
  } else if (sub === "save") {
284
309
  if (!b || !c) die("auth save: <project> <name> --from <state.json>");
285
- const fromFile = argv[argv.indexOf("--from") + 1];
310
+ const fromFile = cliFlags.from;
286
311
  if (!fromFile) die("auth save: missing --from <state.json>");
287
312
  const state = JSON.parse(readFile(fromFile, "utf8"));
288
313
  const r = saveAuthState({ project: b, name: c, state });
289
314
  console.log(JSON.stringify({ saved: true, ...r }, null, 2));
290
315
  } else if (sub === "load") {
291
316
  if (!b || !c) die("auth load: <project> <name> --out <state.json>");
292
- const outFile = argv[argv.indexOf("--out") + 1];
317
+ const outFile = cliFlags.out;
293
318
  if (!outFile) die("auth load: missing --out <state.json>");
294
319
  const state = loadAuthState({ project: b, name: c });
295
320
  if (!state) { console.error("not found"); process.exit(2); }
@@ -312,9 +337,9 @@ await loadPlugins(pluginPaths);
312
337
  die("watch: missing <url> (or use --plan <plan.json>)");
313
338
  }
314
339
  const { startWatch } = await import("../src/watch.js");
315
- const out = (b && !b.startsWith("--")) ? b : (opts.out || makeOut("watch.png"));
340
+ const out = opts.out || b || makeOut("watch.png");
316
341
  if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
317
- const flags = parseFlags(argv.slice(3));
342
+ const flags = { ...cliFlags };
318
343
  const onGlobs = [];
319
344
  for (let i = 0; i < argv.length; i++) {
320
345
  if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
@@ -341,9 +366,9 @@ await loadPlugins(pluginPaths);
341
366
  // pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
342
367
  if (!url) die("snap: missing <url>");
343
368
  const sel = b; if (!sel) die("snap: missing <selector>");
344
- const flags = parseFlags(argv.slice(4));
369
+ const flags = { ...cliFlags };
345
370
  const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
346
- const outDir = flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
371
+ const outDir = flags["out-dir"] || flags.out || makeOut("snaps").replace(/pursr-[^-]+-snap\.png$/, "snaps");
347
372
  const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
348
373
  console.log(JSON.stringify({
349
374
  url: snap.url,
@@ -363,7 +388,7 @@ await loadPlugins(pluginPaths);
363
388
  case "check": {
364
389
  // pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
365
390
  if (!url) die("check: missing <url>");
366
- const flags = parseFlags(argv.slice(4));
391
+ const flags = { ...cliFlags };
367
392
  const update = !!flags.update;
368
393
  const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
369
394
  const { runCheck } = await import("../src/check.js");