surf-cli 2.8.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 (47) hide show
  1. package/README.md +146 -8
  2. package/native/abort.cjs +65 -0
  3. package/native/activity-journal.cjs +55 -0
  4. package/native/ai-queue.cjs +64 -0
  5. package/native/aistudio-build.cjs +21 -13
  6. package/native/aistudio-client.cjs +40 -20
  7. package/native/browser-lock.cjs +2 -2
  8. package/native/chatgpt-client.cjs +49 -31
  9. package/native/cli.cjs +352 -482
  10. package/native/client-transport.cjs +168 -0
  11. package/native/do-executor.cjs +68 -510
  12. package/native/do-parser.cjs +8 -249
  13. package/native/doctor.cjs +55 -5
  14. package/native/endpoint.cjs +174 -0
  15. package/native/file-transfer.cjs +734 -0
  16. package/native/gemini-client.cjs +156 -71
  17. package/native/grok-client.cjs +98 -89
  18. package/native/host-helpers.cjs +43 -26
  19. package/native/host-sessions.cjs +287 -0
  20. package/native/host.cjs +998 -620
  21. package/native/listener.cjs +20 -0
  22. package/native/mcp-server.cjs +60 -65
  23. package/native/network-export.cjs +116 -0
  24. package/native/network-store.cjs +38 -58
  25. package/native/perplexity-client.cjs +46 -17
  26. package/native/playbook-authoring.cjs +44 -0
  27. package/native/playbook-cli.cjs +157 -0
  28. package/native/playbook-client.cjs +259 -0
  29. package/native/playbook-receipts.cjs +109 -0
  30. package/native/playbook-records.cjs +208 -0
  31. package/native/playbook-runtime.cjs +177 -0
  32. package/native/playbooks.cjs +235 -0
  33. package/native/private-state.cjs +156 -0
  34. package/native/redaction.cjs +104 -0
  35. package/native/remote-auth.cjs +279 -0
  36. package/native/remote-transport.cjs +337 -0
  37. package/native/request-pending.cjs +148 -0
  38. package/native/socket-path.cjs +1 -1
  39. package/native/workflow-definition.cjs +368 -0
  40. package/native/workflow-runtime.cjs +225 -0
  41. package/package.json +9 -6
  42. package/playbooks/page/ops/read.json +22 -0
  43. package/playbooks/page/playbook.json +7 -0
  44. package/scripts/install-native-host.cjs +36 -5
  45. package/skills/README.md +11 -5
  46. package/skills/deep-x-research/SKILL.md +106 -0
  47. package/skills/surf/SKILL.md +72 -5
@@ -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.8.0",
3
+ "version": "2.10.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/",
@@ -43,6 +44,7 @@
43
44
  "lint:test": "biome check test/",
44
45
  "format": "biome format --write .",
45
46
  "test": "vitest run",
47
+ "test:e2e:chrome": "node test/e2e/real-chrome.mjs",
46
48
  "test:watch": "vitest",
47
49
  "test:coverage": "vitest run --coverage",
48
50
  "test:ui": "vitest --ui",
@@ -56,16 +58,17 @@
56
58
  "crypto-browserify": "^3.12.1",
57
59
  "events": "^3.3.0",
58
60
  "stream-browserify": "^3.0.0",
59
- "vite-plugin-node-polyfills": "^0.25.0",
61
+ "vite-plugin-node-polyfills": "^0.28.0",
60
62
  "zod": "^4.3.6"
61
63
  },
62
64
  "devDependencies": {
63
- "@biomejs/biome": "^2.4.4",
64
- "@types/chrome": "^0.1.37",
65
+ "@biomejs/biome": "^2.5.4",
66
+ "@types/chrome": "^0.2.2",
65
67
  "@vitest/coverage-v8": "^4.1.9",
66
68
  "@vitest/ui": "^4.1.9",
67
- "typescript": "^6.0.3",
68
- "vite": "^7.3.1",
69
+ "puppeteer": "25.3.0",
70
+ "typescript": "^7.0.2",
71
+ "vite": "^8.1.4",
69
72
  "vitest": "^4.1.9"
70
73
  }
71
74
  }
@@ -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
+ }
@@ -3,6 +3,8 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
5
  const { execFileSync, execSync } = require("child_process");
6
+ const { parseListenEndpoint } = require("../native/listener.cjs");
7
+ const { getStateDir, loadHostIdentity, loadRegistry } = require("../native/remote-auth.cjs");
6
8
 
7
9
  const HOST_NAME = "surf.browser.host";
8
10
 
@@ -164,7 +166,7 @@ function wslPathToWindowsPath(wslPath) {
164
166
  }
165
167
  }
166
168
 
167
- function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform) {
169
+ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen) {
168
170
  fs.mkdirSync(wrapperDir, { recursive: true });
169
171
 
170
172
  if (target === "wsl-windows") {
@@ -186,13 +188,19 @@ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform
186
188
  const hostDir = path.dirname(hostPath);
187
189
  const content = `#!/usr/bin/env bash
188
190
  cd "${hostDir}"
189
- exec "${nodePath}" "${hostPath}" "$@"
191
+ ${listen ? `: "\${SURF_LISTEN:=${listen}}"\nexport SURF_LISTEN\n` : ""}exec "${nodePath}" "${hostPath}" "$@"
190
192
  `;
191
193
  fs.writeFileSync(shPath, content);
192
194
  fs.chmodSync(shPath, "755");
193
195
  return shPath;
194
196
  }
195
197
 
198
+ function assertListenTargetSupported(listen, target) {
199
+ if (listen && (target === "win32" || target === "wsl-windows")) {
200
+ throw new Error("--listen is not supported for Windows native-host wrappers");
201
+ }
202
+ }
203
+
196
204
  function readExistingManifest(manifestPath) {
197
205
  if (!fs.existsSync(manifestPath)) return {};
198
206
  return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -268,7 +276,7 @@ function installWindowsRegistry(browser, extensionId, wrapperPath) {
268
276
 
269
277
  function parseArgs() {
270
278
  const args = process.argv.slice(2);
271
- const result = { extensionId: null, browsers: ["chrome"], target: "auto" };
279
+ const result = { extensionId: null, browsers: ["chrome"], target: "auto", listen: undefined };
272
280
 
273
281
  for (let i = 0; i < args.length; i++) {
274
282
  const arg = args[i];
@@ -281,6 +289,9 @@ function parseArgs() {
281
289
  }
282
290
  } else if (arg === "--target") {
283
291
  result.target = args[++i];
292
+ } else if (arg === "--listen") {
293
+ result.listen = args[++i];
294
+ if (!result.listen || result.listen.startsWith("--")) throw new Error("--listen requires a Tailnet IP and port");
284
295
  } else if (arg === "--help" || arg === "-h") {
285
296
  printHelp();
286
297
  process.exit(0);
@@ -307,17 +318,24 @@ Options:
307
318
  Multiple: --browser chrome,brave
308
319
  --target Install target: auto, linux, windows
309
320
  On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
321
+ --listen <tailscale-ip>:<port>
322
+ Persist an authenticated Tailnet-only listener endpoint.
323
+ Requires at least one surf remote authorize client first.
324
+ Supports Tailscale IPv4 or IPv6 addresses; POSIX wrappers only.
310
325
 
311
326
  Examples:
312
327
  node install-native-host.cjs abcdefghijklmnopabcdefghijklmnop
313
328
  node install-native-host.cjs abcdefghijklmnop --browser brave
314
329
  node install-native-host.cjs abcdefghijklmnop --browser all
315
330
  node install-native-host.cjs abcdefghijklmnop --target linux
331
+ node install-native-host.cjs abcdefghijklmnop --listen 100.64.1.2:4321
316
332
  `);
317
333
  }
318
334
 
319
335
  function main() {
320
- const { extensionId, browsers, target } = parseArgs();
336
+ let parsed;
337
+ try { parsed = parseArgs(); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
338
+ const { extensionId, browsers, target, listen } = parsed;
321
339
 
322
340
  if (!extensionId) {
323
341
  console.error("Error: Extension ID required");
@@ -331,6 +349,17 @@ function main() {
331
349
  console.error("Expected 32 lowercase letters (a-p)");
332
350
  process.exit(1);
333
351
  }
352
+ let listener;
353
+ try {
354
+ listener = listen ? parseListenEndpoint(listen).display : undefined;
355
+ if (listener) {
356
+ const stateDir = getStateDir();
357
+ loadHostIdentity(stateDir);
358
+ if (loadRegistry(stateDir).clients.length === 0) {
359
+ throw new Error("--listen requires at least one authorized remote client; run `surf remote authorize <label> --output <path>` first");
360
+ }
361
+ }
362
+ } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
334
363
 
335
364
  if (!["auto", "linux", "windows"].includes(target)) {
336
365
  console.error("Error: Invalid --target value. Expected auto, linux, or windows");
@@ -349,6 +378,7 @@ function main() {
349
378
  }
350
379
 
351
380
  const effectiveTarget = runningInWsl && target !== "linux" ? "wsl-windows" : process.platform;
381
+ try { assertListenTargetSupported(listen, effectiveTarget); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
352
382
 
353
383
  const nodePath = findNode();
354
384
  if (!nodePath) {
@@ -377,7 +407,7 @@ function main() {
377
407
  console.log(`Wrapper dir: ${wrapperDir}`);
378
408
  console.log("");
379
409
 
380
- const wrapperPath = createWrapper(wrapperDir, nodePath, hostPath, effectiveTarget);
410
+ const wrapperPath = createWrapper(wrapperDir, nodePath, hostPath, effectiveTarget, listener);
381
411
  console.log(`Created wrapper: ${wrapperPath}`);
382
412
  console.log("");
383
413
 
@@ -419,4 +449,5 @@ if (require.main === module) {
419
449
  module.exports = {
420
450
  createWrapper,
421
451
  writeManifest,
452
+ assertListenTargetSupported,
422
453
  };
package/skills/README.md CHANGED
@@ -1,21 +1,27 @@
1
1
  # Surf Skills
2
2
 
3
- This directory contains skill files for AI coding agents.
3
+ This directory contains skill files for AI coding agents:
4
+
5
+ - **`surf/`** — the core browser-automation reference: every surf command, workflows, AI assistants, troubleshooting.
6
+ - **`deep-x-research/`** — a research procedure built on surf: exhaustive, multi-angle X (Twitter) research with categorized findings and full post-URL traceability. Requires x.com login in Chrome.
7
+
8
+ Install each skill folder the same way (symlink or copy).
4
9
 
5
10
  ## Pi Agent
6
11
 
7
- To use the surf skill with [Pi coding agent](https://github.com/badlogic/pi-mono):
12
+ To use a skill with [Pi coding agent](https://github.com/badlogic/pi-mono):
8
13
 
9
14
  ```bash
10
15
  # Option 1: Symlink (auto-updates)
11
16
  ln -s "$(pwd)/skills/surf" ~/.agents/skills/surf
17
+ ln -s "$(pwd)/skills/deep-x-research" ~/.agents/skills/deep-x-research
12
18
 
13
19
  # Option 2: Copy
14
- cp -r skills/surf ~/.agents/skills/
20
+ cp -r skills/surf skills/deep-x-research ~/.agents/skills/
15
21
  ```
16
22
 
17
- The skill will be available when pi detects browser automation tasks.
23
+ The skills will be available when pi detects browser automation or X research tasks.
18
24
 
19
25
  ## Other Agents
20
26
 
21
- The `SKILL.md` file is a comprehensive reference that can be adapted for other AI coding agents or used as documentation for LLM prompts.
27
+ Each `SKILL.md` file can be adapted for other AI coding agents (Claude Code, Codex) or used as documentation for LLM prompts — copy the skill folder into the agent's skills directory (e.g. `~/.claude/skills/`, `~/.agents/skills/`).
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: deep-x-research
3
+ description: Deep, exhaustive research on a topic across X (Twitter) by driving Grok (x.com/i/grok) through surf. Use when the user wants comprehensive X research on a concept, technique, trend, tool, or creator scene; needs categorized findings with every claim traceable to post URLs; or when a single Grok query is not enough.
4
+ ---
5
+
6
+ # Deep X Research
7
+
8
+ Research a topic across X by putting Grok to work from multiple angles — it runs keyword and semantic X searches and watches videos natively — then deliver categorized findings where every claim is traceable to a post URL.
9
+
10
+ Requires: surf installed and connected (`surf doctor`), Chrome logged into x.com. Command reference: the `surf` skill or `surf --help`.
11
+
12
+ **Quota:** X caps Grok requests (typically 15 per 20 hours on a standard plan). Every `surf grok` call spends one. Budget the session before the first query and make each query do multi-angle work — never spend a request on what a quota-free step can answer.
13
+
14
+ ## Steps
15
+
16
+ ### 1. Decompose the topic and budget the queries
17
+
18
+ Break the topic into angles: showcases/examples, techniques & tutorials, tools, notable creators, community discussion — adapt to the topic. Plan a Grok budget of **4-8 queries** covering every angle (combine related angles into one query rather than spending two). Done when each angle is assigned to a budgeted query.
19
+
20
+ ### 2. Grok sweep
21
+
22
+ Run the budgeted queries sequentially. Engineer each so Grok does the fan-out internally and returns traceable sources:
23
+
24
+ ```bash
25
+ # Broad pass — force multi-angle search and URLs
26
+ surf grok "Do deep research on TOPIC on X. Search both latest and top posts, keyword and semantic. Return the most relevant posts with full post URLs (x.com/user/status/ID) and a one-line description of each."
27
+
28
+ # Focused passes — one per remaining angle group
29
+ surf grok "TOPIC on X: tutorials, techniques, and the tools people use. Include post URLs for every example."
30
+
31
+ # Deepest pass — spend DeepSearch on the highest-value angle
32
+ surf grok "TOPIC: notable creators, how the trend is evolving, and the standout posts of the last 6 months. Post URLs required." --deep-search
33
+ ```
34
+
35
+ Record every post Grok cites: author, one-line gist, full `https://x.com/USER/status/ID` URL. If a response gives claims without URLs, the *next* query in the budget re-asks for sources — never leave an angle sourceless. Done when every planned angle has been queried and the final response adds no new relevant posts, or the budget is spent.
36
+
37
+ ### 3. Video pass (visual topics)
38
+
39
+ When the topic involves video, editing, or visual style, spend 1-3 budgeted queries having Grok analyze the strongest video posts — it can watch X videos natively:
40
+
41
+ ```bash
42
+ surf grok "Analyze the videos in these posts: URL1 URL2 URL3 — for each, describe the techniques, pacing, and style, and why it works."
43
+ ```
44
+
45
+ Batch several URLs per query to conserve budget. Done when each analyzed video has notes on what it shows and why it matters for the topic.
46
+
47
+ ### 4. Enrich and verify — quota-free
48
+
49
+ For each cited post, open it directly with surf (no Grok spend) to verify the URL resolves and harvest detail Grok didn't give:
50
+
51
+ ```bash
52
+ surf navigate "https://x.com/USER/status/ID" && surf wait 2
53
+ surf page.read --compact # engagement numbers, thread context
54
+ surf network | grep video.twimg # direct video URL after playback
55
+ ```
56
+
57
+ Done when every URL destined for the References section has been resolved (dead or hallucinated links dropped or replaced).
58
+
59
+ ### 5. Categorize and analyze
60
+
61
+ Group findings into categories that fit the topic. Extract trends: momentum on X, recurring techniques, notable creators, how the topic is evolving. Done when every recorded post is either placed in a category or deliberately dropped as irrelevant.
62
+
63
+ ### 6. Report with full traceability
64
+
65
+ ```md
66
+ # Deep Research on [Topic]
67
+
68
+ ## Summary
69
+ [2-4 paragraphs: state of the topic on X]
70
+
71
+ ## Key Trends
72
+ - ...
73
+
74
+ ## Categorized Findings
75
+ ### [Category]
76
+ - [Finding with inline post reference]
77
+
78
+ ## Notable Creators & Techniques
79
+ - ...
80
+
81
+ ## References
82
+ 1. [Author — one-line description]
83
+ https://x.com/USER/status/ID
84
+ Video: https://video.twimg.com/... (when captured)
85
+ ```
86
+
87
+ The report is done when **every post mentioned anywhere in it appears in References with its full, verified URL** — no bare @handles, no "a viral post showed…" without a link.
88
+
89
+ ## Fallback: direct search when the Grok quota is exhausted
90
+
91
+ The x.com search UI costs no Grok requests. Slower and keyword-only, but it keeps the sweep going:
92
+
93
+ ```bash
94
+ surf navigate "https://x.com/search?q=QUERY&f=live" && surf wait 3 # Latest
95
+ surf page.read --compact
96
+ surf scroll down 2000 # then page.read again — repeat to load more
97
+ ```
98
+
99
+ Modes via the `f=` param: **Top is the default (no `f` param)** — there is no `f=top`; `f=live` = Latest, `f=user` = People, `f=media` = Media. Operators compose into the URL-encoded `q=`: `"exact phrase"`, `filter:videos`, `min_faves:100`, `min_retweets:50`, `from:user`, `since:2026-01-01`, `until:2026-06-01`.
100
+
101
+ ## Troubleshooting
102
+
103
+ - Grok replies with a rate-limit message → quota exhausted; switch to the fallback sweep and tell the user when the quota resets.
104
+ - Grok queries fail outright → `surf grok --validate`, then retry with a model from the validation output (see the `surf` skill's AI troubleshooting section).
105
+ - Grok cites posts without URLs → re-ask in the next budgeted query; do not invent URLs.
106
+ - Search page shows a login wall → Chrome isn't logged into x.com; ask the user to log in.