surf-cli 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
+ <p>
2
+ <img src="surf-banner.png" alt="surf" width="1100">
3
+ </p>
4
+
1
5
  # Surf
2
6
 
3
- The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.
7
+ **The CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.**
8
+
9
+ [![npm version](https://img.shields.io/npm/v/surf-cli?style=for-the-badge)](https://www.npmjs.com/package/surf-cli)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](LICENSE)
11
+ [![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux-blue?style=for-the-badge)]()
4
12
 
5
13
  ```bash
6
14
  surf go "https://example.com"
@@ -365,6 +373,54 @@ surf network.stats # Capture statistics
365
373
  Storage location: `/tmp/surf/` (override with `--network-path` or `SURF_NETWORK_PATH` env).
366
374
  Auto-cleanup: 24 hours TTL, 200MB max.
367
375
 
376
+ ### Workflows
377
+
378
+ Execute multi-step browser automation as a single command:
379
+
380
+ ```bash
381
+ # Inline workflow (pipe-separated)
382
+ surf do 'go "https://example.com" | click e5 | screenshot'
383
+
384
+ # Multi-step login flow
385
+ surf do 'go "https://example.com/login" | type "user@example.com" --selector "#email" | type "pass" --selector "#password" | click --selector "button[type=submit]"'
386
+
387
+ # From JSON file
388
+ surf do --file workflow.json
389
+
390
+ # Validate without executing
391
+ surf do 'go "url" | click e5 | screenshot' --dry-run
392
+ ```
393
+
394
+ **Why workflows?** Instead of 6-8 separate CLI calls with LLM orchestration between each step, a workflow executes deterministically with smart auto-waits. Faster, cheaper, and more reliable.
395
+
396
+ **Options:**
397
+ - `--file`, `-f` - Load workflow from JSON file
398
+ - `--dry-run` - Parse and validate without executing
399
+ - `--on-error stop|continue` - Error handling (default: stop)
400
+ - `--step-delay <ms>` - Delay between steps (default: 100, use 0 to disable)
401
+ - `--no-auto-wait` - Disable automatic waits between steps
402
+ - `--json` - Output structured JSON result
403
+
404
+ **Auto-waits:** Commands that trigger page changes automatically wait for completion:
405
+ - Navigation (`go`, `back`, `forward`) → waits for page load
406
+ - Clicks, key presses, form fills → waits for DOM stability
407
+ - Tab switches → waits for tab to load
408
+
409
+ **JSON file format:**
410
+ ```json
411
+ {
412
+ "name": "Login Flow",
413
+ "steps": [
414
+ { "tool": "navigate", "args": { "url": "https://example.com/login" } },
415
+ { "tool": "type", "args": { "text": "user@example.com", "selector": "input[name=email]" } },
416
+ { "tool": "click", "args": { "selector": "button[type=submit]" } },
417
+ { "tool": "screenshot", "args": {} }
418
+ ]
419
+ }
420
+ ```
421
+
422
+ **Supported commands:** All surf commands work in workflows. Use aliases (`go`, `snap`, `read`) or full names (`navigate`, `screenshot`, `page.read`).
423
+
368
424
  ## Global Options
369
425
 
370
426
  ```bash
@@ -385,10 +441,50 @@ For programmatic integration, send JSON to `/tmp/surf.sock`:
385
441
  echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list","args":{}},"id":"1"}' | nc -U /tmp/surf.sock
386
442
  ```
387
443
 
444
+ ### Protocol Reference
445
+
446
+ **Request:**
447
+ ```json
448
+ {
449
+ "type": "tool_request",
450
+ "method": "execute_tool",
451
+ "params": {
452
+ "tool": "click",
453
+ "args": { "ref": "e5" }
454
+ },
455
+ "id": "unique-request-id",
456
+ "tabId": 123,
457
+ "windowId": 456
458
+ }
459
+ ```
460
+
461
+ **Success Response:**
462
+ ```json
463
+ {
464
+ "type": "tool_response",
465
+ "id": "unique-request-id",
466
+ "result": {
467
+ "content": [{ "type": "text", "text": "Result message" }]
468
+ }
469
+ }
470
+ ```
471
+
472
+ **Error Response:**
473
+ ```json
474
+ {
475
+ "type": "tool_response",
476
+ "id": "unique-request-id",
477
+ "error": {
478
+ "content": [{ "type": "text", "text": "Error message" }]
479
+ }
480
+ }
481
+ ```
482
+
388
483
  ## Command Groups
389
484
 
390
485
  | Group | Commands |
391
486
  |-------|----------|
487
+ | `workflow` | `do` |
392
488
  | `window.*` | `new`, `list`, `focus`, `close`, `resize` |
393
489
  | `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
394
490
  | `scroll.*` | `top`, `bottom`, `to`, `info` |
@@ -449,6 +545,20 @@ surf install <extension-id> --browser chromium
449
545
  - Screenshot resize uses ImageMagick instead of macOS `sips`
450
546
  - Headless servers need Xvfb + VNC for initial login setup
451
547
 
548
+ ## AI Agent Integration
549
+
550
+ Surf includes a skill file for AI coding agents like [Pi](https://github.com/badlogic/pi-mono):
551
+
552
+ ```bash
553
+ # Symlink for auto-updates
554
+ ln -s "$(pwd)/skills/surf" ~/.pi/agent/skills/surf
555
+
556
+ # Or copy
557
+ cp -r skills/surf ~/.pi/agent/skills/
558
+ ```
559
+
560
+ See [`skills/README.md`](skills/README.md) for details.
561
+
452
562
  ## Development
453
563
 
454
564
  ```bash
package/native/cli.cjs CHANGED
@@ -5,6 +5,8 @@ const { execSync } = require("child_process");
5
5
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
6
6
  const networkFormatters = require("./formatters/network.cjs");
7
7
  const networkStore = require("./network-store.cjs");
8
+ const { parseDoCommands } = require("./do-parser.cjs");
9
+ const { executeDoSteps } = require("./do-executor.cjs");
8
10
 
9
11
  const SOCKET_PATH = "/tmp/surf.sock";
10
12
 
@@ -801,6 +803,27 @@ const TOOLS = {
801
803
  },
802
804
  }
803
805
  },
806
+ workflow: {
807
+ desc: "Workflow execution",
808
+ commands: {
809
+ "do": {
810
+ desc: "Execute multiple commands as a single workflow",
811
+ args: ["commands"],
812
+ opts: {
813
+ file: "Load workflow from JSON file",
814
+ "on-error": "stop (default) | continue",
815
+ "no-auto-wait": "Disable automatic waits between steps",
816
+ "step-delay": "Delay between steps in ms (default: 100)",
817
+ "dry-run": "Parse and validate without executing"
818
+ },
819
+ examples: [
820
+ { cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
821
+ { cmd: 'do -f login.json', desc: "From JSON file" },
822
+ { cmd: 'do \'go "url" | click e5\' --dry-run', desc: "Validate without running" },
823
+ ]
824
+ },
825
+ }
826
+ },
804
827
  zoom: {
805
828
  desc: "Zoom control",
806
829
  commands: {
@@ -1725,7 +1748,139 @@ if (args.includes("--script")) {
1725
1748
  return;
1726
1749
  }
1727
1750
 
1728
- 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"];
1751
+ // Handle `surf do` workflow command
1752
+ // Must be parsed before general parseArgs since it uses its own arg handling
1753
+ if (args[0] === "do") {
1754
+ const doArgs = args.slice(1);
1755
+ let commandsInput = null;
1756
+ let fileInput = null;
1757
+ let dryRun = false;
1758
+ let onError = "stop";
1759
+ let noAutoWait = false;
1760
+ let stepDelay = 100;
1761
+ let wantJson = false;
1762
+ let tabId = undefined;
1763
+ let windowId = undefined;
1764
+
1765
+ // Parse do-specific arguments
1766
+ for (let i = 0; i < doArgs.length; i++) {
1767
+ const arg = doArgs[i];
1768
+ if (arg === "--file" || arg === "-f") {
1769
+ fileInput = doArgs[i + 1];
1770
+ i++;
1771
+ } else if (arg === "--dry-run") {
1772
+ dryRun = true;
1773
+ } else if (arg === "--on-error") {
1774
+ onError = doArgs[i + 1] || "stop";
1775
+ i++;
1776
+ } else if (arg === "--no-auto-wait") {
1777
+ noAutoWait = true;
1778
+ } else if (arg === "--step-delay") {
1779
+ const parsed = parseInt(doArgs[i + 1], 10);
1780
+ stepDelay = isNaN(parsed) ? 100 : parsed;
1781
+ i++;
1782
+ } else if (arg === "--json") {
1783
+ wantJson = true;
1784
+ } else if (arg === "--tab-id") {
1785
+ tabId = parseInt(doArgs[i + 1], 10);
1786
+ i++;
1787
+ } else if (arg === "--window-id") {
1788
+ windowId = parseInt(doArgs[i + 1], 10);
1789
+ i++;
1790
+ } else if (!arg.startsWith("-")) {
1791
+ commandsInput = arg;
1792
+ }
1793
+ }
1794
+
1795
+ if (!commandsInput && !fileInput) {
1796
+ console.error("Error: commands string or --file required");
1797
+ console.error('Usage: surf do \'go "url"\\nclick e5\'');
1798
+ console.error(" surf do --file workflow.json");
1799
+ process.exit(1);
1800
+ }
1801
+
1802
+ let steps;
1803
+ try {
1804
+ if (fileInput) {
1805
+ if (!fs.existsSync(fileInput)) {
1806
+ console.error(`Error: File not found: ${fileInput}`);
1807
+ process.exit(1);
1808
+ }
1809
+ const content = fs.readFileSync(fileInput, "utf8");
1810
+ // JSON file format (same as --script)
1811
+ const script = JSON.parse(content);
1812
+ if (!script.steps || !Array.isArray(script.steps)) {
1813
+ throw new Error("JSON must have a 'steps' array");
1814
+ }
1815
+ // Convert --script format { tool, args } to do format { cmd, args }
1816
+ steps = script.steps.map(s => ({ cmd: s.tool, args: s.args || {} }));
1817
+ } else {
1818
+ // Inline string parsing
1819
+ steps = parseDoCommands(commandsInput);
1820
+ }
1821
+ } catch (e) {
1822
+ console.error(`Error: Failed to parse workflow: ${e.message}`);
1823
+ process.exit(1);
1824
+ }
1825
+
1826
+ if (steps.length === 0) {
1827
+ console.error("Error: No commands found in workflow");
1828
+ process.exit(1);
1829
+ }
1830
+
1831
+ // Validate with --dry-run
1832
+ if (dryRun) {
1833
+ console.log(`Would execute ${steps.length} steps:`);
1834
+ steps.forEach((s, i) => {
1835
+ const argStr = Object.entries(s.args || {})
1836
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
1837
+ .join(" ");
1838
+ console.log(` ${i + 1}. ${s.cmd} ${argStr}`);
1839
+ });
1840
+ process.exit(0);
1841
+ }
1842
+
1843
+ if (!wantJson) {
1844
+ console.log(`Running workflow (${steps.length} steps)...\n`);
1845
+ }
1846
+
1847
+ const runWorkflow = async () => {
1848
+ const result = await executeDoSteps(steps, {
1849
+ onError,
1850
+ autoWait: !noAutoWait,
1851
+ stepDelay,
1852
+ quiet: wantJson,
1853
+ context: {
1854
+ tabId,
1855
+ windowId,
1856
+ },
1857
+ });
1858
+
1859
+ // Print summary
1860
+ if (wantJson) {
1861
+ console.log(JSON.stringify(result, null, 2));
1862
+ process.exit(result.status === "completed" ? 0 : 1);
1863
+ }
1864
+
1865
+ console.log("");
1866
+ if (result.status === "completed") {
1867
+ console.log(`Completed: ${result.completedSteps}/${result.totalSteps} steps (${result.totalMs}ms)`);
1868
+ process.exit(0);
1869
+ } else if (result.status === "partial") {
1870
+ console.log(`Partial: ${result.completedSteps}/${result.totalSteps} steps completed, ${result.failed} failed`);
1871
+ process.exit(1);
1872
+ } else {
1873
+ console.error(`Failed: ${result.completedSteps}/${result.totalSteps} steps completed`);
1874
+ if (result.error) console.error(`Error: ${result.error}`);
1875
+ process.exit(1);
1876
+ }
1877
+ };
1878
+
1879
+ runWorkflow();
1880
+ return;
1881
+ }
1882
+
1883
+ 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"];
1729
1884
 
1730
1885
  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"];
1731
1886
 
@@ -1755,8 +1910,12 @@ const parseArgs = (rawArgs) => {
1755
1910
  result.options.v = true;
1756
1911
  } else if (arg === "-vv") {
1757
1912
  result.options.vv = true;
1913
+ } else if (arg === "-f" && rawArgs[i + 1] && !rawArgs[i + 1].startsWith("-")) {
1914
+ // -f takes a file path argument (for surf do -f <file>)
1915
+ result.options.file = rawArgs[i + 1];
1916
+ i++;
1758
1917
  } else if (arg.startsWith("-") && arg.length === 2) {
1759
- // Short flag like -n, -f
1918
+ // Short flag like -n
1760
1919
  result.options[arg.slice(1)] = true;
1761
1920
  } else {
1762
1921
  result.positional.push(arg);
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Executor for surf `do` workflow commands
3
+ *
4
+ * Executes steps sequentially with auto-waits and streaming progress output.
5
+ * Follows the same socket communication pattern as --script mode in cli.cjs.
6
+ */
7
+
8
+ const net = require("net");
9
+
10
+ const SOCKET_PATH = "/tmp/surf.sock";
11
+
12
+ // Commands that trigger auto-wait after execution
13
+ // Note: 'type' is intentionally excluded - typing doesn't trigger navigation or DOM changes
14
+ const AUTO_WAIT_COMMANDS = [
15
+ 'go', 'navigate', 'click', 'key', 'form.fill', 'submit',
16
+ 'tab.switch', 'tab.new', 'back', 'forward'
17
+ ];
18
+
19
+ // Auto-wait strategies per command type
20
+ const AUTO_WAIT_MAP = {
21
+ 'navigate': 'wait.load',
22
+ 'go': 'wait.load',
23
+ 'click': 'wait.dom',
24
+ 'key': 'wait.dom',
25
+ 'form.fill': 'wait.dom',
26
+ 'submit': 'wait.load', // Form submission typically triggers navigation
27
+ 'tab.switch': 'wait.load',
28
+ 'tab.new': 'wait.load',
29
+ 'back': 'wait.load',
30
+ 'forward': 'wait.load',
31
+ };
32
+
33
+ /**
34
+ * Check if a command should trigger an auto-wait
35
+ * @param {string} cmd - Command name
36
+ * @returns {boolean}
37
+ */
38
+ function shouldAutoWait(cmd) {
39
+ return AUTO_WAIT_COMMANDS.some(c => cmd === c || cmd.startsWith(c + '.'));
40
+ }
41
+
42
+ /**
43
+ * Get the appropriate auto-wait command for a given command
44
+ * @param {string} cmd - Command name
45
+ * @returns {string|null} - Wait command to execute, or null
46
+ */
47
+ function getAutoWaitCommand(cmd) {
48
+ // Check exact match first
49
+ if (AUTO_WAIT_MAP[cmd] !== undefined) return AUTO_WAIT_MAP[cmd];
50
+
51
+ // Check prefix match
52
+ for (const [prefix, waitCmd] of Object.entries(AUTO_WAIT_MAP)) {
53
+ if (cmd.startsWith(prefix + '.')) return waitCmd;
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ /**
60
+ * Send a single tool request over socket
61
+ * @param {string} toolName - Tool/command name
62
+ * @param {object} toolArgs - Tool arguments
63
+ * @param {object} context - Execution context (tabId, windowId)
64
+ * @returns {Promise<object>} - Response from host
65
+ */
66
+ function sendDoRequest(toolName, toolArgs, context = {}) {
67
+ return new Promise((resolve, reject) => {
68
+ const sock = net.createConnection(SOCKET_PATH, () => {
69
+ const req = {
70
+ type: "tool_request",
71
+ method: "execute_tool",
72
+ params: { tool: toolName, args: toolArgs },
73
+ id: "do-" + Date.now() + "-" + Math.random(),
74
+ };
75
+ if (context.tabId) req.tabId = context.tabId;
76
+ if (context.windowId) req.windowId = context.windowId;
77
+ sock.write(JSON.stringify(req) + "\n");
78
+ });
79
+
80
+ let buf = "";
81
+ sock.on("data", (d) => {
82
+ buf += d.toString();
83
+ const lines = buf.split("\n");
84
+ buf = lines.pop();
85
+ for (const line of lines) {
86
+ if (!line.trim()) continue;
87
+ try {
88
+ const resp = JSON.parse(line);
89
+ sock.end();
90
+ resolve(resp);
91
+ } catch {
92
+ sock.end();
93
+ reject(new Error("Invalid JSON response"));
94
+ }
95
+ }
96
+ });
97
+
98
+ sock.on("error", (e) => {
99
+ if (e.code === "ENOENT") {
100
+ reject(new Error("Socket not found. Is Chrome running with the extension?"));
101
+ } else if (e.code === "ECONNREFUSED") {
102
+ reject(new Error("Connection refused. Native host not running."));
103
+ } else {
104
+ reject(e);
105
+ }
106
+ });
107
+
108
+ const timeoutId = setTimeout(() => {
109
+ sock.destroy();
110
+ reject(new Error("Request timeout"));
111
+ }, 30000);
112
+
113
+ sock.on("close", () => clearTimeout(timeoutId));
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Substitute variables in arguments using %{varname} syntax
119
+ * @param {object} args - Arguments object
120
+ * @param {object} vars - Variables map
121
+ * @returns {object} - Arguments with variables substituted
122
+ */
123
+ function substituteVars(args, vars) {
124
+ if (!args || typeof args !== 'object') return args;
125
+
126
+ const result = {};
127
+ for (const [key, val] of Object.entries(args)) {
128
+ if (typeof val === 'string') {
129
+ result[key] = val.replace(/%\{(\w+)\}/g, (_, name) => vars[name] ?? `%{${name}}`);
130
+ } else {
131
+ result[key] = val;
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Execute all workflow steps sequentially
139
+ * @param {Array<{ cmd: string, args: object }>} steps - Steps to execute
140
+ * @param {object} options - Execution options
141
+ * @returns {Promise<object>} - Execution result
142
+ */
143
+ async function executeDoSteps(steps, options = {}) {
144
+ const {
145
+ onError = 'stop',
146
+ autoWait = true,
147
+ stepDelay = 100,
148
+ context = {},
149
+ quiet = false, // For --json mode, suppress streaming output
150
+ } = options;
151
+
152
+ const results = [];
153
+ const vars = context.vars || {};
154
+ const total = steps.length;
155
+ let failed = 0;
156
+ const startTotal = Date.now();
157
+
158
+ for (let i = 0; i < total; i++) {
159
+ const step = steps[i];
160
+ const startTime = Date.now();
161
+ const stepNum = `[${i + 1}/${total}]`;
162
+
163
+ // Build description (matches --script output format)
164
+ const argSummary = Object.entries(step.args || {})
165
+ .map(([k, v]) => typeof v === "string" && v.length > 40
166
+ ? `${k}="${v.slice(0, 37)}..."`
167
+ : `${k}=${JSON.stringify(v)}`)
168
+ .join(" ");
169
+ const desc = argSummary ? `${step.cmd} ${argSummary}` : step.cmd;
170
+
171
+ // Print step prefix (streaming output)
172
+ if (!quiet) {
173
+ process.stdout.write(`${stepNum} ${desc} ... `);
174
+ }
175
+
176
+ try {
177
+ // Substitute variables in args
178
+ const resolvedArgs = substituteVars(step.args, vars);
179
+
180
+ const resp = await sendDoRequest(step.cmd, resolvedArgs, context);
181
+ const ms = Date.now() - startTime;
182
+
183
+ if (resp.error) {
184
+ const errText = resp.error.content?.[0]?.text || JSON.stringify(resp.error);
185
+
186
+ if (!quiet) {
187
+ console.log(`FAIL`);
188
+ console.log(` Error: ${errText}`);
189
+ }
190
+
191
+ results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: errText, ms });
192
+ failed++;
193
+
194
+ if (onError === 'stop') {
195
+ return {
196
+ status: 'failed',
197
+ completedSteps: i,
198
+ totalSteps: total,
199
+ results,
200
+ error: errText,
201
+ totalMs: Date.now() - startTotal
202
+ };
203
+ }
204
+ } else {
205
+ if (!quiet) {
206
+ console.log(`OK (${ms}ms)`);
207
+ }
208
+
209
+ results.push({ step: i + 1, cmd: step.cmd, status: 'ok', ms });
210
+
211
+ // Command-specific auto-wait
212
+ if (autoWait) {
213
+ const waitCmd = getAutoWaitCommand(step.cmd);
214
+ if (waitCmd) {
215
+ const waitArgs = waitCmd === 'wait.load'
216
+ ? { timeout: 10000 }
217
+ : { stable: 100, timeout: 5000 };
218
+ try {
219
+ await sendDoRequest(waitCmd, waitArgs, context);
220
+ } catch {
221
+ // Ignore auto-wait failures silently
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ // Fixed delay between steps
228
+ if (stepDelay > 0 && i < total - 1) {
229
+ await new Promise(r => setTimeout(r, stepDelay));
230
+ }
231
+ } catch (err) {
232
+ const ms = Date.now() - startTime;
233
+
234
+ if (!quiet) {
235
+ console.log(`FAIL`);
236
+ console.log(` Error: ${err.message}`);
237
+ }
238
+
239
+ results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: err.message, ms });
240
+ failed++;
241
+
242
+ if (onError === 'stop') {
243
+ return {
244
+ status: 'failed',
245
+ completedSteps: i,
246
+ totalSteps: total,
247
+ results,
248
+ error: err.message,
249
+ totalMs: Date.now() - startTotal
250
+ };
251
+ }
252
+ }
253
+ }
254
+
255
+ return {
256
+ status: failed > 0 ? 'partial' : 'completed',
257
+ completedSteps: total - failed,
258
+ totalSteps: total,
259
+ results,
260
+ failed,
261
+ totalMs: Date.now() - startTotal
262
+ };
263
+ }
264
+
265
+ module.exports = {
266
+ executeDoSteps,
267
+ sendDoRequest,
268
+ shouldAutoWait,
269
+ getAutoWaitCommand,
270
+ substituteVars,
271
+ AUTO_WAIT_COMMANDS,
272
+ AUTO_WAIT_MAP
273
+ };
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Parser for surf `do` workflow commands
3
+ *
4
+ * Parses newline-separated commands into structured step arrays:
5
+ *
6
+ * Input:
7
+ * 'go "https://example.com"
8
+ * click e5
9
+ * screenshot'
10
+ *
11
+ * Output:
12
+ * [
13
+ * { cmd: 'navigate', args: { url: 'https://example.com' } },
14
+ * { cmd: 'click', args: { ref: 'e5' } },
15
+ * { cmd: 'screenshot', args: {} }
16
+ * ]
17
+ */
18
+
19
+ // Aliases mapping (matches cli.cjs)
20
+ const ALIASES = {
21
+ snap: "screenshot",
22
+ read: "page.read",
23
+ find: "search",
24
+ go: "navigate",
25
+ net: "network",
26
+ "network.dump": "network.get",
27
+ };
28
+
29
+ // Primary argument mapping for positional args (matches cli.cjs)
30
+ const PRIMARY_ARG_MAP = {
31
+ ai: "query",
32
+ gemini: "query",
33
+ chatgpt: "query",
34
+ perplexity: "query",
35
+ grok: "query",
36
+ navigate: "url",
37
+ go: "url",
38
+ js: "code",
39
+ javascript_tool: "code",
40
+ key: "key",
41
+ wait: "duration",
42
+ health: "url",
43
+ new_tab: "url",
44
+ "tab.new": "url",
45
+ switch_tab: "tab_id",
46
+ "tab.switch": "id",
47
+ close_tab: "tab_id",
48
+ "tab.close": "id",
49
+ "tab.name": "name",
50
+ "tab.unname": "name",
51
+ scroll_to_position: "position",
52
+ type: "text",
53
+ smart_type: "text",
54
+ "emulate.network": "preset",
55
+ "emulate.cpu": "rate",
56
+ search: "term",
57
+ find: "term",
58
+ "wait.element": "selector",
59
+ "wait.url": "pattern",
60
+ zoom: "level",
61
+ "history.search": "query",
62
+ "network.get": "id",
63
+ "network.body": "id",
64
+ "network.curl": "id",
65
+ "network.path": "id",
66
+ "window.new": "url",
67
+ "window.focus": "id",
68
+ "window.close": "id",
69
+ "locate.role": "role",
70
+ "locate.text": "text",
71
+ "locate.label": "label",
72
+ "emulate.device": "device",
73
+ "frame.js": "code",
74
+ "element.styles": "selector",
75
+ "select": "selector",
76
+ };
77
+
78
+ /**
79
+ * Tokenize a command line, respecting single and double quotes
80
+ * @param {string} line - Single line to tokenize
81
+ * @returns {string[]} - Array of tokens
82
+ */
83
+ function tokenize(line) {
84
+ const tokens = [];
85
+ let current = '';
86
+ let inQuote = null;
87
+
88
+ for (let i = 0; i < line.length; i++) {
89
+ const ch = line[i];
90
+
91
+ if (inQuote) {
92
+ if (ch === inQuote) {
93
+ // End of quoted string
94
+ inQuote = null;
95
+ } else {
96
+ current += ch;
97
+ }
98
+ } else if (ch === '"' || ch === "'") {
99
+ // Start of quoted string
100
+ inQuote = ch;
101
+ } else if (ch === ' ' || ch === '\t') {
102
+ // Whitespace separator
103
+ if (current) {
104
+ tokens.push(current);
105
+ current = '';
106
+ }
107
+ } else {
108
+ current += ch;
109
+ }
110
+ }
111
+
112
+ // Don't forget last token
113
+ if (current) {
114
+ tokens.push(current);
115
+ }
116
+
117
+ return tokens;
118
+ }
119
+
120
+ /**
121
+ * Parse a single command line into a step object
122
+ * @param {string} line - Single command line
123
+ * @returns {{ cmd: string, args: object } | null}
124
+ */
125
+ function parseCommandLine(line) {
126
+ const tokens = tokenize(line);
127
+ if (tokens.length === 0) return null;
128
+
129
+ // Get command and apply alias
130
+ let cmd = tokens[0];
131
+ cmd = ALIASES[cmd] || cmd;
132
+
133
+ const args = {};
134
+ let i = 1;
135
+
136
+ // Handle first positional argument based on command type
137
+ if (i < tokens.length && !tokens[i].startsWith('--')) {
138
+ const firstArg = tokens[i];
139
+
140
+ // Special handling for click command
141
+ if (cmd === 'click') {
142
+ if (/^e\d+$/.test(firstArg)) {
143
+ // Element reference: e5 -> ref
144
+ args.ref = firstArg;
145
+ i++;
146
+ } else if (/^\d+$/.test(firstArg) && tokens[i + 1] && /^\d+$/.test(tokens[i + 1])) {
147
+ // Coordinates: 100 200 -> x, y
148
+ args.x = parseInt(firstArg, 10);
149
+ args.y = parseInt(tokens[i + 1], 10);
150
+ i += 2;
151
+ }
152
+ } else if (cmd === 'select') {
153
+ // Select takes selector + one or more values: select e5 "US" or select e5 "opt1" "opt2"
154
+ args.selector = firstArg;
155
+ i++;
156
+ // Collect remaining positional args as values
157
+ const values = [];
158
+ while (i < tokens.length && !tokens[i].startsWith('--')) {
159
+ values.push(tokens[i]);
160
+ i++;
161
+ }
162
+ // Host expects 'values' (always), matching CLI behavior
163
+ if (values.length === 1) {
164
+ args.values = values[0]; // Single value as string (host will wrap in array)
165
+ } else if (values.length > 1) {
166
+ args.values = values; // Multiple values as array
167
+ }
168
+ } else {
169
+ // Use PRIMARY_ARG_MAP for other commands
170
+ const primaryKey = PRIMARY_ARG_MAP[cmd];
171
+ if (primaryKey) {
172
+ args[primaryKey] = firstArg;
173
+ i++;
174
+ }
175
+ }
176
+ }
177
+
178
+ // Parse --flag value pairs
179
+ while (i < tokens.length) {
180
+ const token = tokens[i];
181
+ if (token.startsWith('--')) {
182
+ const key = token.slice(2);
183
+ const next = tokens[i + 1];
184
+ if (next && !next.startsWith('--')) {
185
+ // Flag with value
186
+ let val = next;
187
+ // Type coercion
188
+ if (val === "true") val = true;
189
+ else if (val === "false") val = false;
190
+ else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
191
+ else if (/^-?\d+\.\d+$/.test(val)) val = parseFloat(val);
192
+ args[key] = val;
193
+ i += 2;
194
+ } else {
195
+ // Boolean flag
196
+ args[key] = true;
197
+ i++;
198
+ }
199
+ } else {
200
+ // Skip unrecognized positional (shouldn't happen normally)
201
+ i++;
202
+ }
203
+ }
204
+
205
+ return { cmd, args };
206
+ }
207
+
208
+ /**
209
+ * Parse a workflow string into step array
210
+ * Supports pipe-separated (inline) or newline-separated (file) commands
211
+ * @param {string} input - Workflow string
212
+ * @returns {Array<{ cmd: string, args: object }>}
213
+ */
214
+ function parseDoCommands(input) {
215
+ // Determine separator: use pipe if present, otherwise newlines
216
+ // Pipe is preferred for inline: 'go "url" | click e5 | screenshot'
217
+ // Newlines for files or heredocs
218
+ const hasPipe = input.includes('|');
219
+ const separator = hasPipe ? '|' : '\n';
220
+
221
+ // Also handle literal \n for backwards compatibility
222
+ const normalized = hasPipe ? input : input.replace(/\\n/g, '\n');
223
+
224
+ return normalized
225
+ .split(separator)
226
+ .map(line => line.trim())
227
+ .filter(line => line && !line.startsWith('#'))
228
+ .map(line => parseCommandLine(line))
229
+ .filter(step => step !== null);
230
+ }
231
+
232
+ module.exports = {
233
+ parseDoCommands,
234
+ parseCommandLine,
235
+ tokenize,
236
+ ALIASES,
237
+ PRIMARY_ARG_MAP
238
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -1,136 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to surf CLI will be documented in this file.
4
-
5
- ## [2.2.0] - 2026-01-07
6
-
7
- ### Added
8
- - Network capture: `network`, `network.stats`, `network.origins`, `network.get`, `network.clear`, `network.export`
9
- - Filtering by method, status, URL, content-type
10
- - Export formats: curl, raw JSON, URL list
11
- - Persistence to `/tmp/surf/` (configurable via `SURF_NETWORK_PATH`)
12
-
13
- ## [2.1.0] - 2025-12-30
14
-
15
- ### Added
16
-
17
- **ChatGPT Integration**
18
- - `chatgpt <query>` - Send prompt to ChatGPT using browser cookies (no API key)
19
- - `--with-page` - Include current page context
20
- - `--model` - Specify model (gpt-4o, o1, etc.)
21
- - `--timeout` - Custom timeout (default: 45 minutes)
22
- - File attachments coming soon
23
-
24
- **Gemini Integration (Coming Soon)**
25
- - `gemini <query>` - Command structure ready, implementation pending
26
-
27
- **Request Queue**
28
- - AI requests are queued sequentially with 2s minimum delay between requests
29
- - Prevents rate limiting when making multiple AI queries
30
-
31
- ### Technical Changes
32
- - New `chatgpt-client.cjs` module for ChatGPT browser automation
33
- - Extension: `GET_CHATGPT_COOKIES`, `GET_GOOGLE_COOKIES` handlers
34
- - Extension: `CHATGPT_NEW_TAB`, `CHATGPT_CLOSE_TAB`, `CHATGPT_CDP_COMMAND`, `CHATGPT_EVALUATE` handlers
35
- - CDP controller: Added public `sendCommand()` method
36
-
37
- ## [2.0.0] - 2025-12-27
38
-
39
- ### Breaking Changes
40
- - Removed snake_case command aliases (use dot-notation instead)
41
- - `read_page` -> `page.read`
42
- - `list_tabs` -> `tab.list`
43
- - `wait_for_element` -> `wait.element`
44
- - `javascript_tool` -> `js`
45
- - And others (see REMOVED_COMMANDS in cli.cjs for full list)
46
- - Removed all single-letter short flags for consistency
47
- - Use `--output` instead of `-o`
48
- - Use `--ref` instead of `-r`
49
- - Use `--annotate` instead of `-a`
50
- - Use `--fullpage` instead of `-f`
51
- - Migration hints shown when using old command names
52
-
53
- ### Added
54
-
55
- **Navigation**
56
- - `back` - Go back in browser history
57
- - `forward` - Go forward in browser history
58
- - `tab.reload` - Reload tab (with `--hard` for cache bypass)
59
-
60
- **Tab Groups**
61
- - `tab.group` - Create or add to tab group
62
- - `tab.ungroup` - Remove tabs from group
63
- - `tab.groups` - List all tab groups
64
-
65
- **Zoom Control**
66
- - `zoom` - Get current zoom level
67
- - `zoom <level>` - Set zoom (e.g., `zoom 1.5` for 150%)
68
- - `zoom --reset` - Reset to default zoom
69
-
70
- **Cookies**
71
- - `cookie.list` - List cookies for current domain
72
- - `cookie.get` - Get specific cookie by name
73
- - `cookie.set` - Set a cookie
74
- - `cookie.clear` - Clear specific cookie or all (`--all`)
75
-
76
- **Search**
77
- - `search <term>` - Search for text in page (alias: `find`)
78
- - Returns match refs, context, and element associations
79
-
80
- **Batch Execution**
81
- - `batch --actions '[...]'` - Execute multiple actions
82
- - `batch --file workflow.json` - Load actions from file
83
-
84
- **Bookmarks**
85
- - `bookmark.add` - Bookmark current page
86
- - `bookmark.remove` - Remove bookmark for current page
87
- - `bookmark.list` - List bookmarks
88
-
89
- **History**
90
- - `history.list` - Recent browser history
91
- - `history.search <query>` - Search history
92
-
93
- **Screenshot Enhancements**
94
- - `--annotate` - Draw element labels on screenshot
95
- - `--fullpage` - Capture entire scrollable page
96
- - `--max-height` - Limit fullpage capture height (default: 4000px)
97
- - Extension UI automatically hidden during capture
98
-
99
- **Aliases**
100
- - `snap` -> `screenshot` (auto-saves to /tmp if no output specified)
101
- - `read` -> `page.read`
102
- - `find` -> `search`
103
- - `go` -> `navigate`
104
-
105
- **Discovery Features**
106
- - `--find <query>` - Fuzzy search for commands
107
- - `--about <topic>` - Learn about a topic
108
-
109
- **Help System**
110
- - `--help` - Basic help with common commands
111
- - `--help-full` - Complete command reference
112
- - `--help-topic <topic>` - Topic-specific guide
113
- - Command-level help with examples
114
-
115
- **Other**
116
- - `--version` - Show version
117
- - `click 100 200` - Positional coordinates for click
118
- - `click --selector ".btn" --index 2` - Click nth element matching selector
119
-
120
- ### Changed
121
- - Primary argument support for commands:
122
- - `wait.element <selector>` (was `--selector`)
123
- - `wait.url <pattern>` (was `--pattern`)
124
- - `click <ref>` with e-prefix detection (e.g., `click e5`)
125
- - Help output includes usage examples for all commands
126
- - `dialog.dismiss --all` for repeatedly dismissing dialogs
127
- - Fullpage screenshot delay increased to 300ms for lazy-loaded content
128
- - Error messages standardized to terse format for AI consumption
129
-
130
- ### Fixed
131
- - `--limit 0` now correctly returns empty results (was defaulting to max)
132
- - Screenshot always hides extension UI (was conditional on `--clean` flag)
133
-
134
- ## [1.x] - Previous Releases
135
-
136
- See git history for changes before v2.0.0.
package/native/README.md DELETED
@@ -1,141 +0,0 @@
1
- # Surf Native Host
2
-
3
- Native messaging host that bridges CLI commands to the Chrome extension via Unix socket.
4
-
5
- ## Architecture
6
-
7
- ```
8
- CLI (surf) → Unix Socket (/tmp/surf.sock) → Native Host → Chrome Extension → CDP
9
- ```
10
-
11
- ## Files
12
-
13
- | File | Purpose |
14
- |------|---------|
15
- | `host.cjs` | Main native host with socket server and tool handling |
16
- | `cli.cjs` | CLI tool for browser automation |
17
- | `chatgpt-client.cjs` | ChatGPT browser automation client |
18
- | `protocol.cjs` | Chrome native messaging protocol helpers |
19
- | `host-wrapper.py` | Python wrapper for native host execution |
20
- | `host.sh` | Shell script to start the host |
21
-
22
- ## Setup
23
-
24
- 1. Install the native host manifest:
25
- ```bash
26
- npm run install:native <extension-id>
27
- ```
28
-
29
- Or manually:
30
- ```bash
31
- mkdir -p ~/Library/Application\ Support/Google/Chrome/NativeMessagingHosts
32
- cat > ~/Library/Application\ Support/Google/Chrome/NativeMessagingHosts/com.anthropic.pi_chrome.json << EOF
33
- {
34
- "name": "com.anthropic.pi_chrome",
35
- "description": "Surf CLI Native Host",
36
- "path": "$PWD/host-wrapper.py",
37
- "type": "stdio",
38
- "allowed_origins": ["chrome-extension://YOUR_EXTENSION_ID/"]
39
- }
40
- EOF
41
- ```
42
-
43
- 2. Start the native host:
44
- ```bash
45
- node host.cjs
46
- ```
47
-
48
- The host creates a Unix socket at `/tmp/surf.sock`.
49
-
50
- ## CLI Reference
51
-
52
- See the main [README](../README.md) for full CLI documentation.
53
-
54
- ### Quick Reference
55
-
56
- ```bash
57
- surf go "https://example.com" # Navigate
58
- surf read # Get accessibility tree
59
- surf click e5 # Click element
60
- surf type "hello" --submit # Type and submit
61
- surf snap # Screenshot to /tmp
62
- surf chatgpt "explain this" # Query ChatGPT
63
- ```
64
-
65
- ### Global Options
66
-
67
- ```bash
68
- --tab-id <id> # Target specific tab
69
- --json # Output raw JSON
70
- --soft-fail # Warn instead of error on restricted pages
71
- --no-screenshot # Skip auto-screenshot after actions
72
- --full # Full resolution screenshots
73
- ```
74
-
75
- ## Protocol
76
-
77
- ### Tool Request
78
-
79
- ```json
80
- {
81
- "type": "tool_request",
82
- "method": "execute_tool",
83
- "params": {
84
- "tool": "TOOL_NAME",
85
- "args": { ... },
86
- "tabId": 123
87
- },
88
- "id": "unique-request-id"
89
- }
90
- ```
91
-
92
- ### Tool Response (Success)
93
-
94
- ```json
95
- {
96
- "type": "tool_response",
97
- "id": "unique-request-id",
98
- "result": {
99
- "content": [
100
- { "type": "text", "text": "Result message" }
101
- ]
102
- }
103
- }
104
- ```
105
-
106
- ### Tool Response (With Image)
107
-
108
- ```json
109
- {
110
- "type": "tool_response",
111
- "id": "unique-request-id",
112
- "result": {
113
- "content": [
114
- { "type": "text", "text": "Screenshot captured" },
115
- { "type": "image", "data": "base64...", "mimeType": "image/png" }
116
- ]
117
- }
118
- }
119
- ```
120
-
121
- ### Tool Response (Error)
122
-
123
- ```json
124
- {
125
- "type": "tool_response",
126
- "id": "unique-request-id",
127
- "error": {
128
- "content": [{ "type": "text", "text": "Error message" }]
129
- }
130
- }
131
- ```
132
-
133
- ## Troubleshooting
134
-
135
- | Issue | Solution |
136
- |-------|----------|
137
- | Socket not found | Ensure `node host.cjs` is running |
138
- | No response | Check extension is loaded in Chrome |
139
- | "Content script not loaded" | Navigate to a page first |
140
- | "Cannot control this page" | Page is restricted (chrome://, extensions) - use `--soft-fail` |
141
- | Slow first operation | Normal - CDP debugger attachment takes ~100-500ms |