surf-cli 2.9.0 → 2.10.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 (37) hide show
  1. package/README.md +48 -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.cjs +2 -0
  13. package/native/cli.cjs +52 -278
  14. package/native/do-executor.cjs +52 -475
  15. package/native/do-parser.cjs +8 -249
  16. package/native/host-helpers.cjs +6 -14
  17. package/native/host-sessions.cjs +5 -1
  18. package/native/host.cjs +199 -1
  19. package/native/network-export.cjs +20 -17
  20. package/native/network-store.cjs +38 -58
  21. package/native/playbook-authoring.cjs +44 -0
  22. package/native/playbook-cli.cjs +157 -0
  23. package/native/playbook-client.cjs +259 -0
  24. package/native/playbook-receipts.cjs +109 -0
  25. package/native/playbook-records.cjs +208 -0
  26. package/native/playbook-runtime.cjs +177 -0
  27. package/native/playbooks.cjs +235 -0
  28. package/native/private-state.cjs +156 -0
  29. package/native/redaction.cjs +104 -0
  30. package/native/workflow-definition.cjs +368 -0
  31. package/native/workflow-runtime.cjs +225 -0
  32. package/package.json +2 -1
  33. package/playbooks/page/ops/read.json +22 -0
  34. package/playbooks/page/playbook.json +7 -0
  35. package/skills/surf/SKILL.md +41 -1
  36. package/dist/content/index.js +0 -116
  37. package/dist/content/index.js.map +0 -1
@@ -0,0 +1,55 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { appendPrivateJsonLine, atomicWriteFile, ensurePrivateDir, getPrivateStateRoot, readPrivateFile } = require("./private-state.cjs");
4
+ const { commandMetadata, redactCommandArgs } = require("./workflow-definition.cjs");
5
+
6
+ const MAX_JOURNAL_BYTES = 1024 * 1024;
7
+ const MAX_JOURNAL_EVENTS = 500;
8
+
9
+ function journalPath(root = getPrivateStateRoot()) {
10
+ return path.join(root, "activity-journal", "events.jsonl");
11
+ }
12
+
13
+ function compactJournal(filePath, root) {
14
+ const stat = fs.statSync(filePath);
15
+ if (stat.size <= MAX_JOURNAL_BYTES) return;
16
+ const lines = readPrivateFile(filePath, { root, encoding: "utf8" }).trim().split("\n").filter(Boolean).slice(-MAX_JOURNAL_EVENTS);
17
+ atomicWriteFile(filePath, `${lines.join("\n")}\n`, { root, encoding: "utf8" });
18
+ }
19
+
20
+ function appendActivity(event, { root = getPrivateStateRoot() } = {}) {
21
+ const filePath = journalPath(root);
22
+ ensurePrivateDir(path.dirname(filePath), root);
23
+ appendPrivateJsonLine(filePath, { version: 1, ...event }, { root });
24
+ compactJournal(filePath, root);
25
+ }
26
+
27
+ function journalCommand(command, args, options = {}) {
28
+ const metadata = commandMetadata(command);
29
+ if (!metadata.recordable) return;
30
+ appendActivity({
31
+ type: "tool.issued",
32
+ command: metadata.name,
33
+ argsRedacted: redactCommandArgs(command, args, options.includeInputValues === true),
34
+ effect: metadata.effect,
35
+ ...(options.tabId ? { tabId: options.tabId } : {}),
36
+ ...(options.origin ? { origin: options.origin } : {}),
37
+ startedAt: new Date().toISOString(),
38
+ }, options);
39
+ }
40
+
41
+ function sinceMilliseconds(value) {
42
+ if (!value) return 60 * 60 * 1000;
43
+ const match = String(value).match(/^(\d+)(m|h|d)$/);
44
+ if (!match) throw new Error("--since must be a duration such as 30m, 1h, or 2d");
45
+ return Number(match[1]) * { m: 60000, h: 3600000, d: 86400000 }[match[2]];
46
+ }
47
+
48
+ function readRecent({ since = "1h", root = getPrivateStateRoot() } = {}) {
49
+ const filePath = journalPath(root);
50
+ const content = readPrivateFile(filePath, { root, allowMissing: true, fallback: "", encoding: "utf8" });
51
+ const cutoff = Date.now() - sinceMilliseconds(since);
52
+ return content.split("\n").filter(Boolean).map((line) => JSON.parse(line)).filter((event) => Date.parse(event.startedAt || event.timestamp) >= cutoff);
53
+ }
54
+
55
+ module.exports = { appendActivity, journalCommand, journalPath, readRecent };
@@ -658,6 +658,7 @@ async function query(options) {
658
658
  cdpEvaluate,
659
659
  cdpCommand,
660
660
  uploadFile,
661
+ beforeSubmit,
661
662
  log = () => {},
662
663
  signal,
663
664
  } = options;
@@ -729,6 +730,7 @@ async function query(options) {
729
730
  await typePrompt(cdp, inputCdp, prompt, signal);
730
731
  log("Prompt typed");
731
732
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
733
+ if (beforeSubmit) await raceAbort(beforeSubmit, signal);
732
734
  await clickSend(cdp, inputCdp, signal);
733
735
  log("Prompt sent, waiting for response...");
734
736
  const response = await waitForResponse(
package/native/cli.cjs CHANGED
@@ -1,15 +1,25 @@
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 { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser } = require("./playbook-cli.cjs");
13
23
 
14
24
  const IS_WIN = process.platform === "win32";
15
25
  const { SURF_TMP, formatSocketError } = require("./socket-path.cjs");
@@ -63,249 +73,6 @@ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
63
73
  });
64
74
  }
65
75
 
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}` };
224
- }
225
-
226
- 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');
294
- }
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
- }
308
-
309
76
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
310
77
  function resizeImage(filePath, maxSize) {
311
78
  const platform = process.platform;
@@ -386,6 +153,23 @@ try {
386
153
  process.exit(1);
387
154
  }
388
155
 
156
+ if (["playbook", "pb", "use"].includes(args[0])) {
157
+ if (playbookCommandNeedsBrowser(args)) {
158
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
159
+ }
160
+ handlePlaybookCli(args, { endpoint, cwd: process.cwd() })
161
+ .then((result) => {
162
+ if (!result.handled) throw new Error("Playbook command was not handled");
163
+ if (result.value !== undefined) console.log(formatPlaybookOutput(result.value, result.json));
164
+ process.exit(0);
165
+ })
166
+ .catch((error) => {
167
+ console.error(`Error: ${error.message}`);
168
+ process.exit(1);
169
+ });
170
+ return;
171
+ }
172
+
389
173
  const ALIASES = {
390
174
  snap: "screenshot",
391
175
  read: "page.read",
@@ -951,6 +735,9 @@ const TOOLS = {
951
735
  all: "Show all (no limit)",
952
736
  v: "Verbose output",
953
737
  vv: "Very verbose output",
738
+ "body-mode": "Response bodies: none, text, or all (default: text)",
739
+ "per-body-bytes": "Maximum captured bytes per response body",
740
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
954
741
  clear: "Clear after reading",
955
742
  stream: "Continuous output"
956
743
  },
@@ -1015,9 +802,17 @@ const TOOLS = {
1015
802
  "network.export": {
1016
803
  desc: "Export captured requests",
1017
804
  args: [],
1018
- opts: { jsonl: "Export as JSONL", output: "Output file path" },
805
+ opts: {
806
+ har: "Export as HAR 1.2",
807
+ jsonl: "Export as JSONL",
808
+ output: "Output file path",
809
+ "body-mode": "Response bodies: none, text, or all (default: text)",
810
+ "per-body-bytes": "Maximum captured bytes per response body",
811
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
812
+ },
1019
813
  examples: [
1020
- { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" }
814
+ { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" },
815
+ { cmd: "network.export --har --output /tmp/requests.har", desc: "Export as HAR" },
1021
816
  ]
1022
817
  },
1023
818
  "network.path": {
@@ -1760,6 +1555,10 @@ const showFullHelp = () => {
1760
1555
 
1761
1556
  Usage: surf <command> [args] [options]
1762
1557
 
1558
+ Playbooks:
1559
+ surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>
1560
+ surf use <playbook> <op> [--arg value]
1561
+
1763
1562
  `);
1764
1563
  for (const [groupName, group] of Object.entries(TOOLS)) {
1765
1564
  console.log(`${groupName.toUpperCase()} - ${group.desc}`);
@@ -2402,9 +2201,7 @@ if (args[0] === "do") {
2402
2201
 
2403
2202
  // Process workflow file if loaded
2404
2203
  if (workflow) {
2405
- if (!workflow.steps || !Array.isArray(workflow.steps)) {
2406
- throw new Error("Workflow must have a 'steps' array");
2407
- }
2204
+ workflow = normalizeWorkflow(workflow);
2408
2205
 
2409
2206
  // Validate required args
2410
2207
  const argErrors = validateWorkflowArgs(workflow, workflowArgs);
@@ -2424,30 +2221,7 @@ if (args[0] === "do") {
2424
2221
  process.exit(1);
2425
2222
  }
2426
2223
 
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
- });
2224
+ steps = workflow.steps;
2451
2225
  }
2452
2226
  } catch (e) {
2453
2227
  console.error(`Error: Failed to parse workflow: ${e.message}`);
@@ -2948,9 +2722,9 @@ if (toolArgs["window-id"] !== undefined) {
2948
2722
  globalOpts.windowId = wid;
2949
2723
  delete toolArgs["window-id"];
2950
2724
  }
2951
- if (toolArgs["network-path"] !== undefined) {
2952
- networkStore.setBasePath(toolArgs["network-path"]);
2953
- delete toolArgs["network-path"];
2725
+ if (toolArgs["network-path"] !== undefined && typeof toolArgs["network-path"] !== "string") {
2726
+ console.error("Error: --network-path requires a directory");
2727
+ process.exit(1);
2954
2728
  }
2955
2729
  const wantJson = toolArgs.json === true;
2956
2730
  delete toolArgs.json;