surf-cli 2.9.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +61 -4
  2. package/dist/content/accessibility-tree.js +11 -0
  3. package/dist/content/accessibility-tree.js.map +1 -0
  4. package/dist/content/visual-indicator.js +111 -0
  5. package/dist/content/visual-indicator.js.map +1 -0
  6. package/dist/manifest.json +11 -2
  7. package/dist/options/options.js +3 -3
  8. package/dist/options/options.js.map +1 -1
  9. package/dist/service-worker/index.js +61 -261
  10. package/dist/service-worker/index.js.map +1 -1
  11. package/native/activity-journal.cjs +55 -0
  12. package/native/chatgpt-client-response.cjs +336 -0
  13. package/native/chatgpt-client-selection.cjs +119 -0
  14. package/native/chatgpt-client-ui.cjs +481 -0
  15. package/native/chatgpt-client.cjs +254 -664
  16. package/native/cli.cjs +100 -273
  17. package/native/do-executor.cjs +52 -475
  18. package/native/do-parser.cjs +8 -249
  19. package/native/host-helpers.cjs +32 -15
  20. package/native/host-sessions.cjs +6 -1
  21. package/native/host.cjs +228 -6
  22. package/native/network-export.cjs +20 -17
  23. package/native/network-store.cjs +38 -58
  24. package/native/oracle-cli.cjs +434 -0
  25. package/native/oracle-context.cjs +311 -0
  26. package/native/oracle-host.cjs +301 -0
  27. package/native/oracle-jobs.cjs +253 -0
  28. package/native/playbook-authoring.cjs +44 -0
  29. package/native/playbook-cli.cjs +157 -0
  30. package/native/playbook-client.cjs +259 -0
  31. package/native/playbook-receipts.cjs +109 -0
  32. package/native/playbook-records.cjs +208 -0
  33. package/native/playbook-runtime.cjs +177 -0
  34. package/native/playbooks.cjs +235 -0
  35. package/native/private-state.cjs +156 -0
  36. package/native/redaction.cjs +104 -0
  37. package/native/workflow-definition.cjs +369 -0
  38. package/native/workflow-runtime.cjs +225 -0
  39. package/package.json +2 -1
  40. package/playbooks/page/ops/read.json +22 -0
  41. package/playbooks/page/playbook.json +7 -0
  42. package/skills/surf/SKILL.md +72 -1
  43. package/dist/content/index.js +0 -116
  44. package/dist/content/index.js.map +0 -1
package/native/cli.cjs CHANGED
@@ -1,15 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
- const os = require("os");
5
4
  const { execFileSync, execSync } = require("child_process");
6
5
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
7
6
  const networkFormatters = require("./formatters/network.cjs");
8
- const networkStore = require("./network-store.cjs");
9
- const { parseDoCommands } = require("./do-parser.cjs");
7
+ const {
8
+ applyArgDefaults,
9
+ formatStep,
10
+ getWorkflowDirs,
11
+ getWorkflowInfo,
12
+ listWorkflows,
13
+ normalizeWorkflow,
14
+ parseDoCommands,
15
+ resolveWorkflow,
16
+ validateWorkflowArgs,
17
+ validateWorkflowFile,
18
+ } = require("./workflow-definition.cjs");
10
19
  const { executeDoSteps } = require("./do-executor.cjs");
11
20
  const { openClientTransport } = require("./client-transport.cjs");
12
21
  const { version: VERSION } = require("../package.json");
22
+ const {
23
+ formatOracleError,
24
+ formatOracleOutput,
25
+ handleOracleCli,
26
+ } = require("./oracle-cli.cjs");
27
+ const { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser } = require("./playbook-cli.cjs");
13
28
 
14
29
  const IS_WIN = process.platform === "win32";
15
30
  const { SURF_TMP, formatSocketError } = require("./socket-path.cjs");
@@ -63,247 +78,26 @@ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
63
78
  });
64
79
  }
65
80
 
66
- // ============================================================================
67
- // Workflow Resolution and Management
68
- // ============================================================================
69
-
70
- /**
71
- * Get workflow search directories
72
- * @returns {Array<{path: string, scope: string}>}
73
- */
74
- function getWorkflowDirs() {
75
- return [
76
- { path: path.join(process.cwd(), '.surf', 'workflows'), scope: 'project' },
77
- { path: path.join(os.homedir(), '.surf', 'workflows'), scope: 'user' },
78
- ];
79
- }
80
-
81
- /**
82
- * Resolve a workflow by name or path
83
- * @param {string} nameOrPath - Workflow name or file path
84
- * @returns {{ type: 'inline'|'file'|'not_found', content?: string, path?: string, name?: string }}
85
- */
86
- function resolveWorkflow(nameOrPath) {
87
- // Check if it's an inline workflow (contains pipe)
88
- if (nameOrPath.includes('|')) {
89
- return { type: 'inline', content: nameOrPath };
90
- }
91
-
92
- // Check if it's a direct file path (with extension or path separator)
93
- if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
94
- if (fs.existsSync(nameOrPath)) {
95
- return { type: 'file', path: nameOrPath };
96
- }
97
- return { type: 'not_found', name: nameOrPath };
98
- }
99
-
100
- // Look up by name in workflow directories
101
- const searchDirs = getWorkflowDirs();
102
-
103
- for (const { path: dir } of searchDirs) {
104
- const filePath = path.join(dir, `${nameOrPath}.json`);
105
- if (fs.existsSync(filePath)) {
106
- return { type: 'file', path: filePath };
107
- }
108
- }
109
-
110
- return { type: 'not_found', name: nameOrPath };
111
- }
112
-
113
- /**
114
- * List all available workflows
115
- * @returns {Array<{name: string, description: string, scope: string, path: string, args?: object}>}
116
- */
117
- function listWorkflows() {
118
- const workflows = [];
119
- const searchDirs = getWorkflowDirs();
120
-
121
- for (const { path: dir, scope } of searchDirs) {
122
- if (fs.existsSync(dir)) {
123
- try {
124
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
125
- for (const file of files) {
126
- const filePath = path.join(dir, file);
127
- try {
128
- const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
129
- workflows.push({
130
- name: content.name || file.replace('.json', ''),
131
- description: content.description || '',
132
- scope,
133
- path: filePath,
134
- args: content.args,
135
- stepCount: content.steps?.length || 0,
136
- });
137
- } catch {
138
- // Skip invalid JSON files
139
- }
140
- }
141
- } catch {
142
- // Skip inaccessible directories
143
- }
144
- }
145
- }
146
-
147
- return workflows;
148
- }
149
-
150
- /**
151
- * Get detailed info about a workflow
152
- * @param {string} name - Workflow name
153
- * @returns {{ error?: string, name?: string, description?: string, args?: object, steps?: Array, path?: string }}
154
- */
155
- function getWorkflowInfo(name) {
156
- const resolved = resolveWorkflow(name);
157
-
158
- if (resolved.type === 'not_found') {
159
- return { error: `Workflow not found: ${name}` };
160
- }
161
-
162
- if (resolved.type === 'inline') {
163
- return { error: 'Cannot get info for inline workflows' };
164
- }
165
-
166
- try {
167
- const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
168
- return {
169
- name: content.name || name,
170
- description: content.description || '',
171
- args: content.args || {},
172
- steps: content.steps || [],
173
- path: resolved.path,
174
- };
175
- } catch (e) {
176
- return { error: `Failed to parse workflow: ${e.message}` };
177
- }
178
- }
179
-
180
- /**
181
- * Validate workflow args against schema
182
- * @param {object} workflow - Workflow with args schema
183
- * @param {object} providedArgs - User-provided args
184
- * @returns {string[]} - Array of error messages
185
- */
186
- function validateWorkflowArgs(workflow, providedArgs) {
187
- const errors = [];
188
- if (workflow.args) {
189
- for (const [name, spec] of Object.entries(workflow.args)) {
190
- if (spec.required && providedArgs[name] === undefined) {
191
- errors.push(`Missing required argument: --${name}`);
192
- }
193
- }
194
- }
195
- return errors;
196
- }
197
-
198
- /**
199
- * Apply default values to workflow args
200
- * @param {object} workflow - Workflow with args schema
201
- * @param {object} providedArgs - User-provided args
202
- * @returns {object} - Args with defaults applied
203
- */
204
- function applyArgDefaults(workflow, providedArgs) {
205
- const vars = { ...providedArgs };
206
- if (workflow.args) {
207
- for (const [name, spec] of Object.entries(workflow.args)) {
208
- if (vars[name] === undefined && spec.default !== undefined) {
209
- vars[name] = spec.default;
210
- }
211
- }
212
- }
213
- return vars;
214
- }
215
-
216
- /**
217
- * Validate a workflow JSON file
218
- * @param {string} filePath - Path to workflow file
219
- * @returns {{ valid: boolean, error?: string, workflow?: object }}
220
- */
221
- function validateWorkflowFile(filePath) {
222
- if (!fs.existsSync(filePath)) {
223
- return { valid: false, error: `File not found: ${filePath}` };
81
+ async function runWithBrowserLock(lockOptions, endpoint, operation) {
82
+ let releaseBrowserLock = () => {};
83
+ if (!lockOptions.noLock) {
84
+ const lock = acquireBrowserLock(endpoint.key, SURF_TMP, {
85
+ timeoutMs: lockOptions.timeoutMs,
86
+ });
87
+ releaseBrowserLock = lock.release;
224
88
  }
225
-
89
+ const release = () => {
90
+ const releaseCurrent = releaseBrowserLock;
91
+ releaseBrowserLock = () => {};
92
+ releaseCurrent();
93
+ };
94
+ process.once("exit", release);
226
95
  try {
227
- const content = fs.readFileSync(filePath, 'utf8');
228
- const workflow = JSON.parse(content);
229
-
230
- // Basic structure validation
231
- if (!workflow.steps || !Array.isArray(workflow.steps)) {
232
- return { valid: false, error: "Workflow must have a 'steps' array" };
233
- }
234
-
235
- if (workflow.steps.length === 0) {
236
- return { valid: false, error: "Workflow has no steps" };
237
- }
238
-
239
- // Validate each step
240
- for (let i = 0; i < workflow.steps.length; i++) {
241
- const step = workflow.steps[i];
242
-
243
- // Check for loops
244
- if (step.repeat !== undefined || step.each !== undefined) {
245
- if (!step.steps || !Array.isArray(step.steps)) {
246
- return { valid: false, error: `Step ${i + 1}: loop must have a 'steps' array` };
247
- }
248
- continue;
249
- }
250
-
251
- // Regular step must have tool/cmd
252
- if (!step.tool && !step.cmd) {
253
- return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
254
- }
255
- }
256
-
257
- // Validate args schema if present
258
- if (workflow.args && typeof workflow.args !== 'object') {
259
- return { valid: false, error: "'args' must be an object" };
260
- }
261
-
262
- return { valid: true, workflow };
263
- } catch (e) {
264
- return { valid: false, error: `Invalid JSON: ${e.message}` };
265
- }
266
- }
267
-
268
- /**
269
- * Format a step for display
270
- * @param {object} step - Workflow step
271
- * @param {number} indent - Indentation level
272
- * @returns {string}
273
- */
274
- function formatStep(step, indent = 0) {
275
- const pad = ' '.repeat(indent);
276
-
277
- if (step.repeat !== undefined) {
278
- const lines = [`${pad}repeat ${step.repeat} times:`];
279
- for (const s of step.steps || []) {
280
- lines.push(formatStep(s, indent + 1));
281
- }
282
- if (step.until) {
283
- lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
284
- }
285
- return lines.join('\n');
286
- }
287
-
288
- if (step.each !== undefined) {
289
- const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
290
- for (const s of step.steps || []) {
291
- lines.push(formatStep(s, indent + 1));
292
- }
293
- return lines.join('\n');
96
+ return await operation();
97
+ } finally {
98
+ process.removeListener("exit", release);
99
+ release();
294
100
  }
295
-
296
- const tool = step.tool || step.cmd;
297
- const args = step.args || {};
298
- const argStr = Object.entries(args)
299
- .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
300
- .join(' ');
301
-
302
- let line = `${pad}${tool}`;
303
- if (argStr) line += ` ${argStr}`;
304
- if (step.as) line += ` → ${step.as}`;
305
-
306
- return line;
307
101
  }
308
102
 
309
103
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
@@ -386,6 +180,45 @@ try {
386
180
  process.exit(1);
387
181
  }
388
182
 
183
+ if (args[0] === "oracle") {
184
+ handleOracleCli(args, {
185
+ endpoint,
186
+ cwd: process.cwd(),
187
+ withBrowserLock: (operation) => runWithBrowserLock(
188
+ parseBrowserLockOptions(args.includes("--no-lock")),
189
+ endpoint,
190
+ operation,
191
+ ),
192
+ })
193
+ .then((result) => {
194
+ if (!result.handled) throw new Error("Oracle command was not handled");
195
+ if (result.value !== undefined) console.log(formatOracleOutput(result.value, result.json));
196
+ process.exit(0);
197
+ })
198
+ .catch((error) => {
199
+ console.error(formatOracleError(error, args.includes("--json")));
200
+ process.exit(1);
201
+ });
202
+ return;
203
+ }
204
+
205
+ if (["playbook", "pb", "use"].includes(args[0])) {
206
+ if (playbookCommandNeedsBrowser(args)) {
207
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
208
+ }
209
+ handlePlaybookCli(args, { endpoint, cwd: process.cwd() })
210
+ .then((result) => {
211
+ if (!result.handled) throw new Error("Playbook command was not handled");
212
+ if (result.value !== undefined) console.log(formatPlaybookOutput(result.value, result.json));
213
+ process.exit(0);
214
+ })
215
+ .catch((error) => {
216
+ console.error(`Error: ${error.message}`);
217
+ process.exit(1);
218
+ });
219
+ return;
220
+ }
221
+
389
222
  const ALIASES = {
390
223
  snap: "screenshot",
391
224
  read: "page.read",
@@ -951,6 +784,9 @@ const TOOLS = {
951
784
  all: "Show all (no limit)",
952
785
  v: "Verbose output",
953
786
  vv: "Very verbose output",
787
+ "body-mode": "Response bodies: none, text, or all (default: text)",
788
+ "per-body-bytes": "Maximum captured bytes per response body",
789
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
954
790
  clear: "Clear after reading",
955
791
  stream: "Continuous output"
956
792
  },
@@ -1015,9 +851,17 @@ const TOOLS = {
1015
851
  "network.export": {
1016
852
  desc: "Export captured requests",
1017
853
  args: [],
1018
- opts: { jsonl: "Export as JSONL", output: "Output file path" },
854
+ opts: {
855
+ har: "Export as HAR 1.2",
856
+ jsonl: "Export as JSONL",
857
+ output: "Output file path",
858
+ "body-mode": "Response bodies: none, text, or all (default: text)",
859
+ "per-body-bytes": "Maximum captured bytes per response body",
860
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
861
+ },
1019
862
  examples: [
1020
- { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" }
863
+ { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" },
864
+ { cmd: "network.export --har --output /tmp/requests.har", desc: "Export as HAR" },
1021
865
  ]
1022
866
  },
1023
867
  "network.path": {
@@ -1701,6 +1545,7 @@ Common Commands:
1701
1545
  search <term> Search for text in page (alias: find)
1702
1546
  window.new <url> Create isolated browser window
1703
1547
  doctor Diagnose native host/socket setup
1548
+ oracle ask <prompt> Start a durable ChatGPT consult
1704
1549
  wait <seconds> Wait N seconds
1705
1550
 
1706
1551
  Quick Examples:
@@ -1760,6 +1605,13 @@ const showFullHelp = () => {
1760
1605
 
1761
1606
  Usage: surf <command> [args] [options]
1762
1607
 
1608
+ Oracle:
1609
+ surf oracle <ask|status|result|follow|list>
1610
+
1611
+ Playbooks:
1612
+ surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>
1613
+ surf use <playbook> <op> [--arg value]
1614
+
1763
1615
  `);
1764
1616
  for (const [groupName, group] of Object.entries(TOOLS)) {
1765
1617
  console.log(`${groupName.toUpperCase()} - ${group.desc}`);
@@ -2402,9 +2254,7 @@ if (args[0] === "do") {
2402
2254
 
2403
2255
  // Process workflow file if loaded
2404
2256
  if (workflow) {
2405
- if (!workflow.steps || !Array.isArray(workflow.steps)) {
2406
- throw new Error("Workflow must have a 'steps' array");
2407
- }
2257
+ workflow = normalizeWorkflow(workflow);
2408
2258
 
2409
2259
  // Validate required args
2410
2260
  const argErrors = validateWorkflowArgs(workflow, workflowArgs);
@@ -2424,30 +2274,7 @@ if (args[0] === "do") {
2424
2274
  process.exit(1);
2425
2275
  }
2426
2276
 
2427
- // Convert steps: support both { tool, args } and { cmd, args } formats
2428
- // Also preserve loop steps as-is
2429
- steps = workflow.steps.map(s => {
2430
- if (s.repeat !== undefined || s.each !== undefined) {
2431
- // Loop step - convert nested steps recursively
2432
- const convertSteps = (stepsArr) => stepsArr.map(ns => {
2433
- if (ns.repeat !== undefined || ns.each !== undefined) {
2434
- // Recursively convert nested loop steps and until condition
2435
- return {
2436
- ...ns,
2437
- steps: convertSteps(ns.steps || []),
2438
- until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
2439
- };
2440
- }
2441
- return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
2442
- });
2443
- return {
2444
- ...s,
2445
- steps: convertSteps(s.steps || []),
2446
- until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
2447
- };
2448
- }
2449
- return { cmd: s.tool || s.cmd, args: s.args || {}, as: s.as };
2450
- });
2277
+ steps = workflow.steps;
2451
2278
  }
2452
2279
  } catch (e) {
2453
2280
  console.error(`Error: Failed to parse workflow: ${e.message}`);
@@ -2948,9 +2775,9 @@ if (toolArgs["window-id"] !== undefined) {
2948
2775
  globalOpts.windowId = wid;
2949
2776
  delete toolArgs["window-id"];
2950
2777
  }
2951
- if (toolArgs["network-path"] !== undefined) {
2952
- networkStore.setBasePath(toolArgs["network-path"]);
2953
- delete toolArgs["network-path"];
2778
+ if (toolArgs["network-path"] !== undefined && typeof toolArgs["network-path"] !== "string") {
2779
+ console.error("Error: --network-path requires a directory");
2780
+ process.exit(1);
2954
2781
  }
2955
2782
  const wantJson = toolArgs.json === true;
2956
2783
  delete toolArgs.json;