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.
- package/README.md +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- 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.
|
|
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.
|
|
61
|
+
"vite-plugin-node-polyfills": "^0.28.0",
|
|
60
62
|
"zod": "^4.3.6"
|
|
61
63
|
},
|
|
62
64
|
"devDependencies": {
|
|
63
|
-
"@biomejs/biome": "^2.
|
|
64
|
-
"@types/chrome": "^0.
|
|
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
|
-
"
|
|
68
|
-
"
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
23
|
+
The skills will be available when pi detects browser automation or X research tasks.
|
|
18
24
|
|
|
19
25
|
## Other Agents
|
|
20
26
|
|
|
21
|
-
|
|
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.
|