surf-cli 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -76,10 +76,21 @@ surf install <extension-id> # Chrome (default)
76
76
  surf install <extension-id> --browser brave # Brave
77
77
  surf install <extension-id> --browser helium # Helium
78
78
  surf install <extension-id> --browser all # All supported browsers
79
+ surf install <extension-id> --target linux # WSLg/Linux browser from WSL2
79
80
  ```
80
81
 
81
82
  Supported: `chrome`, `chromium`, `brave`, `edge`, `arc`, `helium`
82
83
 
84
+ **WSL2 with Windows Chrome**
85
+ When you run `surf install <extension-id>` inside WSL2, Surf detects WSL2 and installs a Windows-side native messaging manifest for Windows Chrome/Brave/Edge by default. The generated Windows wrapper launches the WSL2 host with `wsl.exe`, so `surf` commands run inside WSL2 still connect to the WSL socket.
86
+
87
+ If you use a Linux browser inside WSLg instead, install with:
88
+ ```bash
89
+ surf install <extension-id> --target linux
90
+ ```
91
+
92
+ Restart Windows Chrome after installing. If the extension reports `Access to the specified native messaging host is forbidden`, rerun `surf install <extension-id>` from the same WSL distro and confirm the extension ID was copied from `chrome://extensions`.
93
+
83
94
  **Package Manager Installs (Nix, Homebrew, etc.)**
84
95
  If surf is installed via a package manager that stores binaries in non-standard locations, set these environment variables before running `surf install`:
85
96
  ```bash
@@ -94,6 +105,7 @@ See [Environment Variables](#environment-variables) for details.
94
105
  ```bash
95
106
  surf uninstall # Chrome only
96
107
  surf uninstall --all # All browsers + wrapper files
108
+ surf uninstall --target linux # Remove WSLg/Linux-browser config from WSL2
97
109
  ```
98
110
 
99
111
  ### Development Setup
@@ -111,6 +123,7 @@ npm run build
111
123
  ```bash
112
124
  surf <command> [args] [options]
113
125
  surf --help # Basic help
126
+ surf --llm-context # Compact reference for AI agents
114
127
  surf --help-full # All 50+ commands
115
128
  surf <command> --help # Command details
116
129
  surf --find <query> # Search commands
@@ -185,7 +198,9 @@ surf click 100 200 # Click by coordinates
185
198
  surf type "hello" --submit # Type and press Enter
186
199
  surf type "email@example.com" --ref e12 # Type into specific element
187
200
  surf key Escape # Press key
188
- surf scroll.bottom # Scroll to bottom
201
+ surf scroll down 800 # Scroll down 800px
202
+ surf scroll bottom # Scroll to bottom
203
+ surf scroll.bottom # Dot command form also works
189
204
  ```
190
205
 
191
206
  ### Forms
@@ -221,6 +236,7 @@ surf screenshot --output /tmp/shot.png # Save to specific path
221
236
  surf screenshot --full --output /tmp/hd.png # Full resolution (skip resize)
222
237
  surf screenshot --annotate # With element labels
223
238
  surf screenshot --fullpage # Entire page
239
+ surf screenshot --full-page /tmp/full.png # Entire page, save to path
224
240
  surf screenshot --no-save # Return base64 + ID only (no file)
225
241
  surf snap # Alias for screenshot
226
242
  ```
@@ -246,15 +262,19 @@ surf tab.group --name "Work" --color blue
246
262
  Keep using your browser while the agent works in a separate window:
247
263
 
248
264
  ```bash
249
- # Create isolated window for agent
265
+ # Create a separate window for agent work
250
266
  surf window.new "https://example.com"
251
267
  # Returns: Window 123456 (tab 789)
252
268
 
253
- # All subsequent commands target that window
269
+ # Target that window or its tab from later commands
254
270
  surf click e5 --window-id 123456
255
- surf read --window-id 123456
271
+ surf read --tab-id 789
256
272
  surf tab.new "https://other.com" --window-id 123456
257
273
 
274
+ # Name tabs when humans or agents need stable aliases
275
+ surf tab.name dashboard --tab-id 789
276
+ surf tab.switch dashboard
277
+
258
278
  # Or manage windows directly
259
279
  surf window.list # List all windows
260
280
  surf window.list --tabs # Include tab details
@@ -262,6 +282,19 @@ surf window.focus 123456 # Bring window to front
262
282
  surf window.close 123456 # Close window
263
283
  ```
264
284
 
285
+ `window.new`, `--window-id`, `--tab-id`, and named tabs are Surf's supported coordination tools for parallel workflows. They help agents avoid accidentally driving the same visible tab.
286
+
287
+ Surf also serializes non-streaming browser CLI requests per socket with a file-based lock, so two agents sharing the same native host wait instead of interleaving browser commands. Use `--no-lock` only when you intentionally want to bypass the guard for a command.
288
+
289
+ For hard isolation, run separate browser instances/profiles with separate Surf native hosts and socket paths, then point each shell at the matching socket. Each socket has its own independent lock:
290
+
291
+ ```bash
292
+ SURF_SOCKET=/tmp/surf-agent-a.sock surf tab.list
293
+ SURF_SOCKET=/tmp/surf-agent-b.sock surf tab.list
294
+ ```
295
+
296
+ Surf does not yet provide `session.new`, session IDs, or independent per-agent CDP sessions.
297
+
265
298
  ### Device Emulation
266
299
 
267
300
  Test responsive designs and mobile layouts:
@@ -283,6 +316,39 @@ surf emulate.touch --enabled false # Disable touch
283
316
 
284
317
  Available devices: iPhone 12-14 (Pro/Max), iPhone SE, iPad (Pro/Mini), Pixel 5-7 (Pro), Galaxy S21-S23, Galaxy Tab S7, Nest Hub (Max).
285
318
 
319
+ ### Animation Recording
320
+
321
+ Capture a screenshot burst and assemble it into an animated GIF with ImageMagick:
322
+
323
+ ```bash
324
+ surf record --duration 2000 --fps 10 --output /tmp/anim.gif
325
+ surf record --trigger "click:#btn" --output /tmp/click.gif
326
+ surf record --rect 0,200,1440,800 --output /tmp/region.gif
327
+ ```
328
+
329
+ `record` defaults to 2000ms at 10fps and writes to `/tmp/surf-record-*.gif` when no output is provided. `--duration` is capped at 10000ms and `--fps` is capped at 30. `--trigger` supports `click:<selector>`, `scroll:up|down|left|right|top|bottom`, and `scroll:<selector>` to scroll a container to the bottom before capture. `--rect` crops the GIF using `x,y,width,height`. ImageMagick must be available as `magick` or `convert`.
330
+
331
+ ### Animation Audit
332
+
333
+ Sample matching elements over time and return a bounded JSON timeline for agent inspection:
334
+
335
+ ```bash
336
+ surf animate-audit --selector ".thing" --duration 2000 --fps 10
337
+ ```
338
+
339
+ The command captures rect, opacity, transform, visibility, display, and a short text snippet for up to 25 matching elements per sample. `--selector` is required. `--duration` defaults to 2000ms and is capped at 10000ms; `--fps` defaults to 10 and is capped at 30. This command returns JSON only and does not record GIF/video output.
340
+
341
+ ### Performance Audit
342
+
343
+ Capture layout shift, long animation frame, event timing, long task, and paint entries during a short window:
344
+
345
+ ```bash
346
+ surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
347
+ surf perf-audit --duration 1000 --json
348
+ ```
349
+
350
+ `perf-audit` defaults to 3000ms and is capped at 10000ms. `--trigger` supports the same `click:<selector>` and `scroll:<target>` forms as `record`. `--output` writes the JSON snapshot to disk.
351
+
286
352
  ### Performance Tracing
287
353
 
288
354
  Capture performance metrics and traces:
@@ -324,7 +390,7 @@ surf grok "what are the latest AI agent trends on X" # Search X posts
324
390
  surf grok "analyze @username recent activity" # Profile analysis
325
391
  surf grok "summarize this page" --with-page # Include page context
326
392
  surf grok "find viral AI posts" --deep-search # DeepSearch mode
327
- surf grok "quick question" --model fast # Models: auto, fast, expert, thinking
393
+ surf grok "quick question" --model fast # Models: auto, fast, expert, grok-4.20-beta
328
394
  surf grok --validate # Check UI and available models
329
395
  surf grok --validate --save-models # Save discovered models to settings
330
396
 
@@ -342,7 +408,7 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
342
408
 
343
409
  Each AI tool uses your existing browser login - no API keys needed. Just be logged into the respective service in Chrome (chatgpt.com, gemini.google.com, perplexity.ai, x.com, or aistudio.google.com).
344
410
 
345
- **Grok troubleshooting:** If queries fail, run `surf grok --validate` to check if the UI structure changed. Use `--save-models` to update the model cache in `surf.json`. Default model is "thinking" (Grok 4.1 Thinking).
411
+ **Grok troubleshooting:** If queries fail, run `surf grok --validate` to check if the UI structure changed. Use `--save-models` to update the model cache in `surf.json`. Default model is `fast`.
346
412
 
347
413
  ### Waiting
348
414
 
@@ -357,8 +423,11 @@ surf wait.url "/dashboard" # Wait for URL pattern
357
423
 
358
424
  ```bash
359
425
  surf js "return document.title" # Execute JavaScript
426
+ surf record --duration 2000 --fps 10 --output /tmp/anim.gif # Animated GIF capture
427
+ surf animate-audit --selector ".thing" --duration 2000 --fps 10 # JSON animation timeline
428
+ surf perf-audit --duration 3000 --output /tmp/perf.json # PerformanceObserver snapshot
360
429
  surf search "login" # Find text in page
361
- surf cookie.list # List cookies
430
+ surf cookie list # List cookies
362
431
  surf zoom 1.5 # Set zoom to 150%
363
432
  surf console # Read console messages
364
433
  surf network # Read network requests
@@ -529,6 +598,7 @@ surf workflow.validate ./my-workflow.json
529
598
  --window-id <id> # Target specific window (isolate agent from your browsing)
530
599
  --json # Output raw JSON
531
600
  --soft-fail # Warn instead of error (exit 0) on restricted pages
601
+ --no-lock # Bypass the per-socket browser request lock
532
602
  --no-screenshot # Skip auto-screenshot after actions
533
603
  --full # Full resolution screenshots (skip resize)
534
604
  --network-path <path> # Custom path for network logs (default: /tmp/surf, or SURF_NETWORK_PATH env)
@@ -538,12 +608,14 @@ surf workflow.validate ./my-workflow.json
538
608
 
539
609
  ```bash
540
610
  SURF_NETWORK_PATH # Path for network capture logs (default: /tmp/surf)
611
+ SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
541
612
  SURF_NODE_PATH # Path to node binary (for native host wrapper)
542
613
  SURF_HOST_PATH # Path to native/host.cjs (for native host wrapper)
543
614
  SURF_EXTENSION_PATH # Path to extension dist/ directory
544
615
  ```
545
616
 
546
617
  **Use cases:**
618
+ - `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI if you need a non-default socket, including separate sockets for separate browser/profile instances in hard-isolated multi-agent workflows. Each socket gets an independent request lock.
547
619
  - `SURF_NODE_PATH` / `SURF_HOST_PATH`: Package manager installs (e.g., Nix) that store binaries in non-standard locations
548
620
  - `SURF_EXTENSION_PATH`: Package managers that create stable symlinks instead of changing paths on reinstall
549
621
 
@@ -554,9 +626,38 @@ export SURF_HOST_PATH=~/.local/share/surf-cli/native/host.cjs
554
626
  export SURF_EXTENSION_PATH=~/.local/share/surf-cli/extension
555
627
  ```
556
628
 
629
+ ## Troubleshooting native host connections
630
+
631
+ If a command fails with `Socket connect failed`, start with:
632
+
633
+ ```bash
634
+ surf doctor
635
+ surf doctor --browser all
636
+ surf doctor --json
637
+ ```
638
+
639
+ `doctor` does not require a working browser connection. It checks the socket path, native messaging manifest, manifest `allowed_origins`, and wrapper path, then prints targeted next steps.
640
+
641
+ Read the `Attempted socket:` line first. The CLI and native host must agree on the same socket path. By default this is `/tmp/surf.sock` on macOS/Linux/WSL2 and `//./pipe/surf` on Windows.
642
+
643
+ Common fixes:
644
+ - Restart the browser after `surf install <extension-id>`.
645
+ - Confirm the Surf extension is enabled and the extension ID matches the one passed to `surf install`.
646
+ - On WSL2 with Windows Chrome, run `surf install <extension-id>` from WSL2 and restart Windows Chrome. Use `--target linux` only for a Linux browser running inside WSLg.
647
+ - If `SURF_SOCKET` is set, set the same value for both the browser-launched native host and the shell running `surf`.
648
+
649
+ macOS checklist:
650
+ - Confirm Chrome has a native messaging manifest at `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/surf.browser.host.json`.
651
+ - Confirm the manifest `allowed_origins` entry uses the same extension ID shown on `chrome://extensions` for the Surf extension.
652
+ - Reinstall the manifest with `surf install <extension-id>` after copying a fresh extension build or if the extension ID changed.
653
+ - Fully restart Chrome, then reload the Surf extension on `chrome://extensions`.
654
+ - Open the extension service worker from `chrome://extensions` and check its console for native messaging or socket errors.
655
+ - If `SURF_SOCKET` is set in your shell, make sure Chrome launches the native host with the same value; otherwise both sides should use `/tmp/surf.sock`.
656
+ - Run a simple CLI command such as `surf tab.list`; if it fails, compare its `Attempted socket:` line with the socket expected by the native host.
657
+
557
658
  ## Socket API
558
659
 
559
- For programmatic integration, send JSON to `/tmp/surf.sock`:
660
+ For programmatic integration, send JSON to `/tmp/surf.sock` by default, or to `SURF_SOCKET` when set:
560
661
 
561
662
  ```bash
562
663
  echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list","args":{}},"id":"1"}' | nc -U /tmp/surf.sock
@@ -614,7 +715,7 @@ echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list"
614
715
  | `element.*` | `styles` |
615
716
  | `frame.*` | `list`, `switch`, `main`, `js` |
616
717
  | `wait.*` | `element`, `network`, `url`, `dom`, `load` |
617
- | `cookie.*` | `list`, `get`, `set`, `clear` |
718
+ | `cookie` / `cookie.*` | `list`, `get`, `set`, `clear`, `delete` |
618
719
  | `bookmark.*` | `add`, `remove`, `list` |
619
720
  | `history.*` | `list`, `search` |
620
721
  | `dialog.*` | `accept`, `dismiss`, `info` |
@@ -0,0 +1,169 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+
5
+ const DEFAULT_STALE_MS = 30000;
6
+ const DEFAULT_TIMEOUT_MS = 60000;
7
+ const DEFAULT_MIN_WAIT_MS = 50;
8
+ const DEFAULT_MAX_WAIT_MS = 1000;
9
+
10
+ function sleepSync(ms) {
11
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
12
+ }
13
+
14
+ function getBrowserLockDir(socketPath, tempDir) {
15
+ const hash = crypto.createHash("sha256").update(socketPath).digest("hex").slice(0, 16);
16
+ return path.join(tempDir, `surf-lock-${hash}`);
17
+ }
18
+
19
+ function createToken() {
20
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
21
+ return crypto.randomBytes(16).toString("hex");
22
+ }
23
+
24
+ function readLockOwner(lockDir) {
25
+ try {
26
+ return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
27
+ } catch (error) {
28
+ if (error && (error.code === "ENOENT" || error instanceof SyntaxError)) return null;
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ function isProcessAlive(pid) {
34
+ if (!Number.isInteger(pid) || pid <= 0) return false;
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (error) {
39
+ return Boolean(error && error.code === "EPERM");
40
+ }
41
+ }
42
+
43
+ function writeLockOwner(lockDir, socketPath, token) {
44
+ fs.writeFileSync(
45
+ path.join(lockDir, "owner.json"),
46
+ JSON.stringify({ pid: process.pid, token, socketPath, createdAt: new Date().toISOString() }),
47
+ );
48
+ }
49
+
50
+ function removeLockDir(lockDir) {
51
+ fs.rmSync(lockDir, { recursive: true, force: true });
52
+ }
53
+
54
+ function getLockTimestamp(lockDir, owner) {
55
+ const target = owner ? path.join(lockDir, "owner.json") : lockDir;
56
+ try {
57
+ return fs.statSync(target).mtimeMs;
58
+ } catch (error) {
59
+ if (error && error.code === "ENOENT") return 0;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function ownerMatches(left, right) {
65
+ if (!left && !right) return true;
66
+ return Boolean(left && right && left.token && left.token === right.token);
67
+ }
68
+
69
+ function tryCreateStaleClaim(lockDir, staleMs, now = Date.now()) {
70
+ const claimDir = path.join(lockDir, "stale-claim");
71
+ try {
72
+ fs.mkdirSync(claimDir);
73
+ return claimDir;
74
+ } catch (error) {
75
+ if (!error || error.code !== "EEXIST") throw error;
76
+ try {
77
+ if (now - fs.statSync(claimDir).mtimeMs > staleMs) {
78
+ fs.rmSync(claimDir, { recursive: true, force: true });
79
+ }
80
+ } catch (claimError) {
81
+ if (!claimError || claimError.code !== "ENOENT") throw claimError;
82
+ }
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function claimAndRemoveStaleLock(lockDir, staleMs, now = Date.now()) {
88
+ let lockStats;
89
+ try {
90
+ lockStats = fs.statSync(lockDir);
91
+ } catch (error) {
92
+ if (error && error.code === "ENOENT") return false;
93
+ throw error;
94
+ }
95
+
96
+ const inspectedOwner = readLockOwner(lockDir);
97
+ if (inspectedOwner && isProcessAlive(inspectedOwner.pid)) return false;
98
+ if (now - getLockTimestamp(lockDir, inspectedOwner) <= staleMs) return false;
99
+
100
+ const claimDir = tryCreateStaleClaim(lockDir, staleMs, now);
101
+ if (!claimDir) return false;
102
+
103
+ try {
104
+ const currentOwner = readLockOwner(lockDir);
105
+ if (!ownerMatches(inspectedOwner, currentOwner)) return false;
106
+ if (currentOwner && isProcessAlive(currentOwner.pid)) return false;
107
+ if (!currentOwner && Date.now() - lockStats.mtimeMs <= staleMs) return false;
108
+
109
+ removeLockDir(lockDir);
110
+ return true;
111
+ } finally {
112
+ try {
113
+ fs.rmSync(claimDir, { recursive: true, force: true });
114
+ } catch {}
115
+ }
116
+ }
117
+
118
+ function acquireBrowserLock(socketPath, tempDir, options = {}) {
119
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
120
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
121
+ const sleep = options.sleep ?? sleepSync;
122
+ const lockDir = getBrowserLockDir(socketPath, tempDir);
123
+ const startedAt = Date.now();
124
+ let waitMs = options.minWaitMs ?? DEFAULT_MIN_WAIT_MS;
125
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
126
+
127
+ while (true) {
128
+ const token = createToken();
129
+ try {
130
+ fs.mkdirSync(lockDir, { recursive: false, mode: 0o700 });
131
+ try {
132
+ writeLockOwner(lockDir, socketPath, token);
133
+ } catch (error) {
134
+ removeLockDir(lockDir);
135
+ throw error;
136
+ }
137
+ let released = false;
138
+ return {
139
+ lockDir,
140
+ release() {
141
+ if (released) return;
142
+ released = true;
143
+ const owner = readLockOwner(lockDir);
144
+ if (owner && owner.token === token) removeLockDir(lockDir);
145
+ },
146
+ };
147
+ } catch (error) {
148
+ if (!error || error.code !== "EEXIST") throw error;
149
+ }
150
+
151
+ if (claimAndRemoveStaleLock(lockDir, staleMs)) continue;
152
+
153
+ if (Date.now() - startedAt >= timeoutMs) {
154
+ throw new Error(
155
+ `Timed out waiting for browser lock after ${Math.round(timeoutMs / 1000)}s. Use --no-lock to bypass.`,
156
+ );
157
+ }
158
+
159
+ sleep(waitMs);
160
+ waitMs = Math.min(Math.round(waitMs * 1.5), maxWaitMs);
161
+ }
162
+ }
163
+
164
+ module.exports = {
165
+ DEFAULT_STALE_MS,
166
+ DEFAULT_TIMEOUT_MS,
167
+ acquireBrowserLock,
168
+ getBrowserLockDir,
169
+ };
@@ -1,3 +1,5 @@
1
+ const path = require("path");
2
+
1
3
  const CHATGPT_URL = "https://chatgpt.com/";
2
4
 
3
5
  const SELECTORS = {
@@ -645,6 +647,7 @@ async function query(options) {
645
647
  closeTab,
646
648
  cdpEvaluate,
647
649
  cdpCommand,
650
+ uploadFile,
648
651
  log = () => {},
649
652
  } = options;
650
653
  const startTime = Date.now();
@@ -692,7 +695,21 @@ async function query(options) {
692
695
  log(`Selected model: ${selectedLabel}`);
693
696
  }
694
697
  if (file) {
695
- throw new Error("File upload not yet implemented");
698
+ if (!uploadFile) {
699
+ throw new Error("ChatGPT file upload unavailable: native host did not provide upload callback");
700
+ }
701
+ const files = Array.isArray(file) ? file : [file];
702
+ const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
703
+ log(`Uploading ${absFiles.length} file(s) to ChatGPT...`);
704
+ const uploadResult = await uploadFile(tabId, absFiles);
705
+ if (uploadResult?.error) {
706
+ throw new Error(`ChatGPT file upload failed: ${uploadResult.error}`);
707
+ }
708
+ if (!uploadResult?.success) {
709
+ throw new Error("ChatGPT file upload failed: upload did not report success");
710
+ }
711
+ log("File uploaded, waiting for ChatGPT attachment processing...");
712
+ await delay(1500);
696
713
  }
697
714
  await typePrompt(cdp, inputCdp, prompt);
698
715
  log("Prompt typed");