pursr 0.10.1 → 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
 
@@ -165,10 +182,12 @@ pursr full https://example.com --preset desktop-1280 --out-dir ./captures
165
182
 
166
183
  `--out` is a complete file path. `--out-dir` is a directory where Pursr writes the command's standard filename.
167
184
 
168
- | Subcommand | Purpose |
169
- | --- | --- |
170
- | `probe` | Health check (HTTP status, page title) |
171
- | `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 |
172
191
  | `eval` | Execute JS in the page, return result |
173
192
  | `click` / `type` / `wait` / `seq` | Interaction primitives |
174
193
  | `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
package/SKILL.md CHANGED
@@ -1,21 +1,90 @@
1
1
  ---
2
2
  name: pursr
3
- description: Use Pursr for browser screenshots, scripted visual operation, visual regression, accessibility audits, DOM inspection, and MCP-driven browser sessions.
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
4
  ---
5
5
 
6
6
  # Pursr
7
7
 
8
8
  Use this skill when a user asks an agent to inspect, operate, record, test, or compare a browser interface.
9
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
+
10
27
  ## Choose The Right Surface
11
28
 
12
29
  - Use the CLI for repeatable commands and prewritten action plans.
13
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.
14
33
  - Use `pursr operator` for a visible scripted walkthrough or silent WebM recording.
15
34
  - Use `pursr shoot` for a rich screenshot with viewport, layer, camera, grid, or animation controls.
16
35
  - Use `pursr check` for CI regression against an approved baseline.
17
36
  - Use `pursr sweep` only with a local JSON plan path. A URL is not a sweep plan.
18
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
+
19
88
  ## CLI Argument Contract
20
89
 
21
90
  Flags may appear before or after positional arguments.
@@ -67,6 +136,24 @@ Common actions:
67
136
  6. Read `pursr_diagnostics` after failures.
68
137
  7. Close with `pursr_session_close`; this finalizes any video recording.
69
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
+
70
157
  ## Safety
71
158
 
72
159
  - Inspect before acting on unfamiliar pages.
package/bin/pursr.mjs CHANGED
@@ -13,12 +13,13 @@ 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";
16
+ import { runAudit } from "../src/plugin-audit.js";
17
+ import { captureDomSnapshot } from "../src/dom-snapshot.js";
18
+ import { listViewports } from "../src/viewport.js";
19
19
  import { asNum, readArg, makeOut, __PURSR_GET } from "../src/util.js";
20
20
  import { filePathArg, parseCommandArgs } from "../src/cli-args.js";
21
- import { writeFileSync, existsSync, mkdirSync } from "node:fs";
21
+ import { notifyUpdate } from "../src/update-notifier.js";
22
+ import { writeFileSync, existsSync, mkdirSync } from "node:fs";
22
23
  import { dirname, join } from "node:path";
23
24
  import { readFileSync as _readFileSync } from "node:fs";
24
25
  const readFile = _readFileSync;
@@ -28,15 +29,16 @@ const USAGE = `usage:
28
29
  v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
29
30
  v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
30
31
  operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
31
- flags: --preset <name> --width N --height N --dpr N
32
+ flags: --preset <name> --width N --height N --dpr N
32
33
  --zoom 1.5 --panX 200 --panY -100
33
34
  --cursor pointer|grab|grabbing|crosshair|none
34
35
  --layer entity|terrain|hud|ui
35
36
  --grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
36
37
  --no-animation --wait-frame 600 --full
37
38
  @file prefix reads argv contents from file (UTF-8, newline trimmed).
38
- report: pursr report --sweep <sweep.json> [--out <report.pdf>] [--title "..."] [--no-embed]
39
- 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]
40
42
  plugins: pursr automatically loads built-in plugins from plugins/.
41
43
  You can also pass --plugin <path> to load custom plugins (repeatable).`;
42
44
 
@@ -67,10 +69,11 @@ function dataArg(value) {
67
69
  // Plugin loading: scan for --plugin <path> and built-in plugins/
68
70
  const pluginPaths = [];
69
71
  for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv.length) pluginPaths.push(argv[++i]);
70
- await loadPlugins(pluginPaths);
71
-
72
- (async () => {
72
+ await loadPlugins(pluginPaths);
73
+
74
+ (async () => {
73
75
  try {
76
+ await notifyUpdate({ currentVersion: VERSION, stdout: process.stdout, stderr: process.stderr });
74
77
  if (cliFlags.help) { console.log(JSON.stringify({ usage: USAGE }, null, 2)); return; }
75
78
  switch (cmd) {
76
79
  case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
@@ -134,8 +137,24 @@ await loadPlugins(pluginPaths);
134
137
  if (!r.ok) process.exitCode = 1;
135
138
  break;
136
139
  }
137
- case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
138
- 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": {
139
158
  if (!url) die("missing url");
140
159
  const out = outputPath(b, "shoot.png");
141
160
  const r = await runShootWithSidecar({ url, out, flags: { ...cliFlags } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pursr",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
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",
@@ -35,6 +35,9 @@
35
35
  "./mcp-resources": "./src/mcp-resources.js",
36
36
  "./har": "./src/har.js",
37
37
  "./auth": "./src/auth.js",
38
+ "./browser-discovery": "./src/browser-discovery.js",
39
+ "./doctor": "./src/doctor.js",
40
+ "./update-notifier": "./src/update-notifier.js",
38
41
  "./watch": "./src/watch.js",
39
42
  "./snap": "./src/snap.js",
40
43
  "./report": "./src/report.js",
@@ -0,0 +1,196 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { posix, win32 } from "node:path";
4
+
5
+ function uniq(list) {
6
+ return [...new Set(list.filter(Boolean).map(String))];
7
+ }
8
+
9
+ function envPath(env, key) {
10
+ return env && env[key] ? String(env[key]) : "";
11
+ }
12
+
13
+ function pathJoin(platform, ...parts) {
14
+ return platform === "win32" ? win32.join(...parts) : posix.join(...parts);
15
+ }
16
+
17
+ function joinIf(platform, base, ...parts) {
18
+ return base ? pathJoin(platform, base, ...parts) : "";
19
+ }
20
+
21
+ function pathExecutableCandidates(env, names, platform) {
22
+ const pathValue = envPath(env, "PATH");
23
+ if (!pathValue) return [];
24
+ const dirs = pathValue.split(platform === "win32" ? ";" : ":").filter(Boolean);
25
+ const exts = platform === "win32"
26
+ ? (envPath(env, "PATHEXT") || ".EXE;.CMD;.BAT").split(";").filter(Boolean)
27
+ : [""];
28
+ const out = [];
29
+ for (const dir of dirs) {
30
+ for (const name of names) {
31
+ if (platform === "win32" && /\.[a-z0-9]+$/i.test(name)) out.push(pathJoin(platform, dir, name));
32
+ else for (const ext of exts) out.push(pathJoin(platform, dir, name + ext.toLowerCase()));
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ export function browserCandidates(options = {}) {
39
+ const platform = options.platform || process.platform;
40
+ const env = options.env || process.env;
41
+ const home = options.homeDir || homedir();
42
+ const localAppData = envPath(env, "LOCALAPPDATA");
43
+ const programFiles = envPath(env, "ProgramFiles") || "C:\\Program Files";
44
+ const programFilesX86 = envPath(env, "ProgramFiles(x86)") || "C:\\Program Files (x86)";
45
+
46
+ const explicit = [
47
+ envPath(env, "PURSR_BROWSER_PATH"),
48
+ envPath(env, "CHROME_PATH"),
49
+ envPath(env, "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"),
50
+ ];
51
+
52
+ if (platform === "win32") {
53
+ const pathNames = [
54
+ "chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
55
+ ];
56
+ return uniq([
57
+ ...explicit,
58
+ pathJoin(platform, programFiles, "Google", "Chrome", "Application", "chrome.exe"),
59
+ pathJoin(platform, programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
60
+ joinIf(platform, localAppData, "Google", "Chrome", "Application", "chrome.exe"),
61
+ pathJoin(platform, programFiles, "Google", "Chrome Beta", "Application", "chrome.exe"),
62
+ pathJoin(platform, programFilesX86, "Google", "Chrome Beta", "Application", "chrome.exe"),
63
+ joinIf(platform, localAppData, "Google", "Chrome Beta", "Application", "chrome.exe"),
64
+ pathJoin(platform, programFiles, "Google", "Chrome Dev", "Application", "chrome.exe"),
65
+ pathJoin(platform, programFilesX86, "Google", "Chrome Dev", "Application", "chrome.exe"),
66
+ joinIf(platform, localAppData, "Google", "Chrome Dev", "Application", "chrome.exe"),
67
+ joinIf(platform, localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
68
+ pathJoin(platform, programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
69
+ pathJoin(platform, programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
70
+ joinIf(platform, localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
71
+ pathJoin(platform, programFiles, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
72
+ pathJoin(platform, programFilesX86, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
73
+ joinIf(platform, localAppData, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
74
+ pathJoin(platform, programFiles, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
75
+ pathJoin(platform, programFilesX86, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
76
+ joinIf(platform, localAppData, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
77
+ pathJoin(platform, programFiles, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
78
+ pathJoin(platform, programFilesX86, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
79
+ joinIf(platform, localAppData, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
80
+ pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
81
+ pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
82
+ joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
83
+ pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
84
+ pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
85
+ joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
86
+ pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
87
+ pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
88
+ joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
89
+ pathJoin(platform, programFiles, "Chromium", "Application", "chrome.exe"),
90
+ pathJoin(platform, programFilesX86, "Chromium", "Application", "chrome.exe"),
91
+ ...pathExecutableCandidates(env, pathNames, platform),
92
+ ]);
93
+ }
94
+
95
+ if (platform === "darwin") {
96
+ return uniq([
97
+ ...explicit,
98
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
99
+ pathJoin(platform, home, "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
100
+ "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
101
+ pathJoin(platform, home, "Applications", "Google Chrome Beta.app", "Contents", "MacOS", "Google Chrome Beta"),
102
+ "/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
103
+ pathJoin(platform, home, "Applications", "Google Chrome Dev.app", "Contents", "MacOS", "Google Chrome Dev"),
104
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
105
+ pathJoin(platform, home, "Applications", "Google Chrome Canary.app", "Contents", "MacOS", "Google Chrome Canary"),
106
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
107
+ "/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
108
+ "/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
109
+ "/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
110
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
111
+ "/Applications/Brave Browser Beta.app/Contents/MacOS/Brave Browser Beta",
112
+ "/Applications/Brave Browser Nightly.app/Contents/MacOS/Brave Browser Nightly",
113
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
114
+ ...pathExecutableCandidates(env, [
115
+ "google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable",
116
+ "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-beta",
117
+ "microsoft-edge-dev", "brave-browser", "brave-browser-beta", "brave-browser-nightly",
118
+ ], platform),
119
+ ]);
120
+ }
121
+
122
+ return uniq([
123
+ ...explicit,
124
+ "/usr/bin/google-chrome",
125
+ "/usr/bin/google-chrome-stable",
126
+ "/usr/bin/google-chrome-beta",
127
+ "/usr/bin/google-chrome-unstable",
128
+ "/usr/bin/chromium",
129
+ "/usr/bin/chromium-browser",
130
+ "/usr/bin/microsoft-edge",
131
+ "/usr/bin/microsoft-edge-stable",
132
+ "/usr/bin/microsoft-edge-beta",
133
+ "/usr/bin/microsoft-edge-dev",
134
+ "/usr/bin/brave-browser",
135
+ "/usr/bin/brave-browser-beta",
136
+ "/usr/bin/brave-browser-nightly",
137
+ "/snap/bin/chromium",
138
+ "/snap/bin/brave",
139
+ ...pathExecutableCandidates(env, [
140
+ "google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable",
141
+ "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable",
142
+ "microsoft-edge-beta", "microsoft-edge-dev", "brave-browser", "brave-browser-beta",
143
+ "brave-browser-nightly",
144
+ ], platform),
145
+ ]);
146
+ }
147
+
148
+ export function discoverBrowsers(options = {}) {
149
+ const exists = options.exists || existsSync;
150
+ const candidates = browserCandidates(options);
151
+ const found = candidates.filter((path) => {
152
+ try { return exists(path); } catch { return false; }
153
+ });
154
+ return {
155
+ found,
156
+ preferred: found[0] || null,
157
+ candidates,
158
+ env: {
159
+ PURSR_BROWSER_PATH: !!(options.env || process.env).PURSR_BROWSER_PATH,
160
+ CHROME_PATH: !!(options.env || process.env).CHROME_PATH,
161
+ },
162
+ };
163
+ }
164
+
165
+ export function findBrowserExecutable(options = {}) {
166
+ return discoverBrowsers(options).preferred;
167
+ }
168
+
169
+ export function browserSetupHints(platform = process.platform) {
170
+ const common = [
171
+ "Set PURSR_BROWSER_PATH to a Chrome-compatible executable if auto-detection misses it.",
172
+ "Install playwright-core in the project when using pursr as a local dependency: npm i -D playwright-core",
173
+ ];
174
+ if (platform === "win32") {
175
+ return [
176
+ "Install Google Chrome, Microsoft Edge, Brave, or Chromium.",
177
+ "Dev/Beta/Canary channels and PATH-installed browser executables are also detected when available.",
178
+ "Windows example: setx PURSR_BROWSER_PATH \"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\"",
179
+ ...common,
180
+ ];
181
+ }
182
+ if (platform === "darwin") {
183
+ return [
184
+ "Install Google Chrome, Microsoft Edge, Brave, or Chromium.",
185
+ "Dev/Beta/Canary channels, user Applications, and PATH-installed browser executables are also detected when available.",
186
+ "macOS example: export PURSR_BROWSER_PATH=\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\"",
187
+ ...common,
188
+ ];
189
+ }
190
+ return [
191
+ "Install google-chrome-stable, chromium, microsoft-edge-stable, or brave-browser.",
192
+ "Dev/Beta/unstable channels and PATH-installed browser executables are also detected when available.",
193
+ "Linux example: export PURSR_BROWSER_PATH=/usr/bin/google-chrome",
194
+ ...common,
195
+ ];
196
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,88 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { browserSetupHints, discoverBrowsers } from "./browser-discovery.js";
5
+ import { resolvePlaywrightCore } from "./runway.js";
6
+
7
+ function nodeMajor(version = process.version) {
8
+ const m = String(version).match(/^v?(\d+)/);
9
+ return m ? Number(m[1]) : 0;
10
+ }
11
+
12
+ function okCheck(name, ok, details = {}) {
13
+ return { name, ok: !!ok, ...details };
14
+ }
15
+
16
+ export async function runDoctor(options = {}) {
17
+ const version = options.version || process.version;
18
+ const browsers = discoverBrowsers(options);
19
+ let playwright = null;
20
+ try {
21
+ const resolved = await resolvePlaywrightCore();
22
+ playwright = okCheck("playwright-core", true, { source: resolved.source });
23
+ } catch (e) {
24
+ playwright = okCheck("playwright-core", false, { error: e.message });
25
+ }
26
+
27
+ const packageRoot = options.packageRoot || dirname(dirname(fileURLToPath(import.meta.url)));
28
+ const skillPath = join(packageRoot, "SKILL.md");
29
+ const checks = [
30
+ okCheck("node", nodeMajor(version) >= 18, { version, required: ">=18" }),
31
+ playwright,
32
+ okCheck("browser", !!browsers.preferred, { preferred: browsers.preferred, found: browsers.found }),
33
+ okCheck("skill", existsSync(skillPath), { path: skillPath }),
34
+ ];
35
+
36
+ const ok = checks.every((check) => check.ok);
37
+ return {
38
+ ok,
39
+ checks,
40
+ browsers,
41
+ hints: ok ? [] : browserSetupHints(options.platform || process.platform),
42
+ };
43
+ }
44
+
45
+ export function renderDoctorText(result) {
46
+ const lines = [];
47
+ lines.push(result.ok ? "pursr doctor: OK" : "pursr doctor: attention needed");
48
+ for (const check of result.checks) {
49
+ const mark = check.ok ? "ok" : "missing";
50
+ let detail = "";
51
+ if (check.name === "browser") detail = check.preferred ? ` (${check.preferred})` : "";
52
+ if (check.name === "playwright-core") detail = check.source ? ` (${check.source})` : check.error ? ` (${check.error})` : "";
53
+ if (check.name === "node") detail = ` (${check.version}, required ${check.required})`;
54
+ lines.push(`- ${mark}: ${check.name}${detail}`);
55
+ }
56
+ if (result.hints.length) {
57
+ lines.push("");
58
+ lines.push("Next steps:");
59
+ for (const hint of result.hints) lines.push(`- ${hint}`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+
64
+ export async function runSetup(options = {}) {
65
+ const doctor = await runDoctor(options);
66
+ return {
67
+ ok: doctor.ok,
68
+ doctor,
69
+ recommended: doctor.ok ? [
70
+ "Run: pursr probe https://example.com",
71
+ "For agents, point them at node_modules/pursr/SKILL.md or enable the pursr MCP server.",
72
+ ] : [
73
+ ...doctor.hints,
74
+ "After fixing dependencies, run: pursr doctor",
75
+ ],
76
+ };
77
+ }
78
+
79
+ export function renderSetupText(result) {
80
+ const lines = [];
81
+ lines.push(result.ok ? "pursr setup: ready" : "pursr setup: manual steps required");
82
+ lines.push("");
83
+ lines.push(renderDoctorText(result.doctor));
84
+ lines.push("");
85
+ lines.push("Recommended:");
86
+ for (const item of result.recommended) lines.push(`- ${item}`);
87
+ return lines.join("\n");
88
+ }
package/src/runway.js CHANGED
@@ -1,55 +1,53 @@
1
- // Browser launcher: auto-detect Playwright + system Chrome.
2
-
3
- import { existsSync } from "node:fs";
4
- import { homedir } from "node:os";
5
- import { join } from "node:path";
6
-
7
- let _chromium = null;
8
- async function getChromium() {
9
- if (_chromium) return _chromium;
10
- // Try local node_modules first
11
- try { return _chromium = (await import("playwright-core")).chromium; } catch {}
12
- // Try Codex cua_node runtime (current Codex Desktop bundles it).
13
- // The cua_node folder has a hash subdir (e.g. 789504f803e82e2b), so we
14
- // walk it to find the playwright-core entry.
1
+ // Browser launcher: auto-detect Playwright + system Chrome.
2
+
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { findBrowserExecutable } from "./browser-discovery.js";
7
+
8
+ let _chromium = null;
9
+ export async function resolvePlaywrightCore() {
10
+ if (_chromium) return { chromium: _chromium, source: "cached" };
11
+ // Try local node_modules first
12
+ try {
13
+ _chromium = (await import("playwright-core")).chromium;
14
+ return { chromium: _chromium, source: "playwright-core" };
15
+ } catch {}
16
+ // Try Codex cua_node runtime (current Codex Desktop bundles it).
17
+ // The cua_node folder has a hash subdir (e.g. 789504f803e82e2b), so we
18
+ // walk it to find the playwright-core entry.
15
19
  const cuaRoot = join(homedir(), "AppData", "Local", "OpenAI", "Codex", "runtimes", "cua_node");
16
20
  if (existsSync(cuaRoot)) {
17
21
  const { readdirSync } = await import("node:fs");
18
22
  const subdirs = (() => { try { return readdirSync(cuaRoot); } catch { return []; } })();
19
23
  for (const sub of subdirs) {
20
24
  const cand = join(cuaRoot, sub, "bin", "node_modules", "playwright-core", "index.mjs");
21
- if (existsSync(cand)) {
22
- try {
23
- const url = "file:///" + cand.replace(/\\/g, "/");
24
- return _chromium = (await import(url)).chromium;
25
- } catch {}
26
- }
27
- }
28
- }
29
- throw new Error("playwright-core not found. Install it: npm i -D playwright-core");
30
- }
31
-
32
- const CHROME_PATHS = [
33
- "C:/Program Files/Google/Chrome/Application/chrome.exe",
34
- "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
35
- "/usr/bin/google-chrome",
36
- "/usr/bin/chromium-browser",
37
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
38
- ];
39
- function findChrome() {
40
- for (const p of CHROME_PATHS) if (existsSync(p)) return p;
41
- return null;
42
- }
43
-
44
- const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
45
-
25
+ if (existsSync(cand)) {
26
+ try {
27
+ const url = "file:///" + cand.replace(/\\/g, "/");
28
+ _chromium = (await import(url)).chromium;
29
+ return { chromium: _chromium, source: "codex-cua-node" };
30
+ } catch {}
31
+ }
32
+ }
33
+ }
34
+ throw new Error("playwright-core not found. Install it: npm i -D playwright-core");
35
+ }
36
+
37
+ async function getChromium() {
38
+ const resolved = await resolvePlaywrightCore();
39
+ return resolved.chromium;
40
+ }
41
+
42
+ const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
43
+
46
44
  export async function launch(options = {}) {
47
45
  const chromium = await getChromium();
48
- const exec = findChrome();
49
- if (!exec) throw new Error("system Chrome not found in standard paths");
46
+ const exec = options.executablePath || findBrowserExecutable();
47
+ if (!exec) throw new Error("Chrome-compatible browser not found. Run: pursr doctor");
50
48
  return await chromium.launch({
51
49
  headless: options.headless !== false,
52
- executablePath: options.executablePath || exec,
50
+ executablePath: exec,
53
51
  slowMo: Math.max(0, Number(options.slowMo) || 0),
54
52
  args: [...BROWSER_ARGS, ...(Array.isArray(options.args) ? options.args : [])],
55
53
  });
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { request as httpsRequest } from "node:https";
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+
8
+ export function compareVersions(a, b) {
9
+ const pa = String(a || "0").split(".").map((x) => Number.parseInt(x, 10) || 0);
10
+ const pb = String(b || "0").split(".").map((x) => Number.parseInt(x, 10) || 0);
11
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
12
+ const da = pa[i] || 0;
13
+ const db = pb[i] || 0;
14
+ if (da > db) return 1;
15
+ if (da < db) return -1;
16
+ }
17
+ return 0;
18
+ }
19
+
20
+ export function updateCachePath(env = process.env) {
21
+ const root = env.PURSR_HOME || join(homedir(), ".pursr");
22
+ return join(root, "update-check.json");
23
+ }
24
+
25
+ export function shouldCheckForUpdate({ env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now(), cachePath = updateCachePath(env) } = {}) {
26
+ if (env.PURSR_NO_UPDATE_NOTIFIER || env.NO_UPDATE_NOTIFIER || env.CI) return false;
27
+ if (!stderr?.isTTY || !stdout?.isTTY) return false;
28
+ try {
29
+ if (!existsSync(cachePath)) return true;
30
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
31
+ return !cached.checkedAt || now - Date.parse(cached.checkedAt) > DAY_MS;
32
+ } catch {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ function fetchLatestVersion({ registryUrl = "https://registry.npmjs.org/pursr/latest", timeoutMs = 800 } = {}) {
38
+ return new Promise((resolve) => {
39
+ const req = httpsRequest(registryUrl, { method: "GET", timeout: timeoutMs, headers: { accept: "application/json" } }, (res) => {
40
+ let body = "";
41
+ res.setEncoding("utf8");
42
+ res.on("data", (chunk) => { body += chunk; });
43
+ res.on("end", () => {
44
+ try {
45
+ const json = JSON.parse(body);
46
+ resolve(json.version || null);
47
+ } catch {
48
+ resolve(null);
49
+ }
50
+ });
51
+ });
52
+ req.on("timeout", () => { req.destroy(); resolve(null); });
53
+ req.on("error", () => resolve(null));
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ export async function checkForUpdate({ currentVersion, env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now(), cachePath = updateCachePath(env), fetchLatest = fetchLatestVersion } = {}) {
59
+ if (!shouldCheckForUpdate({ env, stdout, stderr, now, cachePath })) return null;
60
+ const latest = await fetchLatest();
61
+ try {
62
+ mkdirSync(dirname(cachePath), { recursive: true });
63
+ writeFileSync(cachePath, JSON.stringify({ checkedAt: new Date(now).toISOString(), latest }, null, 2), "utf8");
64
+ } catch {}
65
+ if (!latest || compareVersions(latest, currentVersion) <= 0) return null;
66
+ return { current: currentVersion, latest, command: "npm i -g pursr@latest" };
67
+ }
68
+
69
+ export async function notifyUpdate(options = {}) {
70
+ const result = await checkForUpdate(options);
71
+ if (!result) return null;
72
+ options.stderr?.write?.(`\npursr update available: ${result.current} -> ${result.latest}\nRun: ${result.command}\n\n`);
73
+ return result;
74
+ }