@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.
Files changed (165) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +35 -58
  6. package/README.zh-CN.md +36 -60
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -12
  16. package/dist/browser/index.js +2 -12
  17. package/dist/browser/mcp.d.ts +9 -21
  18. package/dist/browser/mcp.js +70 -285
  19. package/dist/browser/page.d.ts +36 -7
  20. package/dist/browser/page.js +212 -81
  21. package/dist/browser.test.js +10 -231
  22. package/dist/cli-manifest.json +561 -14
  23. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  24. package/dist/clis/apple-podcasts/episodes.js +28 -0
  25. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  26. package/dist/clis/apple-podcasts/search.js +29 -0
  27. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  28. package/dist/clis/apple-podcasts/top.js +34 -0
  29. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  30. package/dist/clis/apple-podcasts/utils.js +30 -0
  31. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  33. package/dist/clis/chatwise/history.js +18 -1
  34. package/dist/clis/discord-app/channels.js +33 -21
  35. package/dist/clis/neteasemusic/like.d.ts +1 -0
  36. package/dist/clis/neteasemusic/like.js +25 -0
  37. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  38. package/dist/clis/neteasemusic/lyrics.js +47 -0
  39. package/dist/clis/neteasemusic/next.d.ts +1 -0
  40. package/dist/clis/neteasemusic/next.js +26 -0
  41. package/dist/clis/neteasemusic/play.d.ts +1 -0
  42. package/dist/clis/neteasemusic/play.js +26 -0
  43. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  44. package/dist/clis/neteasemusic/playing.js +59 -0
  45. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  46. package/dist/clis/neteasemusic/playlist.js +46 -0
  47. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  48. package/dist/clis/neteasemusic/prev.js +25 -0
  49. package/dist/clis/neteasemusic/search.d.ts +1 -0
  50. package/dist/clis/neteasemusic/search.js +52 -0
  51. package/dist/clis/neteasemusic/status.d.ts +1 -0
  52. package/dist/clis/neteasemusic/status.js +16 -0
  53. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  54. package/dist/clis/neteasemusic/volume.js +54 -0
  55. package/dist/clis/twitter/accept.d.ts +1 -0
  56. package/dist/clis/twitter/accept.js +202 -0
  57. package/dist/clis/twitter/followers.js +30 -22
  58. package/dist/clis/twitter/following.js +19 -14
  59. package/dist/clis/twitter/notifications.js +29 -22
  60. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  61. package/dist/clis/twitter/reply-dm.js +181 -0
  62. package/dist/clis/twitter/search.js +50 -12
  63. package/dist/clis/weread/book.d.ts +1 -0
  64. package/dist/clis/weread/book.js +26 -0
  65. package/dist/clis/weread/highlights.d.ts +1 -0
  66. package/dist/clis/weread/highlights.js +23 -0
  67. package/dist/clis/weread/notebooks.d.ts +1 -0
  68. package/dist/clis/weread/notebooks.js +21 -0
  69. package/dist/clis/weread/notes.d.ts +1 -0
  70. package/dist/clis/weread/notes.js +29 -0
  71. package/dist/clis/weread/ranking.d.ts +1 -0
  72. package/dist/clis/weread/ranking.js +28 -0
  73. package/dist/clis/weread/search.d.ts +1 -0
  74. package/dist/clis/weread/search.js +25 -0
  75. package/dist/clis/weread/shelf.d.ts +1 -0
  76. package/dist/clis/weread/shelf.js +24 -0
  77. package/dist/clis/weread/utils.d.ts +20 -0
  78. package/dist/clis/weread/utils.js +72 -0
  79. package/dist/clis/weread/utils.test.d.ts +1 -0
  80. package/dist/clis/weread/utils.test.js +85 -0
  81. package/dist/daemon.d.ts +13 -0
  82. package/dist/daemon.js +187 -0
  83. package/dist/doctor.d.ts +10 -65
  84. package/dist/doctor.js +49 -602
  85. package/dist/doctor.test.js +30 -170
  86. package/dist/main.js +12 -41
  87. package/dist/pipeline/executor.test.js +1 -0
  88. package/dist/pipeline/steps/browser.js +2 -2
  89. package/dist/pipeline/steps/intercept.js +1 -2
  90. package/dist/runtime.d.ts +1 -4
  91. package/dist/runtime.js +1 -4
  92. package/dist/setup.d.ts +6 -0
  93. package/dist/setup.js +46 -160
  94. package/dist/types.d.ts +6 -0
  95. package/extension/dist/background.js +484 -0
  96. package/extension/icons/icon-128.png +0 -0
  97. package/extension/icons/icon-16.png +0 -0
  98. package/extension/icons/icon-32.png +0 -0
  99. package/extension/icons/icon-48.png +0 -0
  100. package/extension/manifest.json +31 -0
  101. package/extension/package.json +16 -0
  102. package/extension/src/background.ts +370 -0
  103. package/extension/src/cdp.ts +125 -0
  104. package/extension/src/protocol.ts +57 -0
  105. package/extension/store-assets/screenshot-1280x800.png +0 -0
  106. package/extension/tsconfig.json +15 -0
  107. package/extension/vite.config.ts +18 -0
  108. package/package.json +5 -5
  109. package/src/browser/daemon-client.ts +113 -0
  110. package/src/browser/discover.ts +18 -232
  111. package/src/browser/errors.ts +30 -100
  112. package/src/browser/index.ts +2 -13
  113. package/src/browser/mcp.ts +81 -282
  114. package/src/browser/page.ts +223 -83
  115. package/src/browser.test.ts +9 -239
  116. package/src/clis/apple-podcasts/episodes.ts +28 -0
  117. package/src/clis/apple-podcasts/search.ts +29 -0
  118. package/src/clis/apple-podcasts/top.ts +34 -0
  119. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  120. package/src/clis/apple-podcasts/utils.ts +37 -0
  121. package/src/clis/chatgpt/README.md +1 -1
  122. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  123. package/src/clis/chatwise/history.ts +15 -1
  124. package/src/clis/discord-app/channels.ts +33 -21
  125. package/src/clis/neteasemusic/README.md +31 -0
  126. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  127. package/src/clis/neteasemusic/like.ts +28 -0
  128. package/src/clis/neteasemusic/lyrics.ts +53 -0
  129. package/src/clis/neteasemusic/next.ts +30 -0
  130. package/src/clis/neteasemusic/play.ts +30 -0
  131. package/src/clis/neteasemusic/playing.ts +62 -0
  132. package/src/clis/neteasemusic/playlist.ts +51 -0
  133. package/src/clis/neteasemusic/prev.ts +29 -0
  134. package/src/clis/neteasemusic/search.ts +58 -0
  135. package/src/clis/neteasemusic/status.ts +18 -0
  136. package/src/clis/neteasemusic/volume.ts +61 -0
  137. package/src/clis/twitter/accept.ts +213 -0
  138. package/src/clis/twitter/followers.ts +36 -29
  139. package/src/clis/twitter/following.ts +25 -20
  140. package/src/clis/twitter/notifications.ts +34 -27
  141. package/src/clis/twitter/reply-dm.ts +193 -0
  142. package/src/clis/twitter/search.ts +53 -13
  143. package/src/clis/weread/book.ts +28 -0
  144. package/src/clis/weread/highlights.ts +25 -0
  145. package/src/clis/weread/notebooks.ts +23 -0
  146. package/src/clis/weread/notes.ts +31 -0
  147. package/src/clis/weread/ranking.ts +29 -0
  148. package/src/clis/weread/search.ts +26 -0
  149. package/src/clis/weread/shelf.ts +26 -0
  150. package/src/clis/weread/utils.test.ts +104 -0
  151. package/src/clis/weread/utils.ts +74 -0
  152. package/src/daemon.ts +217 -0
  153. package/src/doctor.test.ts +32 -193
  154. package/src/doctor.ts +58 -669
  155. package/src/main.ts +11 -34
  156. package/src/pipeline/executor.test.ts +1 -0
  157. package/src/pipeline/steps/browser.ts +2 -2
  158. package/src/pipeline/steps/intercept.ts +1 -2
  159. package/src/runtime.ts +2 -6
  160. package/src/setup.ts +47 -183
  161. package/src/types.ts +1 -0
  162. package/tests/e2e/public-commands.test.ts +68 -1
  163. package/dist/clis/grok/debug.d.ts +0 -1
  164. package/dist/clis/grok/debug.js +0 -45
  165. 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;
@@ -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.9.8",
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",