surf-cli 2.7.2 → 2.9.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.
Files changed (45) hide show
  1. package/README.md +208 -13
  2. package/dist/content/index.js +116 -0
  3. package/dist/content/index.js.map +1 -0
  4. package/dist/manifest.json +2 -11
  5. package/dist/options/options.js +3 -3
  6. package/dist/options/options.js.map +1 -1
  7. package/dist/service-worker/index.js +261 -61
  8. package/dist/service-worker/index.js.map +1 -1
  9. package/native/abort.cjs +65 -0
  10. package/native/ai-queue.cjs +64 -0
  11. package/native/aistudio-build.cjs +21 -13
  12. package/native/aistudio-client.cjs +40 -20
  13. package/native/browser-lock.cjs +169 -0
  14. package/native/chatgpt-client.cjs +63 -30
  15. package/native/cli.cjs +947 -460
  16. package/native/client-transport.cjs +168 -0
  17. package/native/config.cjs +2 -2
  18. package/native/do-executor.cjs +25 -51
  19. package/native/do-parser.cjs +12 -0
  20. package/native/doctor.cjs +633 -0
  21. package/native/endpoint.cjs +174 -0
  22. package/native/file-transfer.cjs +734 -0
  23. package/native/gemini-client.cjs +244 -88
  24. package/native/grok-client.cjs +321 -212
  25. package/native/host-helpers.cjs +88 -16
  26. package/native/host-sessions.cjs +283 -0
  27. package/native/host.cjs +811 -616
  28. package/native/listener.cjs +20 -0
  29. package/native/mcp-server.cjs +60 -62
  30. package/native/network-export.cjs +113 -0
  31. package/native/perplexity-client.cjs +46 -17
  32. package/native/remote-auth.cjs +279 -0
  33. package/native/remote-transport.cjs +337 -0
  34. package/native/request-pending.cjs +148 -0
  35. package/native/socket-path.cjs +46 -0
  36. package/package.json +11 -9
  37. package/scripts/install-native-host.cjs +184 -51
  38. package/scripts/uninstall-native-host.cjs +93 -15
  39. package/skills/README.md +11 -5
  40. package/skills/deep-x-research/SKILL.md +106 -0
  41. package/skills/surf/SKILL.md +77 -22
  42. package/dist/content/accessibility-tree.js +0 -11
  43. package/dist/content/accessibility-tree.js.map +0 -1
  44. package/dist/content/visual-indicator.js +0 -111
  45. package/dist/content/visual-indicator.js.map +0 -1
package/native/cli.cjs CHANGED
@@ -1,21 +1,68 @@
1
1
  #!/usr/bin/env node
2
- const net = require("net");
3
2
  const fs = require("fs");
4
3
  const path = require("path");
5
4
  const os = require("os");
6
- const { execSync } = require("child_process");
5
+ const { execFileSync, execSync } = require("child_process");
7
6
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
8
7
  const networkFormatters = require("./formatters/network.cjs");
9
8
  const networkStore = require("./network-store.cjs");
10
9
  const { parseDoCommands } = require("./do-parser.cjs");
11
10
  const { executeDoSteps } = require("./do-executor.cjs");
11
+ const { openClientTransport } = require("./client-transport.cjs");
12
12
  const { version: VERSION } = require("../package.json");
13
13
 
14
14
  const IS_WIN = process.platform === "win32";
15
- const SURF_TMP = IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp";
16
- const SOCKET_PATH = IS_WIN ? "//./pipe/surf" : "/tmp/surf.sock";
15
+ const { SURF_TMP, formatSocketError } = require("./socket-path.cjs");
16
+ const { acquireBrowserLock } = require("./browser-lock.cjs");
17
+ const { selectEndpoint, connectEndpoint, formatEndpointError } = require("./endpoint.cjs");
18
+ const { createFrameParser, createSocketWriter, writeFrame } = require("./remote-transport.cjs");
19
+ const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
20
+ const { AUTO_SCREENSHOT_TOOLS, prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
21
+ const { authorizeClient, listClients, revokeClient, getStateDir } = require("./remote-auth.cjs");
17
22
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
18
23
 
24
+ function parseBrowserLockOptions(noLockFlag) {
25
+ const noLock = noLockFlag || process.env.SURF_NO_LOCK === "1" || process.env.SURF_NO_LOCK === "true";
26
+ let timeoutMs;
27
+ if (process.env.SURF_LOCK_TIMEOUT_MS !== undefined) {
28
+ timeoutMs = Number(process.env.SURF_LOCK_TIMEOUT_MS);
29
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
30
+ console.error("Error: SURF_LOCK_TIMEOUT_MS must be a non-negative number");
31
+ process.exit(1);
32
+ }
33
+ }
34
+ return { noLock, timeoutMs };
35
+ }
36
+
37
+ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
38
+ let releaseBrowserLock = () => {};
39
+ if (!noLock) {
40
+ try {
41
+ const lock = acquireBrowserLock(endpoint.key, SURF_TMP, { timeoutMs });
42
+ releaseBrowserLock = lock.release;
43
+ } catch (error) {
44
+ console.error("Error:", error && error.message ? error.message : String(error));
45
+ process.exit(1);
46
+ }
47
+ }
48
+
49
+ const release = () => {
50
+ const releaseCurrent = releaseBrowserLock;
51
+ releaseBrowserLock = () => {};
52
+ releaseCurrent();
53
+ };
54
+
55
+ process.once("exit", release);
56
+ process.once("SIGINT", () => {
57
+ release();
58
+ process.exit(130);
59
+ });
60
+ process.once("SIGTERM", () => {
61
+ release();
62
+ process.exit(143);
63
+ });
64
+ }
65
+
19
66
  // ============================================================================
20
67
  // Workflow Resolution and Management
21
68
  // ============================================================================
@@ -41,7 +88,7 @@ function resolveWorkflow(nameOrPath) {
41
88
  if (nameOrPath.includes('|')) {
42
89
  return { type: 'inline', content: nameOrPath };
43
90
  }
44
-
91
+
45
92
  // Check if it's a direct file path (with extension or path separator)
46
93
  if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
47
94
  if (fs.existsSync(nameOrPath)) {
@@ -49,17 +96,17 @@ function resolveWorkflow(nameOrPath) {
49
96
  }
50
97
  return { type: 'not_found', name: nameOrPath };
51
98
  }
52
-
99
+
53
100
  // Look up by name in workflow directories
54
101
  const searchDirs = getWorkflowDirs();
55
-
102
+
56
103
  for (const { path: dir } of searchDirs) {
57
104
  const filePath = path.join(dir, `${nameOrPath}.json`);
58
105
  if (fs.existsSync(filePath)) {
59
106
  return { type: 'file', path: filePath };
60
107
  }
61
108
  }
62
-
109
+
63
110
  return { type: 'not_found', name: nameOrPath };
64
111
  }
65
112
 
@@ -70,7 +117,7 @@ function resolveWorkflow(nameOrPath) {
70
117
  function listWorkflows() {
71
118
  const workflows = [];
72
119
  const searchDirs = getWorkflowDirs();
73
-
120
+
74
121
  for (const { path: dir, scope } of searchDirs) {
75
122
  if (fs.existsSync(dir)) {
76
123
  try {
@@ -96,7 +143,7 @@ function listWorkflows() {
96
143
  }
97
144
  }
98
145
  }
99
-
146
+
100
147
  return workflows;
101
148
  }
102
149
 
@@ -107,15 +154,15 @@ function listWorkflows() {
107
154
  */
108
155
  function getWorkflowInfo(name) {
109
156
  const resolved = resolveWorkflow(name);
110
-
157
+
111
158
  if (resolved.type === 'not_found') {
112
159
  return { error: `Workflow not found: ${name}` };
113
160
  }
114
-
161
+
115
162
  if (resolved.type === 'inline') {
116
163
  return { error: 'Cannot get info for inline workflows' };
117
164
  }
118
-
165
+
119
166
  try {
120
167
  const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
121
168
  return {
@@ -175,24 +222,24 @@ function validateWorkflowFile(filePath) {
175
222
  if (!fs.existsSync(filePath)) {
176
223
  return { valid: false, error: `File not found: ${filePath}` };
177
224
  }
178
-
225
+
179
226
  try {
180
227
  const content = fs.readFileSync(filePath, 'utf8');
181
228
  const workflow = JSON.parse(content);
182
-
229
+
183
230
  // Basic structure validation
184
231
  if (!workflow.steps || !Array.isArray(workflow.steps)) {
185
232
  return { valid: false, error: "Workflow must have a 'steps' array" };
186
233
  }
187
-
234
+
188
235
  if (workflow.steps.length === 0) {
189
236
  return { valid: false, error: "Workflow has no steps" };
190
237
  }
191
-
238
+
192
239
  // Validate each step
193
240
  for (let i = 0; i < workflow.steps.length; i++) {
194
241
  const step = workflow.steps[i];
195
-
242
+
196
243
  // Check for loops
197
244
  if (step.repeat !== undefined || step.each !== undefined) {
198
245
  if (!step.steps || !Array.isArray(step.steps)) {
@@ -200,18 +247,18 @@ function validateWorkflowFile(filePath) {
200
247
  }
201
248
  continue;
202
249
  }
203
-
250
+
204
251
  // Regular step must have tool/cmd
205
252
  if (!step.tool && !step.cmd) {
206
253
  return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
207
254
  }
208
255
  }
209
-
256
+
210
257
  // Validate args schema if present
211
258
  if (workflow.args && typeof workflow.args !== 'object') {
212
259
  return { valid: false, error: "'args' must be an object" };
213
260
  }
214
-
261
+
215
262
  return { valid: true, workflow };
216
263
  } catch (e) {
217
264
  return { valid: false, error: `Invalid JSON: ${e.message}` };
@@ -226,7 +273,7 @@ function validateWorkflowFile(filePath) {
226
273
  */
227
274
  function formatStep(step, indent = 0) {
228
275
  const pad = ' '.repeat(indent);
229
-
276
+
230
277
  if (step.repeat !== undefined) {
231
278
  const lines = [`${pad}repeat ${step.repeat} times:`];
232
279
  for (const s of step.steps || []) {
@@ -237,7 +284,7 @@ function formatStep(step, indent = 0) {
237
284
  }
238
285
  return lines.join('\n');
239
286
  }
240
-
287
+
241
288
  if (step.each !== undefined) {
242
289
  const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
243
290
  for (const s of step.steps || []) {
@@ -245,24 +292,24 @@ function formatStep(step, indent = 0) {
245
292
  }
246
293
  return lines.join('\n');
247
294
  }
248
-
295
+
249
296
  const tool = step.tool || step.cmd;
250
297
  const args = step.args || {};
251
298
  const argStr = Object.entries(args)
252
299
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
253
300
  .join(' ');
254
-
301
+
255
302
  let line = `${pad}${tool}`;
256
303
  if (argStr) line += ` ${argStr}`;
257
304
  if (step.as) line += ` → ${step.as}`;
258
-
305
+
259
306
  return line;
260
307
  }
261
308
 
262
309
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
263
310
  function resizeImage(filePath, maxSize) {
264
311
  const platform = process.platform;
265
-
312
+
266
313
  try {
267
314
  if (platform === "darwin") {
268
315
  // macOS: use sips
@@ -294,7 +341,50 @@ function resizeImage(filePath, maxSize) {
294
341
  return { success: false, error: e.message };
295
342
  }
296
343
  }
297
- const args = process.argv.slice(2);
344
+ let args = process.argv.slice(2);
345
+ if (args[0] === "remote") {
346
+ const remoteArgs = args.slice(1);
347
+ const subcommand = remoteArgs[0];
348
+ const stateDir = getStateDir();
349
+ try {
350
+ if (subcommand === "authorize") {
351
+ const label = remoteArgs[1];
352
+ const outputIndex = remoteArgs.indexOf("--output");
353
+ const output = outputIndex === -1 ? undefined : remoteArgs[outputIndex + 1];
354
+ if (!label || !output || output.startsWith("--")) throw new Error("Usage: surf remote authorize <label> --output <credential-file>");
355
+ const client = authorizeClient(label, output, stateDir);
356
+ console.log(`Authorized remote client: ${client.label}`);
357
+ console.log(`Credential: ${client.output}`);
358
+ process.exit(0);
359
+ }
360
+ if (subcommand === "list") {
361
+ const clients = listClients(stateDir);
362
+ if (clients.length === 0) console.log("No authorized remote clients.");
363
+ else for (const client of clients) console.log(`${client.label}\t${client.id}\t${client.createdAt}`);
364
+ process.exit(0);
365
+ }
366
+ if (subcommand === "revoke") {
367
+ const label = remoteArgs[1];
368
+ if (!label || label.startsWith("--")) throw new Error("Usage: surf remote revoke <label>");
369
+ revokeClient(label, stateDir);
370
+ console.log(`Revoked remote client: ${label}`);
371
+ process.exit(0);
372
+ }
373
+ console.error("Usage: surf remote authorize <label> --output <credential-file> | list | revoke <label>");
374
+ process.exit(1);
375
+ } catch (error) {
376
+ console.error(`Error: ${error.message}`);
377
+ process.exit(1);
378
+ }
379
+ }
380
+
381
+ let endpoint;
382
+ try {
383
+ ({ args, endpoint } = selectEndpoint(args));
384
+ } catch (error) {
385
+ console.error(`Error: ${error.message}`);
386
+ process.exit(1);
387
+ }
298
388
 
299
389
  const ALIASES = {
300
390
  snap: "screenshot",
@@ -342,10 +432,10 @@ const TOOLS = {
342
432
  ai: {
343
433
  desc: "AI assistants (ChatGPT, Gemini)",
344
434
  commands: {
345
- "chatgpt": {
346
- desc: "Send prompt to ChatGPT (uses browser cookies)",
347
- args: ["query"],
348
- opts: {
435
+ "chatgpt": {
436
+ desc: "Send prompt to ChatGPT (uses browser cookies)",
437
+ args: ["query"],
438
+ opts: {
349
439
  "with-page": "Include current page context",
350
440
  model: "Model: gpt-4o, o1, etc.",
351
441
  file: "Attach file",
@@ -358,12 +448,12 @@ const TOOLS = {
358
448
  { cmd: 'chatgpt "analyze" --model gpt-4o', desc: "Specify model" },
359
449
  ]
360
450
  },
361
- "gemini": {
362
- desc: "Send prompt to Gemini (uses browser cookies)",
363
- args: ["query"],
364
- opts: {
451
+ "gemini": {
452
+ desc: "Send prompt to Gemini (uses browser cookies)",
453
+ args: ["query"],
454
+ opts: {
365
455
  "with-page": "Include current page context",
366
- model: "Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash",
456
+ model: "Model: gemini-3.1-pro (default), gemini-3.5-flash, gemini-3.1-flash-lite",
367
457
  file: "Attach file to analyze",
368
458
  "generate-image": "Generate image and save to path",
369
459
  "edit-image": "Edit existing image (use with --output)",
@@ -402,9 +492,9 @@ const TOOLS = {
402
492
  args: ["query"],
403
493
  opts: {
404
494
  "with-page": "Include current page context",
405
- model: "Model: auto, fast, expert, thinking (default)",
495
+ model: "Model: auto, fast (default), expert, grok-4.20-beta",
406
496
  "deep-search": "Enable DeepSearch for X post searching",
407
- timeout: "Timeout in seconds (default: 300 for thinking models)",
497
+ timeout: "Timeout in seconds (default: 300)",
408
498
  validate: "Check Grok UI and scrape available models (no query sent)",
409
499
  "save-models": "Save discovered models to surf.json config"
410
500
  },
@@ -447,9 +537,9 @@ const TOOLS = {
447
537
  { cmd: 'aistudio.build "crm dashboard" --output ./out', desc: "Build and extract to directory" },
448
538
  ]
449
539
  },
450
- "ai": {
451
- desc: "Analyze page with AI (requires GOOGLE_API_KEY)",
452
- args: ["query"],
540
+ "ai": {
541
+ desc: "Analyze page with AI (requires GOOGLE_API_KEY)",
542
+ args: ["query"],
453
543
  opts: { mode: "Query mode: find|summary|extract (auto-detected)" },
454
544
  examples: [
455
545
  { cmd: 'ai "find the login button"', desc: "Find element" },
@@ -463,39 +553,45 @@ const TOOLS = {
463
553
  desc: "Tab management",
464
554
  commands: {
465
555
  "tab.list": { desc: "List all open tabs", args: [], examples: [{ cmd: "tab.list", desc: "Show all tabs" }] },
466
- "tab.new": {
467
- desc: "Open new tab",
468
- args: ["url"],
556
+ "tab.new": {
557
+ desc: "Open new tab",
558
+ args: ["url"],
469
559
  opts: { urls: "Open multiple URLs" },
470
560
  examples: [
471
561
  { cmd: 'tab.new "https://google.com"', desc: "Open single tab" },
472
562
  { cmd: 'tab.new --urls "https://a.com" "https://b.com"', desc: "Open multiple" },
473
563
  ]
474
564
  },
475
- "tab.switch": {
476
- desc: "Switch to tab by ID or name",
565
+ "tab.switch": {
566
+ desc: "Switch to tab by ID or name",
477
567
  args: ["id"],
478
568
  examples: [
479
569
  { cmd: "tab.switch 123", desc: "Switch by ID" },
480
570
  { cmd: 'tab.switch "myTab"', desc: "Switch by name" },
481
571
  ]
482
572
  },
483
- "tab.close": {
484
- desc: "Close tab by ID or name",
485
- args: ["id"],
573
+ "tab.close": {
574
+ desc: "Close tab by ID or name",
575
+ args: ["id"],
486
576
  opts: { ids: "Close multiple tabs" },
487
577
  examples: [{ cmd: "tab.close 123", desc: "Close tab" }]
488
578
  },
489
- "tab.name": {
490
- desc: "Register current tab with a name",
579
+ "tab.move": {
580
+ desc: "Move tab to another window",
581
+ args: ["id"],
582
+ opts: { ids: "Move multiple tabs", "to-window": "Destination window ID", index: "Destination index" },
583
+ examples: [{ cmd: "tab.move 123 --to-window 456", desc: "Move tab to window" }]
584
+ },
585
+ "tab.name": {
586
+ desc: "Register current tab with a name",
491
587
  args: ["name"],
492
588
  examples: [{ cmd: 'tab.name "dashboard"', desc: "Name current tab" }]
493
589
  },
494
590
  "tab.unname": { desc: "Unregister a named tab", args: ["name"] },
495
591
  "tab.named": { desc: "List all named tabs", args: [] },
496
- "tab.group": {
497
- desc: "Create/add to tab group",
498
- args: [],
592
+ "tab.group": {
593
+ desc: "Create/add to tab group",
594
+ args: [],
499
595
  opts: { name: "Group name", tabs: "Tab IDs (comma-separated)", color: "Group color" },
500
596
  examples: [
501
597
  { cmd: 'tab.group --name "Work" --color blue', desc: "Group current tab" },
@@ -504,9 +600,9 @@ const TOOLS = {
504
600
  },
505
601
  "tab.ungroup": { desc: "Remove tabs from group", args: [], opts: { tabs: "Tab IDs (comma-separated)" } },
506
602
  "tab.groups": { desc: "List all tab groups", args: [] },
507
- "tab.reload": {
508
- desc: "Reload current tab",
509
- args: [],
603
+ "tab.reload": {
604
+ desc: "Reload current tab",
605
+ args: [],
510
606
  opts: { hard: "Bypass cache" },
511
607
  examples: [
512
608
  { cmd: "tab.reload", desc: "Soft reload" },
@@ -518,30 +614,31 @@ const TOOLS = {
518
614
  nav: {
519
615
  desc: "Navigation",
520
616
  commands: {
521
- "navigate": {
522
- desc: "Go to URL",
617
+ "navigate": {
618
+ desc: "Go to URL",
523
619
  args: ["url"],
524
620
  examples: [{ cmd: 'navigate "https://example.com"', desc: "Go to URL" }]
525
621
  },
526
622
  "go": { desc: "Alias for navigate", args: ["url"], alias: "navigate" },
527
- "back": {
528
- desc: "Go back in history",
623
+ "back": {
624
+ desc: "Go back in history",
529
625
  args: [],
530
626
  examples: [{ cmd: "back", desc: "Browser back" }]
531
627
  },
532
- "forward": {
533
- desc: "Go forward in history",
628
+ "forward": {
629
+ desc: "Go forward in history",
534
630
  args: [],
535
631
  examples: [{ cmd: "forward", desc: "Browser forward" }]
536
632
  },
537
- "screenshot": {
538
- desc: "Capture screenshot (auto-saves to /tmp by default)",
539
- args: [],
540
- opts: {
541
- output: "Save to file",
542
- selector: "Capture specific element",
543
- annotate: "Draw element labels",
544
- fullpage: "Capture full page",
633
+ "screenshot": {
634
+ desc: "Capture screenshot (auto-saves to /tmp by default)",
635
+ args: [],
636
+ opts: {
637
+ output: "Save to file",
638
+ selector: "Capture specific element",
639
+ annotate: "Draw element labels",
640
+ fullpage: "Capture full page",
641
+ "full-page": "Capture full page (alias for --fullpage)",
545
642
  "max-height": "Max height for fullpage (default: 4000)",
546
643
  full: "Skip resize, save at full resolution",
547
644
  "max-size": "Max dimension in px (default: 1200)",
@@ -555,23 +652,65 @@ const TOOLS = {
555
652
  { cmd: "snap", desc: "Alias for screenshot" },
556
653
  ]
557
654
  },
655
+ "record": {
656
+ desc: "Capture screenshot frames over time and assemble an animated GIF",
657
+ args: [],
658
+ opts: {
659
+ output: "GIF output path (default: /tmp/surf-record-*.gif)",
660
+ duration: "Capture duration in ms (default: 2000, max: 10000)",
661
+ fps: "Frames per second (default: 10, max: 30)",
662
+ trigger: "Optional action before capture: click:<selector> or scroll:<target>",
663
+ rect: "Crop rectangle x,y,width,height"
664
+ },
665
+ examples: [
666
+ { cmd: "record --duration 2000 --fps 10 --output /tmp/anim.gif", desc: "Record a 2s GIF" },
667
+ { cmd: 'record --trigger "click:#btn" --output /tmp/click.gif', desc: "Click, then record" },
668
+ ]
669
+ },
670
+ "animate-audit": {
671
+ desc: "Sample matching elements over time and return a JSON animation timeline",
672
+ args: [],
673
+ opts: {
674
+ selector: "CSS selector to sample (required)",
675
+ duration: "Capture duration in ms (default: 2000, max: 10000)",
676
+ fps: "Samples per second (default: 10, max: 30)"
677
+ },
678
+ examples: [
679
+ { cmd: 'animate-audit --selector ".thing" --duration 2000 --fps 10', desc: "Capture a bounded JSON timeline" },
680
+ ]
681
+ },
682
+ "perf-audit": {
683
+ desc: "Capture layout shift, event, long task, and animation-frame performance entries",
684
+ args: [],
685
+ opts: {
686
+ duration: "Capture duration in ms (default: 3000, max: 10000)",
687
+ trigger: "Optional action before capture: click:<selector> or scroll:<target>",
688
+ output: "Save JSON to file"
689
+ },
690
+ examples: [
691
+ { cmd: 'perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json', desc: "Capture a performance snapshot" },
692
+ ]
693
+ },
558
694
  "snap": { desc: "Alias for screenshot (auto-saves to /tmp)", args: [], alias: "screenshot" },
559
695
  }
560
696
  },
561
697
  scroll: {
562
698
  desc: "Scrolling",
563
699
  commands: {
564
- "scroll": {
565
- desc: "Scroll in direction",
566
- args: [],
700
+ "scroll": {
701
+ desc: "Scroll in direction",
702
+ args: ["direction", "pixels"],
567
703
  opts: { direction: "up|down|left|right", amount: "Scroll amount (1-10)" },
568
- examples: [{ cmd: "scroll --direction down --amount 3", desc: "Scroll down" }]
704
+ examples: [
705
+ { cmd: "scroll down 800", desc: "Scroll down 800px" },
706
+ { cmd: "scroll --direction down --amount 3", desc: "Scroll down" },
707
+ ]
569
708
  },
570
709
  "scroll.top": { desc: "Scroll to top of page", args: [], opts: { selector: "Target specific container" } },
571
710
  "scroll.bottom": { desc: "Scroll to bottom of page", args: [], opts: { selector: "Target specific container" } },
572
- "scroll.to": {
573
- desc: "Scroll element into view",
574
- args: [],
711
+ "scroll.to": {
712
+ desc: "Scroll element into view",
713
+ args: [],
575
714
  opts: { ref: "Element ref" },
576
715
  examples: [{ cmd: "scroll.to --ref e5", desc: "Scroll to element" }]
577
716
  },
@@ -581,15 +720,16 @@ const TOOLS = {
581
720
  page: {
582
721
  desc: "Page inspection",
583
722
  commands: {
584
- "page.read": {
585
- desc: "Get accessibility tree + visible text",
586
- args: [],
587
- opts: {
588
- all: "Include all elements",
589
- ref: "Get specific element",
723
+ "page.read": {
724
+ desc: "Get accessibility tree + visible text",
725
+ args: [],
726
+ opts: {
727
+ all: "Include all elements",
728
+ ref: "Get specific element",
590
729
  "no-text": "Exclude visible text content",
591
730
  depth: "Maximum tree depth (default: unlimited)",
592
731
  compact: "Remove empty structural elements",
732
+ "max-bytes": "Maximum visible text bytes",
593
733
  },
594
734
  examples: [
595
735
  { cmd: "page.read", desc: "Interactive elements + text content" },
@@ -597,7 +737,7 @@ const TOOLS = {
597
737
  { cmd: "page.read --no-text", desc: "Interactive elements only (no text)" },
598
738
  { cmd: "page.read --depth 3", desc: "Limit to 3 levels deep" },
599
739
  { cmd: "page.read --compact", desc: "Skip empty containers" },
600
- { cmd: "page.read --depth 3 --compact", desc: "Shallow + compact (60% smaller)" },
740
+ { cmd: "page.read --depth 3 --compact --max-bytes 2000", desc: "Shallow + compact output" },
601
741
  { cmd: "read", desc: "Alias" },
602
742
  ]
603
743
  },
@@ -612,7 +752,7 @@ const TOOLS = {
612
752
  "locate.role": {
613
753
  desc: "Find element by ARIA role",
614
754
  args: ["role"],
615
- opts: {
755
+ opts: {
616
756
  name: "Element name/text",
617
757
  action: "Action to perform (click|fill|hover|text)",
618
758
  value: "Value for fill action",
@@ -684,14 +824,14 @@ const TOOLS = {
684
824
  wait: {
685
825
  desc: "Waiting",
686
826
  commands: {
687
- "wait": {
688
- desc: "Wait N seconds",
827
+ "wait": {
828
+ desc: "Wait N seconds",
689
829
  args: ["duration"],
690
830
  examples: [{ cmd: "wait 2", desc: "Wait 2 seconds" }]
691
831
  },
692
- "wait.element": {
693
- desc: "Wait for element to appear",
694
- args: ["selector"],
832
+ "wait.element": {
833
+ desc: "Wait for element to appear",
834
+ args: ["selector"],
695
835
  opts: { timeout: "Timeout in ms" },
696
836
  examples: [
697
837
  { cmd: 'wait.element ".loading"', desc: "Wait for element" },
@@ -699,9 +839,9 @@ const TOOLS = {
699
839
  ]
700
840
  },
701
841
  "wait.network": { desc: "Wait for network idle", args: [], opts: { timeout: "Timeout in ms" } },
702
- "wait.url": {
703
- desc: "Wait for URL to match",
704
- args: ["pattern"],
842
+ "wait.url": {
843
+ desc: "Wait for URL to match",
844
+ args: ["pattern"],
705
845
  opts: { timeout: "Timeout in ms" },
706
846
  examples: [{ cmd: 'wait.url "/dashboard"', desc: "Wait for URL pattern" }]
707
847
  },
@@ -712,15 +852,15 @@ const TOOLS = {
712
852
  input: {
713
853
  desc: "Input actions",
714
854
  commands: {
715
- "click": {
716
- desc: "Click element or coordinates",
717
- args: ["ref"],
718
- opts: {
719
- ref: "Element ref",
720
- x: "X coordinate",
721
- y: "Y coordinate",
722
- button: "left|right|double|triple",
723
- selector: "CSS selector",
855
+ "click": {
856
+ desc: "Click element or coordinates",
857
+ args: ["ref"],
858
+ opts: {
859
+ ref: "Element ref",
860
+ x: "X coordinate",
861
+ y: "Y coordinate",
862
+ button: "left|right|double|triple",
863
+ selector: "CSS selector",
724
864
  index: "Which match (0-indexed) for selector",
725
865
  },
726
866
  examples: [
@@ -730,15 +870,15 @@ const TOOLS = {
730
870
  { cmd: "click --x 100 --y 200", desc: "Click coordinates" },
731
871
  ]
732
872
  },
733
- "type": {
734
- desc: "Type text (uses form.fill when --ref provided for better modal/form support)",
735
- args: ["text"],
736
- opts: {
873
+ "type": {
874
+ desc: "Type text (uses form.fill when --ref provided for better modal/form support)",
875
+ args: ["text"],
876
+ opts: {
737
877
  into: "Target selector",
738
- ref: "Element ref (uses JS DOM method, more reliable for modals)",
739
- submit: "Press enter after",
740
- clear: "Clear first",
741
- method: "cdp|js (default: cdp, but ref uses JS automatically)"
878
+ ref: "Element ref (uses JS DOM method, more reliable for modals)",
879
+ submit: "Press enter after",
880
+ clear: "Clear first",
881
+ method: "cdp|js (cursor typing uses CDP; selector/ref targets use JS)"
742
882
  },
743
883
  examples: [
744
884
  { cmd: 'type "hello world"', desc: "Type at cursor (CDP events)" },
@@ -747,9 +887,9 @@ const TOOLS = {
747
887
  ]
748
888
  },
749
889
  "smart_type": { desc: "Type into specific element (js method)", args: [], opts: { selector: "CSS selector", text: "Text to type", clear: "Clear first (default: true)", submit: "Submit after" } },
750
- "key": {
751
- desc: "Press key",
752
- args: ["key"],
890
+ "key": {
891
+ desc: "Press key",
892
+ args: ["key"],
753
893
  examples: [
754
894
  { cmd: "key Enter", desc: "Press Enter" },
755
895
  { cmd: "key Escape", desc: "Press Escape" },
@@ -764,9 +904,9 @@ const TOOLS = {
764
904
  js: {
765
905
  desc: "JavaScript execution",
766
906
  commands: {
767
- "js": {
768
- desc: "Execute JavaScript (use 'return' for values)",
769
- args: ["code"],
907
+ "js": {
908
+ desc: "Execute JavaScript (use 'return' for values)",
909
+ args: ["code"],
770
910
  opts: { file: "Run JS from file" },
771
911
  examples: [
772
912
  { cmd: 'js "return document.title"', desc: "Get title" },
@@ -779,9 +919,9 @@ const TOOLS = {
779
919
  dev: {
780
920
  desc: "Dev tools",
781
921
  commands: {
782
- "console": {
783
- desc: "Read console messages",
784
- args: [],
922
+ "console": {
923
+ desc: "Read console messages",
924
+ args: [],
785
925
  opts: { clear: "Clear after reading", stream: "Continuous output", level: "Filter by level (log,warn,error)", limit: "Max messages" },
786
926
  examples: [
787
927
  { cmd: "console", desc: "Get recent messages" },
@@ -794,10 +934,10 @@ const TOOLS = {
794
934
  network: {
795
935
  desc: "Network capture",
796
936
  commands: {
797
- "network": {
798
- desc: "List captured network requests",
799
- args: [],
800
- opts: {
937
+ "network": {
938
+ desc: "List captured network requests",
939
+ args: [],
940
+ opts: {
801
941
  origin: "Filter by origin (domain)",
802
942
  method: "Filter by method (GET,POST,...)",
803
943
  status: "Filter by status (200, 4xx, 5xx)",
@@ -822,16 +962,16 @@ const TOOLS = {
822
962
  { cmd: "network -v", desc: "Verbose with headers" },
823
963
  ]
824
964
  },
825
- "network.get": {
826
- desc: "Get full details for a request",
965
+ "network.get": {
966
+ desc: "Get full details for a request",
827
967
  args: ["id"],
828
968
  opts: {},
829
969
  examples: [
830
970
  { cmd: "network.get r_001", desc: "Get request details" }
831
971
  ]
832
972
  },
833
- "network.body": {
834
- desc: "Get response body (for piping)",
973
+ "network.body": {
974
+ desc: "Get response body (for piping)",
835
975
  args: ["id"],
836
976
  opts: { request: "Get request body instead" },
837
977
  examples: [
@@ -839,24 +979,24 @@ const TOOLS = {
839
979
  { cmd: "network.body r_001 | jq .", desc: "Pipe JSON to jq" }
840
980
  ]
841
981
  },
842
- "network.curl": {
843
- desc: "Generate curl command for request",
982
+ "network.curl": {
983
+ desc: "Generate curl command for request",
844
984
  args: ["id"],
845
985
  opts: {},
846
986
  examples: [
847
987
  { cmd: "network.curl r_001", desc: "Generate curl" }
848
988
  ]
849
989
  },
850
- "network.origins": {
851
- desc: "List captured origins with stats",
990
+ "network.origins": {
991
+ desc: "List captured origins with stats",
852
992
  args: [],
853
993
  opts: { "by-tab": "Group by tab" },
854
994
  examples: [
855
995
  { cmd: "network.origins", desc: "List origins" }
856
996
  ]
857
997
  },
858
- "network.clear": {
859
- desc: "Clear captured requests",
998
+ "network.clear": {
999
+ desc: "Clear captured requests",
860
1000
  args: [],
861
1001
  opts: { before: "Clear before timestamp/duration", origin: "Clear specific origin" },
862
1002
  examples: [
@@ -864,24 +1004,24 @@ const TOOLS = {
864
1004
  { cmd: "network.clear --before 1h", desc: "Clear older than 1 hour" }
865
1005
  ]
866
1006
  },
867
- "network.stats": {
868
- desc: "Show capture statistics",
1007
+ "network.stats": {
1008
+ desc: "Show capture statistics",
869
1009
  args: [],
870
1010
  opts: {},
871
1011
  examples: [
872
1012
  { cmd: "network.stats", desc: "Show stats" }
873
1013
  ]
874
1014
  },
875
- "network.export": {
876
- desc: "Export captured requests",
1015
+ "network.export": {
1016
+ desc: "Export captured requests",
877
1017
  args: [],
878
1018
  opts: { jsonl: "Export as JSONL", output: "Output file path" },
879
1019
  examples: [
880
1020
  { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" }
881
1021
  ]
882
1022
  },
883
- "network.path": {
884
- desc: "Get file paths for request data",
1023
+ "network.path": {
1024
+ desc: "Get file paths for request data",
885
1025
  args: ["id"],
886
1026
  opts: {},
887
1027
  examples: [
@@ -893,9 +1033,19 @@ const TOOLS = {
893
1033
  health: {
894
1034
  desc: "Health checks",
895
1035
  commands: {
896
- "health": {
897
- desc: "Wait for URL or element",
898
- args: [],
1036
+ "doctor": {
1037
+ desc: "Diagnose native host manifests and socket connectivity",
1038
+ args: [],
1039
+ opts: { browser: "Browser to inspect (default: chrome)", target: "auto|linux|windows", socket: "Socket path to check", json: "Raw diagnostic JSON" },
1040
+ examples: [
1041
+ { cmd: "doctor", desc: "Check default Chrome setup" },
1042
+ { cmd: "doctor --browser all", desc: "Check all supported browsers" },
1043
+ { cmd: "doctor --json", desc: "Machine-readable diagnostics" },
1044
+ ]
1045
+ },
1046
+ "health": {
1047
+ desc: "Wait for URL or element",
1048
+ args: [],
899
1049
  opts: { url: "URL to check (expects 200)", selector: "CSS selector to wait for", expect: "Expected status code (default: 200)", timeout: "Timeout in ms" },
900
1050
  examples: [
901
1051
  { cmd: 'health --url "https://api.example.com"', desc: "Check URL" },
@@ -914,9 +1064,9 @@ const TOOLS = {
914
1064
  desc: "Browser dialog handling",
915
1065
  commands: {
916
1066
  "dialog.accept": { desc: "Accept current dialog", args: [], opts: { text: "Text for prompt input" } },
917
- "dialog.dismiss": {
918
- desc: "Dismiss current dialog",
919
- args: [],
1067
+ "dialog.dismiss": {
1068
+ desc: "Dismiss current dialog",
1069
+ args: [],
920
1070
  opts: { all: "Dismiss all dialogs repeatedly" },
921
1071
  examples: [
922
1072
  { cmd: "dialog.dismiss", desc: "Dismiss once" },
@@ -980,9 +1130,9 @@ const TOOLS = {
980
1130
  upload: {
981
1131
  desc: "File upload",
982
1132
  commands: {
983
- "upload": {
984
- desc: "Upload file(s) to input",
985
- args: [],
1133
+ "upload": {
1134
+ desc: "Upload file(s) to input",
1135
+ args: [],
986
1136
  opts: { ref: "Element ref", files: "File path(s) comma-separated" },
987
1137
  examples: [{ cmd: 'upload --ref e5 --files "/path/to/file.pdf"', desc: "Upload file" }]
988
1138
  },
@@ -991,8 +1141,8 @@ const TOOLS = {
991
1141
  frame: {
992
1142
  desc: "Iframe handling",
993
1143
  commands: {
994
- "frame.list": {
995
- desc: "List all frames in page",
1144
+ "frame.list": {
1145
+ desc: "List all frames in page",
996
1146
  args: [],
997
1147
  examples: [{ cmd: "frame.list", desc: "Show frame tree" }]
998
1148
  },
@@ -1015,9 +1165,9 @@ const TOOLS = {
1015
1165
  args: [],
1016
1166
  examples: [{ cmd: "frame.main", desc: "Exit iframe context" }]
1017
1167
  },
1018
- "frame.js": {
1019
- desc: "Execute JS in specific frame",
1020
- args: ["code"],
1168
+ "frame.js": {
1169
+ desc: "Execute JS in specific frame",
1170
+ args: ["code"],
1021
1171
  opts: { id: "Frame ID from frame.list", file: "Run JS from file" },
1022
1172
  examples: [
1023
1173
  { cmd: 'frame.js "return document.title" --id frame1', desc: "JS in specific frame" },
@@ -1028,25 +1178,37 @@ const TOOLS = {
1028
1178
  cookie: {
1029
1179
  desc: "Cookie management",
1030
1180
  commands: {
1031
- "cookie.list": {
1032
- desc: "List all cookies for current tab's domain",
1181
+ "cookie.list": {
1182
+ desc: "List all cookies for current tab's domain",
1183
+ args: [],
1184
+ examples: [
1185
+ { cmd: "cookie list", desc: "Show all cookies" },
1186
+ { cmd: "cookie.list", desc: "Dot command form" },
1187
+ ]
1188
+ },
1189
+ "cookie.get": {
1190
+ desc: "Get specific cookie",
1033
1191
  args: [],
1034
- examples: [{ cmd: "cookie.list", desc: "Show all cookies" }]
1192
+ opts: { name: "Cookie name" },
1193
+ examples: [{ cmd: "cookie get session", desc: "Get cookie" }]
1035
1194
  },
1036
- "cookie.get": { desc: "Get specific cookie", args: [], opts: { name: "Cookie name" } },
1037
- "cookie.set": {
1038
- desc: "Set a cookie",
1039
- args: [],
1195
+ "cookie.set": {
1196
+ desc: "Set a cookie",
1197
+ args: [],
1040
1198
  opts: { name: "Cookie name", value: "Cookie value", expires: "Expiry date (optional)" },
1041
- examples: [{ cmd: 'cookie.set --name "session" --value "abc123"', desc: "Set cookie" }]
1199
+ examples: [
1200
+ { cmd: 'cookie set --name "session" --value "abc123"', desc: "Set cookie" },
1201
+ { cmd: 'cookie.set --name "session" --value "abc123"', desc: "Dot command form" },
1202
+ ]
1042
1203
  },
1043
- "cookie.clear": {
1044
- desc: "Clear cookies",
1045
- args: [],
1204
+ "cookie.clear": {
1205
+ desc: "Clear cookies",
1206
+ args: [],
1046
1207
  opts: { name: "Specific cookie (optional)", all: "Clear all for domain" },
1047
1208
  examples: [
1048
- { cmd: 'cookie.clear --name "session"', desc: "Clear one" },
1049
- { cmd: "cookie.clear --all", desc: "Clear all" },
1209
+ { cmd: 'cookie delete "session"', desc: "Clear one" },
1210
+ { cmd: "cookie clear --all", desc: "Clear all" },
1211
+ { cmd: 'cookie.clear --name "session"', desc: "Dot command form" },
1050
1212
  ]
1051
1213
  },
1052
1214
  }
@@ -1054,9 +1216,9 @@ const TOOLS = {
1054
1216
  search: {
1055
1217
  desc: "Text search",
1056
1218
  commands: {
1057
- "search": {
1058
- desc: "Search for text in page",
1059
- args: ["term"],
1219
+ "search": {
1220
+ desc: "Search for text in page",
1221
+ args: ["term"],
1060
1222
  opts: { "case-sensitive": "Case-sensitive match", limit: "Max results" },
1061
1223
  examples: [
1062
1224
  { cmd: 'search "login"', desc: "Find text" },
@@ -1070,9 +1232,9 @@ const TOOLS = {
1070
1232
  batch: {
1071
1233
  desc: "Batch execution",
1072
1234
  commands: {
1073
- "batch": {
1074
- desc: "Execute multiple actions",
1075
- args: [],
1235
+ "batch": {
1236
+ desc: "Execute multiple actions",
1237
+ args: [],
1076
1238
  opts: { actions: "JSON array of actions", file: "Path to actions JSON file" },
1077
1239
  examples: [
1078
1240
  { cmd: 'batch --actions \'[{"type":"click","ref":"e1"},{"type":"wait","ms":500}]\'', desc: "Inline actions" },
@@ -1130,9 +1292,9 @@ const TOOLS = {
1130
1292
  zoom: {
1131
1293
  desc: "Zoom control",
1132
1294
  commands: {
1133
- "zoom": {
1134
- desc: "Get or set zoom level",
1135
- args: [],
1295
+ "zoom": {
1296
+ desc: "Get or set zoom level",
1297
+ args: [],
1136
1298
  opts: { level: "Zoom level (e.g., 1.5 for 150%)", reset: "Reset to default zoom" },
1137
1299
  examples: [
1138
1300
  { cmd: "zoom", desc: "Get current zoom" },
@@ -1145,11 +1307,14 @@ const TOOLS = {
1145
1307
  resize: {
1146
1308
  desc: "Window management",
1147
1309
  commands: {
1148
- "resize": {
1149
- desc: "Resize browser window",
1150
- args: [],
1310
+ "resize": {
1311
+ desc: "Resize browser window",
1312
+ args: ["width", "height"],
1151
1313
  opts: { width: "Window width", height: "Window height" },
1152
- examples: [{ cmd: "resize --width 1280 --height 720", desc: "Set size" }]
1314
+ examples: [
1315
+ { cmd: "resize 1280 720", desc: "Set size" },
1316
+ { cmd: "resize --width 1280 --height 720", desc: "Set size with flags" },
1317
+ ]
1153
1318
  },
1154
1319
  }
1155
1320
  },
@@ -1164,14 +1329,14 @@ const TOOLS = {
1164
1329
  history: {
1165
1330
  desc: "Browser history",
1166
1331
  commands: {
1167
- "history.list": {
1168
- desc: "Recent history",
1169
- args: [],
1332
+ "history.list": {
1333
+ desc: "Recent history",
1334
+ args: [],
1170
1335
  opts: { limit: "Max results" },
1171
1336
  examples: [{ cmd: "history.list --limit 20", desc: "Last 20 items" }]
1172
1337
  },
1173
- "history.search": {
1174
- desc: "Search history",
1338
+ "history.search": {
1339
+ desc: "Search history",
1175
1340
  args: ["query"],
1176
1341
  examples: [{ cmd: 'history.search "github"', desc: "Search history" }]
1177
1342
  },
@@ -1180,10 +1345,10 @@ const TOOLS = {
1180
1345
  window: {
1181
1346
  desc: "Window management (isolate agent from your browsing)",
1182
1347
  commands: {
1183
- "window.new": {
1184
- desc: "Create new browser window",
1185
- args: ["url"],
1186
- opts: {
1348
+ "window.new": {
1349
+ desc: "Create new browser window",
1350
+ args: ["url"],
1351
+ opts: {
1187
1352
  width: "Window width",
1188
1353
  height: "Window height",
1189
1354
  incognito: "Open incognito window",
@@ -1195,28 +1360,28 @@ const TOOLS = {
1195
1360
  { cmd: 'window.new --incognito', desc: "Incognito window" },
1196
1361
  ]
1197
1362
  },
1198
- "window.list": {
1199
- desc: "List all browser windows",
1363
+ "window.list": {
1364
+ desc: "List all browser windows",
1200
1365
  args: [],
1201
1366
  opts: { tabs: "Include tab details" },
1202
1367
  examples: [{ cmd: "window.list", desc: "Show all windows" }]
1203
1368
  },
1204
- "window.focus": {
1205
- desc: "Focus a window by ID",
1369
+ "window.focus": {
1370
+ desc: "Focus a window by ID",
1206
1371
  args: ["id"],
1207
1372
  examples: [{ cmd: "window.focus 123", desc: "Focus window" }]
1208
1373
  },
1209
- "window.close": {
1210
- desc: "Close a window by ID",
1374
+ "window.close": {
1375
+ desc: "Close a window by ID",
1211
1376
  args: ["id"],
1212
1377
  examples: [{ cmd: "window.close 123", desc: "Close window" }]
1213
1378
  },
1214
- "window.resize": {
1215
- desc: "Resize or reposition a window",
1216
- args: [],
1217
- opts: {
1218
- id: "Window ID (required)",
1219
- width: "Window width",
1379
+ "window.resize": {
1380
+ desc: "Resize or reposition a window",
1381
+ args: [],
1382
+ opts: {
1383
+ id: "Window ID (required)",
1384
+ width: "Window width",
1220
1385
  height: "Window height",
1221
1386
  left: "Window X position",
1222
1387
  top: "Window Y position",
@@ -1263,10 +1428,17 @@ Use --index to select from multiple matches:
1263
1428
  content: `Cookies are scoped to the current tab's domain.
1264
1429
 
1265
1430
  Commands:
1266
- cookie.list List all cookies
1267
- cookie.get --name X Get specific cookie
1268
- cookie.set Set a cookie
1269
- cookie.clear Clear cookies
1431
+ cookie list List all cookies
1432
+ cookie get X Get specific cookie
1433
+ cookie set Set a cookie
1434
+ cookie clear --all Clear all cookies
1435
+ cookie delete X Clear one cookie
1436
+
1437
+ Dot commands remain supported:
1438
+ cookie.list
1439
+ cookie.get --name X
1440
+ cookie.set
1441
+ cookie.clear
1270
1442
 
1271
1443
  Notes:
1272
1444
  - HttpOnly cookies are accessible
@@ -1298,6 +1470,7 @@ Commands:
1298
1470
  screenshot --output file.png Basic screenshot
1299
1471
  screenshot --annotate --output file.png With element labels
1300
1472
  screenshot --fullpage --output file.png Full page capture
1473
+ screenshot --full-page --output file.png Full page capture (alias)
1301
1474
  screenshot --annotate --fullpage --output file.png Full page with labels
1302
1475
  snap Auto-save to /tmp
1303
1476
 
@@ -1305,6 +1478,7 @@ Options:
1305
1478
  --output Save path
1306
1479
  --annotate Draw element refs
1307
1480
  --fullpage Capture entire page
1481
+ --full-page Capture entire page (alias)
1308
1482
  --max-height Max height for fullpage (default: 4000)`
1309
1483
  },
1310
1484
  automation: {
@@ -1327,7 +1501,7 @@ Wait for dynamic content:
1327
1501
 
1328
1502
  Scroll and capture:
1329
1503
  scroll.bottom
1330
- screenshot --fullpage --output full.png`
1504
+ screenshot --full-page --output full.png`
1331
1505
  },
1332
1506
  windows: {
1333
1507
  title: "Window Isolation",
@@ -1346,7 +1520,7 @@ All commands in that window:
1346
1520
 
1347
1521
  Manage windows:
1348
1522
  surf window.list # List all windows
1349
- surf window.list --tabs # Include tab details
1523
+ surf window.list --tabs # Include tab details
1350
1524
  surf window.focus 123 # Bring window to front
1351
1525
  surf window.close 123 # Close when done
1352
1526
 
@@ -1439,7 +1613,7 @@ Exclude text content:
1439
1613
  };
1440
1614
 
1441
1615
  const ALL_SOCKET_TOOLS = [
1442
- "ai", "screenshot", "navigate",
1616
+ "ai", "screenshot", "record", "animate-audit", "perf-audit", "navigate",
1443
1617
  "form_input", "find_and_type", "autocomplete", "set_value", "smart_type",
1444
1618
  "scroll_to_position", "get_scroll_info", "close_dialogs", "page_state",
1445
1619
  "javascript_tool", "health", "smoke",
@@ -1448,13 +1622,13 @@ const ALL_SOCKET_TOOLS = [
1448
1622
  "computer",
1449
1623
  "page.read", "page.text", "page.state",
1450
1624
  "locate.role", "locate.text", "locate.label",
1451
- "tab.list", "tab.new", "tab.switch", "tab.close", "tab.name", "tab.unname", "tab.named",
1625
+ "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1452
1626
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
1453
1627
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1454
1628
  "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
1455
1629
  "click", "hover", "drag",
1456
- "js", "console", "network",
1457
- "network.get", "network.body", "network.curl", "network.origins",
1630
+ "js", "console", "network",
1631
+ "network.get", "network.body", "network.curl", "network.origins",
1458
1632
  "network.clear", "network.stats", "network.export", "network.path",
1459
1633
  "dialog.accept", "dialog.dismiss", "dialog.info",
1460
1634
  "emulate.network", "emulate.cpu", "emulate.geo", "emulate.device", "emulate.viewport", "emulate.touch",
@@ -1497,6 +1671,9 @@ const SEE_ALSO = {
1497
1671
  "perf.metrics": ["perf.start", "console", "network"],
1498
1672
  "navigate": ["wait.load", "page.read"],
1499
1673
  "screenshot": ["page.read", "scroll.bottom for fullpage"],
1674
+ "record": ["screenshot", "animate-audit", "perf-audit"],
1675
+ "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1676
+ "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1500
1677
  "search": ["locate.text", "page.read"],
1501
1678
  "wait.element": ["wait.load", "wait.network"],
1502
1679
  "wait.load": ["wait.element", "wait.network"],
@@ -1516,10 +1693,14 @@ Common Commands:
1516
1693
  click <ref> Click element by ref or selector
1517
1694
  type <text> Type text at cursor or into element
1518
1695
  screenshot Capture screenshot (alias: snap)
1696
+ record Capture screenshot frames into an animated GIF
1697
+ animate-audit JSON timeline of element animation/style samples
1698
+ perf-audit PerformanceObserver snapshot for motion/jank debugging
1519
1699
  page.read Get page accessibility tree (alias: read)
1520
1700
  locate.role <role> Find element by ARIA role
1521
1701
  search <term> Search for text in page (alias: find)
1522
1702
  window.new <url> Create isolated browser window
1703
+ doctor Diagnose native host/socket setup
1523
1704
  wait <seconds> Wait N seconds
1524
1705
 
1525
1706
  Quick Examples:
@@ -1533,7 +1714,12 @@ Quick Examples:
1533
1714
  surf window.new "https://example.com" && surf --window-id 123 go "https://other.com"
1534
1715
 
1535
1716
  More Help:
1717
+ --remote <host>:<port> Route requests to a remote native host
1718
+ --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1719
+ surf remote authorize <label> --output <path>
1720
+ surf remote list | surf remote revoke <label>
1536
1721
  surf --help-full All commands
1722
+ surf --llm-context Compact reference for AI agents
1537
1723
  surf --help-topic <topic> Topic guide (refs, semantic, frames, devices...)
1538
1724
  surf <command> --help Command details
1539
1725
  surf --find <query> Search for commands
@@ -1541,6 +1727,34 @@ More Help:
1541
1727
  `);
1542
1728
  };
1543
1729
 
1730
+ const showLlmContext = () => {
1731
+ console.log(`SURF CLI LLM CONTEXT
1732
+ Purpose: control Chrome from shell. Commands are \`surf <command> [args] [options]\`.
1733
+ Core loop: navigate -> wait/read -> act -> screenshot/read.
1734
+ Navigate: surf navigate "https://example.com" # alias: surf go "..."
1735
+ Wait after navigation: surf wait 2 # or wait.load for load complete
1736
+ Read DOM/refs: surf page.read --depth 3 --compact # alias: surf read
1737
+ Refs: use e1/e2 refs from page.read; prefer refs over CSS when available.
1738
+ Click ref: surf click e5
1739
+ Click selector/coords: surf click --selector ".btn" | surf click 100 200
1740
+ Type: surf type "text" --submit # use --ref e5 to target a field
1741
+ Screenshot: surf screenshot /tmp/shot.png # auto-saves to /tmp if no path
1742
+ Full page screenshot: surf screenshot --full-page /tmp/full.png
1743
+ Record animation: surf record --duration 2000 --fps 10 --output /tmp/anim.gif
1744
+ Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1745
+ Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1746
+ JavaScript: surf js "return document.title"
1747
+ Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf scroll top
1748
+ Find by semantics: surf locate.role button --name "Submit" --action click
1749
+ Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
1750
+ Cookies: surf cookie list | surf cookie get "name" | surf cookie delete "name"
1751
+ Window isolation: surf window.new "https://example.com" then pass --window-id <id>
1752
+ Concurrency: surf serializes commands per socket; use --no-lock only for intentional bypass
1753
+ Doctor: surf doctor --browser all # native host/socket diagnostics
1754
+ Workflow: surf do 'go "https://example.com" | wait 2 | read | click e5 | screenshot'
1755
+ More help: surf --help-full | surf <command> --help | surf --help-topic refs | surf --find <query>`);
1756
+ };
1757
+
1544
1758
  const showFullHelp = () => {
1545
1759
  console.log(`surf v${VERSION} - Browser automation CLI
1546
1760
 
@@ -1560,11 +1774,19 @@ Usage: surf <command> [args] [options]
1560
1774
  console.log(`Aliases: snap -> screenshot, read -> page.read, find -> search, go -> navigate
1561
1775
 
1562
1776
  Options:
1777
+ --remote <host>:<port> Route requests to a remote native host
1778
+ --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1563
1779
  --tab-id <id> Target specific tab
1564
1780
  --window-id <id> Target specific window (isolate from your browsing)
1565
1781
  --json Output raw JSON
1566
1782
  --auto-capture On error: capture screenshot + console to /tmp
1567
1783
  --soft-fail On error: warn and exit 0 (for non-critical commands)
1784
+ --no-lock Bypass the per-socket browser request lock
1785
+
1786
+ Remote Credentials (run on the browser host):
1787
+ surf remote authorize <label> --output <credential-file>
1788
+ surf remote list
1789
+ surf remote revoke <label>
1568
1790
 
1569
1791
  Script Mode:
1570
1792
  surf --script <file> Run workflow from JSON
@@ -1673,7 +1895,7 @@ const showToolHelp = (toolName) => {
1673
1895
  const fuzzyFind = (query) => {
1674
1896
  const terms = query.toLowerCase().split(/\s+/);
1675
1897
  const results = [];
1676
-
1898
+
1677
1899
  for (const [groupName, group] of Object.entries(TOOLS)) {
1678
1900
  for (const [cmd, info] of Object.entries(group.commands)) {
1679
1901
  if (info.alias) continue;
@@ -1684,7 +1906,7 @@ const fuzzyFind = (query) => {
1684
1906
  }
1685
1907
  }
1686
1908
  }
1687
-
1909
+
1688
1910
  return results.sort((a, b) => b.score - a.score);
1689
1911
  };
1690
1912
 
@@ -1732,6 +1954,11 @@ const showAllTools = () => {
1732
1954
  console.log(`\n Total: ${ALL_SOCKET_TOOLS.length} commands\n`);
1733
1955
  };
1734
1956
 
1957
+ if (args[0] === "--llm-context") {
1958
+ showLlmContext();
1959
+ process.exit(0);
1960
+ }
1961
+
1735
1962
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
1736
1963
  showBasicHelp();
1737
1964
  process.exit(0);
@@ -1776,7 +2003,7 @@ if (args[0] === "server") {
1776
2003
  process.exit(0);
1777
2004
  }
1778
2005
  const { PiChromeMcpServer } = require("./mcp-server.cjs");
1779
- const server = new PiChromeMcpServer();
2006
+ const server = new PiChromeMcpServer(endpoint);
1780
2007
  server.start().catch((err) => {
1781
2008
  console.error("MCP Server error:", err.message);
1782
2009
  process.exit(1);
@@ -1790,11 +2017,17 @@ if (args[0] === "extension-path" || args[0] === "path") {
1790
2017
  process.exit(0);
1791
2018
  }
1792
2019
 
2020
+ if (args[0] === "doctor") {
2021
+ const { runDoctorCli } = require("./doctor.cjs");
2022
+ runDoctorCli(args.slice(1), endpoint).then((code) => process.exit(code));
2023
+ return;
2024
+ }
2025
+
1793
2026
  if (args[0] === "install") {
1794
2027
  const { spawnSync } = require("child_process");
1795
2028
  const scriptPath = require("path").resolve(__dirname, "../scripts/install-native-host.cjs");
1796
2029
  const installArgs = args.slice(1);
1797
-
2030
+
1798
2031
  if (installArgs.length === 0 || installArgs[0] === "--help" || installArgs[0] === "-h") {
1799
2032
  console.log(`
1800
2033
  Usage: surf install <extension-id> [options]
@@ -1809,11 +2042,16 @@ Options:
1809
2042
  -b, --browser Browser(s) to install for (default: chrome)
1810
2043
  Values: chrome, chromium, brave, edge, arc, helium, all
1811
2044
  Multiple: --browser chrome,brave
2045
+ --target Install target: auto, linux, windows
2046
+ On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
2047
+ --listen <tailscale-ip>:<port>
2048
+ Requires surf remote authorize <label> --output <path> first.
1812
2049
 
1813
2050
  Examples:
1814
2051
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl
1815
2052
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser brave
1816
2053
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser all
2054
+ surf install hnfbepgmaoklhekckbpjnleifhahkcpl --target linux
1817
2055
  `);
1818
2056
  process.exit(0);
1819
2057
  }
@@ -1828,7 +2066,7 @@ if (args[0] === "uninstall") {
1828
2066
  const { spawnSync } = require("child_process");
1829
2067
  const scriptPath = require("path").resolve(__dirname, "../scripts/uninstall-native-host.cjs");
1830
2068
  const uninstallArgs = args.slice(1);
1831
-
2069
+
1832
2070
  if (uninstallArgs.includes("--help") || uninstallArgs.includes("-h")) {
1833
2071
  console.log(`
1834
2072
  Usage: surf uninstall [options]
@@ -1839,11 +2077,14 @@ Options:
1839
2077
  -b, --browser Browser(s) to uninstall from (default: chrome)
1840
2078
  Values: chrome, chromium, brave, edge, arc, helium, all
1841
2079
  -a, --all Uninstall from all browsers and remove wrapper
2080
+ --target Install target to remove: auto, linux, windows
2081
+ On WSL2, auto removes Windows-browser manifests. Use linux for WSLg/Linux browsers.
1842
2082
 
1843
2083
  Examples:
1844
2084
  surf uninstall
1845
2085
  surf uninstall --browser brave
1846
2086
  surf uninstall --all
2087
+ surf uninstall --target linux
1847
2088
  `);
1848
2089
  process.exit(0);
1849
2090
  }
@@ -1948,44 +2189,24 @@ if (args.includes("--script")) {
1948
2189
  process.exit(1);
1949
2190
  }
1950
2191
 
2192
+ let scriptTransport;
1951
2193
  const sendScriptRequest = (toolName, toolArgs = {}) => {
1952
- return new Promise((resolve, reject) => {
1953
- const sock = net.createConnection(SOCKET_PATH, () => {
1954
- const req = {
1955
- type: "tool_request",
1956
- method: "execute_tool",
1957
- params: { tool: toolName, args: toolArgs },
1958
- id: "cli-" + Date.now() + "-" + Math.random(),
1959
- };
1960
- if (scriptTabId) req.tabId = parseInt(scriptTabId, 10);
1961
- sock.write(JSON.stringify(req) + "\n");
1962
- });
1963
- let buf = "";
1964
- sock.on("data", (d) => {
1965
- buf += d.toString();
1966
- const lines = buf.split("\n");
1967
- buf = lines.pop();
1968
- for (const line of lines) {
1969
- if (!line.trim()) continue;
1970
- try {
1971
- const resp = JSON.parse(line);
1972
- sock.end();
1973
- resolve(resp);
1974
- } catch {
1975
- sock.end();
1976
- reject(new Error("Invalid JSON"));
1977
- }
1978
- }
1979
- });
1980
- sock.on("error", (e) => reject(e));
1981
- let timeoutId;
1982
- timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, 30000);
1983
- sock.on("close", () => clearTimeout(timeoutId));
1984
- });
2194
+ const req = {
2195
+ type: "tool_request",
2196
+ method: "execute_tool",
2197
+ params: { tool: toolName, args: toolArgs },
2198
+ id: "cli-" + Date.now() + "-" + Math.random(),
2199
+ };
2200
+ if (scriptTabId) req.tabId = parseInt(scriptTabId, 10);
2201
+ const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
2202
+ req.params.args = prepared.args;
2203
+ return scriptTransport.request(req, resolveRequestDeadlineMs(toolName, prepared.args), prepared);
1985
2204
  };
1986
2205
 
1987
2206
  const runScript = async () => {
1988
- const total = script.steps.length;
2207
+ try {
2208
+ if (!dryRun) scriptTransport = await openClientTransport(endpoint);
2209
+ const total = script.steps.length;
1989
2210
  const results = [];
1990
2211
  let failed = 0;
1991
2212
 
@@ -2043,10 +2264,22 @@ if (args.includes("--script")) {
2043
2264
  console.log(`Summary: ${passed} passed, ${failed} failed, ${total} total`);
2044
2265
  }
2045
2266
 
2046
- process.exit(failed > 0 ? 1 : 0);
2267
+ return failed > 0 ? 1 : 0;
2268
+ } finally {
2269
+ await scriptTransport?.close();
2270
+ }
2047
2271
  };
2048
2272
 
2049
- runScript();
2273
+ if (!dryRun) {
2274
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
2275
+ }
2276
+
2277
+ runScript()
2278
+ .then((code) => process.exit(code))
2279
+ .catch((error) => {
2280
+ console.error(`Error: ${error.message}`);
2281
+ process.exit(1);
2282
+ });
2050
2283
  return;
2051
2284
  }
2052
2285
 
@@ -2063,13 +2296,13 @@ if (args[0] === "do") {
2063
2296
  let wantJson = false;
2064
2297
  let tabId = undefined;
2065
2298
  let windowId = undefined;
2066
-
2299
+
2067
2300
  // Reserved flags that aren't workflow args
2068
- const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id'];
2069
-
2301
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'no-lock'];
2302
+
2070
2303
  // Workflow-specific args (collected for variable substitution)
2071
2304
  const workflowArgs = {};
2072
-
2305
+
2073
2306
  // Parse do-specific arguments
2074
2307
  for (let i = 0; i < doArgs.length; i++) {
2075
2308
  const arg = doArgs[i];
@@ -2117,7 +2350,7 @@ if (args[0] === "do") {
2117
2350
  commandsInput = arg;
2118
2351
  }
2119
2352
  }
2120
-
2353
+
2121
2354
  if (!commandsInput && !fileInput) {
2122
2355
  console.error("Error: commands string, workflow name, or --file required");
2123
2356
  console.error('Usage: surf do \'go "url" | click e5\'');
@@ -2125,11 +2358,11 @@ if (args[0] === "do") {
2125
2358
  console.error(" surf do my-workflow --arg1 value1 --arg2 value2");
2126
2359
  process.exit(1);
2127
2360
  }
2128
-
2361
+
2129
2362
  let steps;
2130
2363
  let workflow = null; // Full workflow object (for arg validation)
2131
2364
  let workflowName = null;
2132
-
2365
+
2133
2366
  try {
2134
2367
  if (fileInput) {
2135
2368
  // Explicit file path via --file
@@ -2143,7 +2376,7 @@ if (args[0] === "do") {
2143
2376
  } else {
2144
2377
  // Resolve: inline | file path | named workflow
2145
2378
  const resolved = resolveWorkflow(commandsInput);
2146
-
2379
+
2147
2380
  if (resolved.type === 'inline') {
2148
2381
  // Inline pipe syntax
2149
2382
  steps = parseDoCommands(resolved.content);
@@ -2166,13 +2399,13 @@ if (args[0] === "do") {
2166
2399
  }
2167
2400
  }
2168
2401
  }
2169
-
2402
+
2170
2403
  // Process workflow file if loaded
2171
2404
  if (workflow) {
2172
2405
  if (!workflow.steps || !Array.isArray(workflow.steps)) {
2173
2406
  throw new Error("Workflow must have a 'steps' array");
2174
2407
  }
2175
-
2408
+
2176
2409
  // Validate required args
2177
2410
  const argErrors = validateWorkflowArgs(workflow, workflowArgs);
2178
2411
  if (argErrors.length > 0) {
@@ -2190,7 +2423,7 @@ if (args[0] === "do") {
2190
2423
  console.error(`\nRun 'surf workflow.info ${workflowName}' for details.`);
2191
2424
  process.exit(1);
2192
2425
  }
2193
-
2426
+
2194
2427
  // Convert steps: support both { tool, args } and { cmd, args } formats
2195
2428
  // Also preserve loop steps as-is
2196
2429
  steps = workflow.steps.map(s => {
@@ -2199,16 +2432,16 @@ if (args[0] === "do") {
2199
2432
  const convertSteps = (stepsArr) => stepsArr.map(ns => {
2200
2433
  if (ns.repeat !== undefined || ns.each !== undefined) {
2201
2434
  // Recursively convert nested loop steps and until condition
2202
- return {
2203
- ...ns,
2435
+ return {
2436
+ ...ns,
2204
2437
  steps: convertSteps(ns.steps || []),
2205
2438
  until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
2206
2439
  };
2207
2440
  }
2208
2441
  return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
2209
2442
  });
2210
- return {
2211
- ...s,
2443
+ return {
2444
+ ...s,
2212
2445
  steps: convertSteps(s.steps || []),
2213
2446
  until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
2214
2447
  };
@@ -2220,15 +2453,15 @@ if (args[0] === "do") {
2220
2453
  console.error(`Error: Failed to parse workflow: ${e.message}`);
2221
2454
  process.exit(1);
2222
2455
  }
2223
-
2456
+
2224
2457
  if (!steps || steps.length === 0) {
2225
2458
  console.error("Error: No commands found in workflow");
2226
2459
  process.exit(1);
2227
2460
  }
2228
-
2461
+
2229
2462
  // Apply arg defaults
2230
2463
  const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
2231
-
2464
+
2232
2465
  // Validate with --dry-run
2233
2466
  if (dryRun) {
2234
2467
  if (workflowName) {
@@ -2247,7 +2480,9 @@ if (args[0] === "do") {
2247
2480
  }
2248
2481
  process.exit(0);
2249
2482
  }
2250
-
2483
+
2484
+ installBrowserLock(parseBrowserLockOptions(doArgs.includes("--no-lock")), endpoint);
2485
+
2251
2486
  if (!wantJson) {
2252
2487
  if (workflowName) {
2253
2488
  console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
@@ -2255,9 +2490,12 @@ if (args[0] === "do") {
2255
2490
  console.log(`Running workflow (${steps.length} steps)...\n`);
2256
2491
  }
2257
2492
  }
2258
-
2493
+
2259
2494
  const runWorkflow = async () => {
2260
- const result = await executeDoSteps(steps, {
2495
+ let transport;
2496
+ try {
2497
+ transport = await openClientTransport(endpoint);
2498
+ const result = await executeDoSteps(steps, {
2261
2499
  onError,
2262
2500
  autoWait: !noAutoWait,
2263
2501
  stepDelay,
@@ -2266,37 +2504,47 @@ if (args[0] === "do") {
2266
2504
  context: {
2267
2505
  tabId,
2268
2506
  windowId,
2507
+ endpoint,
2508
+ transport,
2269
2509
  },
2270
- });
2271
-
2510
+ });
2511
+
2272
2512
  // Print summary
2273
2513
  if (wantJson) {
2274
2514
  console.log(JSON.stringify(result, null, 2));
2275
- process.exit(result.status === "completed" ? 0 : 1);
2515
+ return result.status === "completed" ? 0 : 1;
2276
2516
  }
2277
-
2517
+
2278
2518
  console.log("");
2279
2519
  if (result.status === "completed") {
2280
2520
  console.log(`Completed: ${result.completedSteps}/${result.totalSteps} steps (${result.totalMs}ms)`);
2281
- process.exit(0);
2521
+ return 0;
2282
2522
  } else if (result.status === "partial") {
2283
2523
  console.log(`Partial: ${result.completedSteps}/${result.totalSteps} steps completed, ${result.failed} failed`);
2284
- process.exit(1);
2524
+ return 1;
2285
2525
  } else {
2286
2526
  console.error(`Failed: ${result.completedSteps}/${result.totalSteps} steps completed`);
2287
2527
  if (result.error) console.error(`Error: ${result.error}`);
2288
- process.exit(1);
2528
+ return 1;
2529
+ }
2530
+ } finally {
2531
+ transport?.close();
2289
2532
  }
2290
2533
  };
2291
-
2292
- runWorkflow();
2534
+
2535
+ runWorkflow()
2536
+ .then((code) => process.exit(code))
2537
+ .catch((error) => {
2538
+ console.error(`Error: ${error.message}`);
2539
+ process.exit(1);
2540
+ });
2293
2541
  return;
2294
2542
  }
2295
2543
 
2296
2544
  // Handle workflow management commands
2297
2545
  if (args[0] === "workflow.list") {
2298
2546
  const workflows = listWorkflows();
2299
-
2547
+
2300
2548
  if (workflows.length === 0) {
2301
2549
  console.log("No workflows found.");
2302
2550
  console.log(`\nWorkflow directories:`);
@@ -2306,13 +2554,13 @@ if (args[0] === "workflow.list") {
2306
2554
  console.log(`\nCreate a workflow JSON file in one of these directories.`);
2307
2555
  process.exit(0);
2308
2556
  }
2309
-
2557
+
2310
2558
  // Group by scope
2311
2559
  const byScope = { project: [], user: [] };
2312
2560
  for (const w of workflows) {
2313
2561
  byScope[w.scope].push(w);
2314
2562
  }
2315
-
2563
+
2316
2564
  if (byScope.user.length > 0) {
2317
2565
  console.log(`User Workflows (~/.surf/workflows/):`);
2318
2566
  for (const w of byScope.user) {
@@ -2321,7 +2569,7 @@ if (args[0] === "workflow.list") {
2321
2569
  }
2322
2570
  console.log("");
2323
2571
  }
2324
-
2572
+
2325
2573
  if (byScope.project.length > 0) {
2326
2574
  console.log(`Project Workflows (./.surf/workflows/):`);
2327
2575
  for (const w of byScope.project) {
@@ -2330,7 +2578,7 @@ if (args[0] === "workflow.list") {
2330
2578
  }
2331
2579
  console.log("");
2332
2580
  }
2333
-
2581
+
2334
2582
  console.log(`Run 'surf workflow.info <name>' for details.`);
2335
2583
  process.exit(0);
2336
2584
  }
@@ -2342,16 +2590,16 @@ if (args[0] === "workflow.info") {
2342
2590
  console.error("Usage: surf workflow.info <name>");
2343
2591
  process.exit(1);
2344
2592
  }
2345
-
2593
+
2346
2594
  const info = getWorkflowInfo(name);
2347
2595
  if (info.error) {
2348
2596
  console.error(`Error: ${info.error}`);
2349
2597
  process.exit(1);
2350
2598
  }
2351
-
2599
+
2352
2600
  console.log(`${info.name}${info.description ? ` - ${info.description}` : ''}`);
2353
2601
  console.log("");
2354
-
2602
+
2355
2603
  // Arguments
2356
2604
  if (info.args && Object.keys(info.args).length > 0) {
2357
2605
  console.log("Arguments:");
@@ -2364,18 +2612,18 @@ if (args[0] === "workflow.info") {
2364
2612
  }
2365
2613
  console.log("");
2366
2614
  }
2367
-
2615
+
2368
2616
  // Steps
2369
2617
  console.log(`Steps (${info.steps.length}):`);
2370
2618
  info.steps.forEach((step, i) => {
2371
2619
  console.log(` ${i + 1}. ${formatStep(step)}`);
2372
2620
  });
2373
2621
  console.log("");
2374
-
2622
+
2375
2623
  // Location
2376
2624
  console.log(`Location: ${info.path}`);
2377
2625
  console.log("");
2378
-
2626
+
2379
2627
  // Example run command
2380
2628
  const argExample = Object.entries(info.args || {})
2381
2629
  .filter(([_, spec]) => spec.required)
@@ -2383,7 +2631,7 @@ if (args[0] === "workflow.info") {
2383
2631
  .join(' ');
2384
2632
  console.log(`Run:`);
2385
2633
  console.log(` surf do ${name}${argExample ? ' ' + argExample : ''}`);
2386
-
2634
+
2387
2635
  process.exit(0);
2388
2636
  }
2389
2637
 
@@ -2394,9 +2642,9 @@ if (args[0] === "workflow.validate") {
2394
2642
  console.error("Usage: surf workflow.validate <file>");
2395
2643
  process.exit(1);
2396
2644
  }
2397
-
2645
+
2398
2646
  const result = validateWorkflowFile(filePath);
2399
-
2647
+
2400
2648
  if (result.valid) {
2401
2649
  console.log(`✓ Valid workflow: ${filePath}`);
2402
2650
  console.log(` Name: ${result.workflow.name || '(unnamed)'}`);
@@ -2414,9 +2662,7 @@ if (args[0] === "workflow.validate") {
2414
2662
  }
2415
2663
  }
2416
2664
 
2417
- const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait"];
2418
-
2419
- const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"];
2665
+ const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "full-page", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait", "no-lock"];
2420
2666
 
2421
2667
  const parseArgs = (rawArgs) => {
2422
2668
  const result = { positional: [], options: {} };
@@ -2462,6 +2708,22 @@ let { positional, options } = parseArgs(args);
2462
2708
  let tool = positional[0];
2463
2709
  let firstArg = positional[1];
2464
2710
 
2711
+ if (tool === "cookie" && firstArg) {
2712
+ const cookieSubcommands = {
2713
+ list: "cookie.list",
2714
+ get: "cookie.get",
2715
+ set: "cookie.set",
2716
+ clear: "cookie.clear",
2717
+ delete: "cookie.clear",
2718
+ };
2719
+ const cookieTool = cookieSubcommands[firstArg];
2720
+ if (cookieTool) {
2721
+ tool = cookieTool;
2722
+ positional = [tool, ...positional.slice(2)];
2723
+ firstArg = positional[1];
2724
+ }
2725
+ }
2726
+
2465
2727
  if (!tool) {
2466
2728
  console.error("Error: No command specified");
2467
2729
  process.exit(1);
@@ -2478,9 +2740,14 @@ tool = ALIASES[tool] || tool;
2478
2740
  // Auto-save screenshots to temp file when no --output specified
2479
2741
  // This ensures agents always get a usable file path, not just an in-memory ID
2480
2742
  // Can be disabled with --no-save flag or autoSaveScreenshots: false in surf.json
2743
+ if (options["full-page"] === true) {
2744
+ options.fullpage = true;
2745
+ delete options["full-page"];
2746
+ }
2747
+
2481
2748
  const config = loadConfig();
2482
2749
  const autoSaveEnabled = config.autoSaveScreenshots !== false && !options["no-save"];
2483
- if (tool === "screenshot" && !options.output && !options.savePath && autoSaveEnabled) {
2750
+ if (tool === "screenshot" && !options.output && !options.savePath && firstArg === undefined && autoSaveEnabled) {
2484
2751
  options.savePath = path.join(SURF_TMP, `surf-snap-${Date.now()}.png`);
2485
2752
  }
2486
2753
 
@@ -2532,6 +2799,7 @@ const PRIMARY_ARG_MAP = {
2532
2799
  "tab.switch": "id",
2533
2800
  close_tab: "tab_id",
2534
2801
  "tab.close": "id",
2802
+ "tab.move": "id",
2535
2803
  "tab.name": "name",
2536
2804
  "tab.unname": "name",
2537
2805
  scroll_to_position: "position",
@@ -2541,6 +2809,8 @@ const PRIMARY_ARG_MAP = {
2541
2809
  "emulate.cpu": "rate",
2542
2810
  search: "term",
2543
2811
  find: "term",
2812
+ "cookie.get": "name",
2813
+ "cookie.clear": "name",
2544
2814
  "wait.element": "selector",
2545
2815
  "wait.url": "pattern",
2546
2816
  zoom: "level",
@@ -2561,7 +2831,20 @@ const PRIMARY_ARG_MAP = {
2561
2831
  "select": "selector",
2562
2832
  };
2563
2833
 
2564
- const toolArgs = { ...options };
2834
+ let toolArgs = { ...options };
2835
+
2836
+ if (tool === "scroll" && firstArg) {
2837
+ if (firstArg === "top" || firstArg === "bottom") {
2838
+ tool = `scroll.${firstArg}`;
2839
+ firstArg = undefined;
2840
+ } else if (["up", "down", "left", "right"].includes(firstArg)) {
2841
+ if (toolArgs.direction === undefined) toolArgs.direction = firstArg;
2842
+ if (positional[2] !== undefined && /^-?\d+$/.test(positional[2]) && toolArgs.amount === undefined && toolArgs.scroll_amount === undefined) {
2843
+ toolArgs.scroll_pixels = parseInt(positional[2], 10);
2844
+ }
2845
+ firstArg = undefined;
2846
+ }
2847
+ }
2565
2848
 
2566
2849
  if (tool === "click" && firstArg) {
2567
2850
  if (/^e\d+$/.test(firstArg)) {
@@ -2574,6 +2857,30 @@ if (tool === "click" && firstArg) {
2574
2857
  }
2575
2858
  }
2576
2859
 
2860
+ if (tool === "resize") {
2861
+ if (firstArg !== undefined && toolArgs.width === undefined) {
2862
+ let val = firstArg;
2863
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
2864
+ toolArgs.width = val;
2865
+ }
2866
+ if (positional[2] !== undefined && toolArgs.height === undefined) {
2867
+ let val = positional[2];
2868
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
2869
+ toolArgs.height = val;
2870
+ }
2871
+ firstArg = undefined;
2872
+ }
2873
+
2874
+ if (tool === "screenshot" && firstArg !== undefined && toolArgs.output === undefined && toolArgs.savePath === undefined) {
2875
+ toolArgs.savePath = firstArg;
2876
+ firstArg = undefined;
2877
+ }
2878
+
2879
+ if (tool === "record" && firstArg !== undefined && toolArgs.output === undefined) {
2880
+ toolArgs.output = firstArg;
2881
+ firstArg = undefined;
2882
+ }
2883
+
2577
2884
  if (firstArg !== undefined) {
2578
2885
  const primaryKey = PRIMARY_ARG_MAP[tool];
2579
2886
  if (primaryKey && toolArgs[primaryKey] === undefined) {
@@ -2585,7 +2892,7 @@ if (firstArg !== undefined) {
2585
2892
  }
2586
2893
  }
2587
2894
 
2588
- if (tool === "js" && toolArgs.file) {
2895
+ if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2589
2896
  try {
2590
2897
  toolArgs.code = fs.readFileSync(toolArgs.file, "utf8");
2591
2898
  delete toolArgs.file;
@@ -2595,6 +2902,17 @@ if (tool === "js" && toolArgs.file) {
2595
2902
  }
2596
2903
  }
2597
2904
 
2905
+ if (tool === "batch" && toolArgs.file) {
2906
+ try {
2907
+ const parsed = JSON.parse(fs.readFileSync(toolArgs.file, "utf8"));
2908
+ toolArgs.actions = parsed;
2909
+ delete toolArgs.file;
2910
+ } catch (e) {
2911
+ console.error(`Error: Failed to read batch file: ${e.message}`);
2912
+ process.exit(1);
2913
+ }
2914
+ }
2915
+
2598
2916
  // Handle select command: capture multiple values after selector
2599
2917
  if (tool === "select" && positional.length > 2) {
2600
2918
  const values = positional.slice(2); // All args after "select <selector>"
@@ -2646,6 +2964,9 @@ delete toolArgs["no-screenshot"];
2646
2964
  const softFail = toolArgs["soft-fail"] === true;
2647
2965
  delete toolArgs["soft-fail"];
2648
2966
 
2967
+ const lockOptions = parseBrowserLockOptions(toolArgs["no-lock"] === true);
2968
+ delete toolArgs["no-lock"];
2969
+
2649
2970
  if (!noScreenshot && AUTO_SCREENSHOT_TOOLS.includes(tool)) {
2650
2971
  toolArgs.autoScreenshot = true;
2651
2972
  }
@@ -2656,23 +2977,26 @@ if (tool === "aistudio.build" && outputPath) {
2656
2977
  toolArgs.output = path.resolve(outputPath);
2657
2978
  }
2658
2979
  if (tool === "gemini") {
2659
- if (outputPath) toolArgs.output = path.resolve(outputPath);
2660
- if (toolArgs["generate-image"] && typeof toolArgs["generate-image"] === "string") {
2661
- toolArgs["generate-image"] = path.resolve(toolArgs["generate-image"]);
2662
- }
2663
- if (toolArgs["edit-image"] && typeof toolArgs["edit-image"] === "string") {
2664
- toolArgs["edit-image"] = path.resolve(toolArgs["edit-image"]);
2665
- }
2666
- if (toolArgs.file && typeof toolArgs.file === "string") {
2667
- toolArgs.file = path.resolve(toolArgs.file);
2980
+ if (outputPath !== undefined) toolArgs.output = outputPath;
2981
+ if (toolArgs.model) {
2982
+ const known = ["gemini-3.1-pro", "gemini-3.5-flash", "gemini-3.1-flash-lite"];
2983
+ if (!known.includes(toolArgs.model)) {
2984
+ process.stderr.write(
2985
+ `warning: unknown Gemini model "${toolArgs.model}"; using "gemini-3.1-pro". Available: ${known.join(", ")}\n`,
2986
+ );
2987
+ }
2668
2988
  }
2669
2989
  }
2990
+ if (tool === "network.export" && outputPath !== undefined) {
2991
+ toolArgs.output = outputPath;
2992
+ }
2993
+
2994
+ if ((tool === "screenshot" || tool === "record" || tool === "perf-audit") && outputPath && typeof outputPath !== "string") {
2995
+ console.error("Error: --output requires a file path");
2996
+ process.exit(1);
2997
+ }
2670
2998
 
2671
2999
  if (tool === "screenshot" && outputPath) {
2672
- if (typeof outputPath !== "string") {
2673
- console.error("Error: --output requires a file path");
2674
- process.exit(1);
2675
- }
2676
3000
  toolArgs.savePath = outputPath;
2677
3001
  if (options.full) toolArgs.full = true;
2678
3002
  if (options["max-size"]) toolArgs["max-size"] = options["max-size"];
@@ -2688,7 +3012,9 @@ const streamMode = toolArgs.stream === true;
2688
3012
  delete toolArgs.stream;
2689
3013
 
2690
3014
  const streamLevel = toolArgs.level;
2691
- delete toolArgs.level;
3015
+ if (tool === "console" || tool === "network") {
3016
+ delete toolArgs.level;
3017
+ }
2692
3018
 
2693
3019
  const streamFilter = toolArgs.filter;
2694
3020
  delete toolArgs.filter;
@@ -2696,11 +3022,15 @@ delete toolArgs.filter;
2696
3022
  let finalTool = tool;
2697
3023
  if (methodFlag === "js") {
2698
3024
  if (tool === "type") {
2699
- if (!toolArgs.selector) {
2700
- console.error("Error: --selector or --into required for type with --method js");
2701
- process.exit(1);
3025
+ if (toolArgs.ref) {
3026
+ finalTool = "type";
3027
+ } else {
3028
+ if (!toolArgs.selector) {
3029
+ console.error("Error: --selector, --into, or --ref required for type with --method js");
3030
+ process.exit(1);
3031
+ }
3032
+ finalTool = "smart_type";
2702
3033
  }
2703
- finalTool = "smart_type";
2704
3034
  } else if (tool === "click") {
2705
3035
  if (!toolArgs.selector) {
2706
3036
  console.error("Error: --selector required for click with --method js");
@@ -2711,8 +3041,13 @@ if (methodFlag === "js") {
2711
3041
  finalTool = "js";
2712
3042
  }
2713
3043
  } else if (methodFlag === "cdp") {
3044
+ if (tool === "type" && (toolArgs.selector || toolArgs.ref)) {
3045
+ console.error("Error: --method cdp types at the current focus and cannot be combined with --into, --selector, or --ref");
3046
+ process.exit(1);
3047
+ }
2714
3048
  if (tool === "smart_type") {
2715
- finalTool = "type";
3049
+ console.error("Error: smart_type uses the JS input path and cannot be combined with --method cdp");
3050
+ process.exit(1);
2716
3051
  }
2717
3052
  }
2718
3053
 
@@ -2730,8 +3065,10 @@ if (streamMode && (tool === "console" || tool === "network")) {
2730
3065
 
2731
3066
  let connectionTimeout = null;
2732
3067
  let receivedData = false;
3068
+ let streamWriter;
2733
3069
 
2734
- const sock = net.createConnection(SOCKET_PATH, () => {
3070
+ const sock = connectEndpoint(endpoint, () => {
3071
+ streamWriter = createSocketWriter(sock, { onOverflow: ({ error }) => sock.destroy(error) });
2735
3072
  const req = {
2736
3073
  type: "stream_request",
2737
3074
  streamType,
@@ -2739,7 +3076,11 @@ if (streamMode && (tool === "console" || tool === "network")) {
2739
3076
  id: "cli-stream-" + Date.now(),
2740
3077
  ...globalOpts,
2741
3078
  };
2742
- sock.write(JSON.stringify(req) + "\n");
3079
+ streamWriter.send(req).catch((error) => sock.destroy(error));
3080
+ if (connectionTimeout) {
3081
+ clearTimeout(connectionTimeout);
3082
+ connectionTimeout = null;
3083
+ }
2743
3084
  connectionTimeout = setTimeout(() => {
2744
3085
  if (!receivedData) {
2745
3086
  console.error("Error: Stream connection timeout (10s) - no data received");
@@ -2749,61 +3090,62 @@ if (streamMode && (tool === "console" || tool === "network")) {
2749
3090
  }, 10000);
2750
3091
  });
2751
3092
 
2752
- let buf = "";
2753
- sock.on("data", (d) => {
2754
- if (!receivedData) {
2755
- receivedData = true;
2756
- if (connectionTimeout) {
2757
- clearTimeout(connectionTimeout);
2758
- connectionTimeout = null;
2759
- }
2760
- }
2761
- buf += d.toString();
2762
- const lines = buf.split("\n");
2763
- buf = lines.pop();
2764
- for (const line of lines) {
2765
- if (!line.trim()) continue;
2766
- try {
2767
- const msg = JSON.parse(line);
2768
- if (msg.error) {
2769
- console.error("Error:", msg.error);
2770
- sock.end();
2771
- process.exit(1);
2772
- }
2773
- if (msg.type === "extension_disconnected") {
2774
- console.error(msg.message);
2775
- sock.end();
2776
- process.exit(1);
2777
- }
2778
- if (msg.type === "stream_started") {
2779
- continue;
2780
- }
2781
- if (msg.type === "console_event") {
2782
- const { level, text, timestamp } = msg;
2783
- if (streamLevel && level !== streamLevel) continue;
2784
- console.log(`[console] [${level}] ${formatTime(timestamp)} ${text}`);
2785
- } else if (msg.type === "network_event") {
2786
- const { method, url, status, duration } = msg;
2787
- if (streamFilter && !url.includes(streamFilter)) continue;
2788
- const statusStr = status !== undefined ? status : "...";
2789
- const durationStr = duration !== undefined ? ` (${duration}ms)` : "";
2790
- console.log(`[network] ${method} ${url} ${statusStr}${durationStr}`);
3093
+ connectionTimeout = setTimeout(() => {
3094
+ console.error(`Error: Stream connection timeout (10s) - could not connect to ${endpoint.display}`);
3095
+ sock.destroy();
3096
+ process.exit(1);
3097
+ }, 10000);
3098
+
3099
+ const parser = createFrameParser({
3100
+ onFrame(msg) {
3101
+ if (!receivedData) {
3102
+ receivedData = true;
3103
+ if (connectionTimeout) {
3104
+ clearTimeout(connectionTimeout);
3105
+ connectionTimeout = null;
2791
3106
  }
2792
- } catch {}
2793
- }
3107
+ }
3108
+ if (msg.error) {
3109
+ console.error("Error:", msg.error);
3110
+ sock.end();
3111
+ process.exit(1);
3112
+ }
3113
+ if (msg.type === "extension_disconnected") {
3114
+ console.error(msg.message);
3115
+ sock.end();
3116
+ process.exit(1);
3117
+ }
3118
+ if (msg.type === "stream_started") return;
3119
+ if (msg.type === "console_event") {
3120
+ const { level, text, timestamp } = msg;
3121
+ if (streamLevel && level !== streamLevel) return;
3122
+ console.log(`[console] [${level}] ${formatTime(timestamp)} ${text}`);
3123
+ } else if (msg.type === "network_event") {
3124
+ const { method, url, status, duration } = msg;
3125
+ if (streamFilter && !url.includes(streamFilter)) return;
3126
+ const statusStr = status !== undefined ? status : "...";
3127
+ const durationStr = duration !== undefined ? ` (${duration}ms)` : "";
3128
+ console.log(`[network] ${method} ${url} ${statusStr}${durationStr}`);
3129
+ }
3130
+ },
3131
+ onError(error) {
3132
+ if (connectionTimeout) clearTimeout(connectionTimeout);
3133
+ console.error("Error:", error.message);
3134
+ sock.destroy();
3135
+ process.exit(1);
3136
+ },
2794
3137
  });
3138
+ sock.on("data", (data) => parser.push(data));
2795
3139
 
2796
3140
  sock.on("error", (e) => {
2797
- if (e.code === "ENOENT") {
2798
- console.error("Error: Socket not found. Is Chrome running with the extension?");
2799
- } else {
2800
- console.error("Error:", e.message);
2801
- }
3141
+ if (connectionTimeout) clearTimeout(connectionTimeout);
3142
+ console.error("Error:", formatEndpointError(e, endpoint, formatSocketError));
2802
3143
  process.exit(1);
2803
3144
  });
2804
3145
 
2805
3146
  process.on("SIGINT", () => {
2806
- sock.write(JSON.stringify({ type: "stream_stop" }) + "\n");
3147
+ if (connectionTimeout) clearTimeout(connectionTimeout);
3148
+ streamWriter?.send({ type: "stream_stop" }).catch(() => {});
2807
3149
  sock.end();
2808
3150
  process.exit(0);
2809
3151
  });
@@ -2811,6 +3153,17 @@ if (streamMode && (tool === "console" || tool === "network")) {
2811
3153
  return;
2812
3154
  }
2813
3155
 
3156
+ let transferPlan;
3157
+ try {
3158
+ transferPlan = endpoint.kind === "remote" ? prepareRemoteTool(finalTool, toolArgs) : (() => { const args = validateLocalToolPaths(finalTool, toolArgs); return { args, uploads: [], downloads: [] }; })();
3159
+ } catch (error) {
3160
+ const message = finalTool === "record" && endpoint.kind === "remote"
3161
+ ? `record is not supported with remote endpoint ${endpoint.display}`
3162
+ : error.message;
3163
+ console.error(`Error: ${message}`);
3164
+ process.exit(1);
3165
+ }
3166
+ toolArgs = transferPlan.args;
2814
3167
  const request = {
2815
3168
  type: "tool_request",
2816
3169
  method: "execute_tool",
@@ -2819,47 +3172,149 @@ const request = {
2819
3172
  ...globalOpts,
2820
3173
  };
2821
3174
 
2822
- const sendRequest = (toolName, toolArgs = {}) => {
2823
- return new Promise((resolve, reject) => {
2824
- const sock = net.createConnection(SOCKET_PATH, () => {
2825
- const req = {
2826
- type: "tool_request",
2827
- method: "execute_tool",
2828
- params: { tool: toolName, args: toolArgs },
2829
- id: "cli-" + Date.now() + "-" + Math.random(),
2830
- ...globalOpts,
2831
- };
2832
- sock.write(JSON.stringify(req) + "\n");
2833
- });
2834
- let buf = "";
2835
- sock.on("data", (d) => {
2836
- buf += d.toString();
2837
- const lines = buf.split("\n");
2838
- buf = lines.pop();
2839
- for (const line of lines) {
2840
- if (!line.trim()) continue;
2841
- try {
2842
- const resp = JSON.parse(line);
2843
- if (resp.type === "extension_disconnected") {
2844
- sock.end();
2845
- reject(new Error(resp.message));
2846
- return;
2847
- }
2848
- sock.end();
2849
- resolve(resp);
2850
- } catch {
2851
- sock.end();
2852
- reject(new Error("Invalid JSON"));
2853
- }
2854
- }
2855
- });
2856
- sock.on("error", (e) => reject(e));
2857
- let timeoutId;
2858
- timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, 5000);
2859
- sock.on("close", () => clearTimeout(timeoutId));
2860
- });
3175
+ const sendRequest = async (toolName, toolArgs = {}, timeoutMs = 5000) => {
3176
+ const transport = await openClientTransport(endpoint, { requestTimeoutMs: timeoutMs });
3177
+ try {
3178
+ const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
3179
+ return await transport.request({
3180
+ type: "tool_request",
3181
+ method: "execute_tool",
3182
+ params: { tool: toolName, args: prepared.args },
3183
+ id: "cli-" + Date.now() + "-" + Math.random(),
3184
+ ...globalOpts,
3185
+ }, timeoutMs, prepared);
3186
+ } finally {
3187
+ await transport.close();
3188
+ }
2861
3189
  };
2862
3190
 
3191
+ function parseRecordNumber(value, fallback, name, min, max) {
3192
+ if (value === undefined) return fallback;
3193
+ if (typeof value === "boolean") throw new Error(`${name} must be a number`);
3194
+ const parsed = Number(value);
3195
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
3196
+ throw new Error(`${name} must be between ${min} and ${max}`);
3197
+ }
3198
+ return parsed;
3199
+ }
3200
+
3201
+ function parseRecordRect(value) {
3202
+ if (value === undefined) return null;
3203
+ if (typeof value !== "string") throw new Error("rect must be x,y,width,height");
3204
+ const parts = value.split(",").map((part) => Number(part.trim()));
3205
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
3206
+ throw new Error("rect must be x,y,width,height");
3207
+ }
3208
+ const [x, y, width, height] = parts;
3209
+ if (x < 0 || y < 0 || width <= 0 || height <= 0) {
3210
+ throw new Error("rect must use non-negative x/y and positive width/height");
3211
+ }
3212
+ return { x, y, width, height, crop: `${width}x${height}+${x}+${y}` };
3213
+ }
3214
+
3215
+ function assertToolOk(response, context) {
3216
+ if (!response?.error) return;
3217
+ const message = response.error.content?.[0]?.text || response.error.message || JSON.stringify(response.error);
3218
+ throw new Error(`${context}: ${message}`);
3219
+ }
3220
+
3221
+ function assembleRecordGif(framePaths, output, fps, rect) {
3222
+ const delay = Math.max(1, Math.round(100 / fps));
3223
+ const args = ["-delay", String(delay), "-loop", "0", ...framePaths];
3224
+ if (rect) args.push("-crop", rect.crop, "+repage");
3225
+ args.push(output);
3226
+
3227
+ try {
3228
+ execFileSync("magick", args, { stdio: "pipe" });
3229
+ return "magick";
3230
+ } catch (magickError) {
3231
+ try {
3232
+ execFileSync("convert", args, { stdio: "pipe" });
3233
+ return "convert";
3234
+ } catch (convertError) {
3235
+ const detail = convertError && convertError.message ? convertError.message : String(convertError);
3236
+ throw new Error(`Failed to assemble GIF with ImageMagick. Install ImageMagick (magick or convert). Last error: ${detail}`);
3237
+ }
3238
+ }
3239
+ }
3240
+
3241
+ async function runRecord() {
3242
+ const durationMs = parseRecordNumber(toolArgs.duration, 2000, "duration", 100, 10000);
3243
+ const fps = parseRecordNumber(toolArgs.fps, 10, "fps", 1, 30);
3244
+ const rect = parseRecordRect(toolArgs.rect);
3245
+ const output = path.resolve(outputPath || path.join(SURF_TMP, `surf-record-${Date.now()}.gif`));
3246
+ const frameCount = Math.max(1, Math.ceil((durationMs / 1000) * fps));
3247
+ const frameDir = fs.mkdtempSync(path.join(SURF_TMP, "surf-record-"));
3248
+ const framePaths = [];
3249
+ let trigger = null;
3250
+
3251
+ try {
3252
+ if (toolArgs.trigger !== undefined) {
3253
+ trigger = await runRecordTrigger(toolArgs.trigger);
3254
+ }
3255
+
3256
+ const startedAt = Date.now();
3257
+ for (let i = 0; i < frameCount; i++) {
3258
+ const framePath = path.join(frameDir, `frame-${String(i).padStart(4, "0")}.png`);
3259
+ const response = await sendRequest("screenshot", {
3260
+ savePath: framePath,
3261
+ full: toolArgs.full,
3262
+ "max-size": toolArgs["max-size"],
3263
+ }, 30000);
3264
+ assertToolOk(response, `record frame ${i + 1}`);
3265
+ framePaths.push(framePath);
3266
+
3267
+ if (i < frameCount - 1) {
3268
+ const nextFrameAt = startedAt + Math.round(((i + 1) * durationMs) / frameCount);
3269
+ const waitMs = nextFrameAt - Date.now();
3270
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
3271
+ }
3272
+ }
3273
+
3274
+ fs.mkdirSync(path.dirname(output), { recursive: true });
3275
+ const imageMagick = assembleRecordGif(framePaths, output, fps, rect);
3276
+ const result = { output, frames: framePaths.length, durationMs, fps, imageMagick, ...(trigger && { trigger }), ...(rect && { rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } }) };
3277
+
3278
+ if (wantJson) {
3279
+ console.log(JSON.stringify(result, null, 2));
3280
+ } else {
3281
+ console.log(`Saved recording to ${output} (${result.frames} frames, ${durationMs}ms @ ${fps}fps)`);
3282
+ }
3283
+ } finally {
3284
+ fs.rmSync(frameDir, { recursive: true, force: true });
3285
+ }
3286
+ }
3287
+
3288
+ async function runRecordTrigger(trigger) {
3289
+ if (typeof trigger !== "string") throw new Error("trigger must be action:target");
3290
+ const separator = trigger.indexOf(":");
3291
+ if (separator === -1) throw new Error("trigger must be action:target");
3292
+ const action = trigger.slice(0, separator).trim();
3293
+ const target = trigger.slice(separator + 1).trim();
3294
+ if (!action || !target) throw new Error("trigger must be action:target");
3295
+
3296
+ if (action === "click") {
3297
+ const response = await sendRequest("click", { selector: target }, 30000);
3298
+ assertToolOk(response, "record trigger");
3299
+ return { action, selector: target };
3300
+ }
3301
+
3302
+ if (action === "scroll") {
3303
+ let response;
3304
+ if (["up", "down", "left", "right"].includes(target)) {
3305
+ response = await sendRequest("scroll", { direction: target }, 30000);
3306
+ } else if (target === "top" || target === "bottom") {
3307
+ response = await sendRequest(`scroll.${target}`, {}, 30000);
3308
+ } else {
3309
+ response = await sendRequest("scroll.bottom", { selector: target }, 30000);
3310
+ }
3311
+ assertToolOk(response, "record trigger");
3312
+ return { action, target };
3313
+ }
3314
+
3315
+ throw new Error("trigger action must be click or scroll");
3316
+ }
3317
+
2863
3318
  const performAutoCapture = async () => {
2864
3319
  const timestamp = Date.now();
2865
3320
  const screenshotPath = path.join(SURF_TMP, `surf-error-${timestamp}.png`);
@@ -2896,61 +3351,82 @@ const performAutoCapture = async () => {
2896
3351
  }
2897
3352
  };
2898
3353
 
2899
- const socket = net.createConnection(SOCKET_PATH, () => {
2900
- socket.write(JSON.stringify(request) + "\n");
2901
- });
3354
+ if (finalTool === "record") {
3355
+ if (endpoint.kind === "remote") {
3356
+ console.error(`Error: record is not supported with remote endpoint ${endpoint.display}`);
3357
+ process.exit(1);
3358
+ }
3359
+ installBrowserLock(lockOptions, endpoint);
3360
+ runRecord()
3361
+ .then(() => process.exit(0))
3362
+ .catch((error) => {
3363
+ console.error("Error:", error && error.message ? error.message : String(error));
3364
+ process.exit(1);
3365
+ });
3366
+ return;
3367
+ }
3368
+
3369
+ installBrowserLock(lockOptions, endpoint);
3370
+ let socket;
3371
+ let timeout;
2902
3372
 
2903
- const AI_TOOLS = ["smoke", "chatgpt", "gemini", "perplexity", "grok", "aistudio", "aistudio.build", "ai"];
2904
- let requestTimeout = AI_TOOLS.includes(tool) ? 300000 : 30000;
2905
- if (tool === "aistudio.build") {
2906
- const userTimeoutSec = parseInt(options.timeout || "600", 10);
2907
- requestTimeout = (userTimeoutSec * 1000) + 60000;
3373
+ if (endpoint.kind === "remote") {
3374
+ socket = { end() {}, destroy() {} };
3375
+ const requestTimeout = resolveRequestDeadlineMs(tool, toolArgs);
3376
+ openClientTransport(endpoint, { requestTimeoutMs: requestTimeout })
3377
+ .then(async (transport) => {
3378
+ try {
3379
+ const response = await transport.request(request, requestTimeout, transferPlan);
3380
+ await handleResponse(response);
3381
+ } finally {
3382
+ await transport.close();
3383
+ }
3384
+ })
3385
+ .catch((error) => {
3386
+ console.error(`Error: ${error.message}`);
3387
+ process.exit(1);
3388
+ });
3389
+ return;
2908
3390
  }
2909
- const timeout = setTimeout(() => {
3391
+
3392
+ socket = connectEndpoint(endpoint, () => {
3393
+ writeFrame(socket, request).catch((error) => socket.destroy(error));
3394
+ });
3395
+
3396
+ const requestTimeout = resolveRequestDeadlineMs(tool, options);
3397
+ timeout = setTimeout(() => {
2910
3398
  console.error(`Error: Request timed out (${requestTimeout / 1000}s)`);
2911
3399
  socket.destroy();
2912
3400
  process.exit(1);
2913
3401
  }, requestTimeout);
2914
3402
 
2915
- let buffer = "";
2916
-
2917
- socket.on("data", (data) => {
2918
- buffer += data.toString();
2919
- const lines = buffer.split("\n");
2920
- buffer = lines.pop();
2921
-
2922
- for (const line of lines) {
2923
- if (!line.trim()) continue;
2924
- try {
2925
- const msg = JSON.parse(line);
2926
-
2927
- if (msg.type === "extension_disconnected") {
2928
- clearTimeout(timeout);
2929
- console.error(msg.message);
2930
- socket.end();
2931
- process.exit(1);
2932
- }
2933
-
2934
- handleResponse(msg).catch((err) => {
2935
- console.error("Handler error:", err.message);
2936
- process.exit(1);
2937
- });
2938
- } catch (e) {
2939
- console.error("Invalid JSON response:", line);
3403
+ const responseParser = createFrameParser({
3404
+ onFrame(msg) {
3405
+ if (msg.type === "extension_disconnected") {
3406
+ clearTimeout(timeout);
3407
+ console.error(msg.message);
3408
+ socket.end();
2940
3409
  process.exit(1);
2941
3410
  }
2942
- }
3411
+ if (msg.id !== request.id) return;
3412
+ handleResponse(msg).catch((err) => {
3413
+ console.error("Handler error:", err.message);
3414
+ process.exit(1);
3415
+ });
3416
+ },
3417
+ onError(error) {
3418
+ clearTimeout(timeout);
3419
+ console.error("Invalid response frame:", error.message);
3420
+ socket.destroy();
3421
+ process.exit(1);
3422
+ },
2943
3423
  });
2944
3424
 
3425
+ socket.on("data", (data) => responseParser.push(data));
3426
+
2945
3427
  socket.on("error", (err) => {
2946
3428
  clearTimeout(timeout);
2947
- if (err.code === "ENOENT") {
2948
- console.error("Error: Socket not found. Is Chrome running with the extension?");
2949
- } else if (err.code === "ECONNREFUSED") {
2950
- console.error("Error: Connection refused. Native host not running.");
2951
- } else {
2952
- console.error("Error:", err.message);
2953
- }
3429
+ console.error("Error:", formatEndpointError(err, endpoint, formatSocketError));
2954
3430
  process.exit(1);
2955
3431
  });
2956
3432
 
@@ -2979,7 +3455,7 @@ async function handleResponse(response) {
2979
3455
  }
2980
3456
 
2981
3457
  const result = response.result?.content?.[0]?.text;
2982
-
3458
+
2983
3459
  let data;
2984
3460
  try {
2985
3461
  data = result ? JSON.parse(result) : response.result;
@@ -2991,6 +3467,17 @@ async function handleResponse(response) {
2991
3467
  data = { response: data };
2992
3468
  }
2993
3469
 
3470
+ if (tool === "perf-audit" && outputPath) {
3471
+ const saveTo = path.resolve(outputPath);
3472
+ fs.mkdirSync(path.dirname(saveTo), { recursive: true });
3473
+ fs.writeFileSync(saveTo, JSON.stringify(data ?? null, null, 2));
3474
+ if (!wantJson) {
3475
+ console.log(`Saved perf audit to ${saveTo}`);
3476
+ socket.end();
3477
+ process.exit(0);
3478
+ }
3479
+ }
3480
+
2994
3481
  if (wantJson) {
2995
3482
  console.log(JSON.stringify(data ?? null, null, 2));
2996
3483
  socket.end();
@@ -2998,14 +3485,14 @@ async function handleResponse(response) {
2998
3485
  }
2999
3486
 
3000
3487
  if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3001
- const saveTo = outputPath || toolArgs.savePath;
3488
+ const saveTo = transferPlan.downloads?.[0]?.destination || toolArgs.savePath || outputPath;
3002
3489
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
3003
-
3490
+
3004
3491
  const skipResize = options.full || toolArgs.full;
3005
3492
  const maxSize = parseInt(options["max-size"] || toolArgs["max-size"] || "1200", 10);
3006
3493
  const origWidth = data.width || 0;
3007
3494
  const origHeight = data.height || 0;
3008
-
3495
+
3009
3496
  if (!skipResize && (origWidth > maxSize || origHeight > maxSize)) {
3010
3497
  const result = resizeImage(saveTo, maxSize);
3011
3498
  if (result.success) {
@@ -3092,7 +3579,7 @@ async function handleResponse(response) {
3092
3579
  } else if (tool === "smoke" && data?.results) {
3093
3580
  const results = data.results;
3094
3581
  const summary = data.summary || { pass: 0, fail: 0, total: results.length };
3095
-
3582
+
3096
3583
  for (const r of results) {
3097
3584
  const status = r.status === "pass" ? "PASS" : "FAIL";
3098
3585
  const timeStr = r.time ? ` (${r.time}ms)` : "";
@@ -3104,10 +3591,10 @@ async function handleResponse(response) {
3104
3591
  }
3105
3592
  }
3106
3593
  }
3107
-
3594
+
3108
3595
  console.log("");
3109
3596
  console.log(`Summary: ${summary.pass} passed, ${summary.fail} failed, ${summary.total} total`);
3110
-
3597
+
3111
3598
  if (summary.fail > 0) {
3112
3599
  socket.end();
3113
3600
  process.exit(1);
@@ -3119,7 +3606,7 @@ async function handleResponse(response) {
3119
3606
  } else if (tool === "network" && (data?.entries || data?.requests)) {
3120
3607
  // Network list - handle both new (entries) and old (requests) formats
3121
3608
  const items = data.entries || data.requests || [];
3122
-
3609
+
3123
3610
  if (items.length === 0) {
3124
3611
  console.log("No network requests captured");
3125
3612
  } else if (data._format === 'raw') {