@jackwener/opencli 0.9.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) 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 +15 -57
  6. package/README.zh-CN.md +16 -59
  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 -11
  16. package/dist/browser/index.js +5 -11
  17. package/dist/browser/mcp.d.ts +9 -18
  18. package/dist/browser/mcp.js +70 -284
  19. package/dist/browser/page.d.ts +28 -6
  20. package/dist/browser/page.js +210 -85
  21. package/dist/browser.test.js +4 -225
  22. package/dist/cli-manifest.json +167 -0
  23. package/dist/clis/neteasemusic/like.d.ts +1 -0
  24. package/dist/clis/neteasemusic/like.js +25 -0
  25. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  26. package/dist/clis/neteasemusic/lyrics.js +47 -0
  27. package/dist/clis/neteasemusic/next.d.ts +1 -0
  28. package/dist/clis/neteasemusic/next.js +26 -0
  29. package/dist/clis/neteasemusic/play.d.ts +1 -0
  30. package/dist/clis/neteasemusic/play.js +26 -0
  31. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  32. package/dist/clis/neteasemusic/playing.js +59 -0
  33. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  34. package/dist/clis/neteasemusic/playlist.js +46 -0
  35. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  36. package/dist/clis/neteasemusic/prev.js +25 -0
  37. package/dist/clis/neteasemusic/search.d.ts +1 -0
  38. package/dist/clis/neteasemusic/search.js +52 -0
  39. package/dist/clis/neteasemusic/status.d.ts +1 -0
  40. package/dist/clis/neteasemusic/status.js +16 -0
  41. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  42. package/dist/clis/neteasemusic/volume.js +54 -0
  43. package/dist/daemon.d.ts +13 -0
  44. package/dist/daemon.js +187 -0
  45. package/dist/doctor.d.ts +27 -61
  46. package/dist/doctor.js +70 -601
  47. package/dist/doctor.test.js +30 -170
  48. package/dist/main.js +6 -25
  49. package/dist/pipeline/executor.test.js +1 -0
  50. package/dist/pipeline/steps/browser.js +2 -2
  51. package/dist/pipeline/steps/intercept.js +1 -2
  52. package/dist/setup.d.ts +6 -0
  53. package/dist/setup.js +46 -160
  54. package/dist/types.d.ts +6 -0
  55. package/extension/icons/icon-128.png +0 -0
  56. package/extension/icons/icon-16.png +0 -0
  57. package/extension/icons/icon-32.png +0 -0
  58. package/extension/icons/icon-48.png +0 -0
  59. package/extension/manifest.json +31 -0
  60. package/extension/package.json +16 -0
  61. package/extension/src/background.ts +293 -0
  62. package/extension/src/cdp.ts +125 -0
  63. package/extension/src/protocol.ts +57 -0
  64. package/extension/store-assets/screenshot-1280x800.png +0 -0
  65. package/extension/tsconfig.json +15 -0
  66. package/extension/vite.config.ts +18 -0
  67. package/package.json +5 -5
  68. package/src/browser/daemon-client.ts +113 -0
  69. package/src/browser/discover.ts +18 -232
  70. package/src/browser/errors.ts +30 -100
  71. package/src/browser/index.ts +6 -12
  72. package/src/browser/mcp.ts +78 -278
  73. package/src/browser/page.ts +222 -88
  74. package/src/browser.test.ts +3 -233
  75. package/src/clis/chatgpt/README.md +1 -1
  76. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  77. package/src/clis/neteasemusic/README.md +31 -0
  78. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  79. package/src/clis/neteasemusic/like.ts +28 -0
  80. package/src/clis/neteasemusic/lyrics.ts +53 -0
  81. package/src/clis/neteasemusic/next.ts +30 -0
  82. package/src/clis/neteasemusic/play.ts +30 -0
  83. package/src/clis/neteasemusic/playing.ts +62 -0
  84. package/src/clis/neteasemusic/playlist.ts +51 -0
  85. package/src/clis/neteasemusic/prev.ts +29 -0
  86. package/src/clis/neteasemusic/search.ts +58 -0
  87. package/src/clis/neteasemusic/status.ts +18 -0
  88. package/src/clis/neteasemusic/volume.ts +61 -0
  89. package/src/daemon.ts +217 -0
  90. package/src/doctor.test.ts +32 -193
  91. package/src/doctor.ts +74 -668
  92. package/src/main.ts +6 -23
  93. package/src/pipeline/executor.test.ts +1 -0
  94. package/src/pipeline/steps/browser.ts +2 -2
  95. package/src/pipeline/steps/intercept.ts +1 -2
  96. package/src/setup.ts +47 -183
  97. package/src/types.ts +1 -0
@@ -0,0 +1,293 @@
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 cdp 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
+ // ─── Lifecycle events ────────────────────────────────────────────────
89
+
90
+ let initialized = false;
91
+
92
+ function initialize(): void {
93
+ if (initialized) return;
94
+ initialized = true;
95
+ chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
96
+ cdp.registerListeners();
97
+ connect();
98
+ console.log('[opencli] Browser Bridge extension initialized');
99
+ }
100
+
101
+ chrome.runtime.onInstalled.addListener(() => {
102
+ initialize();
103
+ });
104
+
105
+ chrome.runtime.onStartup.addListener(() => {
106
+ initialize();
107
+ });
108
+
109
+ chrome.alarms.onAlarm.addListener((alarm) => {
110
+ if (alarm.name === 'keepalive') connect();
111
+ });
112
+
113
+ // ─── Command dispatcher ─────────────────────────────────────────────
114
+
115
+ async function handleCommand(cmd: Command): Promise<Result> {
116
+ try {
117
+ switch (cmd.action) {
118
+ case 'exec':
119
+ return await handleExec(cmd);
120
+ case 'navigate':
121
+ return await handleNavigate(cmd);
122
+ case 'tabs':
123
+ return await handleTabs(cmd);
124
+ case 'cookies':
125
+ return await handleCookies(cmd);
126
+ case 'screenshot':
127
+ return await handleScreenshot(cmd);
128
+ default:
129
+ return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
130
+ }
131
+ } catch (err) {
132
+ return {
133
+ id: cmd.id,
134
+ ok: false,
135
+ error: err instanceof Error ? err.message : String(err),
136
+ };
137
+ }
138
+ }
139
+
140
+ // ─── Action handlers ─────────────────────────────────────────────────
141
+
142
+ /** Check if a URL is a debuggable web page (not chrome:// or extension page) */
143
+ function isWebUrl(url?: string): boolean {
144
+ if (!url) return false;
145
+ return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
146
+ }
147
+
148
+ /** Resolve target tab: use specified tabId or fall back to active web page tab */
149
+ async function resolveTabId(tabId?: number): Promise<number> {
150
+ if (tabId !== undefined) return tabId;
151
+
152
+ // Try the active tab first
153
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
154
+ if (activeTab?.id && isWebUrl(activeTab.url)) {
155
+ return activeTab.id;
156
+ }
157
+
158
+ // Active tab is not debuggable — try to find any open web page tab
159
+ const allTabs = await chrome.tabs.query({ currentWindow: true });
160
+ const webTab = allTabs.find(t => t.id && isWebUrl(t.url));
161
+ if (webTab?.id) {
162
+ await chrome.tabs.update(webTab.id, { active: true });
163
+ return webTab.id;
164
+ }
165
+
166
+ // No web tabs at all — create one
167
+ const newTab = await chrome.tabs.create({ url: 'about:blank', active: true });
168
+ if (!newTab.id) throw new Error('Failed to create new tab');
169
+ return newTab.id;
170
+ }
171
+
172
+ async function handleExec(cmd: Command): Promise<Result> {
173
+ if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
174
+ const tabId = await resolveTabId(cmd.tabId);
175
+ try {
176
+ const data = await cdp.evaluateAsync(tabId, cmd.code);
177
+ return { id: cmd.id, ok: true, data };
178
+ } catch (err) {
179
+ return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
180
+ }
181
+ }
182
+
183
+ async function handleNavigate(cmd: Command): Promise<Result> {
184
+ if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
185
+ const tabId = await resolveTabId(cmd.tabId);
186
+ await chrome.tabs.update(tabId, { url: cmd.url });
187
+
188
+ // Wait for page to finish loading, checking current status first to avoid race
189
+ await new Promise<void>((resolve) => {
190
+ // Check if already complete (e.g. cached pages)
191
+ chrome.tabs.get(tabId).then(tab => {
192
+ if (tab.status === 'complete') { resolve(); return; }
193
+
194
+ const listener = (id: number, info: chrome.tabs.TabChangeInfo) => {
195
+ if (id === tabId && info.status === 'complete') {
196
+ chrome.tabs.onUpdated.removeListener(listener);
197
+ resolve();
198
+ }
199
+ };
200
+ chrome.tabs.onUpdated.addListener(listener);
201
+ // Timeout fallback
202
+ setTimeout(() => {
203
+ chrome.tabs.onUpdated.removeListener(listener);
204
+ resolve();
205
+ }, 15000);
206
+ });
207
+ });
208
+
209
+ const tab = await chrome.tabs.get(tabId);
210
+ return { id: cmd.id, ok: true, data: { title: tab.title, url: tab.url, tabId } };
211
+ }
212
+
213
+ async function handleTabs(cmd: Command): Promise<Result> {
214
+ switch (cmd.op) {
215
+ case 'list': {
216
+ const tabs = await chrome.tabs.query({});
217
+ const data = tabs
218
+ .filter((t) => isWebUrl(t.url))
219
+ .map((t, i) => ({
220
+ index: i,
221
+ tabId: t.id,
222
+ url: t.url,
223
+ title: t.title,
224
+ active: t.active,
225
+ }));
226
+ return { id: cmd.id, ok: true, data };
227
+ }
228
+ case 'new': {
229
+ const tab = await chrome.tabs.create({ url: cmd.url, active: true });
230
+ return { id: cmd.id, ok: true, data: { tabId: tab.id, url: tab.url } };
231
+ }
232
+ case 'close': {
233
+ if (cmd.index !== undefined) {
234
+ const tabs = await chrome.tabs.query({});
235
+ const target = tabs[cmd.index];
236
+ if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
237
+ await chrome.tabs.remove(target.id);
238
+ cdp.detach(target.id);
239
+ return { id: cmd.id, ok: true, data: { closed: target.id } };
240
+ }
241
+ const tabId = await resolveTabId(cmd.tabId);
242
+ await chrome.tabs.remove(tabId);
243
+ cdp.detach(tabId);
244
+ return { id: cmd.id, ok: true, data: { closed: tabId } };
245
+ }
246
+ case 'select': {
247
+ if (cmd.index === undefined && cmd.tabId === undefined)
248
+ return { id: cmd.id, ok: false, error: 'Missing index or tabId' };
249
+ if (cmd.tabId !== undefined) {
250
+ await chrome.tabs.update(cmd.tabId, { active: true });
251
+ return { id: cmd.id, ok: true, data: { selected: cmd.tabId } };
252
+ }
253
+ const tabs = await chrome.tabs.query({});
254
+ const target = tabs[cmd.index!];
255
+ if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
256
+ await chrome.tabs.update(target.id, { active: true });
257
+ return { id: cmd.id, ok: true, data: { selected: target.id } };
258
+ }
259
+ default:
260
+ return { id: cmd.id, ok: false, error: `Unknown tabs op: ${cmd.op}` };
261
+ }
262
+ }
263
+
264
+ async function handleCookies(cmd: Command): Promise<Result> {
265
+ const details: chrome.cookies.GetAllDetails = {};
266
+ if (cmd.domain) details.domain = cmd.domain;
267
+ if (cmd.url) details.url = cmd.url;
268
+ const cookies = await chrome.cookies.getAll(details);
269
+ const data = cookies.map((c) => ({
270
+ name: c.name,
271
+ value: c.value,
272
+ domain: c.domain,
273
+ path: c.path,
274
+ secure: c.secure,
275
+ httpOnly: c.httpOnly,
276
+ expirationDate: c.expirationDate,
277
+ }));
278
+ return { id: cmd.id, ok: true, data };
279
+ }
280
+
281
+ async function handleScreenshot(cmd: Command): Promise<Result> {
282
+ const tabId = await resolveTabId(cmd.tabId);
283
+ try {
284
+ const data = await cdp.screenshot(tabId, {
285
+ format: cmd.format,
286
+ quality: cmd.quality,
287
+ fullPage: cmd.fullPage,
288
+ });
289
+ return { id: cmd.id, ok: true, data };
290
+ } catch (err) {
291
+ return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
292
+ }
293
+ }
@@ -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';
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.0",
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",
@@ -0,0 +1,113 @@
1
+ /**
2
+ * HTTP client for communicating with the opencli daemon.
3
+ *
4
+ * Provides a typed send() function that posts a Command and returns a Result.
5
+ */
6
+
7
+ const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
8
+ const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
9
+
10
+ let _idCounter = 0;
11
+
12
+ function generateId(): string {
13
+ return `cmd_${Date.now()}_${++_idCounter}`;
14
+ }
15
+
16
+ export interface DaemonCommand {
17
+ id: string;
18
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
19
+ tabId?: number;
20
+ code?: string;
21
+ url?: string;
22
+ op?: string;
23
+ index?: number;
24
+ domain?: string;
25
+ format?: 'png' | 'jpeg';
26
+ quality?: number;
27
+ fullPage?: boolean;
28
+ }
29
+
30
+ export interface DaemonResult {
31
+ id: string;
32
+ ok: boolean;
33
+ data?: unknown;
34
+ error?: string;
35
+ }
36
+
37
+ /**
38
+ * Check if daemon is running.
39
+ */
40
+ export async function isDaemonRunning(): Promise<boolean> {
41
+ try {
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), 2000);
44
+ const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
45
+ clearTimeout(timer);
46
+ return res.ok;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if daemon is running AND the extension is connected.
54
+ */
55
+ export async function isExtensionConnected(): Promise<boolean> {
56
+ try {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), 2000);
59
+ const res = await fetch(`${DAEMON_URL}/status`, { signal: controller.signal });
60
+ clearTimeout(timer);
61
+ if (!res.ok) return false;
62
+ const data = await res.json() as { extensionConnected?: boolean };
63
+ return !!data.extensionConnected;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Send a command to the daemon and wait for a result.
71
+ * Retries up to 3 times with 500ms delay for transient failures.
72
+ */
73
+ export async function sendCommand(
74
+ action: DaemonCommand['action'],
75
+ params: Omit<DaemonCommand, 'id' | 'action'> = {},
76
+ ): Promise<unknown> {
77
+ const id = generateId();
78
+ const command: DaemonCommand = { id, action, ...params };
79
+ const maxRetries = 3;
80
+
81
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
82
+ try {
83
+ const controller = new AbortController();
84
+ const timer = setTimeout(() => controller.abort(), 30000);
85
+
86
+ const res = await fetch(`${DAEMON_URL}/command`, {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify(command),
90
+ signal: controller.signal,
91
+ });
92
+ clearTimeout(timer);
93
+
94
+ const result = (await res.json()) as DaemonResult;
95
+
96
+ if (!result.ok) {
97
+ throw new Error(result.error ?? 'Daemon command failed');
98
+ }
99
+
100
+ return result.data;
101
+ } catch (err) {
102
+ const isRetryable = err instanceof TypeError // fetch network error
103
+ || (err instanceof Error && err.name === 'AbortError');
104
+ if (isRetryable && attempt < maxRetries) {
105
+ await new Promise(r => setTimeout(r, 500));
106
+ continue;
107
+ }
108
+ throw err;
109
+ }
110
+ }
111
+ // Unreachable — the loop always returns or throws
112
+ throw new Error('sendCommand: max retries exhausted');
113
+ }