@jackwener/opencli 1.5.9 → 1.6.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/CHANGELOG.md +21 -0
- package/README.md +18 -0
- package/SKILL.md +59 -0
- package/autoresearch/baseline-browse.txt +1 -0
- package/autoresearch/baseline-skill.txt +1 -0
- package/autoresearch/browse-tasks.json +688 -0
- package/autoresearch/eval-browse.ts +185 -0
- package/autoresearch/eval-skill.ts +248 -0
- package/autoresearch/run-browse.sh +9 -0
- package/autoresearch/run-skill.sh +9 -0
- package/dist/browser/daemon-client.d.ts +20 -1
- package/dist/browser/daemon-client.js +37 -30
- package/dist/browser/daemon-client.test.d.ts +1 -0
- package/dist/browser/daemon-client.test.js +77 -0
- package/dist/browser/discover.js +8 -19
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +48 -1
- package/dist/cli.js +392 -0
- package/dist/clis/twitter/article.js +28 -1
- package/dist/clis/xiaohongshu/note.js +11 -0
- package/dist/clis/xiaohongshu/note.test.js +49 -0
- package/dist/commanderAdapter.js +1 -1
- package/dist/commanderAdapter.test.js +43 -0
- package/dist/commands/daemon.js +7 -46
- package/dist/commands/daemon.test.js +44 -69
- package/dist/discovery.js +27 -0
- package/dist/types.d.ts +8 -0
- package/docs/guide/getting-started.md +21 -0
- package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
- package/docs/zh/guide/getting-started.md +21 -0
- package/extension/package-lock.json +2 -2
- package/extension/src/background.ts +51 -4
- package/extension/src/cdp.ts +77 -124
- package/extension/src/protocol.ts +5 -1
- package/package.json +1 -1
- package/skills/opencli-explorer/SKILL.md +6 -0
- package/skills/opencli-oneshot/SKILL.md +6 -0
- package/skills/opencli-operate/SKILL.md +213 -0
- package/skills/opencli-usage/SKILL.md +113 -32
- package/src/browser/daemon-client.test.ts +103 -0
- package/src/browser/daemon-client.ts +53 -30
- package/src/browser/discover.ts +8 -17
- package/src/browser/page.ts +48 -1
- package/src/cli.ts +392 -0
- package/src/clis/twitter/article.ts +31 -1
- package/src/clis/xiaohongshu/note.test.ts +51 -0
- package/src/clis/xiaohongshu/note.ts +18 -0
- package/src/commanderAdapter.test.ts +62 -0
- package/src/commanderAdapter.ts +1 -1
- package/src/commands/daemon.test.ts +49 -83
- package/src/commands/daemon.ts +7 -55
- package/src/discovery.ts +22 -0
- package/src/doctor.ts +1 -1
- package/src/types.ts +8 -0
- package/extension/dist/background.js +0 -681
|
@@ -1,681 +0,0 @@
|
|
|
1
|
-
const DAEMON_PORT = 19825;
|
|
2
|
-
const DAEMON_HOST = "localhost";
|
|
3
|
-
const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
4
|
-
const DAEMON_PING_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}/ping`;
|
|
5
|
-
const WS_RECONNECT_BASE_DELAY = 2e3;
|
|
6
|
-
const WS_RECONNECT_MAX_DELAY = 5e3;
|
|
7
|
-
|
|
8
|
-
const attached = /* @__PURE__ */ new Set();
|
|
9
|
-
const BLANK_PAGE$1 = "data:text/html,<html></html>";
|
|
10
|
-
const FOREIGN_EXTENSION_URL_PREFIX = "chrome-extension://";
|
|
11
|
-
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
12
|
-
function isDebuggableUrl$1(url) {
|
|
13
|
-
if (!url) return true;
|
|
14
|
-
return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE$1;
|
|
15
|
-
}
|
|
16
|
-
async function removeForeignExtensionEmbeds(tabId) {
|
|
17
|
-
const tab = await chrome.tabs.get(tabId);
|
|
18
|
-
if (!tab.url || !tab.url.startsWith("http://") && !tab.url.startsWith("https://")) {
|
|
19
|
-
return { removed: 0 };
|
|
20
|
-
}
|
|
21
|
-
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
|
22
|
-
try {
|
|
23
|
-
const [result] = await chrome.scripting.executeScript({
|
|
24
|
-
target: { tabId },
|
|
25
|
-
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
|
26
|
-
func: (ownExtensionPrefix) => {
|
|
27
|
-
const extensionPrefix = "chrome-extension://";
|
|
28
|
-
const selectors = ["iframe", "frame", "embed", "object"];
|
|
29
|
-
const visitedRoots = /* @__PURE__ */ new Set();
|
|
30
|
-
const roots = [document];
|
|
31
|
-
let removed = 0;
|
|
32
|
-
while (roots.length > 0) {
|
|
33
|
-
const root = roots.pop();
|
|
34
|
-
if (!root || visitedRoots.has(root)) continue;
|
|
35
|
-
visitedRoots.add(root);
|
|
36
|
-
for (const selector of selectors) {
|
|
37
|
-
const nodes = root.querySelectorAll(selector);
|
|
38
|
-
for (const node of nodes) {
|
|
39
|
-
const src = node.getAttribute("src") || node.getAttribute("data") || "";
|
|
40
|
-
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
|
41
|
-
node.remove();
|
|
42
|
-
removed++;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
46
|
-
let current = walker.nextNode();
|
|
47
|
-
while (current) {
|
|
48
|
-
const element = current;
|
|
49
|
-
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
50
|
-
current = walker.nextNode();
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { removed };
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
return result?.result ?? { removed: 0 };
|
|
57
|
-
} catch {
|
|
58
|
-
return { removed: 0 };
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
function delay(ms) {
|
|
62
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
-
}
|
|
64
|
-
async function tryAttach(tabId) {
|
|
65
|
-
await chrome.debugger.attach({ tabId }, "1.3");
|
|
66
|
-
}
|
|
67
|
-
async function ensureAttached(tabId) {
|
|
68
|
-
try {
|
|
69
|
-
const tab = await chrome.tabs.get(tabId);
|
|
70
|
-
if (!isDebuggableUrl$1(tab.url)) {
|
|
71
|
-
attached.delete(tabId);
|
|
72
|
-
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
|
|
73
|
-
}
|
|
74
|
-
} catch (e) {
|
|
75
|
-
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
|
|
76
|
-
attached.delete(tabId);
|
|
77
|
-
throw new Error(`Tab ${tabId} no longer exists`);
|
|
78
|
-
}
|
|
79
|
-
if (attached.has(tabId)) {
|
|
80
|
-
try {
|
|
81
|
-
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
|
82
|
-
expression: "1",
|
|
83
|
-
returnByValue: true
|
|
84
|
-
});
|
|
85
|
-
return;
|
|
86
|
-
} catch {
|
|
87
|
-
attached.delete(tabId);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
await tryAttach(tabId);
|
|
92
|
-
} catch (e) {
|
|
93
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
94
|
-
const hint = msg.includes("chrome-extension://") ? ". Tip: another Chrome extension may be interfering — try disabling other extensions" : "";
|
|
95
|
-
if (msg.includes("chrome-extension://")) {
|
|
96
|
-
const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
|
|
97
|
-
if (recoveryCleanup.removed > 0) {
|
|
98
|
-
console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
|
|
99
|
-
}
|
|
100
|
-
await delay(ATTACH_RECOVERY_DELAY_MS);
|
|
101
|
-
try {
|
|
102
|
-
await tryAttach(tabId);
|
|
103
|
-
} catch {
|
|
104
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
105
|
-
}
|
|
106
|
-
} else if (msg.includes("Another debugger is already attached")) {
|
|
107
|
-
try {
|
|
108
|
-
await chrome.debugger.detach({ tabId });
|
|
109
|
-
} catch {
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
await tryAttach(tabId);
|
|
113
|
-
} catch {
|
|
114
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
115
|
-
}
|
|
116
|
-
} else {
|
|
117
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
attached.add(tabId);
|
|
121
|
-
try {
|
|
122
|
-
await chrome.debugger.sendCommand({ tabId }, "Runtime.enable");
|
|
123
|
-
} catch {
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
await chrome.debugger.sendCommand({ tabId }, "Debugger.enable");
|
|
127
|
-
await chrome.debugger.sendCommand({ tabId }, "Debugger.setBreakpointsActive", { active: false });
|
|
128
|
-
} catch {
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
async function evaluate(tabId, expression) {
|
|
132
|
-
await ensureAttached(tabId);
|
|
133
|
-
const result = await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
|
134
|
-
expression,
|
|
135
|
-
returnByValue: true,
|
|
136
|
-
awaitPromise: true
|
|
137
|
-
});
|
|
138
|
-
if (result.exceptionDetails) {
|
|
139
|
-
const errMsg = result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Eval error";
|
|
140
|
-
throw new Error(errMsg);
|
|
141
|
-
}
|
|
142
|
-
return result.result?.value;
|
|
143
|
-
}
|
|
144
|
-
const evaluateAsync = evaluate;
|
|
145
|
-
async function screenshot(tabId, options = {}) {
|
|
146
|
-
await ensureAttached(tabId);
|
|
147
|
-
const format = options.format ?? "png";
|
|
148
|
-
if (options.fullPage) {
|
|
149
|
-
const metrics = await chrome.debugger.sendCommand({ tabId }, "Page.getLayoutMetrics");
|
|
150
|
-
const size = metrics.cssContentSize || metrics.contentSize;
|
|
151
|
-
if (size) {
|
|
152
|
-
await chrome.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
|
153
|
-
mobile: false,
|
|
154
|
-
width: Math.ceil(size.width),
|
|
155
|
-
height: Math.ceil(size.height),
|
|
156
|
-
deviceScaleFactor: 1
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
try {
|
|
161
|
-
const params = { format };
|
|
162
|
-
if (format === "jpeg" && options.quality !== void 0) {
|
|
163
|
-
params.quality = Math.max(0, Math.min(100, options.quality));
|
|
164
|
-
}
|
|
165
|
-
const result = await chrome.debugger.sendCommand({ tabId }, "Page.captureScreenshot", params);
|
|
166
|
-
return result.data;
|
|
167
|
-
} finally {
|
|
168
|
-
if (options.fullPage) {
|
|
169
|
-
await chrome.debugger.sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride").catch(() => {
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
async function setFileInputFiles(tabId, files, selector) {
|
|
175
|
-
await ensureAttached(tabId);
|
|
176
|
-
await chrome.debugger.sendCommand({ tabId }, "DOM.enable");
|
|
177
|
-
const doc = await chrome.debugger.sendCommand({ tabId }, "DOM.getDocument");
|
|
178
|
-
const query = selector || 'input[type="file"]';
|
|
179
|
-
const result = await chrome.debugger.sendCommand({ tabId }, "DOM.querySelector", {
|
|
180
|
-
nodeId: doc.root.nodeId,
|
|
181
|
-
selector: query
|
|
182
|
-
});
|
|
183
|
-
if (!result.nodeId) {
|
|
184
|
-
throw new Error(`No element found matching selector: ${query}`);
|
|
185
|
-
}
|
|
186
|
-
await chrome.debugger.sendCommand({ tabId }, "DOM.setFileInputFiles", {
|
|
187
|
-
files,
|
|
188
|
-
nodeId: result.nodeId
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
async function detach(tabId) {
|
|
192
|
-
if (!attached.has(tabId)) return;
|
|
193
|
-
attached.delete(tabId);
|
|
194
|
-
try {
|
|
195
|
-
await chrome.debugger.detach({ tabId });
|
|
196
|
-
} catch {
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
function registerListeners() {
|
|
200
|
-
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
201
|
-
attached.delete(tabId);
|
|
202
|
-
});
|
|
203
|
-
chrome.debugger.onDetach.addListener((source) => {
|
|
204
|
-
if (source.tabId) attached.delete(source.tabId);
|
|
205
|
-
});
|
|
206
|
-
chrome.tabs.onUpdated.addListener(async (tabId, info) => {
|
|
207
|
-
if (info.url && !isDebuggableUrl$1(info.url)) {
|
|
208
|
-
await detach(tabId);
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
let ws = null;
|
|
214
|
-
let reconnectTimer = null;
|
|
215
|
-
let reconnectAttempts = 0;
|
|
216
|
-
const _origLog = console.log.bind(console);
|
|
217
|
-
const _origWarn = console.warn.bind(console);
|
|
218
|
-
const _origError = console.error.bind(console);
|
|
219
|
-
function forwardLog(level, args) {
|
|
220
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
221
|
-
try {
|
|
222
|
-
const msg = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
223
|
-
ws.send(JSON.stringify({ type: "log", level, msg, ts: Date.now() }));
|
|
224
|
-
} catch {
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
console.log = (...args) => {
|
|
228
|
-
_origLog(...args);
|
|
229
|
-
forwardLog("info", args);
|
|
230
|
-
};
|
|
231
|
-
console.warn = (...args) => {
|
|
232
|
-
_origWarn(...args);
|
|
233
|
-
forwardLog("warn", args);
|
|
234
|
-
};
|
|
235
|
-
console.error = (...args) => {
|
|
236
|
-
_origError(...args);
|
|
237
|
-
forwardLog("error", args);
|
|
238
|
-
};
|
|
239
|
-
async function connect() {
|
|
240
|
-
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
241
|
-
try {
|
|
242
|
-
const res = await fetch(DAEMON_PING_URL, { signal: AbortSignal.timeout(1e3) });
|
|
243
|
-
if (!res.ok) return;
|
|
244
|
-
} catch {
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
ws = new WebSocket(DAEMON_WS_URL);
|
|
249
|
-
} catch {
|
|
250
|
-
scheduleReconnect();
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
ws.onopen = () => {
|
|
254
|
-
console.log("[opencli] Connected to daemon");
|
|
255
|
-
reconnectAttempts = 0;
|
|
256
|
-
if (reconnectTimer) {
|
|
257
|
-
clearTimeout(reconnectTimer);
|
|
258
|
-
reconnectTimer = null;
|
|
259
|
-
}
|
|
260
|
-
ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
|
|
261
|
-
};
|
|
262
|
-
ws.onmessage = async (event) => {
|
|
263
|
-
try {
|
|
264
|
-
const command = JSON.parse(event.data);
|
|
265
|
-
const result = await handleCommand(command);
|
|
266
|
-
ws?.send(JSON.stringify(result));
|
|
267
|
-
} catch (err) {
|
|
268
|
-
console.error("[opencli] Message handling error:", err);
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
ws.onclose = () => {
|
|
272
|
-
console.log("[opencli] Disconnected from daemon");
|
|
273
|
-
ws = null;
|
|
274
|
-
scheduleReconnect();
|
|
275
|
-
};
|
|
276
|
-
ws.onerror = () => {
|
|
277
|
-
ws?.close();
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
const MAX_EAGER_ATTEMPTS = 6;
|
|
281
|
-
function scheduleReconnect() {
|
|
282
|
-
if (reconnectTimer) return;
|
|
283
|
-
reconnectAttempts++;
|
|
284
|
-
if (reconnectAttempts > MAX_EAGER_ATTEMPTS) return;
|
|
285
|
-
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
286
|
-
reconnectTimer = setTimeout(() => {
|
|
287
|
-
reconnectTimer = null;
|
|
288
|
-
void connect();
|
|
289
|
-
}, delay);
|
|
290
|
-
}
|
|
291
|
-
const automationSessions = /* @__PURE__ */ new Map();
|
|
292
|
-
const WINDOW_IDLE_TIMEOUT = 3e4;
|
|
293
|
-
function getWorkspaceKey(workspace) {
|
|
294
|
-
return workspace?.trim() || "default";
|
|
295
|
-
}
|
|
296
|
-
function resetWindowIdleTimer(workspace) {
|
|
297
|
-
const session = automationSessions.get(workspace);
|
|
298
|
-
if (!session) return;
|
|
299
|
-
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
300
|
-
session.idleDeadlineAt = Date.now() + WINDOW_IDLE_TIMEOUT;
|
|
301
|
-
session.idleTimer = setTimeout(async () => {
|
|
302
|
-
const current = automationSessions.get(workspace);
|
|
303
|
-
if (!current) return;
|
|
304
|
-
try {
|
|
305
|
-
await chrome.windows.remove(current.windowId);
|
|
306
|
-
console.log(`[opencli] Automation window ${current.windowId} (${workspace}) closed (idle timeout)`);
|
|
307
|
-
} catch {
|
|
308
|
-
}
|
|
309
|
-
automationSessions.delete(workspace);
|
|
310
|
-
}, WINDOW_IDLE_TIMEOUT);
|
|
311
|
-
}
|
|
312
|
-
async function getAutomationWindow(workspace) {
|
|
313
|
-
const existing = automationSessions.get(workspace);
|
|
314
|
-
if (existing) {
|
|
315
|
-
try {
|
|
316
|
-
await chrome.windows.get(existing.windowId);
|
|
317
|
-
return existing.windowId;
|
|
318
|
-
} catch {
|
|
319
|
-
automationSessions.delete(workspace);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
const win = await chrome.windows.create({
|
|
323
|
-
url: BLANK_PAGE,
|
|
324
|
-
focused: false,
|
|
325
|
-
width: 1280,
|
|
326
|
-
height: 900,
|
|
327
|
-
type: "normal"
|
|
328
|
-
});
|
|
329
|
-
const session = {
|
|
330
|
-
windowId: win.id,
|
|
331
|
-
idleTimer: null,
|
|
332
|
-
idleDeadlineAt: Date.now() + WINDOW_IDLE_TIMEOUT
|
|
333
|
-
};
|
|
334
|
-
automationSessions.set(workspace, session);
|
|
335
|
-
console.log(`[opencli] Created automation window ${session.windowId} (${workspace})`);
|
|
336
|
-
resetWindowIdleTimer(workspace);
|
|
337
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
338
|
-
return session.windowId;
|
|
339
|
-
}
|
|
340
|
-
chrome.windows.onRemoved.addListener((windowId) => {
|
|
341
|
-
for (const [workspace, session] of automationSessions.entries()) {
|
|
342
|
-
if (session.windowId === windowId) {
|
|
343
|
-
console.log(`[opencli] Automation window closed (${workspace})`);
|
|
344
|
-
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
345
|
-
automationSessions.delete(workspace);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
let initialized = false;
|
|
350
|
-
function initialize() {
|
|
351
|
-
if (initialized) return;
|
|
352
|
-
initialized = true;
|
|
353
|
-
chrome.alarms.create("keepalive", { periodInMinutes: 0.4 });
|
|
354
|
-
registerListeners();
|
|
355
|
-
void connect();
|
|
356
|
-
console.log("[opencli] OpenCLI extension initialized");
|
|
357
|
-
}
|
|
358
|
-
chrome.runtime.onInstalled.addListener(() => {
|
|
359
|
-
initialize();
|
|
360
|
-
});
|
|
361
|
-
chrome.runtime.onStartup.addListener(() => {
|
|
362
|
-
initialize();
|
|
363
|
-
});
|
|
364
|
-
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
365
|
-
if (alarm.name === "keepalive") void connect();
|
|
366
|
-
});
|
|
367
|
-
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
368
|
-
if (msg?.type === "getStatus") {
|
|
369
|
-
sendResponse({
|
|
370
|
-
connected: ws?.readyState === WebSocket.OPEN,
|
|
371
|
-
reconnecting: reconnectTimer !== null
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
return false;
|
|
375
|
-
});
|
|
376
|
-
async function handleCommand(cmd) {
|
|
377
|
-
const workspace = getWorkspaceKey(cmd.workspace);
|
|
378
|
-
resetWindowIdleTimer(workspace);
|
|
379
|
-
try {
|
|
380
|
-
switch (cmd.action) {
|
|
381
|
-
case "exec":
|
|
382
|
-
return await handleExec(cmd, workspace);
|
|
383
|
-
case "navigate":
|
|
384
|
-
return await handleNavigate(cmd, workspace);
|
|
385
|
-
case "tabs":
|
|
386
|
-
return await handleTabs(cmd, workspace);
|
|
387
|
-
case "cookies":
|
|
388
|
-
return await handleCookies(cmd);
|
|
389
|
-
case "screenshot":
|
|
390
|
-
return await handleScreenshot(cmd, workspace);
|
|
391
|
-
case "close-window":
|
|
392
|
-
return await handleCloseWindow(cmd, workspace);
|
|
393
|
-
case "sessions":
|
|
394
|
-
return await handleSessions(cmd);
|
|
395
|
-
case "set-file-input":
|
|
396
|
-
return await handleSetFileInput(cmd, workspace);
|
|
397
|
-
default:
|
|
398
|
-
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
399
|
-
}
|
|
400
|
-
} catch (err) {
|
|
401
|
-
return {
|
|
402
|
-
id: cmd.id,
|
|
403
|
-
ok: false,
|
|
404
|
-
error: err instanceof Error ? err.message : String(err)
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
const BLANK_PAGE = "data:text/html,<html></html>";
|
|
409
|
-
function isDebuggableUrl(url) {
|
|
410
|
-
if (!url) return true;
|
|
411
|
-
return url.startsWith("http://") || url.startsWith("https://") || url === BLANK_PAGE;
|
|
412
|
-
}
|
|
413
|
-
function isSafeNavigationUrl(url) {
|
|
414
|
-
return url.startsWith("http://") || url.startsWith("https://");
|
|
415
|
-
}
|
|
416
|
-
function normalizeUrlForComparison(url) {
|
|
417
|
-
if (!url) return "";
|
|
418
|
-
try {
|
|
419
|
-
const parsed = new URL(url);
|
|
420
|
-
if (parsed.protocol === "https:" && parsed.port === "443" || parsed.protocol === "http:" && parsed.port === "80") {
|
|
421
|
-
parsed.port = "";
|
|
422
|
-
}
|
|
423
|
-
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
424
|
-
return `${parsed.protocol}//${parsed.host}${pathname}${parsed.search}${parsed.hash}`;
|
|
425
|
-
} catch {
|
|
426
|
-
return url;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
function isTargetUrl(currentUrl, targetUrl) {
|
|
430
|
-
return normalizeUrlForComparison(currentUrl) === normalizeUrlForComparison(targetUrl);
|
|
431
|
-
}
|
|
432
|
-
async function resolveTabId(tabId, workspace) {
|
|
433
|
-
if (tabId !== void 0) {
|
|
434
|
-
try {
|
|
435
|
-
const tab = await chrome.tabs.get(tabId);
|
|
436
|
-
const session = automationSessions.get(workspace);
|
|
437
|
-
const matchesSession = session ? tab.windowId === session.windowId : false;
|
|
438
|
-
if (isDebuggableUrl(tab.url) && matchesSession) return tabId;
|
|
439
|
-
if (session && !matchesSession) {
|
|
440
|
-
console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`);
|
|
441
|
-
} else if (!isDebuggableUrl(tab.url)) {
|
|
442
|
-
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
443
|
-
}
|
|
444
|
-
} catch {
|
|
445
|
-
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const windowId = await getAutomationWindow(workspace);
|
|
449
|
-
const tabs = await chrome.tabs.query({ windowId });
|
|
450
|
-
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
|
|
451
|
-
if (debuggableTab?.id) return debuggableTab.id;
|
|
452
|
-
const reuseTab = tabs.find((t) => t.id);
|
|
453
|
-
if (reuseTab?.id) {
|
|
454
|
-
await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE });
|
|
455
|
-
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
456
|
-
try {
|
|
457
|
-
const updated = await chrome.tabs.get(reuseTab.id);
|
|
458
|
-
if (isDebuggableUrl(updated.url)) return reuseTab.id;
|
|
459
|
-
console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`);
|
|
460
|
-
} catch {
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true });
|
|
464
|
-
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
|
465
|
-
return newTab.id;
|
|
466
|
-
}
|
|
467
|
-
async function listAutomationTabs(workspace) {
|
|
468
|
-
const session = automationSessions.get(workspace);
|
|
469
|
-
if (!session) return [];
|
|
470
|
-
try {
|
|
471
|
-
return await chrome.tabs.query({ windowId: session.windowId });
|
|
472
|
-
} catch {
|
|
473
|
-
automationSessions.delete(workspace);
|
|
474
|
-
return [];
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
async function listAutomationWebTabs(workspace) {
|
|
478
|
-
const tabs = await listAutomationTabs(workspace);
|
|
479
|
-
return tabs.filter((tab) => isDebuggableUrl(tab.url));
|
|
480
|
-
}
|
|
481
|
-
async function handleExec(cmd, workspace) {
|
|
482
|
-
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
|
483
|
-
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
484
|
-
try {
|
|
485
|
-
const data = await evaluateAsync(tabId, cmd.code);
|
|
486
|
-
return { id: cmd.id, ok: true, data };
|
|
487
|
-
} catch (err) {
|
|
488
|
-
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
async function handleNavigate(cmd, workspace) {
|
|
492
|
-
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
|
493
|
-
if (!isSafeNavigationUrl(cmd.url)) {
|
|
494
|
-
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
495
|
-
}
|
|
496
|
-
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
497
|
-
const beforeTab = await chrome.tabs.get(tabId);
|
|
498
|
-
const beforeNormalized = normalizeUrlForComparison(beforeTab.url);
|
|
499
|
-
const targetUrl = cmd.url;
|
|
500
|
-
if (beforeTab.status === "complete" && isTargetUrl(beforeTab.url, targetUrl)) {
|
|
501
|
-
return {
|
|
502
|
-
id: cmd.id,
|
|
503
|
-
ok: true,
|
|
504
|
-
data: { title: beforeTab.title, url: beforeTab.url, tabId, timedOut: false }
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
await detach(tabId);
|
|
508
|
-
await chrome.tabs.update(tabId, { url: targetUrl });
|
|
509
|
-
let timedOut = false;
|
|
510
|
-
await new Promise((resolve) => {
|
|
511
|
-
let settled = false;
|
|
512
|
-
let checkTimer = null;
|
|
513
|
-
let timeoutTimer = null;
|
|
514
|
-
const finish = () => {
|
|
515
|
-
if (settled) return;
|
|
516
|
-
settled = true;
|
|
517
|
-
chrome.tabs.onUpdated.removeListener(listener);
|
|
518
|
-
if (checkTimer) clearTimeout(checkTimer);
|
|
519
|
-
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
520
|
-
resolve();
|
|
521
|
-
};
|
|
522
|
-
const isNavigationDone = (url) => {
|
|
523
|
-
return isTargetUrl(url, targetUrl) || normalizeUrlForComparison(url) !== beforeNormalized;
|
|
524
|
-
};
|
|
525
|
-
const listener = (id, info, tab2) => {
|
|
526
|
-
if (id !== tabId) return;
|
|
527
|
-
if (info.status === "complete" && isNavigationDone(tab2.url ?? info.url)) {
|
|
528
|
-
finish();
|
|
529
|
-
}
|
|
530
|
-
};
|
|
531
|
-
chrome.tabs.onUpdated.addListener(listener);
|
|
532
|
-
checkTimer = setTimeout(async () => {
|
|
533
|
-
try {
|
|
534
|
-
const currentTab = await chrome.tabs.get(tabId);
|
|
535
|
-
if (currentTab.status === "complete" && isNavigationDone(currentTab.url)) {
|
|
536
|
-
finish();
|
|
537
|
-
}
|
|
538
|
-
} catch {
|
|
539
|
-
}
|
|
540
|
-
}, 100);
|
|
541
|
-
timeoutTimer = setTimeout(() => {
|
|
542
|
-
timedOut = true;
|
|
543
|
-
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
|
544
|
-
finish();
|
|
545
|
-
}, 15e3);
|
|
546
|
-
});
|
|
547
|
-
const tab = await chrome.tabs.get(tabId);
|
|
548
|
-
return {
|
|
549
|
-
id: cmd.id,
|
|
550
|
-
ok: true,
|
|
551
|
-
data: { title: tab.title, url: tab.url, tabId, timedOut }
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
async function handleTabs(cmd, workspace) {
|
|
555
|
-
switch (cmd.op) {
|
|
556
|
-
case "list": {
|
|
557
|
-
const tabs = await listAutomationWebTabs(workspace);
|
|
558
|
-
const data = tabs.map((t, i) => ({
|
|
559
|
-
index: i,
|
|
560
|
-
tabId: t.id,
|
|
561
|
-
url: t.url,
|
|
562
|
-
title: t.title,
|
|
563
|
-
active: t.active
|
|
564
|
-
}));
|
|
565
|
-
return { id: cmd.id, ok: true, data };
|
|
566
|
-
}
|
|
567
|
-
case "new": {
|
|
568
|
-
if (cmd.url && !isSafeNavigationUrl(cmd.url)) {
|
|
569
|
-
return { id: cmd.id, ok: false, error: "Blocked URL scheme -- only http:// and https:// are allowed" };
|
|
570
|
-
}
|
|
571
|
-
const windowId = await getAutomationWindow(workspace);
|
|
572
|
-
const tab = await chrome.tabs.create({ windowId, url: cmd.url ?? BLANK_PAGE, active: true });
|
|
573
|
-
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
|
574
|
-
}
|
|
575
|
-
case "close": {
|
|
576
|
-
if (cmd.index !== void 0) {
|
|
577
|
-
const tabs = await listAutomationWebTabs(workspace);
|
|
578
|
-
const target = tabs[cmd.index];
|
|
579
|
-
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
580
|
-
await chrome.tabs.remove(target.id);
|
|
581
|
-
await detach(target.id);
|
|
582
|
-
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
|
583
|
-
}
|
|
584
|
-
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
585
|
-
await chrome.tabs.remove(tabId);
|
|
586
|
-
await detach(tabId);
|
|
587
|
-
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
|
588
|
-
}
|
|
589
|
-
case "select": {
|
|
590
|
-
if (cmd.index === void 0 && cmd.tabId === void 0)
|
|
591
|
-
return { id: cmd.id, ok: false, error: "Missing index or tabId" };
|
|
592
|
-
if (cmd.tabId !== void 0) {
|
|
593
|
-
const session = automationSessions.get(workspace);
|
|
594
|
-
let tab;
|
|
595
|
-
try {
|
|
596
|
-
tab = await chrome.tabs.get(cmd.tabId);
|
|
597
|
-
} catch {
|
|
598
|
-
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} no longer exists` };
|
|
599
|
-
}
|
|
600
|
-
if (!session || tab.windowId !== session.windowId) {
|
|
601
|
-
return { id: cmd.id, ok: false, error: `Tab ${cmd.tabId} is not in the automation window` };
|
|
602
|
-
}
|
|
603
|
-
await chrome.tabs.update(cmd.tabId, { active: true });
|
|
604
|
-
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
|
605
|
-
}
|
|
606
|
-
const tabs = await listAutomationWebTabs(workspace);
|
|
607
|
-
const target = tabs[cmd.index];
|
|
608
|
-
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
609
|
-
await chrome.tabs.update(target.id, { active: true });
|
|
610
|
-
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
|
611
|
-
}
|
|
612
|
-
default:
|
|
613
|
-
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
async function handleCookies(cmd) {
|
|
617
|
-
if (!cmd.domain && !cmd.url) {
|
|
618
|
-
return { id: cmd.id, ok: false, error: "Cookie scope required: provide domain or url to avoid dumping all cookies" };
|
|
619
|
-
}
|
|
620
|
-
const details = {};
|
|
621
|
-
if (cmd.domain) details.domain = cmd.domain;
|
|
622
|
-
if (cmd.url) details.url = cmd.url;
|
|
623
|
-
const cookies = await chrome.cookies.getAll(details);
|
|
624
|
-
const data = cookies.map((c) => ({
|
|
625
|
-
name: c.name,
|
|
626
|
-
value: c.value,
|
|
627
|
-
domain: c.domain,
|
|
628
|
-
path: c.path,
|
|
629
|
-
secure: c.secure,
|
|
630
|
-
httpOnly: c.httpOnly,
|
|
631
|
-
expirationDate: c.expirationDate
|
|
632
|
-
}));
|
|
633
|
-
return { id: cmd.id, ok: true, data };
|
|
634
|
-
}
|
|
635
|
-
async function handleScreenshot(cmd, workspace) {
|
|
636
|
-
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
637
|
-
try {
|
|
638
|
-
const data = await screenshot(tabId, {
|
|
639
|
-
format: cmd.format,
|
|
640
|
-
quality: cmd.quality,
|
|
641
|
-
fullPage: cmd.fullPage
|
|
642
|
-
});
|
|
643
|
-
return { id: cmd.id, ok: true, data };
|
|
644
|
-
} catch (err) {
|
|
645
|
-
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
async function handleCloseWindow(cmd, workspace) {
|
|
649
|
-
const session = automationSessions.get(workspace);
|
|
650
|
-
if (session) {
|
|
651
|
-
try {
|
|
652
|
-
await chrome.windows.remove(session.windowId);
|
|
653
|
-
} catch {
|
|
654
|
-
}
|
|
655
|
-
if (session.idleTimer) clearTimeout(session.idleTimer);
|
|
656
|
-
automationSessions.delete(workspace);
|
|
657
|
-
}
|
|
658
|
-
return { id: cmd.id, ok: true, data: { closed: true } };
|
|
659
|
-
}
|
|
660
|
-
async function handleSetFileInput(cmd, workspace) {
|
|
661
|
-
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
|
|
662
|
-
return { id: cmd.id, ok: false, error: "Missing or empty files array" };
|
|
663
|
-
}
|
|
664
|
-
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
665
|
-
try {
|
|
666
|
-
await setFileInputFiles(tabId, cmd.files, cmd.selector);
|
|
667
|
-
return { id: cmd.id, ok: true, data: { count: cmd.files.length } };
|
|
668
|
-
} catch (err) {
|
|
669
|
-
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
async function handleSessions(cmd) {
|
|
673
|
-
const now = Date.now();
|
|
674
|
-
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
|
675
|
-
workspace,
|
|
676
|
-
windowId: session.windowId,
|
|
677
|
-
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
|
|
678
|
-
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
|
679
|
-
})));
|
|
680
|
-
return { id: cmd.id, ok: true, data };
|
|
681
|
-
}
|