@jackwener/opencli 0.9.8 → 1.0.1
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/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/CLI-ELECTRON.md +2 -2
- package/CLI-EXPLORER.md +4 -4
- package/README.md +35 -58
- package/README.zh-CN.md +36 -60
- package/SKILL.md +10 -8
- package/TESTING.md +7 -7
- package/dist/browser/daemon-client.d.ts +37 -0
- package/dist/browser/daemon-client.js +82 -0
- package/dist/browser/discover.d.ts +11 -34
- package/dist/browser/discover.js +15 -205
- package/dist/browser/errors.d.ts +6 -20
- package/dist/browser/errors.js +24 -63
- package/dist/browser/index.d.ts +2 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/neteasemusic/like.d.ts +1 -0
- package/dist/clis/neteasemusic/like.js +25 -0
- package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
- package/dist/clis/neteasemusic/lyrics.js +47 -0
- package/dist/clis/neteasemusic/next.d.ts +1 -0
- package/dist/clis/neteasemusic/next.js +26 -0
- package/dist/clis/neteasemusic/play.d.ts +1 -0
- package/dist/clis/neteasemusic/play.js +26 -0
- package/dist/clis/neteasemusic/playing.d.ts +1 -0
- package/dist/clis/neteasemusic/playing.js +59 -0
- package/dist/clis/neteasemusic/playlist.d.ts +1 -0
- package/dist/clis/neteasemusic/playlist.js +46 -0
- package/dist/clis/neteasemusic/prev.d.ts +1 -0
- package/dist/clis/neteasemusic/prev.js +25 -0
- package/dist/clis/neteasemusic/search.d.ts +1 -0
- package/dist/clis/neteasemusic/search.js +52 -0
- package/dist/clis/neteasemusic/status.d.ts +1 -0
- package/dist/clis/neteasemusic/status.js +16 -0
- package/dist/clis/neteasemusic/volume.d.ts +1 -0
- package/dist/clis/neteasemusic/volume.js +54 -0
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/browser.js +2 -2
- package/dist/pipeline/steps/intercept.js +1 -2
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +31 -0
- package/extension/package.json +16 -0
- package/extension/src/background.ts +370 -0
- package/extension/src/cdp.ts +125 -0
- package/extension/src/protocol.ts +57 -0
- package/extension/store-assets/screenshot-1280x800.png +0 -0
- package/extension/tsconfig.json +15 -0
- package/extension/vite.config.ts +18 -0
- package/package.json +5 -5
- package/src/browser/daemon-client.ts +113 -0
- package/src/browser/discover.ts +18 -232
- package/src/browser/errors.ts +30 -100
- package/src/browser/index.ts +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/neteasemusic/README.md +31 -0
- package/src/clis/neteasemusic/README.zh-CN.md +31 -0
- package/src/clis/neteasemusic/like.ts +28 -0
- package/src/clis/neteasemusic/lyrics.ts +53 -0
- package/src/clis/neteasemusic/next.ts +30 -0
- package/src/clis/neteasemusic/play.ts +30 -0
- package/src/clis/neteasemusic/playing.ts +62 -0
- package/src/clis/neteasemusic/playlist.ts +51 -0
- package/src/clis/neteasemusic/prev.ts +29 -0
- package/src/clis/neteasemusic/search.ts +58 -0
- package/src/clis/neteasemusic/status.ts +18 -0
- package/src/clis/neteasemusic/volume.ts +61 -0
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/browser.ts +2 -2
- package/src/pipeline/steps/intercept.ts +1 -2
- package/src/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencli Browser Bridge — Service Worker (background script).
|
|
3
|
+
*
|
|
4
|
+
* Connects to the opencli daemon via WebSocket, receives commands,
|
|
5
|
+
* dispatches them to Chrome APIs (debugger/tabs/cookies), returns results.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Command, Result } from './protocol';
|
|
9
|
+
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
|
10
|
+
import * as executor from './cdp';
|
|
11
|
+
|
|
12
|
+
let ws: WebSocket | null = null;
|
|
13
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
14
|
+
let reconnectAttempts = 0;
|
|
15
|
+
|
|
16
|
+
// ─── Console log forwarding ──────────────────────────────────────────
|
|
17
|
+
// Hook console.log/warn/error to forward logs to daemon via WebSocket.
|
|
18
|
+
|
|
19
|
+
const _origLog = console.log.bind(console);
|
|
20
|
+
const _origWarn = console.warn.bind(console);
|
|
21
|
+
const _origError = console.error.bind(console);
|
|
22
|
+
|
|
23
|
+
function forwardLog(level: 'info' | 'warn' | 'error', args: unknown[]): void {
|
|
24
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
25
|
+
try {
|
|
26
|
+
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
27
|
+
ws.send(JSON.stringify({ type: 'log', level, msg, ts: Date.now() }));
|
|
28
|
+
} catch { /* don't recurse */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log = (...args: unknown[]) => { _origLog(...args); forwardLog('info', args); };
|
|
32
|
+
console.warn = (...args: unknown[]) => { _origWarn(...args); forwardLog('warn', args); };
|
|
33
|
+
console.error = (...args: unknown[]) => { _origError(...args); forwardLog('error', args); };
|
|
34
|
+
|
|
35
|
+
// ─── WebSocket connection ────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function connect(): void {
|
|
38
|
+
if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
ws = new WebSocket(DAEMON_WS_URL);
|
|
42
|
+
} catch {
|
|
43
|
+
scheduleReconnect();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ws.onopen = () => {
|
|
48
|
+
console.log('[opencli] Connected to daemon');
|
|
49
|
+
reconnectAttempts = 0; // Reset on successful connection
|
|
50
|
+
if (reconnectTimer) {
|
|
51
|
+
clearTimeout(reconnectTimer);
|
|
52
|
+
reconnectTimer = null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
ws.onmessage = async (event) => {
|
|
57
|
+
try {
|
|
58
|
+
const command = JSON.parse(event.data as string) as Command;
|
|
59
|
+
const result = await handleCommand(command);
|
|
60
|
+
ws?.send(JSON.stringify(result));
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('[opencli] Message handling error:', err);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
ws.onclose = () => {
|
|
67
|
+
console.log('[opencli] Disconnected from daemon');
|
|
68
|
+
ws = null;
|
|
69
|
+
scheduleReconnect();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
ws.onerror = () => {
|
|
73
|
+
ws?.close();
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scheduleReconnect(): void {
|
|
78
|
+
if (reconnectTimer) return;
|
|
79
|
+
reconnectAttempts++;
|
|
80
|
+
// Exponential backoff: 2s, 4s, 8s, 16s, ..., capped at 60s
|
|
81
|
+
const delay = Math.min(WS_RECONNECT_BASE_DELAY * Math.pow(2, reconnectAttempts - 1), WS_RECONNECT_MAX_DELAY);
|
|
82
|
+
reconnectTimer = setTimeout(() => {
|
|
83
|
+
reconnectTimer = null;
|
|
84
|
+
connect();
|
|
85
|
+
}, delay);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Automation window isolation ─────────────────────────────────────
|
|
89
|
+
// All opencli operations happen in a dedicated Chrome window so the
|
|
90
|
+
// user's active browsing session is never touched.
|
|
91
|
+
// The window auto-closes after 30s of idle (no commands).
|
|
92
|
+
|
|
93
|
+
let automationWindowId: number | null = null;
|
|
94
|
+
let windowIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
95
|
+
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
|
|
96
|
+
|
|
97
|
+
function resetWindowIdleTimer(): void {
|
|
98
|
+
if (windowIdleTimer) clearTimeout(windowIdleTimer);
|
|
99
|
+
windowIdleTimer = setTimeout(async () => {
|
|
100
|
+
if (automationWindowId !== null) {
|
|
101
|
+
try {
|
|
102
|
+
await chrome.windows.remove(automationWindowId);
|
|
103
|
+
console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`);
|
|
104
|
+
} catch {
|
|
105
|
+
// Already gone
|
|
106
|
+
}
|
|
107
|
+
automationWindowId = null;
|
|
108
|
+
}
|
|
109
|
+
windowIdleTimer = null;
|
|
110
|
+
}, WINDOW_IDLE_TIMEOUT);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get or create the dedicated automation window. */
|
|
114
|
+
async function getAutomationWindow(): Promise<number> {
|
|
115
|
+
// Check if our window is still alive
|
|
116
|
+
if (automationWindowId !== null) {
|
|
117
|
+
try {
|
|
118
|
+
await chrome.windows.get(automationWindowId);
|
|
119
|
+
return automationWindowId;
|
|
120
|
+
} catch {
|
|
121
|
+
// Window was closed by user
|
|
122
|
+
automationWindowId = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
|
|
127
|
+
const win = await chrome.windows.create({
|
|
128
|
+
url: 'about:blank',
|
|
129
|
+
focused: false,
|
|
130
|
+
width: 1280,
|
|
131
|
+
height: 900,
|
|
132
|
+
type: 'normal',
|
|
133
|
+
});
|
|
134
|
+
automationWindowId = win.id!;
|
|
135
|
+
console.log(`[opencli] Created automation window ${automationWindowId}`);
|
|
136
|
+
return automationWindowId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Clean up when the automation window is closed
|
|
140
|
+
chrome.windows.onRemoved.addListener((windowId) => {
|
|
141
|
+
if (windowId === automationWindowId) {
|
|
142
|
+
console.log('[opencli] Automation window closed');
|
|
143
|
+
automationWindowId = null;
|
|
144
|
+
if (windowIdleTimer) { clearTimeout(windowIdleTimer); windowIdleTimer = null; }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── Lifecycle events ────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
let initialized = false;
|
|
151
|
+
|
|
152
|
+
function initialize(): void {
|
|
153
|
+
if (initialized) return;
|
|
154
|
+
initialized = true;
|
|
155
|
+
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
|
156
|
+
executor.registerListeners();
|
|
157
|
+
connect();
|
|
158
|
+
console.log('[opencli] Browser Bridge extension initialized');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
chrome.runtime.onInstalled.addListener(() => {
|
|
162
|
+
initialize();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
chrome.runtime.onStartup.addListener(() => {
|
|
166
|
+
initialize();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
170
|
+
if (alarm.name === 'keepalive') connect();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── Command dispatcher ─────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function handleCommand(cmd: Command): Promise<Result> {
|
|
176
|
+
// Reset idle timer on every command (window stays alive while active)
|
|
177
|
+
resetWindowIdleTimer();
|
|
178
|
+
try {
|
|
179
|
+
switch (cmd.action) {
|
|
180
|
+
case 'exec':
|
|
181
|
+
return await handleExec(cmd);
|
|
182
|
+
case 'navigate':
|
|
183
|
+
return await handleNavigate(cmd);
|
|
184
|
+
case 'tabs':
|
|
185
|
+
return await handleTabs(cmd);
|
|
186
|
+
case 'cookies':
|
|
187
|
+
return await handleCookies(cmd);
|
|
188
|
+
case 'screenshot':
|
|
189
|
+
return await handleScreenshot(cmd);
|
|
190
|
+
case 'close-window':
|
|
191
|
+
return await handleCloseWindow(cmd);
|
|
192
|
+
default:
|
|
193
|
+
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return {
|
|
197
|
+
id: cmd.id,
|
|
198
|
+
ok: false,
|
|
199
|
+
error: err instanceof Error ? err.message : String(err),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Action handlers ─────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Check if a URL is a debuggable web page (not chrome:// or extension page) */
|
|
207
|
+
function isWebUrl(url?: string): boolean {
|
|
208
|
+
if (!url) return false;
|
|
209
|
+
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve target tab in the automation window.
|
|
214
|
+
* If explicit tabId is given, use that directly.
|
|
215
|
+
* Otherwise, find or create a tab in the dedicated automation window.
|
|
216
|
+
*/
|
|
217
|
+
async function resolveTabId(tabId?: number): Promise<number> {
|
|
218
|
+
if (tabId !== undefined) return tabId;
|
|
219
|
+
|
|
220
|
+
// Get (or create) the automation window
|
|
221
|
+
const windowId = await getAutomationWindow();
|
|
222
|
+
|
|
223
|
+
// Find the active tab in our automation window
|
|
224
|
+
const tabs = await chrome.tabs.query({ windowId });
|
|
225
|
+
const webTab = tabs.find(t => t.id && isWebUrl(t.url));
|
|
226
|
+
if (webTab?.id) return webTab.id;
|
|
227
|
+
|
|
228
|
+
// Use the first tab if it's a blank/new tab page
|
|
229
|
+
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
|
|
230
|
+
|
|
231
|
+
// No suitable tab — create one
|
|
232
|
+
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
|
|
233
|
+
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
|
234
|
+
return newTab.id;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function handleExec(cmd: Command): Promise<Result> {
|
|
238
|
+
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
|
239
|
+
const tabId = await resolveTabId(cmd.tabId);
|
|
240
|
+
try {
|
|
241
|
+
const data = await executor.evaluateAsync(tabId, cmd.code);
|
|
242
|
+
return { id: cmd.id, ok: true, data };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function handleNavigate(cmd: Command): Promise<Result> {
|
|
249
|
+
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
|
250
|
+
const tabId = await resolveTabId(cmd.tabId);
|
|
251
|
+
await chrome.tabs.update(tabId, { url: cmd.url });
|
|
252
|
+
|
|
253
|
+
// Wait for page to finish loading, checking current status first to avoid race
|
|
254
|
+
await new Promise<void>((resolve) => {
|
|
255
|
+
// Check if already complete (e.g. cached pages)
|
|
256
|
+
chrome.tabs.get(tabId).then(tab => {
|
|
257
|
+
if (tab.status === 'complete') { resolve(); return; }
|
|
258
|
+
|
|
259
|
+
const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
|
|
260
|
+
if (id === tabId && info.status === 'complete') {
|
|
261
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
262
|
+
resolve();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
266
|
+
// Timeout fallback
|
|
267
|
+
setTimeout(() => {
|
|
268
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
269
|
+
resolve();
|
|
270
|
+
}, 15000);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const tab = await chrome.tabs.get(tabId);
|
|
275
|
+
return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function handleTabs(cmd: Command): Promise<Result> {
|
|
279
|
+
switch (cmd.op) {
|
|
280
|
+
case 'list': {
|
|
281
|
+
const tabs = await chrome.tabs.query({});
|
|
282
|
+
const data = tabs
|
|
283
|
+
.filter((t) => isWebUrl(t.url))
|
|
284
|
+
.map((t, i) => ({
|
|
285
|
+
index: i,
|
|
286
|
+
tabId: t.id,
|
|
287
|
+
url: t.url,
|
|
288
|
+
title: t.title,
|
|
289
|
+
active: t.active,
|
|
290
|
+
}));
|
|
291
|
+
return { id: cmd.id, ok: true, data };
|
|
292
|
+
}
|
|
293
|
+
case 'new': {
|
|
294
|
+
const tab = await chrome.tabs.create({ url: cmd.url, active: true });
|
|
295
|
+
return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
|
|
296
|
+
}
|
|
297
|
+
case 'close': {
|
|
298
|
+
if (cmd.index !== undefined) {
|
|
299
|
+
const tabs = await chrome.tabs.query({});
|
|
300
|
+
const target = tabs[cmd.index];
|
|
301
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
302
|
+
await chrome.tabs.remove(target.id);
|
|
303
|
+
executor.detach(target.id);
|
|
304
|
+
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
|
305
|
+
}
|
|
306
|
+
const tabId = await resolveTabId(cmd.tabId);
|
|
307
|
+
await chrome.tabs.remove(tabId);
|
|
308
|
+
executor.detach(tabId);
|
|
309
|
+
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
|
310
|
+
}
|
|
311
|
+
case 'select': {
|
|
312
|
+
if (cmd.index === undefined && cmd.tabId === undefined)
|
|
313
|
+
return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
|
|
314
|
+
if (cmd.tabId !== undefined) {
|
|
315
|
+
await chrome.tabs.update(cmd.tabId, { active: true });
|
|
316
|
+
return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
|
|
317
|
+
}
|
|
318
|
+
const tabs = await chrome.tabs.query({});
|
|
319
|
+
const target = tabs[cmd.index!];
|
|
320
|
+
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
321
|
+
await chrome.tabs.update(target.id, { active: true });
|
|
322
|
+
return { id: cmd.id, ok: true, data: { selected: target.id } };
|
|
323
|
+
}
|
|
324
|
+
default:
|
|
325
|
+
return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function handleCookies(cmd: Command): Promise<Result> {
|
|
330
|
+
const details: chrome.cookies.GetAllDetails = {};
|
|
331
|
+
if (cmd.domain) details.domain = cmd.domain;
|
|
332
|
+
if (cmd.url) details.url = cmd.url;
|
|
333
|
+
const cookies = await chrome.cookies.getAll(details);
|
|
334
|
+
const data = cookies.map((c) => ({
|
|
335
|
+
name: c.name,
|
|
336
|
+
value: c.value,
|
|
337
|
+
domain: c.domain,
|
|
338
|
+
path: c.path,
|
|
339
|
+
secure: c.secure,
|
|
340
|
+
httpOnly: c.httpOnly,
|
|
341
|
+
expirationDate: c.expirationDate,
|
|
342
|
+
}));
|
|
343
|
+
return { id: cmd.id, ok: true, data };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function handleScreenshot(cmd: Command): Promise<Result> {
|
|
347
|
+
const tabId = await resolveTabId(cmd.tabId);
|
|
348
|
+
try {
|
|
349
|
+
const data = await executor.screenshot(tabId, {
|
|
350
|
+
format: cmd.format,
|
|
351
|
+
quality: cmd.quality,
|
|
352
|
+
fullPage: cmd.fullPage,
|
|
353
|
+
});
|
|
354
|
+
return { id: cmd.id, ok: true, data };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function handleCloseWindow(cmd: Command): Promise<Result> {
|
|
361
|
+
if (automationWindowId !== null) {
|
|
362
|
+
try {
|
|
363
|
+
await chrome.windows.remove(automationWindowId);
|
|
364
|
+
} catch {
|
|
365
|
+
// Window may already be closed
|
|
366
|
+
}
|
|
367
|
+
automationWindowId = null;
|
|
368
|
+
}
|
|
369
|
+
return { id: cmd.id, ok: true, data: { closed: true } };
|
|
370
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP execution via chrome.debugger API.
|
|
3
|
+
*
|
|
4
|
+
* chrome.debugger only needs the "debugger" permission — no host_permissions.
|
|
5
|
+
* It can attach to any http/https tab. Avoid chrome:// and chrome-extension://
|
|
6
|
+
* tabs (resolveTabId in background.ts filters them).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const attached = new Set<number>();
|
|
10
|
+
|
|
11
|
+
async function ensureAttached(tabId: number): Promise<void> {
|
|
12
|
+
if (attached.has(tabId)) return;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
16
|
+
} catch (e: unknown) {
|
|
17
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18
|
+
if (msg.includes('Another debugger is already attached')) {
|
|
19
|
+
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
20
|
+
try {
|
|
21
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`attach failed: ${msg}`);
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error(`attach failed: ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
attached.add(tabId);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable');
|
|
33
|
+
} catch {
|
|
34
|
+
// Some pages may not need explicit enable
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
|
39
|
+
await ensureAttached(tabId);
|
|
40
|
+
|
|
41
|
+
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
42
|
+
expression,
|
|
43
|
+
returnByValue: true,
|
|
44
|
+
awaitPromise: true,
|
|
45
|
+
}) as {
|
|
46
|
+
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
|
47
|
+
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (result.exceptionDetails) {
|
|
51
|
+
const errMsg = result.exceptionDetails.exception?.description
|
|
52
|
+
|| result.exceptionDetails.text
|
|
53
|
+
|| 'Eval error';
|
|
54
|
+
throw new Error(errMsg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result.result?.value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const evaluateAsync = evaluate;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
64
|
+
* Returns base64-encoded image data.
|
|
65
|
+
*/
|
|
66
|
+
export async function screenshot(
|
|
67
|
+
tabId: number,
|
|
68
|
+
options: { format?: 'png' | 'jpeg'; quality?: number; fullPage?: boolean } = {},
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
await ensureAttached(tabId);
|
|
71
|
+
|
|
72
|
+
const format = options.format ?? 'png';
|
|
73
|
+
|
|
74
|
+
// For full-page screenshots, get the full page dimensions first
|
|
75
|
+
if (options.fullPage) {
|
|
76
|
+
// Get full page metrics
|
|
77
|
+
const metrics = await chrome.debugger.sendCommand({ tabId }, 'Page.getLayoutMetrics') as {
|
|
78
|
+
contentSize?: { width: number; height: number };
|
|
79
|
+
cssContentSize?: { width: number; height: number };
|
|
80
|
+
};
|
|
81
|
+
const size = metrics.cssContentSize || metrics.contentSize;
|
|
82
|
+
if (size) {
|
|
83
|
+
// Set device metrics to full page size
|
|
84
|
+
await chrome.debugger.sendCommand({ tabId }, 'Emulation.setDeviceMetricsOverride', {
|
|
85
|
+
mobile: false,
|
|
86
|
+
width: Math.ceil(size.width),
|
|
87
|
+
height: Math.ceil(size.height),
|
|
88
|
+
deviceScaleFactor: 1,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const params: Record<string, unknown> = { format };
|
|
95
|
+
if (format === 'jpeg' && options.quality !== undefined) {
|
|
96
|
+
params.quality = Math.max(0, Math.min(100, options.quality));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', params) as {
|
|
100
|
+
data: string; // base64-encoded
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return result.data;
|
|
104
|
+
} finally {
|
|
105
|
+
// Reset device metrics if we changed them for full-page
|
|
106
|
+
if (options.fullPage) {
|
|
107
|
+
await chrome.debugger.sendCommand({ tabId }, 'Emulation.clearDeviceMetricsOverride').catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function detach(tabId: number): void {
|
|
113
|
+
if (!attached.has(tabId)) return;
|
|
114
|
+
attached.delete(tabId);
|
|
115
|
+
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function registerListeners(): void {
|
|
119
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
120
|
+
attached.delete(tabId);
|
|
121
|
+
});
|
|
122
|
+
chrome.debugger.onDetach.addListener((source) => {
|
|
123
|
+
if (source.tabId) attached.delete(source.tabId);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencli browser protocol — shared types between daemon, extension, and CLI.
|
|
3
|
+
*
|
|
4
|
+
* 5 actions: exec, navigate, tabs, cookies, screenshot.
|
|
5
|
+
* Everything else is just JS code sent via 'exec'.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
|
9
|
+
|
|
10
|
+
export interface Command {
|
|
11
|
+
/** Unique request ID */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Action type */
|
|
14
|
+
action: Action;
|
|
15
|
+
/** Target tab ID (omit for active tab) */
|
|
16
|
+
tabId?: number;
|
|
17
|
+
/** JS code to evaluate in page context (exec action) */
|
|
18
|
+
code?: string;
|
|
19
|
+
/** URL to navigate to (navigate action) */
|
|
20
|
+
url?: string;
|
|
21
|
+
/** Sub-operation for tabs: list, new, close, select */
|
|
22
|
+
op?: 'list' | 'new' | 'close' | 'select';
|
|
23
|
+
/** Tab index for tabs select/close */
|
|
24
|
+
index?: number;
|
|
25
|
+
/** Cookie domain filter */
|
|
26
|
+
domain?: string;
|
|
27
|
+
/** Screenshot format: png (default) or jpeg */
|
|
28
|
+
format?: 'png' | 'jpeg';
|
|
29
|
+
/** JPEG quality (0-100), only for jpeg format */
|
|
30
|
+
quality?: number;
|
|
31
|
+
/** Whether to capture full page (not just viewport) */
|
|
32
|
+
fullPage?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Result {
|
|
36
|
+
/** Matching request ID */
|
|
37
|
+
id: string;
|
|
38
|
+
/** Whether the command succeeded */
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** Result data on success */
|
|
41
|
+
data?: unknown;
|
|
42
|
+
/** Error message on failure */
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Default daemon port */
|
|
47
|
+
export const DAEMON_PORT = 19825;
|
|
48
|
+
export const DAEMON_HOST = 'localhost';
|
|
49
|
+
export const DAEMON_WS_URL = `ws://${DAEMON_HOST}:${DAEMON_PORT}/ext`;
|
|
50
|
+
export const DAEMON_HTTP_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
51
|
+
|
|
52
|
+
/** Base reconnect delay for extension WebSocket (ms) */
|
|
53
|
+
export const WS_RECONNECT_BASE_DELAY = 2000;
|
|
54
|
+
/** Max reconnect delay (ms) */
|
|
55
|
+
export const WS_RECONNECT_MAX_DELAY = 60000;
|
|
56
|
+
/** Idle timeout before daemon auto-exits (ms) */
|
|
57
|
+
export const DAEMON_IDLE_TIMEOUT = 5 * 60 * 1000;
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": false,
|
|
12
|
+
"types": ["chrome"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
build: {
|
|
6
|
+
outDir: 'dist',
|
|
7
|
+
emptyOutDir: true,
|
|
8
|
+
rollupOptions: {
|
|
9
|
+
input: resolve(__dirname, 'src/background.ts'),
|
|
10
|
+
output: {
|
|
11
|
+
entryFileNames: 'background.js',
|
|
12
|
+
format: 'es',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
target: 'esnext',
|
|
16
|
+
minify: false,
|
|
17
|
+
},
|
|
18
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -32,8 +32,7 @@
|
|
|
32
32
|
"cli",
|
|
33
33
|
"browser",
|
|
34
34
|
"web",
|
|
35
|
-
"ai"
|
|
36
|
-
"playwright"
|
|
35
|
+
"ai"
|
|
37
36
|
],
|
|
38
37
|
"author": "jackwener",
|
|
39
38
|
"license": "Apache-2.0",
|
|
@@ -45,11 +44,12 @@
|
|
|
45
44
|
"chalk": "^5.3.0",
|
|
46
45
|
"cli-table3": "^0.6.5",
|
|
47
46
|
"commander": "^14.0.3",
|
|
48
|
-
"js-yaml": "^4.1.0"
|
|
47
|
+
"js-yaml": "^4.1.0",
|
|
48
|
+
"ws": "^8.18.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@playwright/mcp": "^0.0.68",
|
|
52
51
|
"@types/js-yaml": "^4.0.9",
|
|
52
|
+
"@types/ws": "^8.5.13",
|
|
53
53
|
"@types/node": "^22.13.10",
|
|
54
54
|
"tsx": "^4.19.3",
|
|
55
55
|
"typescript": "^5.8.2",
|