surf-cli 2.4.2 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -387,6 +387,9 @@ surf do 'go "https://example.com/login" | type "user@example.com" --selector "#e
387
387
  # From JSON file
388
388
  surf do --file workflow.json
389
389
 
390
+ # Run named workflow with arguments
391
+ surf do my-workflow --url "https://example.com" --max_items 10
392
+
390
393
  # Validate without executing
391
394
  surf do 'go "url" | click e5 | screenshot' --dry-run
392
395
  ```
@@ -400,25 +403,100 @@ surf do 'go "url" | click e5 | screenshot' --dry-run
400
403
  - `--step-delay <ms>` - Delay between steps (default: 100, use 0 to disable)
401
404
  - `--no-auto-wait` - Disable automatic waits between steps
402
405
  - `--json` - Output structured JSON result
406
+ - `--<arg> <value>` - Pass arguments to workflow (e.g., `--url "..."`)
403
407
 
404
408
  **Auto-waits:** Commands that trigger page changes automatically wait for completion:
405
409
  - Navigation (`go`, `back`, `forward`) → waits for page load
406
410
  - Clicks, key presses, form fills → waits for DOM stability
407
411
  - Tab switches → waits for tab to load
408
412
 
409
- **JSON file format:**
413
+ #### Workflow Files
414
+
415
+ Workflows can be saved as JSON files and run by name. Place them in `~/.surf/workflows/` (user) or `./.surf/workflows/` (project).
416
+
417
+ **Basic format:**
410
418
  ```json
411
419
  {
412
- "name": "Login Flow",
420
+ "name": "login-flow",
421
+ "description": "Log into example.com",
422
+ "args": {
423
+ "email": { "required": true, "desc": "Login email" },
424
+ "password": { "required": true, "desc": "Login password" }
425
+ },
413
426
  "steps": [
414
427
  { "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": {} }
428
+ { "tool": "type", "args": { "text": "%{email}", "selector": "input[name=email]" } },
429
+ { "tool": "type", "args": { "text": "%{password}", "selector": "input[name=password]" } },
430
+ { "tool": "click", "args": { "selector": "button[type=submit]" } }
431
+ ]
432
+ }
433
+ ```
434
+
435
+ **Step outputs** - Capture results for use in later steps:
436
+ ```json
437
+ {
438
+ "steps": [
439
+ { "tool": "js", "args": { "code": "return document.title" }, "as": "title" },
440
+ { "tool": "js", "args": { "code": "return 'Page: ' + '%{title}'" } }
441
+ ]
442
+ }
443
+ ```
444
+
445
+ **Loops** - `repeat` for fixed iterations, `each` for arrays:
446
+ ```json
447
+ {
448
+ "steps": [
449
+ { "tool": "js", "args": { "code": "return ['a', 'b', 'c']" }, "as": "items" },
450
+ {
451
+ "each": "%{items}",
452
+ "as": "item",
453
+ "steps": [
454
+ { "tool": "js", "args": { "code": "return 'Processing: %{item}'" } }
455
+ ]
456
+ }
457
+ ]
458
+ }
459
+ ```
460
+
461
+ ```json
462
+ {
463
+ "steps": [
464
+ {
465
+ "repeat": 5,
466
+ "steps": [
467
+ { "tool": "scroll", "args": { "direction": "down" } },
468
+ { "tool": "wait", "args": { "duration": 500 } }
469
+ ]
470
+ }
418
471
  ]
419
472
  }
420
473
  ```
421
474
 
475
+ **Loop with exit condition** - Stop early when condition is met:
476
+ ```json
477
+ {
478
+ "repeat": 20,
479
+ "until": { "tool": "js", "args": { "code": "return !document.querySelector('.next-page')" } },
480
+ "steps": [
481
+ { "tool": "click", "args": { "selector": ".next-page" } },
482
+ { "tool": "wait.load" }
483
+ ]
484
+ }
485
+ ```
486
+
487
+ #### Workflow Management
488
+
489
+ ```bash
490
+ # List available workflows
491
+ surf workflow.list
492
+
493
+ # Show workflow details and arguments
494
+ surf workflow.info my-workflow
495
+
496
+ # Validate workflow JSON
497
+ surf workflow.validate ./my-workflow.json
498
+ ```
499
+
422
500
  **Supported commands:** All surf commands work in workflows. Use aliases (`go`, `snap`, `read`) or full names (`navigate`, `screenshot`, `page.read`).
423
501
 
424
502
  ## Global Options
@@ -484,7 +562,7 @@ echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list"
484
562
 
485
563
  | Group | Commands |
486
564
  |-------|----------|
487
- | `workflow` | `do` |
565
+ | `workflow` | `do`, `workflow.list`, `workflow.info`, `workflow.validate` |
488
566
  | `window.*` | `new`, `list`, `focus`, `close`, `resize` |
489
567
  | `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
490
568
  | `scroll.*` | `top`, `bottom`, `to`, `info` |
package/native/cli.cjs CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  const net = require("net");
3
3
  const fs = require("fs");
4
+ const path = require("path");
5
+ const os = require("os");
4
6
  const { execSync } = require("child_process");
5
7
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
6
8
  const networkFormatters = require("./formatters/network.cjs");
@@ -10,6 +12,249 @@ const { executeDoSteps } = require("./do-executor.cjs");
10
12
 
11
13
  const SOCKET_PATH = "/tmp/surf.sock";
12
14
 
15
+ // ============================================================================
16
+ // Workflow Resolution and Management
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Get workflow search directories
21
+ * @returns {Array<{path: string, scope: string}>}
22
+ */
23
+ function getWorkflowDirs() {
24
+ return [
25
+ { path: path.join(process.cwd(), '.surf', 'workflows'), scope: 'project' },
26
+ { path: path.join(os.homedir(), '.surf', 'workflows'), scope: 'user' },
27
+ ];
28
+ }
29
+
30
+ /**
31
+ * Resolve a workflow by name or path
32
+ * @param {string} nameOrPath - Workflow name or file path
33
+ * @returns {{ type: 'inline'|'file'|'not_found', content?: string, path?: string, name?: string }}
34
+ */
35
+ function resolveWorkflow(nameOrPath) {
36
+ // Check if it's an inline workflow (contains pipe)
37
+ if (nameOrPath.includes('|')) {
38
+ return { type: 'inline', content: nameOrPath };
39
+ }
40
+
41
+ // Check if it's a direct file path (with extension or path separator)
42
+ if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
43
+ if (fs.existsSync(nameOrPath)) {
44
+ return { type: 'file', path: nameOrPath };
45
+ }
46
+ return { type: 'not_found', name: nameOrPath };
47
+ }
48
+
49
+ // Look up by name in workflow directories
50
+ const searchDirs = getWorkflowDirs();
51
+
52
+ for (const { path: dir } of searchDirs) {
53
+ const filePath = path.join(dir, `${nameOrPath}.json`);
54
+ if (fs.existsSync(filePath)) {
55
+ return { type: 'file', path: filePath };
56
+ }
57
+ }
58
+
59
+ return { type: 'not_found', name: nameOrPath };
60
+ }
61
+
62
+ /**
63
+ * List all available workflows
64
+ * @returns {Array<{name: string, description: string, scope: string, path: string, args?: object}>}
65
+ */
66
+ function listWorkflows() {
67
+ const workflows = [];
68
+ const searchDirs = getWorkflowDirs();
69
+
70
+ for (const { path: dir, scope } of searchDirs) {
71
+ if (fs.existsSync(dir)) {
72
+ try {
73
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
74
+ for (const file of files) {
75
+ const filePath = path.join(dir, file);
76
+ try {
77
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
78
+ workflows.push({
79
+ name: content.name || file.replace('.json', ''),
80
+ description: content.description || '',
81
+ scope,
82
+ path: filePath,
83
+ args: content.args,
84
+ stepCount: content.steps?.length || 0,
85
+ });
86
+ } catch {
87
+ // Skip invalid JSON files
88
+ }
89
+ }
90
+ } catch {
91
+ // Skip inaccessible directories
92
+ }
93
+ }
94
+ }
95
+
96
+ return workflows;
97
+ }
98
+
99
+ /**
100
+ * Get detailed info about a workflow
101
+ * @param {string} name - Workflow name
102
+ * @returns {{ error?: string, name?: string, description?: string, args?: object, steps?: Array, path?: string }}
103
+ */
104
+ function getWorkflowInfo(name) {
105
+ const resolved = resolveWorkflow(name);
106
+
107
+ if (resolved.type === 'not_found') {
108
+ return { error: `Workflow not found: ${name}` };
109
+ }
110
+
111
+ if (resolved.type === 'inline') {
112
+ return { error: 'Cannot get info for inline workflows' };
113
+ }
114
+
115
+ try {
116
+ const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
117
+ return {
118
+ name: content.name || name,
119
+ description: content.description || '',
120
+ args: content.args || {},
121
+ steps: content.steps || [],
122
+ path: resolved.path,
123
+ };
124
+ } catch (e) {
125
+ return { error: `Failed to parse workflow: ${e.message}` };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Validate workflow args against schema
131
+ * @param {object} workflow - Workflow with args schema
132
+ * @param {object} providedArgs - User-provided args
133
+ * @returns {string[]} - Array of error messages
134
+ */
135
+ function validateWorkflowArgs(workflow, providedArgs) {
136
+ const errors = [];
137
+ if (workflow.args) {
138
+ for (const [name, spec] of Object.entries(workflow.args)) {
139
+ if (spec.required && providedArgs[name] === undefined) {
140
+ errors.push(`Missing required argument: --${name}`);
141
+ }
142
+ }
143
+ }
144
+ return errors;
145
+ }
146
+
147
+ /**
148
+ * Apply default values to workflow args
149
+ * @param {object} workflow - Workflow with args schema
150
+ * @param {object} providedArgs - User-provided args
151
+ * @returns {object} - Args with defaults applied
152
+ */
153
+ function applyArgDefaults(workflow, providedArgs) {
154
+ const vars = { ...providedArgs };
155
+ if (workflow.args) {
156
+ for (const [name, spec] of Object.entries(workflow.args)) {
157
+ if (vars[name] === undefined && spec.default !== undefined) {
158
+ vars[name] = spec.default;
159
+ }
160
+ }
161
+ }
162
+ return vars;
163
+ }
164
+
165
+ /**
166
+ * Validate a workflow JSON file
167
+ * @param {string} filePath - Path to workflow file
168
+ * @returns {{ valid: boolean, error?: string, workflow?: object }}
169
+ */
170
+ function validateWorkflowFile(filePath) {
171
+ if (!fs.existsSync(filePath)) {
172
+ return { valid: false, error: `File not found: ${filePath}` };
173
+ }
174
+
175
+ try {
176
+ const content = fs.readFileSync(filePath, 'utf8');
177
+ const workflow = JSON.parse(content);
178
+
179
+ // Basic structure validation
180
+ if (!workflow.steps || !Array.isArray(workflow.steps)) {
181
+ return { valid: false, error: "Workflow must have a 'steps' array" };
182
+ }
183
+
184
+ if (workflow.steps.length === 0) {
185
+ return { valid: false, error: "Workflow has no steps" };
186
+ }
187
+
188
+ // Validate each step
189
+ for (let i = 0; i < workflow.steps.length; i++) {
190
+ const step = workflow.steps[i];
191
+
192
+ // Check for loops
193
+ if (step.repeat !== undefined || step.each !== undefined) {
194
+ if (!step.steps || !Array.isArray(step.steps)) {
195
+ return { valid: false, error: `Step ${i + 1}: loop must have a 'steps' array` };
196
+ }
197
+ continue;
198
+ }
199
+
200
+ // Regular step must have tool/cmd
201
+ if (!step.tool && !step.cmd) {
202
+ return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
203
+ }
204
+ }
205
+
206
+ // Validate args schema if present
207
+ if (workflow.args && typeof workflow.args !== 'object') {
208
+ return { valid: false, error: "'args' must be an object" };
209
+ }
210
+
211
+ return { valid: true, workflow };
212
+ } catch (e) {
213
+ return { valid: false, error: `Invalid JSON: ${e.message}` };
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Format a step for display
219
+ * @param {object} step - Workflow step
220
+ * @param {number} indent - Indentation level
221
+ * @returns {string}
222
+ */
223
+ function formatStep(step, indent = 0) {
224
+ const pad = ' '.repeat(indent);
225
+
226
+ if (step.repeat !== undefined) {
227
+ const lines = [`${pad}repeat ${step.repeat} times:`];
228
+ for (const s of step.steps || []) {
229
+ lines.push(formatStep(s, indent + 1));
230
+ }
231
+ if (step.until) {
232
+ lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
233
+ }
234
+ return lines.join('\n');
235
+ }
236
+
237
+ if (step.each !== undefined) {
238
+ const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
239
+ for (const s of step.steps || []) {
240
+ lines.push(formatStep(s, indent + 1));
241
+ }
242
+ return lines.join('\n');
243
+ }
244
+
245
+ const tool = step.tool || step.cmd;
246
+ const args = step.args || {};
247
+ const argStr = Object.entries(args)
248
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
249
+ .join(' ');
250
+
251
+ let line = `${pad}${tool}`;
252
+ if (argStr) line += ` ${argStr}`;
253
+ if (step.as) line += ` → ${step.as}`;
254
+
255
+ return line;
256
+ }
257
+
13
258
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
14
259
  function resizeImage(filePath, maxSize) {
15
260
  const platform = process.platform;
@@ -804,7 +1049,7 @@ const TOOLS = {
804
1049
  }
805
1050
  },
806
1051
  workflow: {
807
- desc: "Workflow execution",
1052
+ desc: "Workflow execution and management",
808
1053
  commands: {
809
1054
  "do": {
810
1055
  desc: "Execute multiple commands as a single workflow",
@@ -819,9 +1064,34 @@ const TOOLS = {
819
1064
  examples: [
820
1065
  { cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
821
1066
  { cmd: 'do -f login.json', desc: "From JSON file" },
1067
+ { cmd: 'do github-login --email "x" --password "y"', desc: "Named workflow with args" },
822
1068
  { cmd: 'do \'go "url" | click e5\' --dry-run', desc: "Validate without running" },
823
1069
  ]
824
1070
  },
1071
+ "workflow.list": {
1072
+ desc: "List available workflows",
1073
+ args: [],
1074
+ opts: {},
1075
+ examples: [
1076
+ { cmd: 'workflow.list', desc: "Show all workflows" },
1077
+ ]
1078
+ },
1079
+ "workflow.info": {
1080
+ desc: "Show workflow details and arguments",
1081
+ args: ["name"],
1082
+ opts: {},
1083
+ examples: [
1084
+ { cmd: 'workflow.info github-login', desc: "Show workflow details" },
1085
+ ]
1086
+ },
1087
+ "workflow.validate": {
1088
+ desc: "Validate workflow JSON file",
1089
+ args: ["file"],
1090
+ opts: {},
1091
+ examples: [
1092
+ { cmd: 'workflow.validate ./my-flow.json', desc: "Check JSON validity" },
1093
+ ]
1094
+ },
825
1095
  }
826
1096
  },
827
1097
  zoom: {
@@ -1762,6 +2032,12 @@ if (args[0] === "do") {
1762
2032
  let tabId = undefined;
1763
2033
  let windowId = undefined;
1764
2034
 
2035
+ // Reserved flags that aren't workflow args
2036
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id'];
2037
+
2038
+ // Workflow-specific args (collected for variable substitution)
2039
+ const workflowArgs = {};
2040
+
1765
2041
  // Parse do-specific arguments
1766
2042
  for (let i = 0; i < doArgs.length; i++) {
1767
2043
  const arg = doArgs[i];
@@ -1787,61 +2063,165 @@ if (args[0] === "do") {
1787
2063
  } else if (arg === "--window-id") {
1788
2064
  windowId = parseInt(doArgs[i + 1], 10);
1789
2065
  i++;
2066
+ } else if (arg.startsWith("--")) {
2067
+ // Workflow-specific arg (e.g., --email, --password)
2068
+ const key = arg.slice(2);
2069
+ if (!reservedFlags.includes(key)) {
2070
+ const next = doArgs[i + 1];
2071
+ if (next !== undefined && !next.startsWith("--")) {
2072
+ // Type coercion
2073
+ let val = next;
2074
+ if (val === "true") val = true;
2075
+ else if (val === "false") val = false;
2076
+ else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
2077
+ else if (/^-?\d+\.\d+$/.test(val)) val = parseFloat(val);
2078
+ workflowArgs[key] = val;
2079
+ i++;
2080
+ } else {
2081
+ workflowArgs[key] = true;
2082
+ }
2083
+ }
1790
2084
  } else if (!arg.startsWith("-")) {
1791
2085
  commandsInput = arg;
1792
2086
  }
1793
2087
  }
1794
2088
 
1795
2089
  if (!commandsInput && !fileInput) {
1796
- console.error("Error: commands string or --file required");
1797
- console.error('Usage: surf do \'go "url"\\nclick e5\'');
2090
+ console.error("Error: commands string, workflow name, or --file required");
2091
+ console.error('Usage: surf do \'go "url" | click e5\'');
1798
2092
  console.error(" surf do --file workflow.json");
2093
+ console.error(" surf do my-workflow --arg1 value1 --arg2 value2");
1799
2094
  process.exit(1);
1800
2095
  }
1801
2096
 
1802
2097
  let steps;
2098
+ let workflow = null; // Full workflow object (for arg validation)
2099
+ let workflowName = null;
2100
+
1803
2101
  try {
1804
2102
  if (fileInput) {
2103
+ // Explicit file path via --file
1805
2104
  if (!fs.existsSync(fileInput)) {
1806
2105
  console.error(`Error: File not found: ${fileInput}`);
1807
2106
  process.exit(1);
1808
2107
  }
1809
2108
  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 || {} }));
2109
+ workflow = JSON.parse(content);
2110
+ workflowName = workflow.name || fileInput;
1817
2111
  } else {
1818
- // Inline string parsing
1819
- steps = parseDoCommands(commandsInput);
2112
+ // Resolve: inline | file path | named workflow
2113
+ const resolved = resolveWorkflow(commandsInput);
2114
+
2115
+ if (resolved.type === 'inline') {
2116
+ // Inline pipe syntax
2117
+ steps = parseDoCommands(resolved.content);
2118
+ } else if (resolved.type === 'file') {
2119
+ // Found workflow file
2120
+ const content = fs.readFileSync(resolved.path, "utf8");
2121
+ workflow = JSON.parse(content);
2122
+ workflowName = workflow.name || commandsInput;
2123
+ } else {
2124
+ // Not found - try parsing as inline (might be a single command)
2125
+ steps = parseDoCommands(commandsInput);
2126
+ if (steps.length === 0) {
2127
+ console.error(`Error: Workflow not found: ${commandsInput}`);
2128
+ console.error(`Searched in:`);
2129
+ for (const { path: dir } of getWorkflowDirs()) {
2130
+ console.error(` ${dir}`);
2131
+ }
2132
+ console.error(`\nRun 'surf workflow.list' to see available workflows.`);
2133
+ process.exit(1);
2134
+ }
2135
+ }
2136
+ }
2137
+
2138
+ // Process workflow file if loaded
2139
+ if (workflow) {
2140
+ if (!workflow.steps || !Array.isArray(workflow.steps)) {
2141
+ throw new Error("Workflow must have a 'steps' array");
2142
+ }
2143
+
2144
+ // Validate required args
2145
+ const argErrors = validateWorkflowArgs(workflow, workflowArgs);
2146
+ if (argErrors.length > 0) {
2147
+ console.error("Error: Missing required arguments:");
2148
+ argErrors.forEach(e => console.error(` ${e}`));
2149
+ if (workflow.args) {
2150
+ console.error(`\nWorkflow arguments:`);
2151
+ for (const [name, spec] of Object.entries(workflow.args)) {
2152
+ const req = spec.required ? ' (required)' : '';
2153
+ const def = spec.default !== undefined ? ` [default: ${spec.default}]` : '';
2154
+ const desc = spec.desc || spec.description || '';
2155
+ console.error(` --${name}${req}${def}${desc ? ` - ${desc}` : ''}`);
2156
+ }
2157
+ }
2158
+ console.error(`\nRun 'surf workflow.info ${workflowName}' for details.`);
2159
+ process.exit(1);
2160
+ }
2161
+
2162
+ // Convert steps: support both { tool, args } and { cmd, args } formats
2163
+ // Also preserve loop steps as-is
2164
+ steps = workflow.steps.map(s => {
2165
+ if (s.repeat !== undefined || s.each !== undefined) {
2166
+ // Loop step - convert nested steps recursively
2167
+ const convertSteps = (stepsArr) => stepsArr.map(ns => {
2168
+ if (ns.repeat !== undefined || ns.each !== undefined) {
2169
+ // Recursively convert nested loop steps and until condition
2170
+ return {
2171
+ ...ns,
2172
+ steps: convertSteps(ns.steps || []),
2173
+ until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
2174
+ };
2175
+ }
2176
+ return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
2177
+ });
2178
+ return {
2179
+ ...s,
2180
+ steps: convertSteps(s.steps || []),
2181
+ until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
2182
+ };
2183
+ }
2184
+ return { cmd: s.tool || s.cmd, args: s.args || {}, as: s.as };
2185
+ });
1820
2186
  }
1821
2187
  } catch (e) {
1822
2188
  console.error(`Error: Failed to parse workflow: ${e.message}`);
1823
2189
  process.exit(1);
1824
2190
  }
1825
2191
 
1826
- if (steps.length === 0) {
2192
+ if (!steps || steps.length === 0) {
1827
2193
  console.error("Error: No commands found in workflow");
1828
2194
  process.exit(1);
1829
2195
  }
1830
2196
 
2197
+ // Apply arg defaults
2198
+ const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
2199
+
1831
2200
  // Validate with --dry-run
1832
2201
  if (dryRun) {
1833
- console.log(`Would execute ${steps.length} steps:`);
2202
+ if (workflowName) {
2203
+ console.log(`Workflow: ${workflowName}`);
2204
+ if (workflow?.description) console.log(`Description: ${workflow.description}`);
2205
+ }
2206
+ console.log(`\nWould execute ${steps.length} steps:`);
1834
2207
  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}`);
2208
+ console.log(` ${i + 1}. ${formatStep(s)}`);
1839
2209
  });
2210
+ if (Object.keys(vars).length > 0) {
2211
+ console.log(`\nVariables:`);
2212
+ for (const [k, v] of Object.entries(vars)) {
2213
+ console.log(` ${k} = ${JSON.stringify(v)}`);
2214
+ }
2215
+ }
1840
2216
  process.exit(0);
1841
2217
  }
1842
2218
 
1843
2219
  if (!wantJson) {
1844
- console.log(`Running workflow (${steps.length} steps)...\n`);
2220
+ if (workflowName) {
2221
+ console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
2222
+ } else {
2223
+ console.log(`Running workflow (${steps.length} steps)...\n`);
2224
+ }
1845
2225
  }
1846
2226
 
1847
2227
  const runWorkflow = async () => {
@@ -1850,6 +2230,7 @@ if (args[0] === "do") {
1850
2230
  autoWait: !noAutoWait,
1851
2231
  stepDelay,
1852
2232
  quiet: wantJson,
2233
+ vars,
1853
2234
  context: {
1854
2235
  tabId,
1855
2236
  windowId,
@@ -1880,6 +2261,127 @@ if (args[0] === "do") {
1880
2261
  return;
1881
2262
  }
1882
2263
 
2264
+ // Handle workflow management commands
2265
+ if (args[0] === "workflow.list") {
2266
+ const workflows = listWorkflows();
2267
+
2268
+ if (workflows.length === 0) {
2269
+ console.log("No workflows found.");
2270
+ console.log(`\nWorkflow directories:`);
2271
+ for (const { path: dir, scope } of getWorkflowDirs()) {
2272
+ console.log(` ${scope}: ${dir}`);
2273
+ }
2274
+ console.log(`\nCreate a workflow JSON file in one of these directories.`);
2275
+ process.exit(0);
2276
+ }
2277
+
2278
+ // Group by scope
2279
+ const byScope = { project: [], user: [] };
2280
+ for (const w of workflows) {
2281
+ byScope[w.scope].push(w);
2282
+ }
2283
+
2284
+ if (byScope.user.length > 0) {
2285
+ console.log(`User Workflows (~/.surf/workflows/):`);
2286
+ for (const w of byScope.user) {
2287
+ const desc = w.description ? ` - ${w.description}` : '';
2288
+ console.log(` ${w.name.padEnd(20)} ${desc}`);
2289
+ }
2290
+ console.log("");
2291
+ }
2292
+
2293
+ if (byScope.project.length > 0) {
2294
+ console.log(`Project Workflows (./.surf/workflows/):`);
2295
+ for (const w of byScope.project) {
2296
+ const desc = w.description ? ` - ${w.description}` : '';
2297
+ console.log(` ${w.name.padEnd(20)} ${desc}`);
2298
+ }
2299
+ console.log("");
2300
+ }
2301
+
2302
+ console.log(`Run 'surf workflow.info <name>' for details.`);
2303
+ process.exit(0);
2304
+ }
2305
+
2306
+ if (args[0] === "workflow.info") {
2307
+ const name = args[1];
2308
+ if (!name) {
2309
+ console.error("Error: workflow name required");
2310
+ console.error("Usage: surf workflow.info <name>");
2311
+ process.exit(1);
2312
+ }
2313
+
2314
+ const info = getWorkflowInfo(name);
2315
+ if (info.error) {
2316
+ console.error(`Error: ${info.error}`);
2317
+ process.exit(1);
2318
+ }
2319
+
2320
+ console.log(`${info.name}${info.description ? ` - ${info.description}` : ''}`);
2321
+ console.log("");
2322
+
2323
+ // Arguments
2324
+ if (info.args && Object.keys(info.args).length > 0) {
2325
+ console.log("Arguments:");
2326
+ for (const [argName, spec] of Object.entries(info.args)) {
2327
+ const req = spec.required ? ' (required)' : '';
2328
+ const def = spec.default !== undefined ? ` [default: ${spec.default}]` : '';
2329
+ const desc = spec.desc || spec.description || '';
2330
+ console.log(` --${argName}${req}${def}`);
2331
+ if (desc) console.log(` ${desc}`);
2332
+ }
2333
+ console.log("");
2334
+ }
2335
+
2336
+ // Steps
2337
+ console.log(`Steps (${info.steps.length}):`);
2338
+ info.steps.forEach((step, i) => {
2339
+ console.log(` ${i + 1}. ${formatStep(step)}`);
2340
+ });
2341
+ console.log("");
2342
+
2343
+ // Location
2344
+ console.log(`Location: ${info.path}`);
2345
+ console.log("");
2346
+
2347
+ // Example run command
2348
+ const argExample = Object.entries(info.args || {})
2349
+ .filter(([_, spec]) => spec.required)
2350
+ .map(([name, _]) => `--${name} "..."`)
2351
+ .join(' ');
2352
+ console.log(`Run:`);
2353
+ console.log(` surf do ${name}${argExample ? ' ' + argExample : ''}`);
2354
+
2355
+ process.exit(0);
2356
+ }
2357
+
2358
+ if (args[0] === "workflow.validate") {
2359
+ const filePath = args[1];
2360
+ if (!filePath) {
2361
+ console.error("Error: file path required");
2362
+ console.error("Usage: surf workflow.validate <file>");
2363
+ process.exit(1);
2364
+ }
2365
+
2366
+ const result = validateWorkflowFile(filePath);
2367
+
2368
+ if (result.valid) {
2369
+ console.log(`✓ Valid workflow: ${filePath}`);
2370
+ console.log(` Name: ${result.workflow.name || '(unnamed)'}`);
2371
+ console.log(` Steps: ${result.workflow.steps.length}`);
2372
+ if (result.workflow.args) {
2373
+ const argCount = Object.keys(result.workflow.args).length;
2374
+ const reqCount = Object.values(result.workflow.args).filter(a => a.required).length;
2375
+ console.log(` Args: ${argCount} (${reqCount} required)`);
2376
+ }
2377
+ process.exit(0);
2378
+ } else {
2379
+ console.error(`✗ Invalid workflow: ${filePath}`);
2380
+ console.error(` Error: ${result.error}`);
2381
+ process.exit(1);
2382
+ }
2383
+ }
2384
+
1883
2385
  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"];
1884
2386
 
1885
2387
  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"];
@@ -2,6 +2,11 @@
2
2
  * Executor for surf `do` workflow commands
3
3
  *
4
4
  * Executes steps sequentially with auto-waits and streaming progress output.
5
+ * Supports:
6
+ * - Step outputs: capture results with `as` field
7
+ * - Loops: `repeat` for fixed iterations, `each` for array iteration
8
+ * - Variable substitution: %{varname} syntax
9
+ *
5
10
  * Follows the same socket communication pattern as --script mode in cli.cjs.
6
11
  */
7
12
 
@@ -9,6 +14,9 @@ const net = require("net");
9
14
 
10
15
  const SOCKET_PATH = "/tmp/surf.sock";
11
16
 
17
+ // Maximum iterations for loops (safety cap)
18
+ const MAX_LOOP_ITERATIONS = 100;
19
+
12
20
  // Commands that trigger auto-wait after execution
13
21
  // Note: 'type' is intentionally excluded - typing doesn't trigger navigation or DOM changes
14
22
  const AUTO_WAIT_COMMANDS = [
@@ -114,6 +122,32 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
114
122
  });
115
123
  }
116
124
 
125
+ /**
126
+ * Resolve a variable reference or perform string substitution
127
+ * @param {*} template - Value to resolve (may contain %{var} references)
128
+ * @param {object} vars - Variables map
129
+ * @returns {*} - Resolved value
130
+ */
131
+ function resolveVar(template, vars) {
132
+ if (typeof template !== 'string') return template;
133
+
134
+ // Check if it's a simple variable reference like %{urls}
135
+ const match = template.match(/^%\{(\w+)\}$/);
136
+ if (match) {
137
+ const value = vars[match[1]];
138
+ return value !== undefined ? value : template;
139
+ }
140
+
141
+ // Otherwise do string substitution
142
+ return template.replace(/%\{(\w+)\}/g, (_, name) => {
143
+ const val = vars[name];
144
+ if (val === undefined) return `%{${name}}`;
145
+ // Convert objects/arrays to string for interpolation
146
+ if (typeof val === 'object') return JSON.stringify(val);
147
+ return String(val);
148
+ });
149
+ }
150
+
117
151
  /**
118
152
  * Substitute variables in arguments using %{varname} syntax
119
153
  * @param {object} args - Arguments object
@@ -123,10 +157,28 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
123
157
  function substituteVars(args, vars) {
124
158
  if (!args || typeof args !== 'object') return args;
125
159
 
160
+ // Handle arrays specially to preserve array type
161
+ if (Array.isArray(args)) {
162
+ return args.map(item => {
163
+ if (typeof item === 'string') {
164
+ return resolveVar(item, vars);
165
+ } else if (typeof item === 'object' && item !== null) {
166
+ return substituteVars(item, vars);
167
+ } else {
168
+ return item;
169
+ }
170
+ });
171
+ }
172
+
173
+ // Handle plain objects
126
174
  const result = {};
127
175
  for (const [key, val] of Object.entries(args)) {
128
176
  if (typeof val === 'string') {
129
- result[key] = val.replace(/%\{(\w+)\}/g, (_, name) => vars[name] ?? `%{${name}}`);
177
+ result[key] = resolveVar(val, vars);
178
+ } else if (Array.isArray(val)) {
179
+ result[key] = substituteVars(val, vars);
180
+ } else if (typeof val === 'object' && val !== null) {
181
+ result[key] = substituteVars(val, vars);
130
182
  } else {
131
183
  result[key] = val;
132
184
  }
@@ -134,9 +186,219 @@ function substituteVars(args, vars) {
134
186
  return result;
135
187
  }
136
188
 
189
+ /**
190
+ * Extract usable output from a step response for the `as` capture
191
+ * @param {object} resp - Response from sendDoRequest
192
+ * @returns {*} - Extracted value
193
+ */
194
+ function extractStepOutput(resp) {
195
+ // MCP format: resp.result.content[0].text
196
+ if (resp.result?.content?.[0]?.text) {
197
+ const text = resp.result.content[0].text;
198
+ // Try to parse as JSON, otherwise return raw text
199
+ try {
200
+ return JSON.parse(text);
201
+ } catch {
202
+ return text;
203
+ }
204
+ }
205
+
206
+ // Direct value (some tools return this)
207
+ if (resp.value !== undefined) return resp.value;
208
+
209
+ // Direct result object
210
+ if (resp.result !== undefined) return resp.result;
211
+
212
+ // Fallback to the whole response
213
+ return resp;
214
+ }
215
+
216
+ /**
217
+ * Execute a single tool step (non-loop)
218
+ * @param {object} step - Step to execute { cmd, args, as? }
219
+ * @param {object} vars - Variables map (mutated if step has `as`)
220
+ * @param {object} context - Execution context
221
+ * @param {object} options - Execution options
222
+ * @returns {Promise<object>} - Result { success, error?, output? }
223
+ */
224
+ async function executeSingleStep(step, vars, context, options) {
225
+ const { autoWait = true, stepDelay = 100 } = options;
226
+
227
+ // Substitute variables in args
228
+ const resolvedArgs = substituteVars(step.args || {}, vars);
229
+
230
+ try {
231
+ const resp = await sendDoRequest(step.cmd, resolvedArgs, context);
232
+
233
+ if (resp.error) {
234
+ const errText = resp.error.content?.[0]?.text || JSON.stringify(resp.error);
235
+ return { success: false, error: errText };
236
+ }
237
+
238
+ // Capture output if step has `as` field
239
+ if (step.as) {
240
+ const output = extractStepOutput(resp);
241
+ vars[step.as] = output;
242
+ }
243
+
244
+ // Command-specific auto-wait
245
+ if (autoWait) {
246
+ const waitCmd = getAutoWaitCommand(step.cmd);
247
+ if (waitCmd) {
248
+ const waitArgs = waitCmd === 'wait.load'
249
+ ? { timeout: 10000 }
250
+ : { stable: 100, timeout: 5000 };
251
+ try {
252
+ await sendDoRequest(waitCmd, waitArgs, context);
253
+ } catch {
254
+ // Ignore auto-wait failures silently
255
+ }
256
+ }
257
+ }
258
+
259
+ // Delay between steps
260
+ if (stepDelay > 0) {
261
+ await new Promise(r => setTimeout(r, stepDelay));
262
+ }
263
+
264
+ return { success: true, output: step.as ? vars[step.as] : undefined };
265
+ } catch (err) {
266
+ return { success: false, error: err.message };
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Execute a single step, handling loops recursively
272
+ * @param {object} step - Step to execute (may be a loop or regular step)
273
+ * @param {object} vars - Variables map
274
+ * @param {object} context - Execution context
275
+ * @param {object} options - Execution options
276
+ * @param {function} onProgress - Progress callback for streaming output
277
+ * @returns {Promise<object>} - Result { success, error?, stepsExecuted }
278
+ */
279
+ async function executeStep(step, vars, context, options, onProgress) {
280
+ const { onError = 'stop' } = options;
281
+
282
+ // Handle `repeat` loop
283
+ if (step.repeat !== undefined) {
284
+ // Resolve repeat count (may be a variable)
285
+ let max = resolveVar(step.repeat, vars);
286
+ if (typeof max === 'string') max = parseInt(max, 10);
287
+ if (typeof max !== 'number' || isNaN(max)) max = 1;
288
+
289
+ // Safety cap
290
+ max = Math.min(max, MAX_LOOP_ITERATIONS);
291
+
292
+ if (!Array.isArray(step.steps) || step.steps.length === 0) {
293
+ return { success: false, error: 'repeat: steps array required', stepsExecuted: 0 };
294
+ }
295
+
296
+ let totalExecuted = 0;
297
+
298
+ for (let i = 0; i < max; i++) {
299
+ // Create loop-scoped variables
300
+ const loopVars = { ...vars, _index: i, _iteration: i + 1 };
301
+
302
+ // Execute nested steps
303
+ for (const nestedStep of step.steps) {
304
+ const result = await executeStep(nestedStep, loopVars, context, options, onProgress);
305
+ totalExecuted += result.stepsExecuted || 1;
306
+
307
+ if (!result.success && onError === 'stop') {
308
+ return { success: false, error: result.error, stepsExecuted: totalExecuted };
309
+ }
310
+ }
311
+
312
+ // Copy captured variables back to parent scope (only from regular steps, not loops)
313
+ for (const nestedStep of step.steps) {
314
+ // Skip loop steps - their 'as' is the loop variable, not an output capture
315
+ const isNestedLoop = nestedStep.repeat !== undefined || nestedStep.each !== undefined;
316
+ if (!isNestedLoop && nestedStep.as && loopVars[nestedStep.as] !== undefined) {
317
+ vars[nestedStep.as] = loopVars[nestedStep.as];
318
+ }
319
+ }
320
+
321
+ // Check `until` condition
322
+ if (step.until) {
323
+ const untilResult = await executeSingleStep(step.until, loopVars, context, options);
324
+ totalExecuted++;
325
+
326
+ // Exit loop if until condition is truthy
327
+ const exitValue = untilResult.output;
328
+ if (exitValue === true || exitValue === 'true' || exitValue) {
329
+ break;
330
+ }
331
+ }
332
+ }
333
+
334
+ return { success: true, stepsExecuted: totalExecuted };
335
+ }
336
+
337
+ // Handle `each` loop
338
+ if (step.each !== undefined) {
339
+ const items = resolveVar(step.each, vars);
340
+
341
+ if (!Array.isArray(items)) {
342
+ return {
343
+ success: false,
344
+ error: `each: expected array, got ${typeof items}${items === undefined ? ' (undefined)' : ''}`,
345
+ stepsExecuted: 0
346
+ };
347
+ }
348
+
349
+ if (!Array.isArray(step.steps) || step.steps.length === 0) {
350
+ return { success: false, error: 'each: steps array required', stepsExecuted: 0 };
351
+ }
352
+
353
+ // Safety cap
354
+ const maxItems = Math.min(items.length, MAX_LOOP_ITERATIONS);
355
+ const itemVar = step.as || 'item';
356
+ let totalExecuted = 0;
357
+
358
+ for (let i = 0; i < maxItems; i++) {
359
+ // Create loop-scoped variables
360
+ const loopVars = { ...vars, [itemVar]: items[i], _index: i, _iteration: i + 1 };
361
+
362
+ // Execute nested steps
363
+ for (const nestedStep of step.steps) {
364
+ const result = await executeStep(nestedStep, loopVars, context, options, onProgress);
365
+ totalExecuted += result.stepsExecuted || 1;
366
+
367
+ if (!result.success && onError === 'stop') {
368
+ return { success: false, error: result.error, stepsExecuted: totalExecuted };
369
+ }
370
+ }
371
+
372
+ // Copy captured variables back to parent scope (only from regular steps, not loops)
373
+ for (const nestedStep of step.steps) {
374
+ // Skip loop steps - their 'as' is the loop variable, not an output capture
375
+ const isNestedLoop = nestedStep.repeat !== undefined || nestedStep.each !== undefined;
376
+ if (!isNestedLoop && nestedStep.as && loopVars[nestedStep.as] !== undefined) {
377
+ vars[nestedStep.as] = loopVars[nestedStep.as];
378
+ }
379
+ }
380
+ }
381
+
382
+ return { success: true, stepsExecuted: totalExecuted };
383
+ }
384
+
385
+ // Regular step (non-loop)
386
+ if (onProgress) {
387
+ onProgress(step, 'start');
388
+ }
389
+
390
+ const result = await executeSingleStep(step, vars, context, options);
391
+
392
+ if (onProgress) {
393
+ onProgress(step, result.success ? 'ok' : 'fail', result.error);
394
+ }
395
+
396
+ return { ...result, stepsExecuted: 1 };
397
+ }
398
+
137
399
  /**
138
400
  * Execute all workflow steps sequentially
139
- * @param {Array<{ cmd: string, args: object }>} steps - Steps to execute
401
+ * @param {Array<object>} steps - Steps to execute
140
402
  * @param {object} options - Execution options
141
403
  * @returns {Promise<object>} - Execution result
142
404
  */
@@ -147,118 +409,111 @@ async function executeDoSteps(steps, options = {}) {
147
409
  stepDelay = 100,
148
410
  context = {},
149
411
  quiet = false, // For --json mode, suppress streaming output
412
+ vars: initialVars = {},
150
413
  } = options;
151
414
 
152
415
  const results = [];
153
- const vars = context.vars || {};
416
+ const vars = { ...initialVars, ...(context.vars || {}) };
154
417
  const total = steps.length;
155
418
  let failed = 0;
419
+ let stepsExecuted = 0;
156
420
  const startTotal = Date.now();
157
421
 
158
422
  for (let i = 0; i < total; i++) {
159
423
  const step = steps[i];
160
424
  const startTime = Date.now();
161
- const stepNum = `[${i + 1}/${total}]`;
162
425
 
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;
426
+ // Check if this is a loop step
427
+ const isLoop = step.repeat !== undefined || step.each !== undefined;
170
428
 
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);
429
+ if (isLoop) {
430
+ // Loops handle their own progress output
431
+ if (!quiet) {
432
+ const loopType = step.repeat !== undefined ? `repeat ${step.repeat}` : `each ${step.each}`;
433
+ console.log(`[${i + 1}/${total}] Loop: ${loopType} (${step.steps?.length || 0} nested steps)`);
434
+ }
179
435
 
180
- const resp = await sendDoRequest(step.cmd, resolvedArgs, context);
436
+ const result = await executeStep(step, vars, context, { onError, autoWait, stepDelay }, null);
181
437
  const ms = Date.now() - startTime;
182
438
 
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 });
439
+ stepsExecuted += result.stepsExecuted || 0;
440
+
441
+ if (!result.success) {
442
+ results.push({ step: i + 1, type: 'loop', status: 'error', error: result.error, ms });
192
443
  failed++;
193
444
 
194
445
  if (onError === 'stop') {
195
446
  return {
196
447
  status: 'failed',
197
- completedSteps: i,
448
+ completedSteps: stepsExecuted,
198
449
  totalSteps: total,
199
450
  results,
200
- error: errText,
201
- totalMs: Date.now() - startTotal
451
+ error: result.error,
452
+ totalMs: Date.now() - startTotal,
453
+ vars
202
454
  };
203
455
  }
204
456
  } else {
457
+ results.push({ step: i + 1, type: 'loop', status: 'ok', stepsExecuted: result.stepsExecuted, ms });
205
458
  if (!quiet) {
206
- console.log(`OK (${ms}ms)`);
459
+ console.log(` Loop completed: ${result.stepsExecuted} steps (${ms}ms)`);
207
460
  }
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
461
  }
231
- } catch (err) {
232
- const ms = Date.now() - startTime;
462
+ } else {
463
+ // Regular step
464
+ const stepNum = `[${i + 1}/${total}]`;
465
+ const argSummary = Object.entries(step.args || {})
466
+ .map(([k, v]) => typeof v === "string" && v.length > 40
467
+ ? `${k}="${v.slice(0, 37)}..."`
468
+ : `${k}=${JSON.stringify(v)}`)
469
+ .join(" ");
470
+ const desc = argSummary ? `${step.cmd} ${argSummary}` : step.cmd;
233
471
 
234
472
  if (!quiet) {
235
- console.log(`FAIL`);
236
- console.log(` Error: ${err.message}`);
473
+ process.stdout.write(`${stepNum} ${desc} ... `);
237
474
  }
238
475
 
239
- results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: err.message, ms });
240
- failed++;
476
+ const result = await executeSingleStep(step, vars, context, { onError, autoWait, stepDelay });
477
+ const ms = Date.now() - startTime;
478
+ stepsExecuted++;
241
479
 
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
- };
480
+ if (!result.success) {
481
+ if (!quiet) {
482
+ console.log('FAIL');
483
+ console.log(` Error: ${result.error}`);
484
+ }
485
+
486
+ results.push({ step: i + 1, cmd: step.cmd, status: 'error', error: result.error, ms });
487
+ failed++;
488
+
489
+ if (onError === 'stop') {
490
+ return {
491
+ status: 'failed',
492
+ completedSteps: stepsExecuted - 1,
493
+ totalSteps: total,
494
+ results,
495
+ error: result.error,
496
+ totalMs: Date.now() - startTotal,
497
+ vars
498
+ };
499
+ }
500
+ } else {
501
+ if (!quiet) {
502
+ console.log(`OK (${ms}ms)`);
503
+ }
504
+ results.push({ step: i + 1, cmd: step.cmd, status: 'ok', ms });
251
505
  }
252
506
  }
253
507
  }
254
508
 
255
509
  return {
256
510
  status: failed > 0 ? 'partial' : 'completed',
257
- completedSteps: total - failed,
511
+ completedSteps: stepsExecuted,
258
512
  totalSteps: total,
259
513
  results,
260
514
  failed,
261
- totalMs: Date.now() - startTotal
515
+ totalMs: Date.now() - startTotal,
516
+ vars
262
517
  };
263
518
  }
264
519
 
@@ -268,6 +523,11 @@ module.exports = {
268
523
  shouldAutoWait,
269
524
  getAutoWaitCommand,
270
525
  substituteVars,
526
+ resolveVar,
527
+ extractStepOutput,
528
+ executeStep,
529
+ executeSingleStep,
271
530
  AUTO_WAIT_COMMANDS,
272
- AUTO_WAIT_MAP
531
+ AUTO_WAIT_MAP,
532
+ MAX_LOOP_ITERATIONS
273
533
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",