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
@@ -0,0 +1,369 @@
1
+ const fs = require("fs");
2
+ const os = require("os");
3
+ const path = require("path");
4
+ const { isSensitiveName, redactSensitiveFields, redactUrlSecrets } = require("./redaction.cjs");
5
+
6
+ const COMMANDS = {
7
+ ai: { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
8
+ gemini: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
9
+ chatgpt: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
10
+ "oracle.ask": { primaryArg: "prompt", effect: "page-write", argKinds: { prompt: "user-input" }, sensitiveArgs: ["prompt"] },
11
+ perplexity: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
12
+ grok: { primaryArg: "query", effect: "page-write", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
13
+ navigate: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
14
+ go: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
15
+ back: { effect: "navigation" },
16
+ forward: { effect: "navigation" },
17
+ reload: { effect: "navigation" },
18
+ js: { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
19
+ javascript_tool: { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
20
+ click: { effect: "page-write", argKinds: { ref: "element-ref", selector: "selector", x: "number", y: "number" } },
21
+ key: { primaryArg: "key", effect: "page-write", argKinds: { key: "key" } },
22
+ submit: { effect: "page-write" },
23
+ hover: { effect: "read", argKinds: { ref: "element-ref", selector: "selector" } },
24
+ scroll: { effect: "page-write", argKinds: { direction: "name", scroll_pixels: "number" } },
25
+ "scroll.top": { effect: "page-write", argKinds: { selector: "selector" } },
26
+ "scroll.bottom": { effect: "page-write", argKinds: { selector: "selector" } },
27
+ "scroll.info": { effect: "read", argKinds: { selector: "selector" } },
28
+ wait: { primaryArg: "duration", effect: "read", recordable: false, argKinds: { duration: "duration" } },
29
+ health: { primaryArg: "url", effect: "read", argKinds: { url: "url" } },
30
+ new_tab: { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
31
+ "tab.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
32
+ switch_tab: { primaryArg: "tab_id", effect: "navigation", argKinds: { tab_id: "tab-id" } },
33
+ "tab.switch": { primaryArg: "id", effect: "navigation", argKinds: { id: "tab-id" } },
34
+ close_tab: { primaryArg: "tab_id", effect: "page-write", argKinds: { tab_id: "tab-id" } },
35
+ "tab.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "tab-id" } },
36
+ "tab.name": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
37
+ "tab.unname": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
38
+ scroll_to_position: { primaryArg: "position", effect: "page-write", argKinds: { position: "position" } },
39
+ type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
40
+ smart_type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
41
+ find_and_type: { effect: "page-write", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
42
+ form_input: { effect: "page-write", argKinds: { value: "user-input" }, sensitiveArgs: ["value"] },
43
+ "cookie.set": { effect: "page-write", argKinds: { value: "secret" }, sensitiveArgs: ["value"] },
44
+ "emulate.network": { primaryArg: "preset", effect: "page-write", argKinds: { preset: "name" } },
45
+ "emulate.cpu": { primaryArg: "rate", effect: "page-write", argKinds: { rate: "number" } },
46
+ search: { primaryArg: "term", effect: "read", argKinds: { term: "user-input" }, sensitiveArgs: ["term"] },
47
+ "wait.element": { primaryArg: "selector", effect: "read", recordable: false, argKinds: { selector: "selector" } },
48
+ "wait.url": { primaryArg: "pattern", effect: "read", recordable: false, argKinds: { pattern: "url-pattern" } },
49
+ zoom: { primaryArg: "level", effect: "page-write", argKinds: { level: "number" } },
50
+ "history.search": { primaryArg: "query", effect: "read", argKinds: { query: "user-input" }, sensitiveArgs: ["query"] },
51
+ "network.get": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
52
+ "network.body": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
53
+ "network.curl": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
54
+ "network.path": { primaryArg: "id", effect: "read", argKinds: { id: "request-id" } },
55
+ "page.read": { effect: "read" },
56
+ "page.text": { effect: "read" },
57
+ "page.state": { effect: "read" },
58
+ screenshot: { effect: "read" },
59
+ "window.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
60
+ "window.focus": { primaryArg: "id", effect: "navigation", argKinds: { id: "window-id" } },
61
+ "window.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "window-id" } },
62
+ "locate.role": { primaryArg: "role", effect: "read", argKinds: { role: "role" } },
63
+ "locate.text": { primaryArg: "text", effect: "read", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
64
+ "locate.label": { primaryArg: "label", effect: "read", argKinds: { label: "user-input" }, sensitiveArgs: ["label"] },
65
+ "emulate.device": { primaryArg: "device", effect: "page-write", argKinds: { device: "name" } },
66
+ "frame.js": { primaryArg: "code", effect: "unknown", recordable: false, argKinds: { code: "code" }, sensitiveArgs: ["code"] },
67
+ "element.styles": { primaryArg: "selector", effect: "read", argKinds: { selector: "selector" } },
68
+ select: { primaryArg: "selector", effect: "page-write", argKinds: { selector: "selector", values: "user-input" }, sensitiveArgs: ["values"] },
69
+ "form.fill": { effect: "page-write", argKinds: { data: "user-input" }, sensitiveArgs: ["data"] },
70
+ "dialog.accept": { effect: "page-write", argKinds: { text: "user-input" }, sensitiveArgs: ["text"] },
71
+ "dialog.dismiss": { effect: "page-write" },
72
+ "dialog.info": { effect: "read" },
73
+ };
74
+
75
+ const ALIASES = {
76
+ snap: "screenshot",
77
+ read: "page.read",
78
+ find: "search",
79
+ go: "navigate",
80
+ net: "network",
81
+ "network.dump": "network.get",
82
+ };
83
+
84
+ const PRIMARY_ARG_MAP = Object.fromEntries(
85
+ Object.entries(COMMANDS).filter(([, value]) => value.primaryArg).map(([name, value]) => [name, value.primaryArg]),
86
+ );
87
+ PRIMARY_ARG_MAP.go = "url";
88
+ PRIMARY_ARG_MAP.find = "term";
89
+
90
+ function commandMetadata(command) {
91
+ const name = ALIASES[command] || command;
92
+ const metadata = COMMANDS[name];
93
+ return {
94
+ name,
95
+ primaryArg: metadata?.primaryArg,
96
+ effect: metadata?.effect || "unknown",
97
+ recordable: Boolean(metadata) && metadata.recordable !== false,
98
+ argKinds: metadata?.argKinds || {},
99
+ sensitiveArgs: metadata?.sensitiveArgs || [],
100
+ };
101
+ }
102
+
103
+ function redactCommandArgs(command, args, includeInputValues = false) {
104
+ const metadata = commandMetadata(command);
105
+ const redacted = redactSensitiveFields({ ...(args || {}) });
106
+ for (const name of metadata.sensitiveArgs) {
107
+ if (!includeInputValues && Object.hasOwn(redacted, name)) redacted[name] = `<${name}>`;
108
+ }
109
+ for (const [name, kind] of Object.entries(metadata.argKinds)) {
110
+ if (kind === "url" && Object.hasOwn(redacted, name)) redacted[name] = redactUrlSecrets(redacted[name]);
111
+ }
112
+ for (const name of Object.keys(redacted)) {
113
+ if (isSensitiveName(name)) redacted[name] = "<redacted>";
114
+ }
115
+ return redacted;
116
+ }
117
+
118
+ function templateRedactedArgs(value, args = {}) {
119
+ if (typeof value === "string") {
120
+ const match = value.match(/^<([a-z0-9._-]+)>$/);
121
+ if (!match) return value;
122
+ args[match[1]] = { required: true, desc: `Recorded ${match[1]}` };
123
+ return `{{${match[1]}}}`;
124
+ }
125
+ if (Array.isArray(value)) return value.map((item) => templateRedactedArgs(item, args));
126
+ if (value && typeof value === "object") {
127
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, templateRedactedArgs(item, args)]));
128
+ }
129
+ return value;
130
+ }
131
+
132
+ function promoteRedactedStepArgs(steps) {
133
+ const args = {};
134
+ return {
135
+ args,
136
+ steps: steps.map((step) => ({ ...step, args: templateRedactedArgs(step.args || {}, args) })),
137
+ };
138
+ }
139
+
140
+ function tokenize(line) {
141
+ const tokens = [];
142
+ let current = "";
143
+ let inQuote = null;
144
+ for (const ch of line) {
145
+ if (inQuote) {
146
+ if (ch === inQuote) inQuote = null;
147
+ else current += ch;
148
+ } else if (ch === '"' || ch === "'") inQuote = ch;
149
+ else if (ch === " " || ch === "\t") {
150
+ if (current) {
151
+ tokens.push(current);
152
+ current = "";
153
+ }
154
+ } else current += ch;
155
+ }
156
+ if (current) tokens.push(current);
157
+ return tokens;
158
+ }
159
+
160
+ function coerceValue(value) {
161
+ if (value === "true") return true;
162
+ if (value === "false") return false;
163
+ if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
164
+ if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
165
+ return value;
166
+ }
167
+
168
+ function parseCommandLine(line) {
169
+ const tokens = tokenize(line);
170
+ if (tokens.length === 0) return null;
171
+ let cmd = ALIASES[tokens[0]] || tokens[0];
172
+ const args = {};
173
+ let i = 1;
174
+ if (i < tokens.length && !tokens[i].startsWith("--")) {
175
+ const firstArg = tokens[i];
176
+ if (cmd === "click") {
177
+ if (/^e\d+$/.test(firstArg)) {
178
+ args.ref = firstArg;
179
+ i++;
180
+ } else if (/^\d+$/.test(firstArg) && /^\d+$/.test(tokens[i + 1] || "")) {
181
+ args.x = Number.parseInt(firstArg, 10);
182
+ args.y = Number.parseInt(tokens[i + 1], 10);
183
+ i += 2;
184
+ }
185
+ } else if (cmd === "select") {
186
+ args.selector = firstArg;
187
+ i++;
188
+ const values = [];
189
+ while (i < tokens.length && !tokens[i].startsWith("--")) values.push(tokens[i++]);
190
+ if (values.length === 1) args.values = values[0];
191
+ else if (values.length > 1) args.values = values;
192
+ } else if (cmd === "scroll") {
193
+ if (firstArg === "top" || firstArg === "bottom") {
194
+ cmd = `scroll.${firstArg}`;
195
+ i++;
196
+ } else if (["up", "down", "left", "right"].includes(firstArg)) {
197
+ args.direction = firstArg;
198
+ i++;
199
+ if (/^-?\d+$/.test(tokens[i] || "")) args.scroll_pixels = Number.parseInt(tokens[i++], 10);
200
+ }
201
+ } else {
202
+ const primaryKey = PRIMARY_ARG_MAP[cmd];
203
+ if (primaryKey) {
204
+ args[primaryKey] = firstArg;
205
+ i++;
206
+ }
207
+ }
208
+ }
209
+ while (i < tokens.length) {
210
+ const token = tokens[i];
211
+ if (!token.startsWith("--")) {
212
+ i++;
213
+ continue;
214
+ }
215
+ const key = token.slice(2);
216
+ const next = tokens[i + 1];
217
+ if (next && !next.startsWith("--")) {
218
+ args[key] = coerceValue(next);
219
+ i += 2;
220
+ } else {
221
+ args[key] = true;
222
+ i++;
223
+ }
224
+ }
225
+ return { cmd, args };
226
+ }
227
+
228
+ function parseDoCommands(input) {
229
+ const hasPipe = input.includes("|");
230
+ const normalized = hasPipe ? input : input.replace(/\\n/g, "\n");
231
+ return normalized
232
+ .split(hasPipe ? "|" : "\n")
233
+ .map((line) => line.trim())
234
+ .filter((line) => line && !line.startsWith("#"))
235
+ .map(parseCommandLine)
236
+ .filter(Boolean);
237
+ }
238
+
239
+ function getWorkflowDirs({ cwd = process.cwd(), home = os.homedir() } = {}) {
240
+ return [
241
+ { path: path.join(cwd, ".surf", "workflows"), scope: "project" },
242
+ { path: path.join(home, ".surf", "workflows"), scope: "user" },
243
+ ];
244
+ }
245
+
246
+ function resolveWorkflow(nameOrPath, options = {}) {
247
+ if (nameOrPath.includes("|")) return { type: "inline", content: nameOrPath };
248
+ if (nameOrPath.includes("/") || nameOrPath.includes("\\") || nameOrPath.endsWith(".json")) {
249
+ return fs.existsSync(nameOrPath) ? { type: "file", path: nameOrPath } : { type: "not_found", name: nameOrPath };
250
+ }
251
+ for (const { path: dir } of getWorkflowDirs(options)) {
252
+ const filePath = path.join(dir, `${nameOrPath}.json`);
253
+ if (fs.existsSync(filePath)) return { type: "file", path: filePath };
254
+ }
255
+ return { type: "not_found", name: nameOrPath };
256
+ }
257
+
258
+ function normalizeStep(step) {
259
+ if (!step || typeof step !== "object" || Array.isArray(step)) throw new Error("workflow step must be an object");
260
+ if (step.repeat !== undefined || step.each !== undefined) {
261
+ if (!Array.isArray(step.steps) || step.steps.length === 0) throw new Error("loop must have a non-empty 'steps' array");
262
+ return {
263
+ ...step,
264
+ steps: step.steps.map(normalizeStep),
265
+ ...(step.until ? { until: normalizeStep(step.until) } : {}),
266
+ };
267
+ }
268
+ const cmd = step.tool || step.cmd;
269
+ if (typeof cmd !== "string" || !cmd) throw new Error("workflow step must have a 'tool' field");
270
+ return { cmd: ALIASES[cmd] || cmd, args: step.args || {}, ...(step.as ? { as: step.as } : {}) };
271
+ }
272
+
273
+ function normalizeWorkflow(workflow) {
274
+ if (!workflow || typeof workflow !== "object" || Array.isArray(workflow)) throw new Error("workflow must be an object");
275
+ if (!Array.isArray(workflow.steps)) throw new Error("Workflow must have a 'steps' array");
276
+ if (workflow.steps.length === 0) throw new Error("Workflow has no steps");
277
+ if (workflow.args !== undefined && (!workflow.args || typeof workflow.args !== "object" || Array.isArray(workflow.args))) {
278
+ throw new Error("'args' must be an object");
279
+ }
280
+ return { ...workflow, args: workflow.args || {}, steps: workflow.steps.map(normalizeStep) };
281
+ }
282
+
283
+ function validateWorkflowArgs(workflow, providedArgs) {
284
+ const errors = [];
285
+ for (const [name, spec] of Object.entries(workflow.args || {})) {
286
+ if (spec.required && providedArgs[name] === undefined) errors.push(`Missing required argument: --${name}`);
287
+ }
288
+ return errors;
289
+ }
290
+
291
+ function applyArgDefaults(workflow, providedArgs) {
292
+ const vars = { ...providedArgs };
293
+ for (const [name, spec] of Object.entries(workflow.args || {})) {
294
+ if (vars[name] === undefined && spec.default !== undefined) vars[name] = spec.default;
295
+ }
296
+ return vars;
297
+ }
298
+
299
+ function validateWorkflowFile(filePath) {
300
+ if (!fs.existsSync(filePath)) return { valid: false, error: `File not found: ${filePath}` };
301
+ try {
302
+ const workflow = JSON.parse(fs.readFileSync(filePath, "utf8"));
303
+ normalizeWorkflow(workflow);
304
+ return { valid: true, workflow };
305
+ } catch (error) {
306
+ return { valid: false, error: error instanceof SyntaxError ? `Invalid JSON: ${error.message}` : error.message };
307
+ }
308
+ }
309
+
310
+ function listWorkflows(options = {}) {
311
+ const workflows = [];
312
+ for (const { path: dir, scope } of getWorkflowDirs(options)) {
313
+ if (!fs.existsSync(dir)) continue;
314
+ for (const file of fs.readdirSync(dir).filter((name) => name.endsWith(".json"))) {
315
+ const filePath = path.join(dir, file);
316
+ try {
317
+ const content = JSON.parse(fs.readFileSync(filePath, "utf8"));
318
+ workflows.push({ name: content.name || file.slice(0, -5), description: content.description || "", scope, path: filePath, args: content.args, stepCount: content.steps?.length || 0 });
319
+ } catch {}
320
+ }
321
+ }
322
+ return workflows;
323
+ }
324
+
325
+ function getWorkflowInfo(name, options = {}) {
326
+ const resolved = resolveWorkflow(name, options);
327
+ if (resolved.type === "not_found") return { error: `Workflow not found: ${name}` };
328
+ if (resolved.type === "inline") return { error: "Cannot get info for inline workflows" };
329
+ try {
330
+ const content = JSON.parse(fs.readFileSync(resolved.path, "utf8"));
331
+ return { name: content.name || name, description: content.description || "", args: content.args || {}, steps: content.steps || [], path: resolved.path };
332
+ } catch (error) {
333
+ return { error: `Failed to parse workflow: ${error.message}` };
334
+ }
335
+ }
336
+
337
+ function formatStep(step, indent = 0) {
338
+ const pad = " ".repeat(indent);
339
+ if (step.repeat !== undefined || step.each !== undefined) {
340
+ const label = step.repeat !== undefined ? `repeat ${step.repeat} times:` : `each ${step.each} as ${step.as || "item"}:`;
341
+ const lines = [`${pad}${label}`, ...(step.steps || []).map((nested) => formatStep(nested, indent + 1))];
342
+ if (step.until) lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
343
+ return lines.join("\n");
344
+ }
345
+ const tool = step.tool || step.cmd;
346
+ const argStr = Object.entries(step.args || {}).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(" ");
347
+ return `${pad}${tool}${argStr ? ` ${argStr}` : ""}${step.as ? ` → ${step.as}` : ""}`;
348
+ }
349
+
350
+ module.exports = {
351
+ ALIASES,
352
+ PRIMARY_ARG_MAP,
353
+ applyArgDefaults,
354
+ commandMetadata,
355
+ formatStep,
356
+ getWorkflowDirs,
357
+ getWorkflowInfo,
358
+ listWorkflows,
359
+ normalizeStep,
360
+ normalizeWorkflow,
361
+ parseCommandLine,
362
+ parseDoCommands,
363
+ promoteRedactedStepArgs,
364
+ redactCommandArgs,
365
+ resolveWorkflow,
366
+ tokenize,
367
+ validateWorkflowArgs,
368
+ validateWorkflowFile,
369
+ };
@@ -0,0 +1,225 @@
1
+ const { redactCommandArgs } = require("./workflow-definition.cjs");
2
+
3
+ const MAX_LOOP_ITERATIONS = 100;
4
+ const AUTO_WAIT_COMMANDS = ["go", "navigate", "click", "key", "form.fill", "submit", "tab.switch", "tab.new", "back", "forward"];
5
+ const AUTO_WAIT_MAP = {
6
+ navigate: "wait.load",
7
+ go: "wait.load",
8
+ click: "wait.dom",
9
+ key: "wait.dom",
10
+ "form.fill": "wait.dom",
11
+ submit: "wait.load",
12
+ "tab.switch": "wait.load",
13
+ "tab.new": "wait.load",
14
+ back: "wait.load",
15
+ forward: "wait.load",
16
+ };
17
+
18
+ function shouldAutoWait(cmd) {
19
+ return AUTO_WAIT_COMMANDS.some((candidate) => cmd === candidate || cmd.startsWith(`${candidate}.`));
20
+ }
21
+
22
+ function getAutoWaitCommand(cmd) {
23
+ if (AUTO_WAIT_MAP[cmd] !== undefined) return AUTO_WAIT_MAP[cmd];
24
+ for (const [prefix, waitCmd] of Object.entries(AUTO_WAIT_MAP)) {
25
+ if (cmd.startsWith(`${prefix}.`)) return waitCmd;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function resolveVar(template, vars) {
31
+ if (typeof template !== "string") return template;
32
+ const match = template.match(/^%\{(\w+)\}$/);
33
+ if (match) return vars[match[1]] !== undefined ? vars[match[1]] : template;
34
+ return template.replace(/%\{(\w+)\}/g, (_, name) => {
35
+ const value = vars[name];
36
+ if (value === undefined) return `%{${name}}`;
37
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
38
+ });
39
+ }
40
+
41
+ function substituteVars(value, vars) {
42
+ if (!value || typeof value !== "object") return typeof value === "string" ? resolveVar(value, vars) : value;
43
+ if (Array.isArray(value)) return value.map((item) => substituteVars(item, vars));
44
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteVars(item, vars)]));
45
+ }
46
+
47
+ function extractStepOutput(response) {
48
+ if (response?.result?.content?.[0]?.text) {
49
+ const text = response.result.content[0].text;
50
+ try {
51
+ return JSON.parse(text);
52
+ } catch {
53
+ return text;
54
+ }
55
+ }
56
+ if (response?.value !== undefined) return response.value;
57
+ if (response?.output !== undefined) {
58
+ try {
59
+ return JSON.parse(response.output);
60
+ } catch {
61
+ return response.output;
62
+ }
63
+ }
64
+ if (response?.result !== undefined) return response.result;
65
+ return response;
66
+ }
67
+
68
+ function abortMessage(signal) {
69
+ if (!signal?.aborted) return null;
70
+ return signal.reason instanceof Error ? signal.reason.message : String(signal.reason || "Workflow aborted");
71
+ }
72
+
73
+ function assertNotAborted(signal) {
74
+ const message = abortMessage(signal);
75
+ if (message) throw new Error(message);
76
+ }
77
+
78
+ async function executeSingleStep(step, vars, options) {
79
+ const {
80
+ autoWait = true,
81
+ executeTool,
82
+ includeInputValues = false,
83
+ onEvent = () => {},
84
+ signal,
85
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
86
+ stepDelay = 100,
87
+ } = options;
88
+ if (typeof executeTool !== "function") throw new Error("workflow runtime requires executeTool");
89
+ const args = substituteVars(step.args || {}, vars);
90
+ const startedAt = new Date().toISOString();
91
+ const baseEvent = { command: step.cmd, argsRedacted: redactCommandArgs(step.cmd, args, includeInputValues), startedAt };
92
+ onEvent({ type: "tool.started", ...baseEvent });
93
+ try {
94
+ assertNotAborted(signal);
95
+ const response = await executeTool(step.cmd, args, { signal });
96
+ assertNotAborted(signal);
97
+ if (response?.error) {
98
+ const error = response.error.content?.[0]?.text || (typeof response.error === "string" ? response.error : JSON.stringify(response.error));
99
+ onEvent({ type: "tool.failed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: error });
100
+ return { success: false, error };
101
+ }
102
+ if (step.as) vars[step.as] = extractStepOutput(response);
103
+ if (autoWait) {
104
+ const waitCmd = getAutoWaitCommand(step.cmd);
105
+ if (waitCmd) {
106
+ const waitArgs = waitCmd === "wait.load" ? { timeout: 10000 } : { stable: 100, timeout: 5000 };
107
+ try {
108
+ await executeTool(waitCmd, waitArgs, { signal });
109
+ } catch {}
110
+ }
111
+ }
112
+ if (stepDelay > 0) {
113
+ assertNotAborted(signal);
114
+ await sleep(stepDelay, signal);
115
+ assertNotAborted(signal);
116
+ }
117
+ onEvent({ type: "tool.completed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: "success" });
118
+ return { success: true, ...(step.as ? { output: vars[step.as] } : {}) };
119
+ } catch (error) {
120
+ const message = error?.message || String(error);
121
+ onEvent({ type: "tool.failed", ...baseEvent, endedAt: new Date().toISOString(), resultSummary: message });
122
+ return { success: false, error: message };
123
+ }
124
+ }
125
+
126
+ async function executeStep(step, vars, options) {
127
+ const { onError = "stop" } = options;
128
+ assertNotAborted(options.signal);
129
+ if (step.repeat !== undefined) {
130
+ let max = resolveVar(step.repeat, vars);
131
+ if (typeof max === "string") max = Number.parseInt(max, 10);
132
+ if (typeof max !== "number" || Number.isNaN(max)) max = 1;
133
+ max = Math.min(max, MAX_LOOP_ITERATIONS);
134
+ if (!Array.isArray(step.steps) || step.steps.length === 0) return { success: false, error: "repeat: steps array required", stepsExecuted: 0 };
135
+ let stepsExecuted = 0;
136
+ for (let index = 0; index < max; index++) {
137
+ const loopVars = { ...vars, _index: index, _iteration: index + 1 };
138
+ for (const nestedStep of step.steps) {
139
+ const result = await executeStep(nestedStep, loopVars, options);
140
+ stepsExecuted += result.stepsExecuted || 1;
141
+ if (!result.success && onError === "stop") return { success: false, error: result.error, stepsExecuted };
142
+ }
143
+ copyCapturedVars(step.steps, loopVars, vars);
144
+ if (step.until) {
145
+ const untilResult = await executeSingleStep(step.until, loopVars, options);
146
+ stepsExecuted++;
147
+ if (untilResult.output) break;
148
+ }
149
+ }
150
+ return { success: true, stepsExecuted };
151
+ }
152
+ if (step.each !== undefined) {
153
+ const items = resolveVar(step.each, vars);
154
+ if (!Array.isArray(items)) return { success: false, error: `each: expected array, got ${typeof items}${items === undefined ? " (undefined)" : ""}`, stepsExecuted: 0 };
155
+ if (!Array.isArray(step.steps) || step.steps.length === 0) return { success: false, error: "each: steps array required", stepsExecuted: 0 };
156
+ const itemVar = step.as || "item";
157
+ let stepsExecuted = 0;
158
+ for (let index = 0; index < Math.min(items.length, MAX_LOOP_ITERATIONS); index++) {
159
+ const loopVars = { ...vars, [itemVar]: items[index], _index: index, _iteration: index + 1 };
160
+ for (const nestedStep of step.steps) {
161
+ const result = await executeStep(nestedStep, loopVars, options);
162
+ stepsExecuted += result.stepsExecuted || 1;
163
+ if (!result.success && onError === "stop") return { success: false, error: result.error, stepsExecuted };
164
+ }
165
+ copyCapturedVars(step.steps, loopVars, vars);
166
+ }
167
+ return { success: true, stepsExecuted };
168
+ }
169
+ return { ...(await executeSingleStep(step, vars, options)), stepsExecuted: 1 };
170
+ }
171
+
172
+ function copyCapturedVars(steps, source, target) {
173
+ for (const step of steps) {
174
+ const isLoop = step.repeat !== undefined || step.each !== undefined;
175
+ if (!isLoop && step.as && source[step.as] !== undefined) target[step.as] = source[step.as];
176
+ }
177
+ }
178
+
179
+ async function executeWorkflow(steps, options = {}) {
180
+ const vars = { ...(options.vars || {}), ...(options.context?.vars || {}) };
181
+ const results = [];
182
+ let failed = 0;
183
+ let stepsExecuted = 0;
184
+ const startTotal = Date.now();
185
+ for (let index = 0; index < steps.length; index++) {
186
+ const step = steps[index];
187
+ const startTime = Date.now();
188
+ const type = step.repeat !== undefined || step.each !== undefined ? "loop" : "tool";
189
+ options.onProgress?.({ phase: "start", index, total: steps.length, step, type });
190
+ let result;
191
+ try {
192
+ result = await executeStep(step, vars, options);
193
+ } catch (error) {
194
+ result = { success: false, error: error?.message || String(error), stepsExecuted: 0 };
195
+ }
196
+ const ms = Date.now() - startTime;
197
+ stepsExecuted += type === "loop" ? result.stepsExecuted || 0 : 1;
198
+ if (!result.success) {
199
+ failed++;
200
+ results.push({ step: index + 1, ...(type === "loop" ? { type: "loop" } : { cmd: step.cmd }), status: "error", error: result.error, ms });
201
+ options.onProgress?.({ phase: "fail", index, total: steps.length, step, type, ms, error: result.error });
202
+ if ((options.onError || "stop") === "stop") {
203
+ return { status: "failed", completedSteps: type === "loop" ? stepsExecuted : stepsExecuted - 1, totalSteps: steps.length, results, error: result.error, totalMs: Date.now() - startTotal, vars };
204
+ }
205
+ } else {
206
+ results.push({ step: index + 1, ...(type === "loop" ? { type: "loop", stepsExecuted: result.stepsExecuted } : { cmd: step.cmd }), status: "ok", ms });
207
+ options.onProgress?.({ phase: "ok", index, total: steps.length, step, type, ms, stepsExecuted: result.stepsExecuted });
208
+ }
209
+ }
210
+ return { status: failed > 0 ? "partial" : "completed", completedSteps: stepsExecuted, totalSteps: steps.length, results, failed, totalMs: Date.now() - startTotal, vars };
211
+ }
212
+
213
+ module.exports = {
214
+ AUTO_WAIT_COMMANDS,
215
+ AUTO_WAIT_MAP,
216
+ MAX_LOOP_ITERATIONS,
217
+ executeSingleStep,
218
+ executeStep,
219
+ executeWorkflow,
220
+ extractStepOutput,
221
+ getAutoWaitCommand,
222
+ resolveVar,
223
+ shouldAutoWait,
224
+ substituteVars,
225
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "files": [
30
30
  "native/",
31
+ "playbooks/",
31
32
  "scripts/",
32
33
  "dist/",
33
34
  "skills/",
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "read",
3
+ "description": "Read the current site's root document",
4
+ "effect": "read",
5
+ "run": [
6
+ {
7
+ "using": "network",
8
+ "request": { "method": "GET", "url": "/" },
9
+ "extract": { "field": "body" },
10
+ "expect": { "truthy": true }
11
+ },
12
+ {
13
+ "using": "workflow",
14
+ "steps": [
15
+ { "tool": "page.text", "args": {}, "as": "content" }
16
+ ],
17
+ "extract": { "jsonPath": "$.vars.content" },
18
+ "expect": { "truthy": true }
19
+ }
20
+ ],
21
+ "on": { "drift": { "fallback": "next", "report": true } }
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "page",
3
+ "name": "Current page",
4
+ "version": "1.0.0",
5
+ "description": "Read the current browser page with a network fast path and UI fallback",
6
+ "origins": []
7
+ }