pursr 0.9.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
@@ -35,7 +35,7 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
35
35
  - **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
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
- - **A library API** with 24 subpath modules, so you can embed the browser and QA primitives in your own tooling.
38
+ - **A library API** with 25 subpath modules, so you can embed the browser and QA primitives in your own tooling.
39
39
  - **A plugin system** for custom viewports, sweep ops, and capture hooks.
40
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
41
  - **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
@@ -81,7 +81,7 @@ pursr sweep ./plan.json # see plans/ for an example
81
81
  | Layered states | entity / terrain / hud / ui isolation | `--layer entity` |
82
82
  | Animation freeze | pause CSS/JS animations for stable frames | `--no-animation` |
83
83
  | Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
84
- | Visual Operator | rendered cursor, target labels, click markers, headed and CDP sessions | MCP session tools |
84
+ | Visual Operator | rendered cursor, target labels, click markers, WebM recording, headed and CDP sessions | `operator` CLI + MCP session tools |
85
85
  | Grid overlay | spacing guides, custom color + tile size | `--grid --grid-tile 64` |
86
86
  | Camera control | zoom + pan via mouse wheel/drag | `--zoom 1.5 --panX 200` |
87
87
  | Frame timeline | N captures at intervalMs for animations | `pursr frames <url> 8 200` |
@@ -154,14 +154,24 @@ 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
  | --- | --- |
161
170
  | `probe` | Health check (HTTP status, page title) |
162
171
  | `shot` / `full` | Viewport / full-page screenshot |
163
172
  | `eval` | Execute JS in the page, return result |
164
- | `click` / `type` / `wait` / `seq` | Interaction primitives |
173
+ | `click` / `type` / `wait` / `seq` | Interaction primitives |
174
+ | `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
165
175
  | `diff` | Pixel-level diff vs a reference PNG |
166
176
  | `viewports` | List all registered viewport presets |
167
177
  | `shoot` | Rich capture (overlays, freeze, camera, plugins) |
@@ -174,7 +184,11 @@ pursr validate ./plan.json
174
184
  | `every-viewport` | Capture once per preset in parallel (3-wide pool) |
175
185
  | `baseline` | save / list / approve / show visual baselines |
176
186
  | `auth` | save / load / list / delete Playwright storageState |
177
- | `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.
178
192
 
179
193
  ## MCP Server
180
194
 
@@ -236,6 +250,45 @@ Example action arguments:
236
250
 
237
251
  Set `visual: true` to render the agent cursor and interaction feedback into screenshots. `mode: "visible"` enables it automatically and opens a Chrome window that a developer can watch.
238
252
 
253
+ #### CLI: scripted tutorials and repeatable recordings
254
+
255
+ Use the CLI when the steps are already known. It needs no MCP host and produces a final screenshot, JSON trace, diagnostics, and an optional WebM recording.
256
+
257
+ ```bash
258
+ pursr operator http://localhost:3000 @plans/operator-tutorial.json \
259
+ --visible \
260
+ --start-delay 3000 \
261
+ --slow-mo 100 \
262
+ --video ./recordings \
263
+ --out ./recordings/final.png
264
+ ```
265
+
266
+ The action plan is a JSON array. The same action objects work through `pursr_act` in MCP:
267
+
268
+ ```json
269
+ [
270
+ { "type": "annotate", "selector": "role=button|Build", "label": "Open build menu" },
271
+ { "type": "click", "selector": "role=button|Build", "durationMs": 350, "settleMs": 500 },
272
+ { "type": "click", "x": 640, "y": 420, "durationMs": 250 },
273
+ { "type": "drag", "fromX": 520, "fromY": 400, "toX": 760, "toY": 520, "steps": 30 },
274
+ { "type": "keyDown", "key": "Shift" },
275
+ { "type": "keyUp", "key": "Shift" },
276
+ { "type": "press", "key": "Escape" },
277
+ { "type": "sleep", "ms": 800 },
278
+ { "type": "clearAnnotations", "keepCursor": true }
279
+ ]
280
+ ```
281
+
282
+ Chrome records the browser viewport as silent WebM video. Add narration or system audio in your editor, and convert to MP4 when needed:
283
+
284
+ ```bash
285
+ ffmpeg -i recording.webm -c:v libx264 -pix_fmt yuv420p tutorial.mp4
286
+ ```
287
+
288
+ #### MCP: adaptive agent operation
289
+
290
+ Use MCP when the agent must inspect the current page, decide the next action, verify visual results, or pause for human approval. MCP is not required for CLI recording. Both interfaces use the same session and Visual Operator engine.
291
+
239
292
  ```json
240
293
  {
241
294
  "url": "http://localhost:3000",
@@ -246,6 +299,8 @@ Set `visual: true` to render the agent cursor and interaction feedback into scre
246
299
  }
247
300
  ```
248
301
 
302
+ Add `recordVideoDir` to record an MCP session in headless or visible mode. The final video path is returned by `pursr_session_close`. CDP sessions preserve an existing browser profile but cannot record video because Chrome owns that context.
303
+
249
304
  Visual actions use the regular `pursr_act` tool:
250
305
 
251
306
  ```json
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
@@ -2,7 +2,8 @@
2
2
  // pursr CLI. Thin wrapper around src/* that mirrors the npm bin.
3
3
 
4
4
  import { VERSION } from "../src/index.js";
5
- import { runClick, runType, runWait, runSeq } from "../src/interact.js";
5
+ import { runClick, runType, runWait, runSeq } from "../src/interact.js";
6
+ import { runOperator } from "../src/operator.js";
6
7
  import { runEval } from "../src/eval.js";
7
8
  import { runProbe } from "../src/probe.js";
8
9
  import { runShot } from "../src/shot.js";
@@ -15,16 +16,18 @@ import { runEveryViewport } from "../src/every-viewport.js";
15
16
  import { runAudit } from "../src/plugin-audit.js";
16
17
  import { captureDomSnapshot } from "../src/dom-snapshot.js";
17
18
  import { listViewports } from "../src/viewport.js";
18
- 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";
19
21
  import { writeFileSync, existsSync, mkdirSync } from "node:fs";
20
- import { dirname } from "node:path";
22
+ import { dirname, join } from "node:path";
21
23
  import { readFileSync as _readFileSync } from "node:fs";
22
24
  const readFile = _readFileSync;
23
25
  import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
24
26
 
25
27
  const USAGE = `usage:
26
28
  v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
27
- v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
29
+ v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
30
+ operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
28
31
  flags: --preset <name> --width N --height N --dpr N
29
32
  --zoom 1.5 --panX 200 --panY -100
30
33
  --cursor pointer|grab|grabbing|crosshair|none
@@ -42,19 +45,24 @@ function die(msg, code = 2) {
42
45
  process.exit(code);
43
46
  }
44
47
 
45
- const argv = process.argv;
46
- const [, , cmd, a, b, c, d] = argv;
47
- const url = __PURSR_GET("PURSR_URL") || a;
48
- // Top-level --plan / --out parsing for subcommands that need it before dispatch
49
- function _topOpts() {
50
- const o = {};
51
- for (let i = 2; i < argv.length; i++) {
52
- if (argv[i] === "--plan" && i + 1 < argv.length) o.plan = argv[++i];
53
- if (argv[i] === "--out" && i + 1 < argv.length) o.out = argv[++i];
54
- }
55
- return o;
56
- }
57
- 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
+ }
58
66
 
59
67
  // Plugin loading: scan for --plugin <path> and built-in plugins/
60
68
  const pluginPaths = [];
@@ -62,48 +70,83 @@ for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv
62
70
  await loadPlugins(pluginPaths);
63
71
 
64
72
  (async () => {
65
- try {
66
- switch (cmd) {
73
+ try {
74
+ if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
75
+ switch (cmd) {
67
76
  case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
68
77
  case "version": case "--version": case "-v": {
69
78
  console.log(JSON.stringify({ name: "pursr", version: VERSION, plugins: listPlugins() }, null, 2));
70
79
  break;
71
80
  }
72
81
  case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
73
- 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; }
74
- 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; }
75
- 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; }
76
- 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; }
77
- 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; }
78
- 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; }
79
88
  case "diff": {
80
89
  if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>");
81
- const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1;
82
- 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 };
83
92
  // --ai / --ai-model / --ai-base-url / --ai-api-key
84
93
  const useAi = argv.includes("--ai");
85
- const aiModel = (() => { const i = argv.indexOf("--ai-model"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
86
- const aiBaseUrl = (() => { const i = argv.indexOf("--ai-base-url"); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined; })();
87
- 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"];
88
97
  const r = useAi
89
98
  ? await runDiffWithAi(url, ref, out, threshold, flags, { model: aiModel, baseUrl: aiBaseUrl, apiKey: aiApiKey })
90
99
  : await runDiff(url, ref, out, threshold, flags);
91
100
  console.log(JSON.stringify(r, null, 2)); break;
92
101
  }
93
- 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; }
103
+ case "operator": {
104
+ if (!url) die("operator: missing <url>");
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");
108
+ const videoValue = flags.video ?? flags["record-video"];
109
+ const recordVideoDir = videoValue
110
+ ? (videoValue === true ? dirname(out) : String(videoValue))
111
+ : null;
112
+ const r = await runOperator({
113
+ url,
114
+ actions,
115
+ out,
116
+ outputDir: dirname(out),
117
+ sessionId: flags.session || undefined,
118
+ flags: {
119
+ mode: flags.mode || (flags.cdp ? "cdp" : flags.visible ? "visible" : "headless"),
120
+ visual: !flags["no-visual"],
121
+ cdpUrl: flags.cdp || flags["cdp-url"],
122
+ slowMo: asNum(flags["slow-mo"] ?? flags.slowMo, 0),
123
+ startDelayMs: asNum(flags["start-delay"] ?? flags.startDelayMs, 0),
124
+ operatorColor: flags["operator-color"] || flags.operatorColor,
125
+ recordVideoDir,
126
+ width: flags.width,
127
+ height: flags.height,
128
+ dpr: flags.dpr,
129
+ preset: flags.preset,
130
+ full: !!flags.full,
131
+ },
132
+ });
133
+ console.log(JSON.stringify(r, null, 2));
134
+ if (!r.ok) process.exitCode = 1;
135
+ break;
136
+ }
94
137
  case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
95
138
  case "shoot": {
96
139
  if (!url) die("missing url");
97
- const out = (b && !b.startsWith("--")) ? b : pickOutPath(argv.slice(5)) || makeOut("shoot.png");
98
- 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 } });
99
142
  console.log(JSON.stringify(r, null, 2));
100
143
  break;
101
144
  }
102
145
  case "layer": {
103
146
  if (!url) die("missing url");
104
147
  const layerName = b; if (!layerName) die("layer: missing <name>");
105
- const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`layer-${layerName}.png`);
106
- const flags = parseFlags(argv.slice(7)); flags.layer = layerName;
148
+ const out = outputPath(c, `layer-${layerName}.png`);
149
+ const flags = { ...cliFlags, layer: layerName };
107
150
  const r = await runShootWithSidecar({ url, out, flags });
108
151
  console.log(JSON.stringify(r, null, 2));
109
152
  break;
@@ -112,71 +155,70 @@ await loadPlugins(pluginPaths);
112
155
  if (!url) die("missing url");
113
156
  const count = asNum(b, 8);
114
157
  const stepMs = asNum(c, 250);
115
- const outDir = (d && !d.startsWith("--")) ? d : makeOut(`frames-${count}x${stepMs}ms`);
116
- 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 } });
117
160
  console.log(JSON.stringify(r, null, 2));
118
161
  break;
119
162
  }
120
163
  case "hover": {
121
164
  if (!url) die("missing url");
122
165
  const sel = b; if (!sel) die("hover: missing <selector>");
123
- const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
124
- 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 } });
125
168
  console.log(JSON.stringify(r, null, 2));
126
169
  break;
127
170
  }
128
171
  case "sweep": {
129
- const planPath = readArg(a);
130
- if (!planPath) die("sweep: missing <plan.json> (or @file)");
131
- 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;
132
176
  const r = await runSweep(planPath, outDirArg);
133
177
  console.log(JSON.stringify(r, null, 2));
134
178
  break;
135
179
  }
136
180
  case "report": {
137
181
  // pursr report --sweep <sweep.json> [--out report.pdf] [--title "..."]
138
- const sweepIdx = argv.indexOf("--sweep");
139
- const sweepPath = sweepIdx >= 0 && sweepIdx + 1 < argv.length ? argv[sweepIdx + 1] : a;
182
+ const sweepPath = cliFlags.sweep || a;
140
183
  if (!sweepPath) die("report: missing --sweep <sweep.json>");
141
184
  if (!existsSync(sweepPath)) die("report: sweep not found: " + sweepPath);
142
- const outIdx = argv.indexOf("--out");
143
- 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");
144
186
  if (outPath && outPath !== "-") mkdirSync(dirname(outPath), { recursive: true });
145
- const titleIdx = argv.indexOf("--title");
146
- const title = titleIdx >= 0 && titleIdx + 1 < argv.length ? argv[titleIdx + 1] : undefined;
147
- const noEmbed = argv.includes("--no-embed");
187
+ const title = cliFlags.title;
188
+ const noEmbed = !!cliFlags["no-embed"];
148
189
  const summary = JSON.parse(readFile(sweepPath, "utf8"));
149
190
  const { renderSweepPdf } = await import("../src/report.js");
150
191
  const buf = await renderSweepPdf(summary, { out: outPath === "-" ? undefined : outPath, title, embedImages: !noEmbed });
151
192
  console.log(JSON.stringify({ ok: true, sweep: sweepPath, out: outPath, bytes: buf.length }, null, 2));
152
193
  break;
153
194
  }
154
- case "every-viewport": {
155
- if (!url) die("missing url");
156
- const outDir = (b && !b.startsWith("--")) ? b : undefined;
157
- 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(",");
158
199
  const r = await runEveryViewport({ url, outDir, viewports });
159
200
  console.log(JSON.stringify(r, null, 2));
160
201
  break;
161
202
  }
162
- case "audit": {
163
- if (!url) die("missing url");
164
- const tags = (b && !b.startsWith("--")) ? b : undefined;
165
- 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;
166
207
  const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
167
208
  console.log(JSON.stringify(r, null, 2));
168
209
  break;
169
210
  }
170
- case "dom-snapshot": case "dom": {
171
- if (!url) die("missing url");
172
- 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;
173
214
  const r = await captureDomSnapshot({ url, out });
174
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));
175
216
  break;
176
217
  }
177
- case "validate": {
178
- const planPath = readArg(a);
179
- 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");
180
222
  let plan;
181
223
  try { plan = JSON.parse(readFile(planPath, "utf8")); }
182
224
  catch (e) { die("validate: " + e.message); }
@@ -201,7 +243,7 @@ await loadPlugins(pluginPaths);
201
243
  } else if (sub === "save") {
202
244
  if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
203
245
  const project = b, png = c, step = d;
204
- const flags = parseFlags(argv.slice(7));
246
+ const flags = { ...cliFlags };
205
247
  let meta = null;
206
248
  if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
207
249
  else if (flags.url) meta = { url: flags.url };
@@ -217,14 +259,14 @@ await loadPlugins(pluginPaths);
217
259
  } else if (sub === "approve") {
218
260
  if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
219
261
  const project = b, png = c, step = d;
220
- const flags = parseFlags(argv.slice(7));
262
+ const flags = { ...cliFlags };
221
263
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
222
264
  const result = approveBaseline({ project, id, step, fromPng: png });
223
265
  console.log(JSON.stringify({ approved: true, ...result }, null, 2));
224
266
  } else if (sub === "show") {
225
267
  if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
226
268
  const project = b, step = c;
227
- const flags = parseFlags(argv.slice(5));
269
+ const flags = { ...cliFlags };
228
270
  const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
229
271
  const r = loadBaseline({ project, id, step });
230
272
  console.log(JSON.stringify(r, null, 2));
@@ -246,14 +288,14 @@ await loadPlugins(pluginPaths);
246
288
  console.log(JSON.stringify(listAuthStates(project), null, 2));
247
289
  } else if (sub === "save") {
248
290
  if (!b || !c) die("auth save: <project> <name> --from <state.json>");
249
- const fromFile = argv[argv.indexOf("--from") + 1];
291
+ const fromFile = cliFlags.from;
250
292
  if (!fromFile) die("auth save: missing --from <state.json>");
251
293
  const state = JSON.parse(readFile(fromFile, "utf8"));
252
294
  const r = saveAuthState({ project: b, name: c, state });
253
295
  console.log(JSON.stringify({ saved: true, ...r }, null, 2));
254
296
  } else if (sub === "load") {
255
297
  if (!b || !c) die("auth load: <project> <name> --out <state.json>");
256
- const outFile = argv[argv.indexOf("--out") + 1];
298
+ const outFile = cliFlags.out;
257
299
  if (!outFile) die("auth load: missing --out <state.json>");
258
300
  const state = loadAuthState({ project: b, name: c });
259
301
  if (!state) { console.error("not found"); process.exit(2); }
@@ -276,9 +318,9 @@ await loadPlugins(pluginPaths);
276
318
  die("watch: missing <url> (or use --plan <plan.json>)");
277
319
  }
278
320
  const { startWatch } = await import("../src/watch.js");
279
- const out = (b && !b.startsWith("--")) ? b : (opts.out || makeOut("watch.png"));
321
+ const out = opts.out || b || makeOut("watch.png");
280
322
  if (out && out !== "--plan") mkdirSync(dirname(out), { recursive: true });
281
- const flags = parseFlags(argv.slice(3));
323
+ const flags = { ...cliFlags };
282
324
  const onGlobs = [];
283
325
  for (let i = 0; i < argv.length; i++) {
284
326
  if (argv[i] === "--on" && i + 1 < argv.length) onGlobs.push(argv[++i]);
@@ -305,9 +347,9 @@ await loadPlugins(pluginPaths);
305
347
  // pursr snap <url> <selector> [--out <dir>] [--name <slug>] [--max N] [--baseline <project>]
306
348
  if (!url) die("snap: missing <url>");
307
349
  const sel = b; if (!sel) die("snap: missing <selector>");
308
- const flags = parseFlags(argv.slice(4));
350
+ const flags = { ...cliFlags };
309
351
  const { runSnap, approveSnapsAsBaselines } = await import("../src/snap.js");
310
- 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");
311
353
  const snap = await runSnap({ url, selector: sel, outDir, name: flags.name, max: flags.max, flags });
312
354
  console.log(JSON.stringify({
313
355
  url: snap.url,
@@ -327,7 +369,7 @@ await loadPlugins(pluginPaths);
327
369
  case "check": {
328
370
  // pursr check <url> [--preset <name>] [--update] [--json] [--threshold 0.1] [--out <diff.png>]
329
371
  if (!url) die("check: missing <url>");
330
- const flags = parseFlags(argv.slice(4));
372
+ const flags = { ...cliFlags };
331
373
  const update = !!flags.update;
332
374
  const threshold = flags.threshold !== undefined ? Number(flags.threshold) : 0.1;
333
375
  const { runCheck } = await import("../src/check.js");
@@ -342,4 +384,4 @@ await loadPlugins(pluginPaths);
342
384
  console.error(JSON.stringify({ error: e.message, stack: e.stack?.split("\n").slice(0, 3).join("\n") }, null, 2));
343
385
  process.exit(1);
344
386
  }
345
- })();
387
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.9.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",
@@ -40,6 +40,7 @@
40
40
  "./report": "./src/report.js",
41
41
  "./ai-diff": "./src/ai-diff.js",
42
42
  "./session": "./src/session.js",
43
+ "./operator": "./src/operator.js",
43
44
  "./visual-operator": "./src/visual-operator.js"
44
45
  },
45
46
  "files": [
@@ -48,6 +49,7 @@
48
49
  "plugins",
49
50
  "plans",
50
51
  "assets",
52
+ "SKILL.md",
51
53
  "README.md",
52
54
  "LICENSE"
53
55
  ],
@@ -0,0 +1,16 @@
1
+ [
2
+ {
3
+ "type": "annotate",
4
+ "selector": "body",
5
+ "label": "Visual Operator session",
6
+ "durationMs": 300
7
+ },
8
+ {
9
+ "type": "sleep",
10
+ "ms": 700
11
+ },
12
+ {
13
+ "type": "clearAnnotations",
14
+ "keepCursor": true
15
+ }
16
+ ]
@@ -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/index.js CHANGED
@@ -45,6 +45,7 @@ import { runCheck } from "./check.js";
45
45
  import { renderSweepPdf } from "./report.js";
46
46
  import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
47
47
  import { BrowserSessionManager } from "./session.js";
48
+ import { runOperator } from "./operator.js";
48
49
  import {
49
50
  installVisualOperator, moveVisualCursor, highlightVisualTarget,
50
51
  markVisualClick, clearVisualAnnotations, visualPointForLocator,
@@ -92,6 +93,7 @@ export {
92
93
  renderSweepPdf,
93
94
  aiDiffSummary, aiDiffSidecar,
94
95
  BrowserSessionManager,
96
+ runOperator,
95
97
  installVisualOperator, moveVisualCursor, highlightVisualTarget,
96
98
  markVisualClick, clearVisualAnnotations, visualPointForLocator,
97
99
  VERSION,
@@ -116,6 +118,7 @@ export default {
116
118
  // v6: PDF report, AI diff summary
117
119
  runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
118
120
  BrowserSessionManager,
121
+ runOperator,
119
122
  installVisualOperator, moveVisualCursor, highlightVisualTarget,
120
123
  markVisualClick, clearVisualAnnotations, visualPointForLocator,
121
124
  VERSION,
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/mcp.js CHANGED
@@ -161,6 +161,7 @@ class PursrMCPServer {
161
161
  cdpUrl: { type: "string", description: "Chrome DevTools endpoint for mode=cdp, e.g. http://127.0.0.1:9222" },
162
162
  slowMo: { type: "number", description: "Delay Playwright operations in milliseconds" },
163
163
  operatorColor: { type: "string", description: "Visual Operator accent color" },
164
+ recordVideoDir: { type: "string", description: "Directory for a WebM recording (headless or visible mode only)" },
164
165
  timeoutMs: { type: "number", description: "Navigation/CDP connection timeout" },
165
166
  storageState: { description: "Playwright storageState object or file path" },
166
167
  },
@@ -187,7 +188,7 @@ class PursrMCPServer {
187
188
  },
188
189
  {
189
190
  name: "pursr_act",
190
- description: "Perform ordered actions in a persistent session. Supports click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
191
+ description: "Perform ordered actions in a persistent session. Supports selector or coordinate click/doubleClick, drag, hover, fill, type, check, select, press, keyDown, keyUp, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
191
192
  inputSchema: {
192
193
  type: "object",
193
194
  properties: {
@@ -416,6 +417,7 @@ class PursrMCPServer {
416
417
  preset: args.preset, width: args.width, height: args.height, dpr: args.dpr,
417
418
  mode: args.mode, visible: args.visible, visual: args.visual, cdpUrl: args.cdpUrl,
418
419
  slowMo: args.slowMo, operatorColor: args.operatorColor, timeoutMs: args.timeoutMs,
420
+ recordVideoDir: args.recordVideoDir,
419
421
  };
420
422
  const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
421
423
  return this._text(result);
@@ -0,0 +1,61 @@
1
+ // One-shot Visual Operator workflow for CLI and library consumers.
2
+
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { mkdirSync } from "node:fs";
5
+ import { BrowserSessionManager } from "./session.js";
6
+
7
+ function normalizeActions(actions) {
8
+ if (typeof actions === "string") actions = JSON.parse(actions);
9
+ if (!Array.isArray(actions) || !actions.length) throw new Error("operator actions must be a non-empty JSON array");
10
+ return actions;
11
+ }
12
+
13
+ export async function runOperator({
14
+ url,
15
+ actions,
16
+ out,
17
+ outputDir = process.cwd(),
18
+ sessionId = `operator-${Date.now().toString(36)}`,
19
+ flags = {},
20
+ } = {}) {
21
+ if (!url) throw new Error("operator url is required");
22
+ const steps = normalizeActions(actions);
23
+ const screenshotOut = resolve(out || join(outputDir, `${sessionId}.png`));
24
+ mkdirSync(dirname(screenshotOut), { recursive: true });
25
+
26
+ const manager = new BrowserSessionManager({ outputDir });
27
+ let opened = null;
28
+ let acted = null;
29
+ let shot = null;
30
+ let diagnostics = null;
31
+ let closed = null;
32
+ try {
33
+ opened = await manager.open({
34
+ sessionId,
35
+ url,
36
+ storageState: flags.storageState,
37
+ flags: { ...flags, visual: flags.visual !== false },
38
+ });
39
+ if (Number(flags.startDelayMs) > 0) {
40
+ await manager.get(sessionId).page.waitForTimeout(Number(flags.startDelayMs));
41
+ }
42
+ acted = await manager.act(sessionId, steps);
43
+ shot = await manager.screenshot(sessionId, { out: screenshotOut, full: !!flags.full });
44
+ diagnostics = manager.diagnostics(sessionId);
45
+ } finally {
46
+ closed = await manager.close(sessionId).catch(() => ({ sessionId, closed: false, video: null }));
47
+ }
48
+
49
+ return {
50
+ ok: !acted?.failed,
51
+ sessionId,
52
+ mode: opened?.mode,
53
+ visual: opened?.visual,
54
+ url: acted?.url || opened?.url || url,
55
+ title: acted?.title || opened?.title || null,
56
+ trace: acted?.trace || [],
57
+ screenshot: shot?.out || null,
58
+ video: closed?.video || null,
59
+ diagnostics,
60
+ };
61
+ }
package/src/runway.js CHANGED
@@ -70,6 +70,10 @@ export async function newPage(browser, viewport, opts = {}) {
70
70
  hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
71
71
  isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
72
72
  storageState: opts.storageState || undefined,
73
+ recordVideo: opts.recordVideoDir ? {
74
+ dir: opts.recordVideoDir,
75
+ size: { width: viewport.width, height: viewport.height },
76
+ } : undefined,
73
77
  });
74
78
  const page = await ctx.newPage();
75
79
  if (opts.context) await page.setViewportSize({ width: viewport.width, height: viewport.height }).catch(() => {});
package/src/session.js CHANGED
@@ -71,6 +71,9 @@ export class BrowserSessionManager {
71
71
  const mode = flags.mode || (flags.cdpUrl ? "cdp" : flags.visible ? "visible" : "headless");
72
72
  if (!new Set(["headless", "visible", "cdp"]).has(mode)) throw new Error("mode must be headless, visible, or cdp");
73
73
  const visual = flags.visual === true || mode === "visible";
74
+ const recordVideoDir = flags.recordVideoDir || null;
75
+ if (recordVideoDir && mode === "cdp") throw new Error("video recording is not available in CDP mode; use visible or headless mode");
76
+ if (recordVideoDir) mkdirSync(recordVideoDir, { recursive: true });
74
77
  const operatorOptions = { color: flags.operatorColor || "#ff2ea6" };
75
78
  const browser = mode === "cdp"
76
79
  ? await this.connectBrowser(flags.cdpUrl, { timeoutMs: flags.timeoutMs })
@@ -79,14 +82,18 @@ export class BrowserSessionManager {
79
82
  const viewport = resolveViewport(flags);
80
83
  const context = mode === "cdp" ? browser.contexts()[0] : null;
81
84
  if (mode === "cdp" && !context) throw new Error("CDP browser has no default context");
82
- const page = await newPage(browser, viewport, { storageState, context });
85
+ const page = await newPage(browser, viewport, { storageState, context, recordVideoDir });
83
86
  const diagnostics = { console: [], errors: [], requests: [], responses: [] };
84
87
  attachDiagnostics(page, diagnostics);
85
88
  if (visual) page.on("domcontentloaded", () => installVisualOperator(page, operatorOptions).catch(() => {}));
86
89
  const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
87
90
  await settle(page);
88
91
  if (visual) await installVisualOperator(page, operatorOptions);
89
- const session = { id, browser, page, context: page._pursrContext, viewport, mode, visual, operatorOptions, diagnostics, createdAt: new Date().toISOString() };
92
+ const session = {
93
+ id, browser, page, context: page._pursrContext, viewport, mode, visual,
94
+ operatorOptions, diagnostics, video: page.video?.() || null,
95
+ createdAt: new Date().toISOString(),
96
+ };
90
97
  this.sessions.set(id, session);
91
98
  return { sessionId: id, url: page.url(), title: await page.title(), viewport, mode, visual, status: nav.status, createdAt: session.createdAt };
92
99
  } catch (error) {
@@ -158,7 +165,7 @@ export class BrowserSessionManager {
158
165
  const op = action.type || action.op;
159
166
  const step = { index: i, type: op };
160
167
  try {
161
- if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
168
+ if (["click", "doubleClick", "hover", "fill", "type", "check", "select"].includes(op) && action.selector) {
162
169
  const locator = await resolveLocator(page, action.selector);
163
170
  await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
164
171
  let point = null;
@@ -169,14 +176,42 @@ export class BrowserSessionManager {
169
176
  step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
170
177
  }
171
178
  if (op === "click") await locator.first().click();
179
+ else if (op === "doubleClick") await locator.first().dblclick();
172
180
  else if (op === "hover") await locator.first().hover();
173
181
  else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
174
182
  else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
175
183
  else if (op === "check") await locator.first().setChecked(action.checked !== false);
176
184
  else await locator.first().selectOption(action.value);
177
- if (visual && op === "click" && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
185
+ if (visual && ["click", "doubleClick"].includes(op) && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
178
186
  step.selector = action.selector;
187
+ } else if (["click", "doubleClick"].includes(op) && Number.isFinite(Number(action.x)) && Number.isFinite(Number(action.y))) {
188
+ const x = Number(action.x), y = Number(action.y);
189
+ if (visual) await moveVisualCursor(page, x, y, { ...operatorOptions, durationMs: action.durationMs });
190
+ await page.mouse[op === "doubleClick" ? "dblclick" : "click"](x, y, { button: action.button || "left" });
191
+ if (visual) await markVisualClick(page, x, y, { ...operatorOptions, color: action.color });
192
+ step.cursor = { x: Math.round(x), y: Math.round(y) };
193
+ } else if (op === "drag") {
194
+ const start = action.fromSelector
195
+ ? await visualPointForLocator((await resolveLocator(page, action.fromSelector)).first())
196
+ : { x: Number(action.fromX), y: Number(action.fromY) };
197
+ const end = action.toSelector
198
+ ? await visualPointForLocator((await resolveLocator(page, action.toSelector)).first())
199
+ : { x: Number(action.toX), y: Number(action.toY) };
200
+ if (![start.x, start.y, end.x, end.y].every(Number.isFinite)) throw new Error("drag requires from/to coordinates or selectors");
201
+ if (visual) await moveVisualCursor(page, start.x, start.y, { ...operatorOptions, durationMs: action.durationMs });
202
+ await page.mouse.move(start.x, start.y);
203
+ await page.mouse.down({ button: action.button || "left" });
204
+ const steps = Math.max(1, Math.min(100, Number(action.steps) || 20));
205
+ await page.mouse.move(end.x, end.y, { steps });
206
+ await page.mouse.up({ button: action.button || "left" });
207
+ if (visual) {
208
+ await moveVisualCursor(page, end.x, end.y, { ...operatorOptions, durationMs: 0 });
209
+ await markVisualClick(page, end.x, end.y, { ...operatorOptions, color: action.color });
210
+ }
211
+ step.cursor = { x: Math.round(end.x), y: Math.round(end.y) };
179
212
  } else if (op === "press") await page.keyboard.press(String(action.key));
213
+ else if (op === "keyDown") await page.keyboard.down(String(action.key));
214
+ else if (op === "keyUp") await page.keyboard.up(String(action.key));
180
215
  else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
181
216
  else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
182
217
  else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
@@ -242,8 +277,16 @@ export class BrowserSessionManager {
242
277
  const session = this.sessions.get(id);
243
278
  if (!session) return { sessionId: id, closed: false };
244
279
  this.sessions.delete(id);
280
+ let video = null;
281
+ try {
282
+ if (session.mode === "cdp") await session.page.close();
283
+ else await session.context.close();
284
+ } catch {}
245
285
  try { await session.browser.close(); } catch {}
246
- return { sessionId: id, closed: true };
286
+ if (session.video) {
287
+ try { video = await session.video.path(); } catch {}
288
+ }
289
+ return { sessionId: id, closed: true, video };
247
290
  }
248
291
 
249
292
  async closeAll() {
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
+ }