@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
@@ -1,312 +1,112 @@
1
1
  /**
2
- * Playwright MCP process manager.
3
- * Handles lifecycle management, JSON-RPC communication, and browser session orchestration.
2
+ * Browser session manager — auto-spawns daemon and provides IPage.
3
+ *
4
+ * Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
5
+ * for backward compatibility with main.ts and other consumers.
4
6
  */
5
7
 
6
8
  import { spawn, type ChildProcess } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import * as path from 'node:path';
11
+ import * as fs from 'node:fs';
7
12
  import type { IPage } from '../types.js';
8
- import { withTimeoutMs, DEFAULT_BROWSER_CONNECT_TIMEOUT } from '../runtime.js';
9
- import { PKG_VERSION } from '../version.js';
10
13
  import { Page } from './page.js';
11
- import { getTokenFingerprint, formatBrowserConnectError, inferConnectFailureKind } from './errors.js';
12
- import { findMcpServerPath, buildMcpLaunchSpec, resolveCdpEndpoint } from './discover.js';
13
- import { extractTabIdentities, extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
14
+ import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
14
15
 
15
- const STDERR_BUFFER_LIMIT = 16 * 1024;
16
- const INITIAL_TABS_TIMEOUT_MS = 1500;
17
- const TAB_CLEANUP_TIMEOUT_MS = 2000;
16
+ const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
18
17
 
19
18
  export type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
20
19
 
21
- // JSON-RPC helpers
22
- let _nextId = 1;
23
- export function createJsonRpcRequest(method: string, params: Record<string, unknown> = {}): { id: number; message: string } {
24
- const id = _nextId++;
25
- return {
26
- id,
27
- message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
28
- };
29
- }
20
+
30
21
 
31
22
  /**
32
- * Playwright MCP process manager.
23
+ * Browser factory: manages daemon lifecycle and provides IPage instances.
24
+ *
25
+ * Kept as `PlaywrightMCP` class name for backward compatibility.
33
26
  */
34
27
  export class PlaywrightMCP {
35
- private static _activeInsts: Set<PlaywrightMCP> = new Set();
36
- private static _cleanupRegistered = false;
37
-
38
- private static _registerGlobalCleanup() {
39
- if (this._cleanupRegistered) return;
40
- this._cleanupRegistered = true;
41
- const cleanup = () => {
42
- for (const inst of this._activeInsts) {
43
- if (inst._proc && !inst._proc.killed) {
44
- try { inst._proc.kill('SIGKILL'); } catch {}
45
- }
46
- }
47
- };
48
- process.on('exit', cleanup);
49
- process.on('SIGINT', () => { cleanup(); process.exit(130); });
50
- process.on('SIGTERM', () => { cleanup(); process.exit(143); });
51
- }
52
-
53
- private _proc: ChildProcess | null = null;
54
- private _buffer = '';
55
- private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
56
- private _initialTabIdentities: string[] = [];
57
- private _closingPromise: Promise<void> | null = null;
58
28
  private _state: PlaywrightMCPState = 'idle';
59
-
60
29
  private _page: Page | null = null;
30
+ private _daemonProc: ChildProcess | null = null;
61
31
 
62
32
  get state(): PlaywrightMCPState {
63
33
  return this._state;
64
34
  }
65
35
 
66
- private _sendRequest(method: string, params: Record<string, unknown> = {}): Promise<any> {
67
- return new Promise<any>((resolve, reject) => {
68
- if (!this._proc?.stdin?.writable) {
69
- reject(new Error('Playwright MCP process is not writable'));
70
- return;
71
- }
72
- const { id, message } = createJsonRpcRequest(method, params);
73
- this._pending.set(id, { resolve, reject });
74
- this._proc.stdin.write(message, (err) => {
75
- if (!err) return;
76
- this._pending.delete(id);
77
- reject(err);
78
- });
79
- });
80
- }
81
-
82
- private _rejectPendingRequests(error: Error): void {
83
- const pending = [...this._pending.values()];
84
- this._pending.clear();
85
- for (const waiter of pending) waiter.reject(error);
86
- }
87
-
88
- private _resetAfterFailedConnect(): void {
89
- const proc = this._proc;
90
- this._page = null;
91
- this._proc = null;
92
- this._buffer = '';
93
- this._initialTabIdentities = [];
94
- this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
95
- PlaywrightMCP._activeInsts.delete(this);
96
- if (proc && !proc.killed) {
97
- try { proc.kill('SIGKILL'); } catch {}
98
- }
99
- }
100
-
101
36
  async connect(opts: { timeout?: number } = {}): Promise<IPage> {
102
37
  if (this._state === 'connected' && this._page) return this._page;
103
- if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
104
- if (this._state === 'closing') throw new Error('Playwright MCP is closing');
105
- if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
38
+ if (this._state === 'connecting') throw new Error('Already connecting');
39
+ if (this._state === 'closing') throw new Error('Session is closing');
40
+ if (this._state === 'closed') throw new Error('Session is closed');
106
41
 
107
- const mcpPath = findMcpServerPath();
108
-
109
- PlaywrightMCP._registerGlobalCleanup();
110
- PlaywrightMCP._activeInsts.add(this);
111
42
  this._state = 'connecting';
112
- const timeout = opts.timeout ?? DEFAULT_BROWSER_CONNECT_TIMEOUT;
113
-
114
- return new Promise<Page>((resolve, reject) => {
115
- const isDebug = process.env.DEBUG?.includes('opencli:mcp');
116
- const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
117
- const { endpoint: cdpEndpoint, requestedCdp } = resolveCdpEndpoint();
118
- const useExtension = !requestedCdp;
119
- const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
120
- const tokenFingerprint = getTokenFingerprint(extensionToken);
121
- let stderrBuffer = '';
122
- let settled = false;
123
-
124
- const settleError = (kind: Parameters<typeof formatBrowserConnectError>[0]['kind'], extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
125
- if (settled) return;
126
- settled = true;
127
- this._state = 'idle';
128
- clearTimeout(timer);
129
- this._resetAfterFailedConnect();
130
- reject(formatBrowserConnectError({
131
- kind,
132
- timeout,
133
- hasExtensionToken: !!extensionToken,
134
- tokenFingerprint,
135
- stderr: stderrBuffer,
136
- exitCode: extra.exitCode,
137
- rawMessage: extra.rawMessage,
138
- }));
139
- };
140
-
141
- const settleSuccess = (pageToResolve: Page) => {
142
- if (settled) return;
143
- settled = true;
144
- this._state = 'connected';
145
- clearTimeout(timer);
146
- resolve(pageToResolve);
147
- };
148
-
149
- const timer = setTimeout(() => {
150
- debugLog('Connection timed out');
151
- settleError(inferConnectFailureKind({
152
- hasExtensionToken: !!extensionToken,
153
- stderr: stderrBuffer,
154
- isCdpMode: requestedCdp,
155
- }));
156
- }, timeout * 1000);
157
43
 
158
- const launchSpec = buildMcpLaunchSpec({
159
- mcpPath,
160
- executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
161
- cdpEndpoint,
162
- });
163
- if (process.env.OPENCLI_VERBOSE) {
164
- console.error(`[opencli] Mode: ${requestedCdp ? 'CDP' : useExtension ? 'extension' : 'standalone'}`);
165
- if (useExtension) console.error(`[opencli] Extension token: fingerprint ${tokenFingerprint}`);
166
- if (launchSpec.usedNpxFallback) {
167
- console.error('[opencli] Playwright MCP not found locally; bootstrapping via npx @playwright/mcp@latest');
168
- }
169
- }
170
- debugLog(`Spawning ${launchSpec.command} ${launchSpec.args.join(' ')}`);
171
-
172
- this._proc = spawn(launchSpec.command, launchSpec.args, {
173
- stdio: ['pipe', 'pipe', 'pipe'],
174
- env: { ...process.env },
175
- });
176
-
177
- // Increase max listeners to avoid warnings
178
- this._proc.setMaxListeners(20);
179
- if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
180
-
181
- const page = new Page((method, params = {}) => this._sendRequest(method, params));
182
- this._page = page;
183
-
184
- this._proc.stdout?.on('data', (chunk: Buffer) => {
185
- this._buffer += chunk.toString();
186
- const lines = this._buffer.split('\n');
187
- this._buffer = lines.pop() ?? '';
188
- for (const line of lines) {
189
- if (!line.trim()) continue;
190
- debugLog(`RECV: ${line}`);
191
- try {
192
- const parsed = JSON.parse(line);
193
- if (typeof parsed?.id === 'number') {
194
- const waiter = this._pending.get(parsed.id);
195
- if (waiter) {
196
- this._pending.delete(parsed.id);
197
- waiter.resolve(parsed);
198
- }
199
- }
200
- } catch (e) {
201
- debugLog(`Parse error: ${e}`);
202
- }
203
- }
204
- });
44
+ try {
45
+ await this._ensureDaemon();
46
+ this._page = new Page();
47
+ this._state = 'connected';
48
+ return this._page;
49
+ } catch (err) {
50
+ this._state = 'idle';
51
+ throw err;
52
+ }
53
+ }
205
54
 
206
- this._proc.stderr?.on('data', (chunk: Buffer) => {
207
- const text = chunk.toString();
208
- stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
209
- debugLog(`STDERR: ${text}`);
210
- });
211
- this._proc.on('error', (err) => {
212
- debugLog(`Subprocess error: ${err.message}`);
213
- this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
214
- settleError('process-exit', { rawMessage: err.message });
215
- });
216
- this._proc.on('close', (code) => {
217
- debugLog(`Subprocess closed with code ${code}`);
218
- this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
219
- if (!settled) {
220
- settleError(inferConnectFailureKind({
221
- hasExtensionToken: !!extensionToken,
222
- stderr: stderrBuffer,
223
- exited: true,
224
- isCdpMode: requestedCdp,
225
- }), { exitCode: code });
226
- }
227
- });
55
+ async close(): Promise<void> {
56
+ if (this._state === 'closed') return;
57
+ this._state = 'closing';
58
+ // We don't kill the daemon — it auto-exits on idle.
59
+ // Just clean up our reference.
60
+ this._page = null;
61
+ this._state = 'closed';
62
+ }
228
63
 
229
- // Initialize: send initialize request
230
- debugLog('Waiting for initialize response...');
231
- this._sendRequest('initialize', {
232
- protocolVersion: '2024-11-05',
233
- capabilities: {},
234
- clientInfo: { name: 'opencli', version: PKG_VERSION },
235
- }).then((resp: any) => {
236
- debugLog('Got initialize response');
237
- if (resp.error) {
238
- settleError(inferConnectFailureKind({
239
- hasExtensionToken: !!extensionToken,
240
- stderr: stderrBuffer,
241
- rawMessage: `MCP init failed: ${resp.error.message}`,
242
- isCdpMode: requestedCdp,
243
- }), { rawMessage: resp.error.message });
244
- return;
245
- }
246
-
247
- const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
248
- debugLog(`SEND: ${initializedMsg.trim()}`);
249
- this._proc?.stdin?.write(initializedMsg);
64
+ private async _ensureDaemon(): Promise<void> {
65
+ if (await isDaemonRunning()) return;
66
+
67
+ // Find daemon relative to this file — works for both:
68
+ // npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
69
+ // node dist/main.js → dist/browser/mcp.js dist/daemon.js
70
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
71
+ const parentDir = path.resolve(__dirname, '..');
72
+ const daemonTs = path.join(parentDir, 'daemon.ts');
73
+ const daemonJs = path.join(parentDir, 'daemon.js');
74
+ const isTs = fs.existsSync(daemonTs);
75
+ const daemonPath = isTs ? daemonTs : daemonJs;
76
+
77
+ if (process.env.OPENCLI_VERBOSE) {
78
+ console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
79
+ }
250
80
 
251
- // Use tabs as a readiness probe and for tab cleanup bookkeeping.
252
- debugLog('Fetching initial tabs count...');
253
- withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
254
- debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
255
- this._initialTabIdentities = extractTabIdentities(tabs);
256
- settleSuccess(page);
257
- }).catch((err: Error) => {
258
- debugLog(`Tabs fetch error: ${err.message}`);
259
- settleSuccess(page);
260
- });
261
- }).catch((err: Error) => {
262
- debugLog(`Init promise rejected: ${err.message}`);
263
- settleError('mcp-init', { rawMessage: err.message });
264
- });
81
+ // Use the current runtime to spawn daemon avoids slow npx resolution.
82
+ // If already running under tsx (dev), process.execPath is tsx's node.
83
+ // If running compiled (node dist/), process.execPath is node.
84
+ this._daemonProc = spawn(process.execPath, [daemonPath], {
85
+ detached: true,
86
+ stdio: 'ignore',
87
+ env: { ...process.env },
265
88
  });
266
- }
89
+ this._daemonProc.unref();
267
90
 
91
+ // Wait for daemon to be ready AND extension to connect
92
+ const deadline = Date.now() + DAEMON_SPAWN_TIMEOUT;
93
+ while (Date.now() < deadline) {
94
+ await new Promise(resolve => setTimeout(resolve, 300));
95
+ if (await isExtensionConnected()) return;
96
+ }
268
97
 
269
- async close(): Promise<void> {
270
- if (this._closingPromise) return this._closingPromise;
271
- if (this._state === 'closed') return;
272
- this._state = 'closing';
273
- this._closingPromise = (async () => {
274
- try {
275
- // Extension mode opens bridge/session tabs that we can clean up best-effort.
276
- if (this._page && this._proc && !this._proc.killed) {
277
- try {
278
- const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
279
- const tabEntries = extractTabEntries(tabs);
280
- const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
281
- for (const index of tabsToClose) {
282
- try { await this._page.closeTab(index); } catch {}
283
- }
284
- } catch {}
285
- }
286
- if (this._proc && !this._proc.killed) {
287
- this._proc.kill('SIGTERM');
288
- const exited = await new Promise<boolean>((res) => {
289
- let done = false;
290
- const finish = (value: boolean) => {
291
- if (done) return;
292
- done = true;
293
- res(value);
294
- };
295
- this._proc?.once('exit', () => finish(true));
296
- setTimeout(() => finish(false), 3000);
297
- });
298
- if (!exited && this._proc && !this._proc.killed) {
299
- try { this._proc.kill('SIGKILL'); } catch {}
300
- }
301
- }
302
- } finally {
303
- this._rejectPendingRequests(new Error('Playwright MCP session closed'));
304
- this._page = null;
305
- this._proc = null;
306
- this._state = 'closed';
307
- PlaywrightMCP._activeInsts.delete(this);
308
- }
309
- })();
310
- return this._closingPromise;
98
+ // Daemon might be up but extension not connected — give a useful error
99
+ if (await isDaemonRunning()) {
100
+ throw new Error(
101
+ 'Daemon is running but the Browser Extension is not connected.\n' +
102
+ 'Please install and enable the opencli Browser Bridge extension in Chrome.',
103
+ );
104
+ }
105
+
106
+ throw new Error(
107
+ 'Failed to start opencli daemon. Try running manually:\n' +
108
+ ` node ${daemonPath}\n` +
109
+ 'Make sure port 19825 is available.',
110
+ );
311
111
  }
312
112
  }