surf-cli 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/cli.cjs CHANGED
@@ -3,7 +3,7 @@ const net = require("net");
3
3
  const fs = require("fs");
4
4
  const path = require("path");
5
5
  const os = require("os");
6
- const { execSync } = require("child_process");
6
+ const { execFileSync, execSync } = require("child_process");
7
7
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
8
8
  const networkFormatters = require("./formatters/network.cjs");
9
9
  const networkStore = require("./network-store.cjs");
@@ -12,10 +12,52 @@ const { executeDoSteps } = require("./do-executor.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 { SOCKET_PATH, SURF_TMP, formatSocketError } = require("./socket-path.cjs");
16
+ const { acquireBrowserLock } = require("./browser-lock.cjs");
17
17
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
18
18
 
19
+ function parseBrowserLockOptions(noLockFlag) {
20
+ const noLock = noLockFlag || process.env.SURF_NO_LOCK === "1" || process.env.SURF_NO_LOCK === "true";
21
+ let timeoutMs;
22
+ if (process.env.SURF_LOCK_TIMEOUT_MS !== undefined) {
23
+ timeoutMs = Number(process.env.SURF_LOCK_TIMEOUT_MS);
24
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
25
+ console.error("Error: SURF_LOCK_TIMEOUT_MS must be a non-negative number");
26
+ process.exit(1);
27
+ }
28
+ }
29
+ return { noLock, timeoutMs };
30
+ }
31
+
32
+ function installBrowserLock({ noLock, timeoutMs }) {
33
+ let releaseBrowserLock = () => {};
34
+ if (!noLock) {
35
+ try {
36
+ const lock = acquireBrowserLock(SOCKET_PATH, SURF_TMP, { timeoutMs });
37
+ releaseBrowserLock = lock.release;
38
+ } catch (error) {
39
+ console.error("Error:", error && error.message ? error.message : String(error));
40
+ process.exit(1);
41
+ }
42
+ }
43
+
44
+ const release = () => {
45
+ const releaseCurrent = releaseBrowserLock;
46
+ releaseBrowserLock = () => {};
47
+ releaseCurrent();
48
+ };
49
+
50
+ process.once("exit", release);
51
+ process.once("SIGINT", () => {
52
+ release();
53
+ process.exit(130);
54
+ });
55
+ process.once("SIGTERM", () => {
56
+ release();
57
+ process.exit(143);
58
+ });
59
+ }
60
+
19
61
  // ============================================================================
20
62
  // Workflow Resolution and Management
21
63
  // ============================================================================
@@ -41,7 +83,7 @@ function resolveWorkflow(nameOrPath) {
41
83
  if (nameOrPath.includes('|')) {
42
84
  return { type: 'inline', content: nameOrPath };
43
85
  }
44
-
86
+
45
87
  // Check if it's a direct file path (with extension or path separator)
46
88
  if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
47
89
  if (fs.existsSync(nameOrPath)) {
@@ -49,17 +91,17 @@ function resolveWorkflow(nameOrPath) {
49
91
  }
50
92
  return { type: 'not_found', name: nameOrPath };
51
93
  }
52
-
94
+
53
95
  // Look up by name in workflow directories
54
96
  const searchDirs = getWorkflowDirs();
55
-
97
+
56
98
  for (const { path: dir } of searchDirs) {
57
99
  const filePath = path.join(dir, `${nameOrPath}.json`);
58
100
  if (fs.existsSync(filePath)) {
59
101
  return { type: 'file', path: filePath };
60
102
  }
61
103
  }
62
-
104
+
63
105
  return { type: 'not_found', name: nameOrPath };
64
106
  }
65
107
 
@@ -70,7 +112,7 @@ function resolveWorkflow(nameOrPath) {
70
112
  function listWorkflows() {
71
113
  const workflows = [];
72
114
  const searchDirs = getWorkflowDirs();
73
-
115
+
74
116
  for (const { path: dir, scope } of searchDirs) {
75
117
  if (fs.existsSync(dir)) {
76
118
  try {
@@ -96,7 +138,7 @@ function listWorkflows() {
96
138
  }
97
139
  }
98
140
  }
99
-
141
+
100
142
  return workflows;
101
143
  }
102
144
 
@@ -107,15 +149,15 @@ function listWorkflows() {
107
149
  */
108
150
  function getWorkflowInfo(name) {
109
151
  const resolved = resolveWorkflow(name);
110
-
152
+
111
153
  if (resolved.type === 'not_found') {
112
154
  return { error: `Workflow not found: ${name}` };
113
155
  }
114
-
156
+
115
157
  if (resolved.type === 'inline') {
116
158
  return { error: 'Cannot get info for inline workflows' };
117
159
  }
118
-
160
+
119
161
  try {
120
162
  const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
121
163
  return {
@@ -175,24 +217,24 @@ function validateWorkflowFile(filePath) {
175
217
  if (!fs.existsSync(filePath)) {
176
218
  return { valid: false, error: `File not found: ${filePath}` };
177
219
  }
178
-
220
+
179
221
  try {
180
222
  const content = fs.readFileSync(filePath, 'utf8');
181
223
  const workflow = JSON.parse(content);
182
-
224
+
183
225
  // Basic structure validation
184
226
  if (!workflow.steps || !Array.isArray(workflow.steps)) {
185
227
  return { valid: false, error: "Workflow must have a 'steps' array" };
186
228
  }
187
-
229
+
188
230
  if (workflow.steps.length === 0) {
189
231
  return { valid: false, error: "Workflow has no steps" };
190
232
  }
191
-
233
+
192
234
  // Validate each step
193
235
  for (let i = 0; i < workflow.steps.length; i++) {
194
236
  const step = workflow.steps[i];
195
-
237
+
196
238
  // Check for loops
197
239
  if (step.repeat !== undefined || step.each !== undefined) {
198
240
  if (!step.steps || !Array.isArray(step.steps)) {
@@ -200,18 +242,18 @@ function validateWorkflowFile(filePath) {
200
242
  }
201
243
  continue;
202
244
  }
203
-
245
+
204
246
  // Regular step must have tool/cmd
205
247
  if (!step.tool && !step.cmd) {
206
248
  return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
207
249
  }
208
250
  }
209
-
251
+
210
252
  // Validate args schema if present
211
253
  if (workflow.args && typeof workflow.args !== 'object') {
212
254
  return { valid: false, error: "'args' must be an object" };
213
255
  }
214
-
256
+
215
257
  return { valid: true, workflow };
216
258
  } catch (e) {
217
259
  return { valid: false, error: `Invalid JSON: ${e.message}` };
@@ -226,7 +268,7 @@ function validateWorkflowFile(filePath) {
226
268
  */
227
269
  function formatStep(step, indent = 0) {
228
270
  const pad = ' '.repeat(indent);
229
-
271
+
230
272
  if (step.repeat !== undefined) {
231
273
  const lines = [`${pad}repeat ${step.repeat} times:`];
232
274
  for (const s of step.steps || []) {
@@ -237,7 +279,7 @@ function formatStep(step, indent = 0) {
237
279
  }
238
280
  return lines.join('\n');
239
281
  }
240
-
282
+
241
283
  if (step.each !== undefined) {
242
284
  const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
243
285
  for (const s of step.steps || []) {
@@ -245,24 +287,24 @@ function formatStep(step, indent = 0) {
245
287
  }
246
288
  return lines.join('\n');
247
289
  }
248
-
290
+
249
291
  const tool = step.tool || step.cmd;
250
292
  const args = step.args || {};
251
293
  const argStr = Object.entries(args)
252
294
  .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
253
295
  .join(' ');
254
-
296
+
255
297
  let line = `${pad}${tool}`;
256
298
  if (argStr) line += ` ${argStr}`;
257
299
  if (step.as) line += ` → ${step.as}`;
258
-
300
+
259
301
  return line;
260
302
  }
261
303
 
262
304
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
263
305
  function resizeImage(filePath, maxSize) {
264
306
  const platform = process.platform;
265
-
307
+
266
308
  try {
267
309
  if (platform === "darwin") {
268
310
  // macOS: use sips
@@ -342,10 +384,10 @@ const TOOLS = {
342
384
  ai: {
343
385
  desc: "AI assistants (ChatGPT, Gemini)",
344
386
  commands: {
345
- "chatgpt": {
346
- desc: "Send prompt to ChatGPT (uses browser cookies)",
347
- args: ["query"],
348
- opts: {
387
+ "chatgpt": {
388
+ desc: "Send prompt to ChatGPT (uses browser cookies)",
389
+ args: ["query"],
390
+ opts: {
349
391
  "with-page": "Include current page context",
350
392
  model: "Model: gpt-4o, o1, etc.",
351
393
  file: "Attach file",
@@ -358,10 +400,10 @@ const TOOLS = {
358
400
  { cmd: 'chatgpt "analyze" --model gpt-4o', desc: "Specify model" },
359
401
  ]
360
402
  },
361
- "gemini": {
362
- desc: "Send prompt to Gemini (uses browser cookies)",
363
- args: ["query"],
364
- opts: {
403
+ "gemini": {
404
+ desc: "Send prompt to Gemini (uses browser cookies)",
405
+ args: ["query"],
406
+ opts: {
365
407
  "with-page": "Include current page context",
366
408
  model: "Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash",
367
409
  file: "Attach file to analyze",
@@ -402,9 +444,9 @@ const TOOLS = {
402
444
  args: ["query"],
403
445
  opts: {
404
446
  "with-page": "Include current page context",
405
- model: "Model: auto, fast, expert, thinking (default)",
447
+ model: "Model: auto, fast (default), expert, grok-4.20-beta",
406
448
  "deep-search": "Enable DeepSearch for X post searching",
407
- timeout: "Timeout in seconds (default: 300 for thinking models)",
449
+ timeout: "Timeout in seconds (default: 300)",
408
450
  validate: "Check Grok UI and scrape available models (no query sent)",
409
451
  "save-models": "Save discovered models to surf.json config"
410
452
  },
@@ -447,9 +489,9 @@ const TOOLS = {
447
489
  { cmd: 'aistudio.build "crm dashboard" --output ./out', desc: "Build and extract to directory" },
448
490
  ]
449
491
  },
450
- "ai": {
451
- desc: "Analyze page with AI (requires GOOGLE_API_KEY)",
452
- args: ["query"],
492
+ "ai": {
493
+ desc: "Analyze page with AI (requires GOOGLE_API_KEY)",
494
+ args: ["query"],
453
495
  opts: { mode: "Query mode: find|summary|extract (auto-detected)" },
454
496
  examples: [
455
497
  { cmd: 'ai "find the login button"', desc: "Find element" },
@@ -463,39 +505,39 @@ const TOOLS = {
463
505
  desc: "Tab management",
464
506
  commands: {
465
507
  "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"],
508
+ "tab.new": {
509
+ desc: "Open new tab",
510
+ args: ["url"],
469
511
  opts: { urls: "Open multiple URLs" },
470
512
  examples: [
471
513
  { cmd: 'tab.new "https://google.com"', desc: "Open single tab" },
472
514
  { cmd: 'tab.new --urls "https://a.com" "https://b.com"', desc: "Open multiple" },
473
515
  ]
474
516
  },
475
- "tab.switch": {
476
- desc: "Switch to tab by ID or name",
517
+ "tab.switch": {
518
+ desc: "Switch to tab by ID or name",
477
519
  args: ["id"],
478
520
  examples: [
479
521
  { cmd: "tab.switch 123", desc: "Switch by ID" },
480
522
  { cmd: 'tab.switch "myTab"', desc: "Switch by name" },
481
523
  ]
482
524
  },
483
- "tab.close": {
484
- desc: "Close tab by ID or name",
485
- args: ["id"],
525
+ "tab.close": {
526
+ desc: "Close tab by ID or name",
527
+ args: ["id"],
486
528
  opts: { ids: "Close multiple tabs" },
487
529
  examples: [{ cmd: "tab.close 123", desc: "Close tab" }]
488
530
  },
489
- "tab.name": {
490
- desc: "Register current tab with a name",
531
+ "tab.name": {
532
+ desc: "Register current tab with a name",
491
533
  args: ["name"],
492
534
  examples: [{ cmd: 'tab.name "dashboard"', desc: "Name current tab" }]
493
535
  },
494
536
  "tab.unname": { desc: "Unregister a named tab", args: ["name"] },
495
537
  "tab.named": { desc: "List all named tabs", args: [] },
496
- "tab.group": {
497
- desc: "Create/add to tab group",
498
- args: [],
538
+ "tab.group": {
539
+ desc: "Create/add to tab group",
540
+ args: [],
499
541
  opts: { name: "Group name", tabs: "Tab IDs (comma-separated)", color: "Group color" },
500
542
  examples: [
501
543
  { cmd: 'tab.group --name "Work" --color blue', desc: "Group current tab" },
@@ -504,9 +546,9 @@ const TOOLS = {
504
546
  },
505
547
  "tab.ungroup": { desc: "Remove tabs from group", args: [], opts: { tabs: "Tab IDs (comma-separated)" } },
506
548
  "tab.groups": { desc: "List all tab groups", args: [] },
507
- "tab.reload": {
508
- desc: "Reload current tab",
509
- args: [],
549
+ "tab.reload": {
550
+ desc: "Reload current tab",
551
+ args: [],
510
552
  opts: { hard: "Bypass cache" },
511
553
  examples: [
512
554
  { cmd: "tab.reload", desc: "Soft reload" },
@@ -518,30 +560,31 @@ const TOOLS = {
518
560
  nav: {
519
561
  desc: "Navigation",
520
562
  commands: {
521
- "navigate": {
522
- desc: "Go to URL",
563
+ "navigate": {
564
+ desc: "Go to URL",
523
565
  args: ["url"],
524
566
  examples: [{ cmd: 'navigate "https://example.com"', desc: "Go to URL" }]
525
567
  },
526
568
  "go": { desc: "Alias for navigate", args: ["url"], alias: "navigate" },
527
- "back": {
528
- desc: "Go back in history",
569
+ "back": {
570
+ desc: "Go back in history",
529
571
  args: [],
530
572
  examples: [{ cmd: "back", desc: "Browser back" }]
531
573
  },
532
- "forward": {
533
- desc: "Go forward in history",
574
+ "forward": {
575
+ desc: "Go forward in history",
534
576
  args: [],
535
577
  examples: [{ cmd: "forward", desc: "Browser forward" }]
536
578
  },
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",
579
+ "screenshot": {
580
+ desc: "Capture screenshot (auto-saves to /tmp by default)",
581
+ args: [],
582
+ opts: {
583
+ output: "Save to file",
584
+ selector: "Capture specific element",
585
+ annotate: "Draw element labels",
586
+ fullpage: "Capture full page",
587
+ "full-page": "Capture full page (alias for --fullpage)",
545
588
  "max-height": "Max height for fullpage (default: 4000)",
546
589
  full: "Skip resize, save at full resolution",
547
590
  "max-size": "Max dimension in px (default: 1200)",
@@ -555,23 +598,65 @@ const TOOLS = {
555
598
  { cmd: "snap", desc: "Alias for screenshot" },
556
599
  ]
557
600
  },
601
+ "record": {
602
+ desc: "Capture screenshot frames over time and assemble an animated GIF",
603
+ args: [],
604
+ opts: {
605
+ output: "GIF output path (default: /tmp/surf-record-*.gif)",
606
+ duration: "Capture duration in ms (default: 2000, max: 10000)",
607
+ fps: "Frames per second (default: 10, max: 30)",
608
+ trigger: "Optional action before capture: click:<selector> or scroll:<target>",
609
+ rect: "Crop rectangle x,y,width,height"
610
+ },
611
+ examples: [
612
+ { cmd: "record --duration 2000 --fps 10 --output /tmp/anim.gif", desc: "Record a 2s GIF" },
613
+ { cmd: 'record --trigger "click:#btn" --output /tmp/click.gif', desc: "Click, then record" },
614
+ ]
615
+ },
616
+ "animate-audit": {
617
+ desc: "Sample matching elements over time and return a JSON animation timeline",
618
+ args: [],
619
+ opts: {
620
+ selector: "CSS selector to sample (required)",
621
+ duration: "Capture duration in ms (default: 2000, max: 10000)",
622
+ fps: "Samples per second (default: 10, max: 30)"
623
+ },
624
+ examples: [
625
+ { cmd: 'animate-audit --selector ".thing" --duration 2000 --fps 10', desc: "Capture a bounded JSON timeline" },
626
+ ]
627
+ },
628
+ "perf-audit": {
629
+ desc: "Capture layout shift, event, long task, and animation-frame performance entries",
630
+ args: [],
631
+ opts: {
632
+ duration: "Capture duration in ms (default: 3000, max: 10000)",
633
+ trigger: "Optional action before capture: click:<selector> or scroll:<target>",
634
+ output: "Save JSON to file"
635
+ },
636
+ examples: [
637
+ { cmd: 'perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json', desc: "Capture a performance snapshot" },
638
+ ]
639
+ },
558
640
  "snap": { desc: "Alias for screenshot (auto-saves to /tmp)", args: [], alias: "screenshot" },
559
641
  }
560
642
  },
561
643
  scroll: {
562
644
  desc: "Scrolling",
563
645
  commands: {
564
- "scroll": {
565
- desc: "Scroll in direction",
566
- args: [],
646
+ "scroll": {
647
+ desc: "Scroll in direction",
648
+ args: ["direction", "pixels"],
567
649
  opts: { direction: "up|down|left|right", amount: "Scroll amount (1-10)" },
568
- examples: [{ cmd: "scroll --direction down --amount 3", desc: "Scroll down" }]
650
+ examples: [
651
+ { cmd: "scroll down 800", desc: "Scroll down 800px" },
652
+ { cmd: "scroll --direction down --amount 3", desc: "Scroll down" },
653
+ ]
569
654
  },
570
655
  "scroll.top": { desc: "Scroll to top of page", args: [], opts: { selector: "Target specific container" } },
571
656
  "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: [],
657
+ "scroll.to": {
658
+ desc: "Scroll element into view",
659
+ args: [],
575
660
  opts: { ref: "Element ref" },
576
661
  examples: [{ cmd: "scroll.to --ref e5", desc: "Scroll to element" }]
577
662
  },
@@ -581,12 +666,12 @@ const TOOLS = {
581
666
  page: {
582
667
  desc: "Page inspection",
583
668
  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",
669
+ "page.read": {
670
+ desc: "Get accessibility tree + visible text",
671
+ args: [],
672
+ opts: {
673
+ all: "Include all elements",
674
+ ref: "Get specific element",
590
675
  "no-text": "Exclude visible text content",
591
676
  depth: "Maximum tree depth (default: unlimited)",
592
677
  compact: "Remove empty structural elements",
@@ -612,7 +697,7 @@ const TOOLS = {
612
697
  "locate.role": {
613
698
  desc: "Find element by ARIA role",
614
699
  args: ["role"],
615
- opts: {
700
+ opts: {
616
701
  name: "Element name/text",
617
702
  action: "Action to perform (click|fill|hover|text)",
618
703
  value: "Value for fill action",
@@ -684,14 +769,14 @@ const TOOLS = {
684
769
  wait: {
685
770
  desc: "Waiting",
686
771
  commands: {
687
- "wait": {
688
- desc: "Wait N seconds",
772
+ "wait": {
773
+ desc: "Wait N seconds",
689
774
  args: ["duration"],
690
775
  examples: [{ cmd: "wait 2", desc: "Wait 2 seconds" }]
691
776
  },
692
- "wait.element": {
693
- desc: "Wait for element to appear",
694
- args: ["selector"],
777
+ "wait.element": {
778
+ desc: "Wait for element to appear",
779
+ args: ["selector"],
695
780
  opts: { timeout: "Timeout in ms" },
696
781
  examples: [
697
782
  { cmd: 'wait.element ".loading"', desc: "Wait for element" },
@@ -699,9 +784,9 @@ const TOOLS = {
699
784
  ]
700
785
  },
701
786
  "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"],
787
+ "wait.url": {
788
+ desc: "Wait for URL to match",
789
+ args: ["pattern"],
705
790
  opts: { timeout: "Timeout in ms" },
706
791
  examples: [{ cmd: 'wait.url "/dashboard"', desc: "Wait for URL pattern" }]
707
792
  },
@@ -712,15 +797,15 @@ const TOOLS = {
712
797
  input: {
713
798
  desc: "Input actions",
714
799
  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",
800
+ "click": {
801
+ desc: "Click element or coordinates",
802
+ args: ["ref"],
803
+ opts: {
804
+ ref: "Element ref",
805
+ x: "X coordinate",
806
+ y: "Y coordinate",
807
+ button: "left|right|double|triple",
808
+ selector: "CSS selector",
724
809
  index: "Which match (0-indexed) for selector",
725
810
  },
726
811
  examples: [
@@ -730,15 +815,15 @@ const TOOLS = {
730
815
  { cmd: "click --x 100 --y 200", desc: "Click coordinates" },
731
816
  ]
732
817
  },
733
- "type": {
734
- desc: "Type text (uses form.fill when --ref provided for better modal/form support)",
735
- args: ["text"],
736
- opts: {
818
+ "type": {
819
+ desc: "Type text (uses form.fill when --ref provided for better modal/form support)",
820
+ args: ["text"],
821
+ opts: {
737
822
  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)"
823
+ ref: "Element ref (uses JS DOM method, more reliable for modals)",
824
+ submit: "Press enter after",
825
+ clear: "Clear first",
826
+ method: "cdp|js (default: cdp, but ref uses JS automatically)"
742
827
  },
743
828
  examples: [
744
829
  { cmd: 'type "hello world"', desc: "Type at cursor (CDP events)" },
@@ -747,9 +832,9 @@ const TOOLS = {
747
832
  ]
748
833
  },
749
834
  "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"],
835
+ "key": {
836
+ desc: "Press key",
837
+ args: ["key"],
753
838
  examples: [
754
839
  { cmd: "key Enter", desc: "Press Enter" },
755
840
  { cmd: "key Escape", desc: "Press Escape" },
@@ -764,9 +849,9 @@ const TOOLS = {
764
849
  js: {
765
850
  desc: "JavaScript execution",
766
851
  commands: {
767
- "js": {
768
- desc: "Execute JavaScript (use 'return' for values)",
769
- args: ["code"],
852
+ "js": {
853
+ desc: "Execute JavaScript (use 'return' for values)",
854
+ args: ["code"],
770
855
  opts: { file: "Run JS from file" },
771
856
  examples: [
772
857
  { cmd: 'js "return document.title"', desc: "Get title" },
@@ -779,9 +864,9 @@ const TOOLS = {
779
864
  dev: {
780
865
  desc: "Dev tools",
781
866
  commands: {
782
- "console": {
783
- desc: "Read console messages",
784
- args: [],
867
+ "console": {
868
+ desc: "Read console messages",
869
+ args: [],
785
870
  opts: { clear: "Clear after reading", stream: "Continuous output", level: "Filter by level (log,warn,error)", limit: "Max messages" },
786
871
  examples: [
787
872
  { cmd: "console", desc: "Get recent messages" },
@@ -794,10 +879,10 @@ const TOOLS = {
794
879
  network: {
795
880
  desc: "Network capture",
796
881
  commands: {
797
- "network": {
798
- desc: "List captured network requests",
799
- args: [],
800
- opts: {
882
+ "network": {
883
+ desc: "List captured network requests",
884
+ args: [],
885
+ opts: {
801
886
  origin: "Filter by origin (domain)",
802
887
  method: "Filter by method (GET,POST,...)",
803
888
  status: "Filter by status (200, 4xx, 5xx)",
@@ -822,16 +907,16 @@ const TOOLS = {
822
907
  { cmd: "network -v", desc: "Verbose with headers" },
823
908
  ]
824
909
  },
825
- "network.get": {
826
- desc: "Get full details for a request",
910
+ "network.get": {
911
+ desc: "Get full details for a request",
827
912
  args: ["id"],
828
913
  opts: {},
829
914
  examples: [
830
915
  { cmd: "network.get r_001", desc: "Get request details" }
831
916
  ]
832
917
  },
833
- "network.body": {
834
- desc: "Get response body (for piping)",
918
+ "network.body": {
919
+ desc: "Get response body (for piping)",
835
920
  args: ["id"],
836
921
  opts: { request: "Get request body instead" },
837
922
  examples: [
@@ -839,24 +924,24 @@ const TOOLS = {
839
924
  { cmd: "network.body r_001 | jq .", desc: "Pipe JSON to jq" }
840
925
  ]
841
926
  },
842
- "network.curl": {
843
- desc: "Generate curl command for request",
927
+ "network.curl": {
928
+ desc: "Generate curl command for request",
844
929
  args: ["id"],
845
930
  opts: {},
846
931
  examples: [
847
932
  { cmd: "network.curl r_001", desc: "Generate curl" }
848
933
  ]
849
934
  },
850
- "network.origins": {
851
- desc: "List captured origins with stats",
935
+ "network.origins": {
936
+ desc: "List captured origins with stats",
852
937
  args: [],
853
938
  opts: { "by-tab": "Group by tab" },
854
939
  examples: [
855
940
  { cmd: "network.origins", desc: "List origins" }
856
941
  ]
857
942
  },
858
- "network.clear": {
859
- desc: "Clear captured requests",
943
+ "network.clear": {
944
+ desc: "Clear captured requests",
860
945
  args: [],
861
946
  opts: { before: "Clear before timestamp/duration", origin: "Clear specific origin" },
862
947
  examples: [
@@ -864,24 +949,24 @@ const TOOLS = {
864
949
  { cmd: "network.clear --before 1h", desc: "Clear older than 1 hour" }
865
950
  ]
866
951
  },
867
- "network.stats": {
868
- desc: "Show capture statistics",
952
+ "network.stats": {
953
+ desc: "Show capture statistics",
869
954
  args: [],
870
955
  opts: {},
871
956
  examples: [
872
957
  { cmd: "network.stats", desc: "Show stats" }
873
958
  ]
874
959
  },
875
- "network.export": {
876
- desc: "Export captured requests",
960
+ "network.export": {
961
+ desc: "Export captured requests",
877
962
  args: [],
878
963
  opts: { jsonl: "Export as JSONL", output: "Output file path" },
879
964
  examples: [
880
965
  { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" }
881
966
  ]
882
967
  },
883
- "network.path": {
884
- desc: "Get file paths for request data",
968
+ "network.path": {
969
+ desc: "Get file paths for request data",
885
970
  args: ["id"],
886
971
  opts: {},
887
972
  examples: [
@@ -893,9 +978,19 @@ const TOOLS = {
893
978
  health: {
894
979
  desc: "Health checks",
895
980
  commands: {
896
- "health": {
897
- desc: "Wait for URL or element",
898
- args: [],
981
+ "doctor": {
982
+ desc: "Diagnose native host manifests and socket connectivity",
983
+ args: [],
984
+ opts: { browser: "Browser to inspect (default: chrome)", target: "auto|linux|windows", socket: "Socket path to check", json: "Raw diagnostic JSON" },
985
+ examples: [
986
+ { cmd: "doctor", desc: "Check default Chrome setup" },
987
+ { cmd: "doctor --browser all", desc: "Check all supported browsers" },
988
+ { cmd: "doctor --json", desc: "Machine-readable diagnostics" },
989
+ ]
990
+ },
991
+ "health": {
992
+ desc: "Wait for URL or element",
993
+ args: [],
899
994
  opts: { url: "URL to check (expects 200)", selector: "CSS selector to wait for", expect: "Expected status code (default: 200)", timeout: "Timeout in ms" },
900
995
  examples: [
901
996
  { cmd: 'health --url "https://api.example.com"', desc: "Check URL" },
@@ -914,9 +1009,9 @@ const TOOLS = {
914
1009
  desc: "Browser dialog handling",
915
1010
  commands: {
916
1011
  "dialog.accept": { desc: "Accept current dialog", args: [], opts: { text: "Text for prompt input" } },
917
- "dialog.dismiss": {
918
- desc: "Dismiss current dialog",
919
- args: [],
1012
+ "dialog.dismiss": {
1013
+ desc: "Dismiss current dialog",
1014
+ args: [],
920
1015
  opts: { all: "Dismiss all dialogs repeatedly" },
921
1016
  examples: [
922
1017
  { cmd: "dialog.dismiss", desc: "Dismiss once" },
@@ -980,9 +1075,9 @@ const TOOLS = {
980
1075
  upload: {
981
1076
  desc: "File upload",
982
1077
  commands: {
983
- "upload": {
984
- desc: "Upload file(s) to input",
985
- args: [],
1078
+ "upload": {
1079
+ desc: "Upload file(s) to input",
1080
+ args: [],
986
1081
  opts: { ref: "Element ref", files: "File path(s) comma-separated" },
987
1082
  examples: [{ cmd: 'upload --ref e5 --files "/path/to/file.pdf"', desc: "Upload file" }]
988
1083
  },
@@ -991,8 +1086,8 @@ const TOOLS = {
991
1086
  frame: {
992
1087
  desc: "Iframe handling",
993
1088
  commands: {
994
- "frame.list": {
995
- desc: "List all frames in page",
1089
+ "frame.list": {
1090
+ desc: "List all frames in page",
996
1091
  args: [],
997
1092
  examples: [{ cmd: "frame.list", desc: "Show frame tree" }]
998
1093
  },
@@ -1015,9 +1110,9 @@ const TOOLS = {
1015
1110
  args: [],
1016
1111
  examples: [{ cmd: "frame.main", desc: "Exit iframe context" }]
1017
1112
  },
1018
- "frame.js": {
1019
- desc: "Execute JS in specific frame",
1020
- args: ["code"],
1113
+ "frame.js": {
1114
+ desc: "Execute JS in specific frame",
1115
+ args: ["code"],
1021
1116
  opts: { id: "Frame ID from frame.list", file: "Run JS from file" },
1022
1117
  examples: [
1023
1118
  { cmd: 'frame.js "return document.title" --id frame1', desc: "JS in specific frame" },
@@ -1028,25 +1123,37 @@ const TOOLS = {
1028
1123
  cookie: {
1029
1124
  desc: "Cookie management",
1030
1125
  commands: {
1031
- "cookie.list": {
1032
- desc: "List all cookies for current tab's domain",
1126
+ "cookie.list": {
1127
+ desc: "List all cookies for current tab's domain",
1128
+ args: [],
1129
+ examples: [
1130
+ { cmd: "cookie list", desc: "Show all cookies" },
1131
+ { cmd: "cookie.list", desc: "Dot command form" },
1132
+ ]
1133
+ },
1134
+ "cookie.get": {
1135
+ desc: "Get specific cookie",
1033
1136
  args: [],
1034
- examples: [{ cmd: "cookie.list", desc: "Show all cookies" }]
1137
+ opts: { name: "Cookie name" },
1138
+ examples: [{ cmd: "cookie get session", desc: "Get cookie" }]
1035
1139
  },
1036
- "cookie.get": { desc: "Get specific cookie", args: [], opts: { name: "Cookie name" } },
1037
- "cookie.set": {
1038
- desc: "Set a cookie",
1039
- args: [],
1140
+ "cookie.set": {
1141
+ desc: "Set a cookie",
1142
+ args: [],
1040
1143
  opts: { name: "Cookie name", value: "Cookie value", expires: "Expiry date (optional)" },
1041
- examples: [{ cmd: 'cookie.set --name "session" --value "abc123"', desc: "Set cookie" }]
1144
+ examples: [
1145
+ { cmd: 'cookie set --name "session" --value "abc123"', desc: "Set cookie" },
1146
+ { cmd: 'cookie.set --name "session" --value "abc123"', desc: "Dot command form" },
1147
+ ]
1042
1148
  },
1043
- "cookie.clear": {
1044
- desc: "Clear cookies",
1045
- args: [],
1149
+ "cookie.clear": {
1150
+ desc: "Clear cookies",
1151
+ args: [],
1046
1152
  opts: { name: "Specific cookie (optional)", all: "Clear all for domain" },
1047
1153
  examples: [
1048
- { cmd: 'cookie.clear --name "session"', desc: "Clear one" },
1049
- { cmd: "cookie.clear --all", desc: "Clear all" },
1154
+ { cmd: 'cookie delete "session"', desc: "Clear one" },
1155
+ { cmd: "cookie clear --all", desc: "Clear all" },
1156
+ { cmd: 'cookie.clear --name "session"', desc: "Dot command form" },
1050
1157
  ]
1051
1158
  },
1052
1159
  }
@@ -1054,9 +1161,9 @@ const TOOLS = {
1054
1161
  search: {
1055
1162
  desc: "Text search",
1056
1163
  commands: {
1057
- "search": {
1058
- desc: "Search for text in page",
1059
- args: ["term"],
1164
+ "search": {
1165
+ desc: "Search for text in page",
1166
+ args: ["term"],
1060
1167
  opts: { "case-sensitive": "Case-sensitive match", limit: "Max results" },
1061
1168
  examples: [
1062
1169
  { cmd: 'search "login"', desc: "Find text" },
@@ -1070,9 +1177,9 @@ const TOOLS = {
1070
1177
  batch: {
1071
1178
  desc: "Batch execution",
1072
1179
  commands: {
1073
- "batch": {
1074
- desc: "Execute multiple actions",
1075
- args: [],
1180
+ "batch": {
1181
+ desc: "Execute multiple actions",
1182
+ args: [],
1076
1183
  opts: { actions: "JSON array of actions", file: "Path to actions JSON file" },
1077
1184
  examples: [
1078
1185
  { cmd: 'batch --actions \'[{"type":"click","ref":"e1"},{"type":"wait","ms":500}]\'', desc: "Inline actions" },
@@ -1130,9 +1237,9 @@ const TOOLS = {
1130
1237
  zoom: {
1131
1238
  desc: "Zoom control",
1132
1239
  commands: {
1133
- "zoom": {
1134
- desc: "Get or set zoom level",
1135
- args: [],
1240
+ "zoom": {
1241
+ desc: "Get or set zoom level",
1242
+ args: [],
1136
1243
  opts: { level: "Zoom level (e.g., 1.5 for 150%)", reset: "Reset to default zoom" },
1137
1244
  examples: [
1138
1245
  { cmd: "zoom", desc: "Get current zoom" },
@@ -1145,11 +1252,14 @@ const TOOLS = {
1145
1252
  resize: {
1146
1253
  desc: "Window management",
1147
1254
  commands: {
1148
- "resize": {
1149
- desc: "Resize browser window",
1150
- args: [],
1255
+ "resize": {
1256
+ desc: "Resize browser window",
1257
+ args: ["width", "height"],
1151
1258
  opts: { width: "Window width", height: "Window height" },
1152
- examples: [{ cmd: "resize --width 1280 --height 720", desc: "Set size" }]
1259
+ examples: [
1260
+ { cmd: "resize 1280 720", desc: "Set size" },
1261
+ { cmd: "resize --width 1280 --height 720", desc: "Set size with flags" },
1262
+ ]
1153
1263
  },
1154
1264
  }
1155
1265
  },
@@ -1164,14 +1274,14 @@ const TOOLS = {
1164
1274
  history: {
1165
1275
  desc: "Browser history",
1166
1276
  commands: {
1167
- "history.list": {
1168
- desc: "Recent history",
1169
- args: [],
1277
+ "history.list": {
1278
+ desc: "Recent history",
1279
+ args: [],
1170
1280
  opts: { limit: "Max results" },
1171
1281
  examples: [{ cmd: "history.list --limit 20", desc: "Last 20 items" }]
1172
1282
  },
1173
- "history.search": {
1174
- desc: "Search history",
1283
+ "history.search": {
1284
+ desc: "Search history",
1175
1285
  args: ["query"],
1176
1286
  examples: [{ cmd: 'history.search "github"', desc: "Search history" }]
1177
1287
  },
@@ -1180,10 +1290,10 @@ const TOOLS = {
1180
1290
  window: {
1181
1291
  desc: "Window management (isolate agent from your browsing)",
1182
1292
  commands: {
1183
- "window.new": {
1184
- desc: "Create new browser window",
1185
- args: ["url"],
1186
- opts: {
1293
+ "window.new": {
1294
+ desc: "Create new browser window",
1295
+ args: ["url"],
1296
+ opts: {
1187
1297
  width: "Window width",
1188
1298
  height: "Window height",
1189
1299
  incognito: "Open incognito window",
@@ -1195,28 +1305,28 @@ const TOOLS = {
1195
1305
  { cmd: 'window.new --incognito', desc: "Incognito window" },
1196
1306
  ]
1197
1307
  },
1198
- "window.list": {
1199
- desc: "List all browser windows",
1308
+ "window.list": {
1309
+ desc: "List all browser windows",
1200
1310
  args: [],
1201
1311
  opts: { tabs: "Include tab details" },
1202
1312
  examples: [{ cmd: "window.list", desc: "Show all windows" }]
1203
1313
  },
1204
- "window.focus": {
1205
- desc: "Focus a window by ID",
1314
+ "window.focus": {
1315
+ desc: "Focus a window by ID",
1206
1316
  args: ["id"],
1207
1317
  examples: [{ cmd: "window.focus 123", desc: "Focus window" }]
1208
1318
  },
1209
- "window.close": {
1210
- desc: "Close a window by ID",
1319
+ "window.close": {
1320
+ desc: "Close a window by ID",
1211
1321
  args: ["id"],
1212
1322
  examples: [{ cmd: "window.close 123", desc: "Close window" }]
1213
1323
  },
1214
- "window.resize": {
1215
- desc: "Resize or reposition a window",
1216
- args: [],
1217
- opts: {
1218
- id: "Window ID (required)",
1219
- width: "Window width",
1324
+ "window.resize": {
1325
+ desc: "Resize or reposition a window",
1326
+ args: [],
1327
+ opts: {
1328
+ id: "Window ID (required)",
1329
+ width: "Window width",
1220
1330
  height: "Window height",
1221
1331
  left: "Window X position",
1222
1332
  top: "Window Y position",
@@ -1263,10 +1373,17 @@ Use --index to select from multiple matches:
1263
1373
  content: `Cookies are scoped to the current tab's domain.
1264
1374
 
1265
1375
  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
1376
+ cookie list List all cookies
1377
+ cookie get X Get specific cookie
1378
+ cookie set Set a cookie
1379
+ cookie clear --all Clear all cookies
1380
+ cookie delete X Clear one cookie
1381
+
1382
+ Dot commands remain supported:
1383
+ cookie.list
1384
+ cookie.get --name X
1385
+ cookie.set
1386
+ cookie.clear
1270
1387
 
1271
1388
  Notes:
1272
1389
  - HttpOnly cookies are accessible
@@ -1298,6 +1415,7 @@ Commands:
1298
1415
  screenshot --output file.png Basic screenshot
1299
1416
  screenshot --annotate --output file.png With element labels
1300
1417
  screenshot --fullpage --output file.png Full page capture
1418
+ screenshot --full-page --output file.png Full page capture (alias)
1301
1419
  screenshot --annotate --fullpage --output file.png Full page with labels
1302
1420
  snap Auto-save to /tmp
1303
1421
 
@@ -1305,6 +1423,7 @@ Options:
1305
1423
  --output Save path
1306
1424
  --annotate Draw element refs
1307
1425
  --fullpage Capture entire page
1426
+ --full-page Capture entire page (alias)
1308
1427
  --max-height Max height for fullpage (default: 4000)`
1309
1428
  },
1310
1429
  automation: {
@@ -1327,7 +1446,7 @@ Wait for dynamic content:
1327
1446
 
1328
1447
  Scroll and capture:
1329
1448
  scroll.bottom
1330
- screenshot --fullpage --output full.png`
1449
+ screenshot --full-page --output full.png`
1331
1450
  },
1332
1451
  windows: {
1333
1452
  title: "Window Isolation",
@@ -1346,7 +1465,7 @@ All commands in that window:
1346
1465
 
1347
1466
  Manage windows:
1348
1467
  surf window.list # List all windows
1349
- surf window.list --tabs # Include tab details
1468
+ surf window.list --tabs # Include tab details
1350
1469
  surf window.focus 123 # Bring window to front
1351
1470
  surf window.close 123 # Close when done
1352
1471
 
@@ -1439,7 +1558,7 @@ Exclude text content:
1439
1558
  };
1440
1559
 
1441
1560
  const ALL_SOCKET_TOOLS = [
1442
- "ai", "screenshot", "navigate",
1561
+ "ai", "screenshot", "record", "animate-audit", "perf-audit", "navigate",
1443
1562
  "form_input", "find_and_type", "autocomplete", "set_value", "smart_type",
1444
1563
  "scroll_to_position", "get_scroll_info", "close_dialogs", "page_state",
1445
1564
  "javascript_tool", "health", "smoke",
@@ -1453,8 +1572,8 @@ const ALL_SOCKET_TOOLS = [
1453
1572
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1454
1573
  "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
1455
1574
  "click", "hover", "drag",
1456
- "js", "console", "network",
1457
- "network.get", "network.body", "network.curl", "network.origins",
1575
+ "js", "console", "network",
1576
+ "network.get", "network.body", "network.curl", "network.origins",
1458
1577
  "network.clear", "network.stats", "network.export", "network.path",
1459
1578
  "dialog.accept", "dialog.dismiss", "dialog.info",
1460
1579
  "emulate.network", "emulate.cpu", "emulate.geo", "emulate.device", "emulate.viewport", "emulate.touch",
@@ -1497,6 +1616,9 @@ const SEE_ALSO = {
1497
1616
  "perf.metrics": ["perf.start", "console", "network"],
1498
1617
  "navigate": ["wait.load", "page.read"],
1499
1618
  "screenshot": ["page.read", "scroll.bottom for fullpage"],
1619
+ "record": ["screenshot", "animate-audit", "perf-audit"],
1620
+ "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1621
+ "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1500
1622
  "search": ["locate.text", "page.read"],
1501
1623
  "wait.element": ["wait.load", "wait.network"],
1502
1624
  "wait.load": ["wait.element", "wait.network"],
@@ -1516,10 +1638,14 @@ Common Commands:
1516
1638
  click <ref> Click element by ref or selector
1517
1639
  type <text> Type text at cursor or into element
1518
1640
  screenshot Capture screenshot (alias: snap)
1641
+ record Capture screenshot frames into an animated GIF
1642
+ animate-audit JSON timeline of element animation/style samples
1643
+ perf-audit PerformanceObserver snapshot for motion/jank debugging
1519
1644
  page.read Get page accessibility tree (alias: read)
1520
1645
  locate.role <role> Find element by ARIA role
1521
1646
  search <term> Search for text in page (alias: find)
1522
1647
  window.new <url> Create isolated browser window
1648
+ doctor Diagnose native host/socket setup
1523
1649
  wait <seconds> Wait N seconds
1524
1650
 
1525
1651
  Quick Examples:
@@ -1534,6 +1660,7 @@ Quick Examples:
1534
1660
 
1535
1661
  More Help:
1536
1662
  surf --help-full All commands
1663
+ surf --llm-context Compact reference for AI agents
1537
1664
  surf --help-topic <topic> Topic guide (refs, semantic, frames, devices...)
1538
1665
  surf <command> --help Command details
1539
1666
  surf --find <query> Search for commands
@@ -1541,6 +1668,34 @@ More Help:
1541
1668
  `);
1542
1669
  };
1543
1670
 
1671
+ const showLlmContext = () => {
1672
+ console.log(`SURF CLI LLM CONTEXT
1673
+ Purpose: control Chrome from shell. Commands are \`surf <command> [args] [options]\`.
1674
+ Core loop: navigate -> wait/read -> act -> screenshot/read.
1675
+ Navigate: surf navigate "https://example.com" # alias: surf go "..."
1676
+ Wait after navigation: surf wait 2 # or wait.load for load complete
1677
+ Read DOM/refs: surf page.read --depth 3 --compact # alias: surf read
1678
+ Refs: use e1/e2 refs from page.read; prefer refs over CSS when available.
1679
+ Click ref: surf click e5
1680
+ Click selector/coords: surf click --selector ".btn" | surf click 100 200
1681
+ Type: surf type "text" --submit # use --ref e5 to target a field
1682
+ Screenshot: surf screenshot /tmp/shot.png # auto-saves to /tmp if no path
1683
+ Full page screenshot: surf screenshot --full-page /tmp/full.png
1684
+ Record animation: surf record --duration 2000 --fps 10 --output /tmp/anim.gif
1685
+ Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1686
+ Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1687
+ JavaScript: surf js "return document.title"
1688
+ Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf scroll top
1689
+ Find by semantics: surf locate.role button --name "Submit" --action click
1690
+ Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
1691
+ Cookies: surf cookie list | surf cookie get "name" | surf cookie delete "name"
1692
+ Window isolation: surf window.new "https://example.com" then pass --window-id <id>
1693
+ Concurrency: surf serializes commands per socket; use --no-lock only for intentional bypass
1694
+ Doctor: surf doctor --browser all # native host/socket diagnostics
1695
+ Workflow: surf do 'go "https://example.com" | wait 2 | read | click e5 | screenshot'
1696
+ More help: surf --help-full | surf <command> --help | surf --help-topic refs | surf --find <query>`);
1697
+ };
1698
+
1544
1699
  const showFullHelp = () => {
1545
1700
  console.log(`surf v${VERSION} - Browser automation CLI
1546
1701
 
@@ -1565,6 +1720,7 @@ Options:
1565
1720
  --json Output raw JSON
1566
1721
  --auto-capture On error: capture screenshot + console to /tmp
1567
1722
  --soft-fail On error: warn and exit 0 (for non-critical commands)
1723
+ --no-lock Bypass the per-socket browser request lock
1568
1724
 
1569
1725
  Script Mode:
1570
1726
  surf --script <file> Run workflow from JSON
@@ -1673,7 +1829,7 @@ const showToolHelp = (toolName) => {
1673
1829
  const fuzzyFind = (query) => {
1674
1830
  const terms = query.toLowerCase().split(/\s+/);
1675
1831
  const results = [];
1676
-
1832
+
1677
1833
  for (const [groupName, group] of Object.entries(TOOLS)) {
1678
1834
  for (const [cmd, info] of Object.entries(group.commands)) {
1679
1835
  if (info.alias) continue;
@@ -1684,7 +1840,7 @@ const fuzzyFind = (query) => {
1684
1840
  }
1685
1841
  }
1686
1842
  }
1687
-
1843
+
1688
1844
  return results.sort((a, b) => b.score - a.score);
1689
1845
  };
1690
1846
 
@@ -1732,6 +1888,11 @@ const showAllTools = () => {
1732
1888
  console.log(`\n Total: ${ALL_SOCKET_TOOLS.length} commands\n`);
1733
1889
  };
1734
1890
 
1891
+ if (args[0] === "--llm-context") {
1892
+ showLlmContext();
1893
+ process.exit(0);
1894
+ }
1895
+
1735
1896
  if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
1736
1897
  showBasicHelp();
1737
1898
  process.exit(0);
@@ -1790,11 +1951,17 @@ if (args[0] === "extension-path" || args[0] === "path") {
1790
1951
  process.exit(0);
1791
1952
  }
1792
1953
 
1954
+ if (args[0] === "doctor") {
1955
+ const { runDoctorCli } = require("./doctor.cjs");
1956
+ runDoctorCli(args.slice(1)).then((code) => process.exit(code));
1957
+ return;
1958
+ }
1959
+
1793
1960
  if (args[0] === "install") {
1794
1961
  const { spawnSync } = require("child_process");
1795
1962
  const scriptPath = require("path").resolve(__dirname, "../scripts/install-native-host.cjs");
1796
1963
  const installArgs = args.slice(1);
1797
-
1964
+
1798
1965
  if (installArgs.length === 0 || installArgs[0] === "--help" || installArgs[0] === "-h") {
1799
1966
  console.log(`
1800
1967
  Usage: surf install <extension-id> [options]
@@ -1809,11 +1976,14 @@ Options:
1809
1976
  -b, --browser Browser(s) to install for (default: chrome)
1810
1977
  Values: chrome, chromium, brave, edge, arc, helium, all
1811
1978
  Multiple: --browser chrome,brave
1979
+ --target Install target: auto, linux, windows
1980
+ On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
1812
1981
 
1813
1982
  Examples:
1814
1983
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl
1815
1984
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser brave
1816
1985
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser all
1986
+ surf install hnfbepgmaoklhekckbpjnleifhahkcpl --target linux
1817
1987
  `);
1818
1988
  process.exit(0);
1819
1989
  }
@@ -1828,7 +1998,7 @@ if (args[0] === "uninstall") {
1828
1998
  const { spawnSync } = require("child_process");
1829
1999
  const scriptPath = require("path").resolve(__dirname, "../scripts/uninstall-native-host.cjs");
1830
2000
  const uninstallArgs = args.slice(1);
1831
-
2001
+
1832
2002
  if (uninstallArgs.includes("--help") || uninstallArgs.includes("-h")) {
1833
2003
  console.log(`
1834
2004
  Usage: surf uninstall [options]
@@ -1839,11 +2009,14 @@ Options:
1839
2009
  -b, --browser Browser(s) to uninstall from (default: chrome)
1840
2010
  Values: chrome, chromium, brave, edge, arc, helium, all
1841
2011
  -a, --all Uninstall from all browsers and remove wrapper
2012
+ --target Install target to remove: auto, linux, windows
2013
+ On WSL2, auto removes Windows-browser manifests. Use linux for WSLg/Linux browsers.
1842
2014
 
1843
2015
  Examples:
1844
2016
  surf uninstall
1845
2017
  surf uninstall --browser brave
1846
2018
  surf uninstall --all
2019
+ surf uninstall --target linux
1847
2020
  `);
1848
2021
  process.exit(0);
1849
2022
  }
@@ -1977,7 +2150,7 @@ if (args.includes("--script")) {
1977
2150
  }
1978
2151
  }
1979
2152
  });
1980
- sock.on("error", (e) => reject(e));
2153
+ sock.on("error", (e) => reject(new Error(formatSocketError(e))));
1981
2154
  let timeoutId;
1982
2155
  timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, 30000);
1983
2156
  sock.on("close", () => clearTimeout(timeoutId));
@@ -2046,6 +2219,10 @@ if (args.includes("--script")) {
2046
2219
  process.exit(failed > 0 ? 1 : 0);
2047
2220
  };
2048
2221
 
2222
+ if (!dryRun) {
2223
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")));
2224
+ }
2225
+
2049
2226
  runScript();
2050
2227
  return;
2051
2228
  }
@@ -2063,13 +2240,13 @@ if (args[0] === "do") {
2063
2240
  let wantJson = false;
2064
2241
  let tabId = undefined;
2065
2242
  let windowId = undefined;
2066
-
2243
+
2067
2244
  // 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
-
2245
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'no-lock'];
2246
+
2070
2247
  // Workflow-specific args (collected for variable substitution)
2071
2248
  const workflowArgs = {};
2072
-
2249
+
2073
2250
  // Parse do-specific arguments
2074
2251
  for (let i = 0; i < doArgs.length; i++) {
2075
2252
  const arg = doArgs[i];
@@ -2117,7 +2294,7 @@ if (args[0] === "do") {
2117
2294
  commandsInput = arg;
2118
2295
  }
2119
2296
  }
2120
-
2297
+
2121
2298
  if (!commandsInput && !fileInput) {
2122
2299
  console.error("Error: commands string, workflow name, or --file required");
2123
2300
  console.error('Usage: surf do \'go "url" | click e5\'');
@@ -2125,11 +2302,11 @@ if (args[0] === "do") {
2125
2302
  console.error(" surf do my-workflow --arg1 value1 --arg2 value2");
2126
2303
  process.exit(1);
2127
2304
  }
2128
-
2305
+
2129
2306
  let steps;
2130
2307
  let workflow = null; // Full workflow object (for arg validation)
2131
2308
  let workflowName = null;
2132
-
2309
+
2133
2310
  try {
2134
2311
  if (fileInput) {
2135
2312
  // Explicit file path via --file
@@ -2143,7 +2320,7 @@ if (args[0] === "do") {
2143
2320
  } else {
2144
2321
  // Resolve: inline | file path | named workflow
2145
2322
  const resolved = resolveWorkflow(commandsInput);
2146
-
2323
+
2147
2324
  if (resolved.type === 'inline') {
2148
2325
  // Inline pipe syntax
2149
2326
  steps = parseDoCommands(resolved.content);
@@ -2166,13 +2343,13 @@ if (args[0] === "do") {
2166
2343
  }
2167
2344
  }
2168
2345
  }
2169
-
2346
+
2170
2347
  // Process workflow file if loaded
2171
2348
  if (workflow) {
2172
2349
  if (!workflow.steps || !Array.isArray(workflow.steps)) {
2173
2350
  throw new Error("Workflow must have a 'steps' array");
2174
2351
  }
2175
-
2352
+
2176
2353
  // Validate required args
2177
2354
  const argErrors = validateWorkflowArgs(workflow, workflowArgs);
2178
2355
  if (argErrors.length > 0) {
@@ -2190,7 +2367,7 @@ if (args[0] === "do") {
2190
2367
  console.error(`\nRun 'surf workflow.info ${workflowName}' for details.`);
2191
2368
  process.exit(1);
2192
2369
  }
2193
-
2370
+
2194
2371
  // Convert steps: support both { tool, args } and { cmd, args } formats
2195
2372
  // Also preserve loop steps as-is
2196
2373
  steps = workflow.steps.map(s => {
@@ -2199,16 +2376,16 @@ if (args[0] === "do") {
2199
2376
  const convertSteps = (stepsArr) => stepsArr.map(ns => {
2200
2377
  if (ns.repeat !== undefined || ns.each !== undefined) {
2201
2378
  // Recursively convert nested loop steps and until condition
2202
- return {
2203
- ...ns,
2379
+ return {
2380
+ ...ns,
2204
2381
  steps: convertSteps(ns.steps || []),
2205
2382
  until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
2206
2383
  };
2207
2384
  }
2208
2385
  return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
2209
2386
  });
2210
- return {
2211
- ...s,
2387
+ return {
2388
+ ...s,
2212
2389
  steps: convertSteps(s.steps || []),
2213
2390
  until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
2214
2391
  };
@@ -2220,15 +2397,15 @@ if (args[0] === "do") {
2220
2397
  console.error(`Error: Failed to parse workflow: ${e.message}`);
2221
2398
  process.exit(1);
2222
2399
  }
2223
-
2400
+
2224
2401
  if (!steps || steps.length === 0) {
2225
2402
  console.error("Error: No commands found in workflow");
2226
2403
  process.exit(1);
2227
2404
  }
2228
-
2405
+
2229
2406
  // Apply arg defaults
2230
2407
  const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
2231
-
2408
+
2232
2409
  // Validate with --dry-run
2233
2410
  if (dryRun) {
2234
2411
  if (workflowName) {
@@ -2247,7 +2424,9 @@ if (args[0] === "do") {
2247
2424
  }
2248
2425
  process.exit(0);
2249
2426
  }
2250
-
2427
+
2428
+ installBrowserLock(parseBrowserLockOptions(doArgs.includes("--no-lock")));
2429
+
2251
2430
  if (!wantJson) {
2252
2431
  if (workflowName) {
2253
2432
  console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
@@ -2255,7 +2434,7 @@ if (args[0] === "do") {
2255
2434
  console.log(`Running workflow (${steps.length} steps)...\n`);
2256
2435
  }
2257
2436
  }
2258
-
2437
+
2259
2438
  const runWorkflow = async () => {
2260
2439
  const result = await executeDoSteps(steps, {
2261
2440
  onError,
@@ -2268,13 +2447,13 @@ if (args[0] === "do") {
2268
2447
  windowId,
2269
2448
  },
2270
2449
  });
2271
-
2450
+
2272
2451
  // Print summary
2273
2452
  if (wantJson) {
2274
2453
  console.log(JSON.stringify(result, null, 2));
2275
2454
  process.exit(result.status === "completed" ? 0 : 1);
2276
2455
  }
2277
-
2456
+
2278
2457
  console.log("");
2279
2458
  if (result.status === "completed") {
2280
2459
  console.log(`Completed: ${result.completedSteps}/${result.totalSteps} steps (${result.totalMs}ms)`);
@@ -2288,7 +2467,7 @@ if (args[0] === "do") {
2288
2467
  process.exit(1);
2289
2468
  }
2290
2469
  };
2291
-
2470
+
2292
2471
  runWorkflow();
2293
2472
  return;
2294
2473
  }
@@ -2296,7 +2475,7 @@ if (args[0] === "do") {
2296
2475
  // Handle workflow management commands
2297
2476
  if (args[0] === "workflow.list") {
2298
2477
  const workflows = listWorkflows();
2299
-
2478
+
2300
2479
  if (workflows.length === 0) {
2301
2480
  console.log("No workflows found.");
2302
2481
  console.log(`\nWorkflow directories:`);
@@ -2306,13 +2485,13 @@ if (args[0] === "workflow.list") {
2306
2485
  console.log(`\nCreate a workflow JSON file in one of these directories.`);
2307
2486
  process.exit(0);
2308
2487
  }
2309
-
2488
+
2310
2489
  // Group by scope
2311
2490
  const byScope = { project: [], user: [] };
2312
2491
  for (const w of workflows) {
2313
2492
  byScope[w.scope].push(w);
2314
2493
  }
2315
-
2494
+
2316
2495
  if (byScope.user.length > 0) {
2317
2496
  console.log(`User Workflows (~/.surf/workflows/):`);
2318
2497
  for (const w of byScope.user) {
@@ -2321,7 +2500,7 @@ if (args[0] === "workflow.list") {
2321
2500
  }
2322
2501
  console.log("");
2323
2502
  }
2324
-
2503
+
2325
2504
  if (byScope.project.length > 0) {
2326
2505
  console.log(`Project Workflows (./.surf/workflows/):`);
2327
2506
  for (const w of byScope.project) {
@@ -2330,7 +2509,7 @@ if (args[0] === "workflow.list") {
2330
2509
  }
2331
2510
  console.log("");
2332
2511
  }
2333
-
2512
+
2334
2513
  console.log(`Run 'surf workflow.info <name>' for details.`);
2335
2514
  process.exit(0);
2336
2515
  }
@@ -2342,16 +2521,16 @@ if (args[0] === "workflow.info") {
2342
2521
  console.error("Usage: surf workflow.info <name>");
2343
2522
  process.exit(1);
2344
2523
  }
2345
-
2524
+
2346
2525
  const info = getWorkflowInfo(name);
2347
2526
  if (info.error) {
2348
2527
  console.error(`Error: ${info.error}`);
2349
2528
  process.exit(1);
2350
2529
  }
2351
-
2530
+
2352
2531
  console.log(`${info.name}${info.description ? ` - ${info.description}` : ''}`);
2353
2532
  console.log("");
2354
-
2533
+
2355
2534
  // Arguments
2356
2535
  if (info.args && Object.keys(info.args).length > 0) {
2357
2536
  console.log("Arguments:");
@@ -2364,18 +2543,18 @@ if (args[0] === "workflow.info") {
2364
2543
  }
2365
2544
  console.log("");
2366
2545
  }
2367
-
2546
+
2368
2547
  // Steps
2369
2548
  console.log(`Steps (${info.steps.length}):`);
2370
2549
  info.steps.forEach((step, i) => {
2371
2550
  console.log(` ${i + 1}. ${formatStep(step)}`);
2372
2551
  });
2373
2552
  console.log("");
2374
-
2553
+
2375
2554
  // Location
2376
2555
  console.log(`Location: ${info.path}`);
2377
2556
  console.log("");
2378
-
2557
+
2379
2558
  // Example run command
2380
2559
  const argExample = Object.entries(info.args || {})
2381
2560
  .filter(([_, spec]) => spec.required)
@@ -2383,7 +2562,7 @@ if (args[0] === "workflow.info") {
2383
2562
  .join(' ');
2384
2563
  console.log(`Run:`);
2385
2564
  console.log(` surf do ${name}${argExample ? ' ' + argExample : ''}`);
2386
-
2565
+
2387
2566
  process.exit(0);
2388
2567
  }
2389
2568
 
@@ -2394,9 +2573,9 @@ if (args[0] === "workflow.validate") {
2394
2573
  console.error("Usage: surf workflow.validate <file>");
2395
2574
  process.exit(1);
2396
2575
  }
2397
-
2576
+
2398
2577
  const result = validateWorkflowFile(filePath);
2399
-
2578
+
2400
2579
  if (result.valid) {
2401
2580
  console.log(`✓ Valid workflow: ${filePath}`);
2402
2581
  console.log(` Name: ${result.workflow.name || '(unnamed)'}`);
@@ -2414,7 +2593,7 @@ if (args[0] === "workflow.validate") {
2414
2593
  }
2415
2594
  }
2416
2595
 
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"];
2596
+ 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"];
2418
2597
 
2419
2598
  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"];
2420
2599
 
@@ -2462,6 +2641,22 @@ let { positional, options } = parseArgs(args);
2462
2641
  let tool = positional[0];
2463
2642
  let firstArg = positional[1];
2464
2643
 
2644
+ if (tool === "cookie" && firstArg) {
2645
+ const cookieSubcommands = {
2646
+ list: "cookie.list",
2647
+ get: "cookie.get",
2648
+ set: "cookie.set",
2649
+ clear: "cookie.clear",
2650
+ delete: "cookie.clear",
2651
+ };
2652
+ const cookieTool = cookieSubcommands[firstArg];
2653
+ if (cookieTool) {
2654
+ tool = cookieTool;
2655
+ positional = [tool, ...positional.slice(2)];
2656
+ firstArg = positional[1];
2657
+ }
2658
+ }
2659
+
2465
2660
  if (!tool) {
2466
2661
  console.error("Error: No command specified");
2467
2662
  process.exit(1);
@@ -2478,9 +2673,14 @@ tool = ALIASES[tool] || tool;
2478
2673
  // Auto-save screenshots to temp file when no --output specified
2479
2674
  // This ensures agents always get a usable file path, not just an in-memory ID
2480
2675
  // Can be disabled with --no-save flag or autoSaveScreenshots: false in surf.json
2676
+ if (options["full-page"] === true) {
2677
+ options.fullpage = true;
2678
+ delete options["full-page"];
2679
+ }
2680
+
2481
2681
  const config = loadConfig();
2482
2682
  const autoSaveEnabled = config.autoSaveScreenshots !== false && !options["no-save"];
2483
- if (tool === "screenshot" && !options.output && !options.savePath && autoSaveEnabled) {
2683
+ if (tool === "screenshot" && !options.output && !options.savePath && firstArg === undefined && autoSaveEnabled) {
2484
2684
  options.savePath = path.join(SURF_TMP, `surf-snap-${Date.now()}.png`);
2485
2685
  }
2486
2686
 
@@ -2541,6 +2741,8 @@ const PRIMARY_ARG_MAP = {
2541
2741
  "emulate.cpu": "rate",
2542
2742
  search: "term",
2543
2743
  find: "term",
2744
+ "cookie.get": "name",
2745
+ "cookie.clear": "name",
2544
2746
  "wait.element": "selector",
2545
2747
  "wait.url": "pattern",
2546
2748
  zoom: "level",
@@ -2563,6 +2765,19 @@ const PRIMARY_ARG_MAP = {
2563
2765
 
2564
2766
  const toolArgs = { ...options };
2565
2767
 
2768
+ if (tool === "scroll" && firstArg) {
2769
+ if (firstArg === "top" || firstArg === "bottom") {
2770
+ tool = `scroll.${firstArg}`;
2771
+ firstArg = undefined;
2772
+ } else if (["up", "down", "left", "right"].includes(firstArg)) {
2773
+ if (toolArgs.direction === undefined) toolArgs.direction = firstArg;
2774
+ if (positional[2] !== undefined && /^-?\d+$/.test(positional[2]) && toolArgs.amount === undefined && toolArgs.scroll_amount === undefined) {
2775
+ toolArgs.scroll_pixels = parseInt(positional[2], 10);
2776
+ }
2777
+ firstArg = undefined;
2778
+ }
2779
+ }
2780
+
2566
2781
  if (tool === "click" && firstArg) {
2567
2782
  if (/^e\d+$/.test(firstArg)) {
2568
2783
  toolArgs.ref = firstArg;
@@ -2574,6 +2789,30 @@ if (tool === "click" && firstArg) {
2574
2789
  }
2575
2790
  }
2576
2791
 
2792
+ if (tool === "resize") {
2793
+ if (firstArg !== undefined && toolArgs.width === undefined) {
2794
+ let val = firstArg;
2795
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
2796
+ toolArgs.width = val;
2797
+ }
2798
+ if (positional[2] !== undefined && toolArgs.height === undefined) {
2799
+ let val = positional[2];
2800
+ if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
2801
+ toolArgs.height = val;
2802
+ }
2803
+ firstArg = undefined;
2804
+ }
2805
+
2806
+ if (tool === "screenshot" && firstArg !== undefined && toolArgs.output === undefined && toolArgs.savePath === undefined) {
2807
+ toolArgs.savePath = firstArg;
2808
+ firstArg = undefined;
2809
+ }
2810
+
2811
+ if (tool === "record" && firstArg !== undefined && toolArgs.output === undefined) {
2812
+ toolArgs.output = firstArg;
2813
+ firstArg = undefined;
2814
+ }
2815
+
2577
2816
  if (firstArg !== undefined) {
2578
2817
  const primaryKey = PRIMARY_ARG_MAP[tool];
2579
2818
  if (primaryKey && toolArgs[primaryKey] === undefined) {
@@ -2646,6 +2885,9 @@ delete toolArgs["no-screenshot"];
2646
2885
  const softFail = toolArgs["soft-fail"] === true;
2647
2886
  delete toolArgs["soft-fail"];
2648
2887
 
2888
+ const lockOptions = parseBrowserLockOptions(toolArgs["no-lock"] === true);
2889
+ delete toolArgs["no-lock"];
2890
+
2649
2891
  if (!noScreenshot && AUTO_SCREENSHOT_TOOLS.includes(tool)) {
2650
2892
  toolArgs.autoScreenshot = true;
2651
2893
  }
@@ -2667,12 +2909,20 @@ if (tool === "gemini") {
2667
2909
  toolArgs.file = path.resolve(toolArgs.file);
2668
2910
  }
2669
2911
  }
2912
+ if (tool === "chatgpt" && toolArgs.file) {
2913
+ if (Array.isArray(toolArgs.file)) {
2914
+ toolArgs.file = toolArgs.file.map((filePath) => path.resolve(filePath));
2915
+ } else if (typeof toolArgs.file === "string") {
2916
+ toolArgs.file = path.resolve(toolArgs.file);
2917
+ }
2918
+ }
2919
+
2920
+ if ((tool === "screenshot" || tool === "record" || tool === "perf-audit") && outputPath && typeof outputPath !== "string") {
2921
+ console.error("Error: --output requires a file path");
2922
+ process.exit(1);
2923
+ }
2670
2924
 
2671
2925
  if (tool === "screenshot" && outputPath) {
2672
- if (typeof outputPath !== "string") {
2673
- console.error("Error: --output requires a file path");
2674
- process.exit(1);
2675
- }
2676
2926
  toolArgs.savePath = outputPath;
2677
2927
  if (options.full) toolArgs.full = true;
2678
2928
  if (options["max-size"]) toolArgs["max-size"] = options["max-size"];
@@ -2794,11 +3044,7 @@ if (streamMode && (tool === "console" || tool === "network")) {
2794
3044
  });
2795
3045
 
2796
3046
  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
- }
3047
+ console.error("Error:", formatSocketError(e));
2802
3048
  process.exit(1);
2803
3049
  });
2804
3050
 
@@ -2819,7 +3065,7 @@ const request = {
2819
3065
  ...globalOpts,
2820
3066
  };
2821
3067
 
2822
- const sendRequest = (toolName, toolArgs = {}) => {
3068
+ const sendRequest = (toolName, toolArgs = {}, timeoutMs = 5000) => {
2823
3069
  return new Promise((resolve, reject) => {
2824
3070
  const sock = net.createConnection(SOCKET_PATH, () => {
2825
3071
  const req = {
@@ -2853,13 +3099,140 @@ const sendRequest = (toolName, toolArgs = {}) => {
2853
3099
  }
2854
3100
  }
2855
3101
  });
2856
- sock.on("error", (e) => reject(e));
3102
+ sock.on("error", (e) => reject(new Error(formatSocketError(e))));
2857
3103
  let timeoutId;
2858
- timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, 5000);
3104
+ timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, timeoutMs);
2859
3105
  sock.on("close", () => clearTimeout(timeoutId));
2860
3106
  });
2861
3107
  };
2862
3108
 
3109
+ function parseRecordNumber(value, fallback, name, min, max) {
3110
+ if (value === undefined) return fallback;
3111
+ if (typeof value === "boolean") throw new Error(`${name} must be a number`);
3112
+ const parsed = Number(value);
3113
+ if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
3114
+ throw new Error(`${name} must be between ${min} and ${max}`);
3115
+ }
3116
+ return parsed;
3117
+ }
3118
+
3119
+ function parseRecordRect(value) {
3120
+ if (value === undefined) return null;
3121
+ if (typeof value !== "string") throw new Error("rect must be x,y,width,height");
3122
+ const parts = value.split(",").map((part) => Number(part.trim()));
3123
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
3124
+ throw new Error("rect must be x,y,width,height");
3125
+ }
3126
+ const [x, y, width, height] = parts;
3127
+ if (x < 0 || y < 0 || width <= 0 || height <= 0) {
3128
+ throw new Error("rect must use non-negative x/y and positive width/height");
3129
+ }
3130
+ return { x, y, width, height, crop: `${width}x${height}+${x}+${y}` };
3131
+ }
3132
+
3133
+ function assertToolOk(response, context) {
3134
+ if (!response?.error) return;
3135
+ const message = response.error.content?.[0]?.text || response.error.message || JSON.stringify(response.error);
3136
+ throw new Error(`${context}: ${message}`);
3137
+ }
3138
+
3139
+ function assembleRecordGif(framePaths, output, fps, rect) {
3140
+ const delay = Math.max(1, Math.round(100 / fps));
3141
+ const args = ["-delay", String(delay), "-loop", "0", ...framePaths];
3142
+ if (rect) args.push("-crop", rect.crop, "+repage");
3143
+ args.push(output);
3144
+
3145
+ try {
3146
+ execFileSync("magick", args, { stdio: "pipe" });
3147
+ return "magick";
3148
+ } catch (magickError) {
3149
+ try {
3150
+ execFileSync("convert", args, { stdio: "pipe" });
3151
+ return "convert";
3152
+ } catch (convertError) {
3153
+ const detail = convertError && convertError.message ? convertError.message : String(convertError);
3154
+ throw new Error(`Failed to assemble GIF with ImageMagick. Install ImageMagick (magick or convert). Last error: ${detail}`);
3155
+ }
3156
+ }
3157
+ }
3158
+
3159
+ async function runRecord() {
3160
+ const durationMs = parseRecordNumber(toolArgs.duration, 2000, "duration", 100, 10000);
3161
+ const fps = parseRecordNumber(toolArgs.fps, 10, "fps", 1, 30);
3162
+ const rect = parseRecordRect(toolArgs.rect);
3163
+ const output = path.resolve(outputPath || path.join(SURF_TMP, `surf-record-${Date.now()}.gif`));
3164
+ const frameCount = Math.max(1, Math.ceil((durationMs / 1000) * fps));
3165
+ const frameDir = fs.mkdtempSync(path.join(SURF_TMP, "surf-record-"));
3166
+ const framePaths = [];
3167
+ let trigger = null;
3168
+
3169
+ try {
3170
+ if (toolArgs.trigger !== undefined) {
3171
+ trigger = await runRecordTrigger(toolArgs.trigger);
3172
+ }
3173
+
3174
+ const startedAt = Date.now();
3175
+ for (let i = 0; i < frameCount; i++) {
3176
+ const framePath = path.join(frameDir, `frame-${String(i).padStart(4, "0")}.png`);
3177
+ const response = await sendRequest("screenshot", {
3178
+ savePath: framePath,
3179
+ full: toolArgs.full,
3180
+ "max-size": toolArgs["max-size"],
3181
+ }, 30000);
3182
+ assertToolOk(response, `record frame ${i + 1}`);
3183
+ framePaths.push(framePath);
3184
+
3185
+ if (i < frameCount - 1) {
3186
+ const nextFrameAt = startedAt + Math.round(((i + 1) * durationMs) / frameCount);
3187
+ const waitMs = nextFrameAt - Date.now();
3188
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
3189
+ }
3190
+ }
3191
+
3192
+ fs.mkdirSync(path.dirname(output), { recursive: true });
3193
+ const imageMagick = assembleRecordGif(framePaths, output, fps, rect);
3194
+ 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 } }) };
3195
+
3196
+ if (wantJson) {
3197
+ console.log(JSON.stringify(result, null, 2));
3198
+ } else {
3199
+ console.log(`Saved recording to ${output} (${result.frames} frames, ${durationMs}ms @ ${fps}fps)`);
3200
+ }
3201
+ } finally {
3202
+ fs.rmSync(frameDir, { recursive: true, force: true });
3203
+ }
3204
+ }
3205
+
3206
+ async function runRecordTrigger(trigger) {
3207
+ if (typeof trigger !== "string") throw new Error("trigger must be action:target");
3208
+ const separator = trigger.indexOf(":");
3209
+ if (separator === -1) throw new Error("trigger must be action:target");
3210
+ const action = trigger.slice(0, separator).trim();
3211
+ const target = trigger.slice(separator + 1).trim();
3212
+ if (!action || !target) throw new Error("trigger must be action:target");
3213
+
3214
+ if (action === "click") {
3215
+ const response = await sendRequest("click", { selector: target }, 30000);
3216
+ assertToolOk(response, "record trigger");
3217
+ return { action, selector: target };
3218
+ }
3219
+
3220
+ if (action === "scroll") {
3221
+ let response;
3222
+ if (["up", "down", "left", "right"].includes(target)) {
3223
+ response = await sendRequest("scroll", { direction: target }, 30000);
3224
+ } else if (target === "top" || target === "bottom") {
3225
+ response = await sendRequest(`scroll.${target}`, {}, 30000);
3226
+ } else {
3227
+ response = await sendRequest("scroll.bottom", { selector: target }, 30000);
3228
+ }
3229
+ assertToolOk(response, "record trigger");
3230
+ return { action, target };
3231
+ }
3232
+
3233
+ throw new Error("trigger action must be click or scroll");
3234
+ }
3235
+
2863
3236
  const performAutoCapture = async () => {
2864
3237
  const timestamp = Date.now();
2865
3238
  const screenshotPath = path.join(SURF_TMP, `surf-error-${timestamp}.png`);
@@ -2896,6 +3269,19 @@ const performAutoCapture = async () => {
2896
3269
  }
2897
3270
  };
2898
3271
 
3272
+ if (finalTool === "record") {
3273
+ installBrowserLock(lockOptions);
3274
+ runRecord()
3275
+ .then(() => process.exit(0))
3276
+ .catch((error) => {
3277
+ console.error("Error:", error && error.message ? error.message : String(error));
3278
+ process.exit(1);
3279
+ });
3280
+ return;
3281
+ }
3282
+
3283
+ installBrowserLock(lockOptions);
3284
+
2899
3285
  const socket = net.createConnection(SOCKET_PATH, () => {
2900
3286
  socket.write(JSON.stringify(request) + "\n");
2901
3287
  });
@@ -2923,14 +3309,14 @@ socket.on("data", (data) => {
2923
3309
  if (!line.trim()) continue;
2924
3310
  try {
2925
3311
  const msg = JSON.parse(line);
2926
-
3312
+
2927
3313
  if (msg.type === "extension_disconnected") {
2928
3314
  clearTimeout(timeout);
2929
3315
  console.error(msg.message);
2930
3316
  socket.end();
2931
3317
  process.exit(1);
2932
3318
  }
2933
-
3319
+
2934
3320
  handleResponse(msg).catch((err) => {
2935
3321
  console.error("Handler error:", err.message);
2936
3322
  process.exit(1);
@@ -2944,13 +3330,7 @@ socket.on("data", (data) => {
2944
3330
 
2945
3331
  socket.on("error", (err) => {
2946
3332
  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
- }
3333
+ console.error("Error:", formatSocketError(err));
2954
3334
  process.exit(1);
2955
3335
  });
2956
3336
 
@@ -2979,7 +3359,7 @@ async function handleResponse(response) {
2979
3359
  }
2980
3360
 
2981
3361
  const result = response.result?.content?.[0]?.text;
2982
-
3362
+
2983
3363
  let data;
2984
3364
  try {
2985
3365
  data = result ? JSON.parse(result) : response.result;
@@ -2991,6 +3371,17 @@ async function handleResponse(response) {
2991
3371
  data = { response: data };
2992
3372
  }
2993
3373
 
3374
+ if (tool === "perf-audit" && outputPath) {
3375
+ const saveTo = path.resolve(outputPath);
3376
+ fs.mkdirSync(path.dirname(saveTo), { recursive: true });
3377
+ fs.writeFileSync(saveTo, JSON.stringify(data ?? null, null, 2));
3378
+ if (!wantJson) {
3379
+ console.log(`Saved perf audit to ${saveTo}`);
3380
+ socket.end();
3381
+ process.exit(0);
3382
+ }
3383
+ }
3384
+
2994
3385
  if (wantJson) {
2995
3386
  console.log(JSON.stringify(data ?? null, null, 2));
2996
3387
  socket.end();
@@ -3000,12 +3391,12 @@ async function handleResponse(response) {
3000
3391
  if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3001
3392
  const saveTo = outputPath || toolArgs.savePath;
3002
3393
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
3003
-
3394
+
3004
3395
  const skipResize = options.full || toolArgs.full;
3005
3396
  const maxSize = parseInt(options["max-size"] || toolArgs["max-size"] || "1200", 10);
3006
3397
  const origWidth = data.width || 0;
3007
3398
  const origHeight = data.height || 0;
3008
-
3399
+
3009
3400
  if (!skipResize && (origWidth > maxSize || origHeight > maxSize)) {
3010
3401
  const result = resizeImage(saveTo, maxSize);
3011
3402
  if (result.success) {
@@ -3092,7 +3483,7 @@ async function handleResponse(response) {
3092
3483
  } else if (tool === "smoke" && data?.results) {
3093
3484
  const results = data.results;
3094
3485
  const summary = data.summary || { pass: 0, fail: 0, total: results.length };
3095
-
3486
+
3096
3487
  for (const r of results) {
3097
3488
  const status = r.status === "pass" ? "PASS" : "FAIL";
3098
3489
  const timeStr = r.time ? ` (${r.time}ms)` : "";
@@ -3104,10 +3495,10 @@ async function handleResponse(response) {
3104
3495
  }
3105
3496
  }
3106
3497
  }
3107
-
3498
+
3108
3499
  console.log("");
3109
3500
  console.log(`Summary: ${summary.pass} passed, ${summary.fail} failed, ${summary.total} total`);
3110
-
3501
+
3111
3502
  if (summary.fail > 0) {
3112
3503
  socket.end();
3113
3504
  process.exit(1);
@@ -3119,7 +3510,7 @@ async function handleResponse(response) {
3119
3510
  } else if (tool === "network" && (data?.entries || data?.requests)) {
3120
3511
  // Network list - handle both new (entries) and old (requests) formats
3121
3512
  const items = data.entries || data.requests || [];
3122
-
3513
+
3123
3514
  if (items.length === 0) {
3124
3515
  console.log("No network requests captured");
3125
3516
  } else if (data._format === 'raw') {