@jackwener/opencli 0.4.2 → 0.4.4
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/{CLI-CREATOR.md → CLI-EXPLORER.md} +15 -11
- package/CLI-ONESHOT.md +216 -0
- package/LICENSE +28 -0
- package/README.md +114 -63
- package/README.zh-CN.md +115 -63
- package/SKILL.md +25 -6
- package/dist/browser.d.ts +53 -10
- package/dist/browser.js +491 -111
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +56 -0
- package/dist/build-manifest.js +4 -0
- package/dist/cli-manifest.json +279 -3
- package/dist/clis/boss/search.js +186 -30
- package/dist/clis/twitter/delete.d.ts +1 -0
- package/dist/clis/twitter/delete.js +73 -0
- package/dist/clis/twitter/followers.d.ts +1 -0
- package/dist/clis/twitter/followers.js +104 -0
- package/dist/clis/twitter/following.d.ts +1 -0
- package/dist/clis/twitter/following.js +90 -0
- package/dist/clis/twitter/like.d.ts +1 -0
- package/dist/clis/twitter/like.js +69 -0
- package/dist/clis/twitter/notifications.d.ts +1 -0
- package/dist/clis/twitter/notifications.js +109 -0
- package/dist/clis/twitter/post.d.ts +1 -0
- package/dist/clis/twitter/post.js +63 -0
- package/dist/clis/twitter/reply.d.ts +1 -0
- package/dist/clis/twitter/reply.js +57 -0
- package/dist/clis/v2ex/daily.d.ts +1 -0
- package/dist/clis/v2ex/daily.js +98 -0
- package/dist/clis/v2ex/me.d.ts +1 -0
- package/dist/clis/v2ex/me.js +99 -0
- package/dist/clis/v2ex/notifications.d.ts +1 -0
- package/dist/clis/v2ex/notifications.js +72 -0
- package/dist/doctor.d.ts +50 -0
- package/dist/doctor.js +372 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +114 -0
- package/dist/main.js +47 -5
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +20 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +2 -2
- package/package.json +2 -2
- package/src/browser.test.ts +77 -0
- package/src/browser.ts +541 -99
- package/src/build-manifest.ts +4 -0
- package/src/clis/boss/search.ts +196 -29
- package/src/clis/twitter/delete.ts +78 -0
- package/src/clis/twitter/followers.ts +119 -0
- package/src/clis/twitter/following.ts +105 -0
- package/src/clis/twitter/like.ts +74 -0
- package/src/clis/twitter/notifications.ts +119 -0
- package/src/clis/twitter/post.ts +68 -0
- package/src/clis/twitter/reply.ts +62 -0
- package/src/clis/v2ex/daily.ts +105 -0
- package/src/clis/v2ex/me.ts +103 -0
- package/src/clis/v2ex/notifications.ts +77 -0
- package/src/doctor.test.ts +133 -0
- package/src/doctor.ts +424 -0
- package/src/main.ts +47 -4
- package/src/output.test.ts +27 -0
- package/src/registry.ts +5 -0
- package/src/runtime.ts +2 -1
package/src/browser.ts
CHANGED
|
@@ -1,28 +1,199 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser interaction via
|
|
3
|
-
* Connects to an existing Chrome browser through
|
|
2
|
+
* Browser interaction via Chrome DevTools Protocol.
|
|
3
|
+
* Connects to an existing Chrome browser through CDP auto-discovery or extension bridge.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import * as net from 'node:net';
|
|
7
9
|
import { fileURLToPath } from 'node:url';
|
|
8
10
|
import * as fs from 'node:fs';
|
|
9
11
|
import * as os from 'node:os';
|
|
10
12
|
import * as path from 'node:path';
|
|
11
13
|
import { formatSnapshot } from './snapshotFormatter.js';
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Chrome 144+ auto-discovery: read DevToolsActivePort file to get CDP endpoint.
|
|
17
|
+
*
|
|
18
|
+
* Starting with Chrome 144, users can enable remote debugging from
|
|
19
|
+
* chrome://inspect#remote-debugging without any command-line flags.
|
|
20
|
+
* Chrome writes the active port and browser GUID to a DevToolsActivePort file
|
|
21
|
+
* in the user data directory, which we read to construct the WebSocket endpoint.
|
|
22
|
+
*
|
|
23
|
+
* Priority: OPENCLI_CDP_ENDPOINT env > DevToolsActivePort auto-discovery > --extension fallback
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Quick TCP port probe to verify Chrome is actually listening */
|
|
27
|
+
function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Promise<boolean> {
|
|
28
|
+
return new Promise(resolve => {
|
|
29
|
+
const sock = net.createConnection({ port, host });
|
|
30
|
+
sock.setTimeout(timeoutMs);
|
|
31
|
+
sock.on('connect', () => { sock.destroy(); resolve(true); });
|
|
32
|
+
sock.on('error', () => resolve(false));
|
|
33
|
+
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function discoverChromeEndpoint(): Promise<string | null> {
|
|
38
|
+
const candidates: string[] = [];
|
|
39
|
+
|
|
40
|
+
// User-specified Chrome data dir takes highest priority
|
|
41
|
+
if (process.env.CHROME_USER_DATA_DIR) {
|
|
42
|
+
candidates.push(path.join(process.env.CHROME_USER_DATA_DIR, 'DevToolsActivePort'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Standard Chrome/Edge user data dirs per platform
|
|
46
|
+
if (process.platform === 'win32') {
|
|
47
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
48
|
+
candidates.push(path.join(localAppData, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'));
|
|
49
|
+
candidates.push(path.join(localAppData, 'Microsoft', 'Edge', 'User Data', 'DevToolsActivePort'));
|
|
50
|
+
} else if (process.platform === 'darwin') {
|
|
51
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'));
|
|
52
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'Microsoft Edge', 'DevToolsActivePort'));
|
|
53
|
+
} else {
|
|
54
|
+
candidates.push(path.join(os.homedir(), '.config', 'google-chrome', 'DevToolsActivePort'));
|
|
55
|
+
candidates.push(path.join(os.homedir(), '.config', 'chromium', 'DevToolsActivePort'));
|
|
56
|
+
candidates.push(path.join(os.homedir(), '.config', 'microsoft-edge', 'DevToolsActivePort'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const filePath of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
62
|
+
const lines = content.split('\n');
|
|
63
|
+
if (lines.length >= 2) {
|
|
64
|
+
const port = parseInt(lines[0], 10);
|
|
65
|
+
const browserPath = lines[1]; // e.g. /devtools/browser/<GUID>
|
|
66
|
+
if (port > 0 && browserPath.startsWith('/devtools/browser/')) {
|
|
67
|
+
const endpoint = `ws://127.0.0.1:${port}${browserPath}`;
|
|
68
|
+
// Verify the port is actually reachable (Chrome may have closed, leaving a stale file)
|
|
69
|
+
if (await isPortReachable(port)) {
|
|
70
|
+
return endpoint;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
13
79
|
// Read version from package.json (single source of truth)
|
|
14
80
|
const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
81
|
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
|
|
16
82
|
|
|
17
|
-
const EXTENSION_LOCK_TIMEOUT = parseInt(process.env.OPENCLI_EXTENSION_LOCK_TIMEOUT ?? '120', 10);
|
|
18
|
-
const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INTERVAL ?? '1', 10);
|
|
19
83
|
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
20
|
-
const
|
|
84
|
+
const STDERR_BUFFER_LIMIT = 16 * 1024;
|
|
85
|
+
const TAB_CLEANUP_TIMEOUT_MS = 2000;
|
|
86
|
+
let _cachedMcpServerPath: string | null | undefined;
|
|
87
|
+
|
|
88
|
+
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
|
|
89
|
+
type PlaywrightMCPState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
|
90
|
+
|
|
91
|
+
type ConnectFailureInput = {
|
|
92
|
+
kind: ConnectFailureKind;
|
|
93
|
+
mode: 'extension' | 'cdp';
|
|
94
|
+
timeout: number;
|
|
95
|
+
hasExtensionToken: boolean;
|
|
96
|
+
tokenFingerprint?: string | null;
|
|
97
|
+
stderr?: string;
|
|
98
|
+
exitCode?: number | null;
|
|
99
|
+
rawMessage?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export function getTokenFingerprint(token: string | undefined): string | null {
|
|
103
|
+
if (!token) return null;
|
|
104
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
|
108
|
+
const stderr = input.stderr?.trim();
|
|
109
|
+
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
|
110
|
+
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
|
111
|
+
|
|
112
|
+
if (input.mode === 'extension') {
|
|
113
|
+
if (input.kind === 'missing-token') {
|
|
114
|
+
return new Error(
|
|
115
|
+
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
|
116
|
+
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
|
117
|
+
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
|
118
|
+
suffix,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (input.kind === 'extension-not-installed') {
|
|
123
|
+
return new Error(
|
|
124
|
+
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
|
125
|
+
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
|
126
|
+
'If Chrome shows an approval dialog, click Allow.' +
|
|
127
|
+
suffix,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (input.kind === 'extension-timeout') {
|
|
132
|
+
const likelyCause = input.hasExtensionToken
|
|
133
|
+
? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.`
|
|
134
|
+
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
|
135
|
+
return new Error(
|
|
136
|
+
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
|
137
|
+
`${likelyCause} If a browser prompt is visible, click Allow. You can also switch to Chrome remote debugging mode with OPENCLI_USE_CDP=1 as a fallback.` +
|
|
138
|
+
suffix,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (input.mode === 'cdp' && input.kind === 'cdp-timeout') {
|
|
144
|
+
return new Error(
|
|
145
|
+
`Timed out connecting to browser via CDP (${input.timeout}s).\n\n` +
|
|
146
|
+
'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' +
|
|
147
|
+
suffix,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (input.kind === 'mcp-init') {
|
|
152
|
+
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (input.kind === 'process-exit') {
|
|
156
|
+
return new Error(
|
|
157
|
+
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
|
158
|
+
suffix,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function inferConnectFailureKind(args: {
|
|
166
|
+
mode: 'extension' | 'cdp';
|
|
167
|
+
hasExtensionToken: boolean;
|
|
168
|
+
stderr: string;
|
|
169
|
+
rawMessage?: string;
|
|
170
|
+
exited?: boolean;
|
|
171
|
+
}): ConnectFailureKind {
|
|
172
|
+
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
|
173
|
+
|
|
174
|
+
if (args.mode === 'extension' && !args.hasExtensionToken)
|
|
175
|
+
return 'missing-token';
|
|
176
|
+
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
|
177
|
+
return 'extension-not-installed';
|
|
178
|
+
if (args.rawMessage?.startsWith('MCP init failed:'))
|
|
179
|
+
return 'mcp-init';
|
|
180
|
+
if (args.exited)
|
|
181
|
+
return 'process-exit';
|
|
182
|
+
if (args.mode === 'extension')
|
|
183
|
+
return 'extension-timeout';
|
|
184
|
+
if (args.mode === 'cdp')
|
|
185
|
+
return 'cdp-timeout';
|
|
186
|
+
return 'unknown';
|
|
187
|
+
}
|
|
21
188
|
|
|
22
189
|
// JSON-RPC helpers
|
|
23
190
|
let _nextId = 1;
|
|
24
|
-
function
|
|
25
|
-
|
|
191
|
+
function createJsonRpcRequest(method: string, params: Record<string, any> = {}): { id: number; message: string } {
|
|
192
|
+
const id = _nextId++;
|
|
193
|
+
return {
|
|
194
|
+
id,
|
|
195
|
+
message: JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
|
|
196
|
+
};
|
|
26
197
|
}
|
|
27
198
|
|
|
28
199
|
import type { IPage } from './types.js';
|
|
@@ -31,11 +202,10 @@ import type { IPage } from './types.js';
|
|
|
31
202
|
* Page abstraction wrapping JSON-RPC calls to Playwright MCP.
|
|
32
203
|
*/
|
|
33
204
|
export class Page implements IPage {
|
|
34
|
-
constructor(private
|
|
205
|
+
constructor(private _request: (method: string, params?: Record<string, any>) => Promise<any>) {}
|
|
35
206
|
|
|
36
207
|
async call(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
37
|
-
this.
|
|
38
|
-
const resp = await this._recv();
|
|
208
|
+
const resp = await this._request(method, params);
|
|
39
209
|
if (resp.error) throw new Error(`page.${method}: ${resp.error.message ?? JSON.stringify(resp.error)}`);
|
|
40
210
|
// Extract text content from MCP result
|
|
41
211
|
const result = resp.result;
|
|
@@ -231,41 +401,172 @@ export class Page implements IPage {
|
|
|
231
401
|
* Playwright MCP process manager.
|
|
232
402
|
*/
|
|
233
403
|
export class PlaywrightMCP {
|
|
404
|
+
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
|
405
|
+
private static _cleanupRegistered = false;
|
|
406
|
+
|
|
407
|
+
private static _registerGlobalCleanup() {
|
|
408
|
+
if (this._cleanupRegistered) return;
|
|
409
|
+
this._cleanupRegistered = true;
|
|
410
|
+
const cleanup = () => {
|
|
411
|
+
for (const inst of this._activeInsts) {
|
|
412
|
+
if (inst._proc && !inst._proc.killed) {
|
|
413
|
+
try { inst._proc.kill('SIGKILL'); } catch {}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
process.on('exit', cleanup);
|
|
418
|
+
process.on('SIGINT', () => { cleanup(); process.exit(130); });
|
|
419
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
420
|
+
}
|
|
421
|
+
|
|
234
422
|
private _proc: ChildProcess | null = null;
|
|
235
423
|
private _buffer = '';
|
|
236
|
-
private
|
|
237
|
-
private
|
|
238
|
-
private
|
|
424
|
+
private _pending = new Map<number, { resolve: (data: any) => void; reject: (error: Error) => void }>();
|
|
425
|
+
private _initialTabIdentities: string[] = [];
|
|
426
|
+
private _closingPromise: Promise<void> | null = null;
|
|
427
|
+
private _state: PlaywrightMCPState = 'idle';
|
|
239
428
|
|
|
240
429
|
private _page: Page | null = null;
|
|
241
430
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
431
|
+
get state(): PlaywrightMCPState {
|
|
432
|
+
return this._state;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private _sendRequest(method: string, params: Record<string, any> = {}): Promise<any> {
|
|
436
|
+
return new Promise<any>((resolve, reject) => {
|
|
437
|
+
if (!this._proc?.stdin?.writable) {
|
|
438
|
+
reject(new Error('Playwright MCP process is not writable'));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const { id, message } = createJsonRpcRequest(method, params);
|
|
442
|
+
this._pending.set(id, { resolve, reject });
|
|
443
|
+
this._proc.stdin.write(message, (err) => {
|
|
444
|
+
if (!err) return;
|
|
445
|
+
this._pending.delete(id);
|
|
446
|
+
reject(err);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private _rejectPendingRequests(error: Error): void {
|
|
452
|
+
const pending = [...this._pending.values()];
|
|
453
|
+
this._pending.clear();
|
|
454
|
+
for (const waiter of pending) waiter.reject(error);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private _resetAfterFailedConnect(): void {
|
|
458
|
+
const proc = this._proc;
|
|
459
|
+
this._page = null;
|
|
460
|
+
this._proc = null;
|
|
461
|
+
this._buffer = '';
|
|
462
|
+
this._initialTabIdentities = [];
|
|
463
|
+
this._rejectPendingRequests(new Error('Playwright MCP connect failed'));
|
|
464
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
465
|
+
if (proc && !proc.killed) {
|
|
466
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise<Page> {
|
|
471
|
+
if (this._state === 'connected' && this._page) return this._page;
|
|
472
|
+
if (this._state === 'connecting') throw new Error('Playwright MCP is already connecting');
|
|
473
|
+
if (this._state === 'closing') throw new Error('Playwright MCP is closing');
|
|
474
|
+
if (this._state === 'closed') throw new Error('Playwright MCP session is closed');
|
|
475
|
+
|
|
245
476
|
const mcpPath = findMcpServerPath();
|
|
246
477
|
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
|
247
478
|
|
|
248
|
-
|
|
249
|
-
|
|
479
|
+
PlaywrightMCP._registerGlobalCleanup();
|
|
480
|
+
PlaywrightMCP._activeInsts.add(this);
|
|
481
|
+
this._state = 'connecting';
|
|
482
|
+
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
|
250
483
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
484
|
+
// Connection priority:
|
|
485
|
+
// 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
|
|
486
|
+
// 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
|
|
487
|
+
// 3. Default → --extension mode (Playwright MCP Bridge)
|
|
488
|
+
// Some anti-bot sites (e.g. BOSS Zhipin) detect CDP — use forceExtension to bypass.
|
|
489
|
+
const forceExt = opts.forceExtension || process.env.OPENCLI_FORCE_EXTENSION === '1';
|
|
490
|
+
let cdpEndpoint: string | null = null;
|
|
491
|
+
if (!forceExt) {
|
|
492
|
+
if (process.env.OPENCLI_CDP_ENDPOINT) {
|
|
493
|
+
cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
494
|
+
} else if (process.env.OPENCLI_USE_CDP === '1') {
|
|
495
|
+
cdpEndpoint = await discoverChromeEndpoint();
|
|
496
|
+
}
|
|
254
497
|
}
|
|
255
498
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
499
|
+
return new Promise<Page>((resolve, reject) => {
|
|
500
|
+
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
501
|
+
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
502
|
+
const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
|
|
503
|
+
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
504
|
+
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
505
|
+
let stderrBuffer = '';
|
|
506
|
+
let settled = false;
|
|
507
|
+
|
|
508
|
+
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
|
509
|
+
if (settled) return;
|
|
510
|
+
settled = true;
|
|
511
|
+
this._state = 'idle';
|
|
512
|
+
clearTimeout(timer);
|
|
513
|
+
this._resetAfterFailedConnect();
|
|
514
|
+
reject(formatBrowserConnectError({
|
|
515
|
+
kind,
|
|
516
|
+
mode,
|
|
517
|
+
timeout,
|
|
518
|
+
hasExtensionToken: !!extensionToken,
|
|
519
|
+
tokenFingerprint,
|
|
520
|
+
stderr: stderrBuffer,
|
|
521
|
+
exitCode: extra.exitCode,
|
|
522
|
+
rawMessage: extra.rawMessage,
|
|
523
|
+
}));
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const settleSuccess = (pageToResolve: Page) => {
|
|
527
|
+
if (settled) return;
|
|
528
|
+
settled = true;
|
|
529
|
+
this._state = 'connected';
|
|
530
|
+
clearTimeout(timer);
|
|
531
|
+
resolve(pageToResolve);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const timer = setTimeout(() => {
|
|
535
|
+
debugLog('Connection timed out');
|
|
536
|
+
settleError(inferConnectFailureKind({
|
|
537
|
+
mode,
|
|
538
|
+
hasExtensionToken: !!extensionToken,
|
|
539
|
+
stderr: stderrBuffer,
|
|
540
|
+
}));
|
|
541
|
+
}, timeout * 1000);
|
|
542
|
+
|
|
543
|
+
const mcpArgs: string[] = [mcpPath];
|
|
544
|
+
if (cdpEndpoint) {
|
|
545
|
+
mcpArgs.push('--cdp-endpoint', cdpEndpoint);
|
|
546
|
+
} else {
|
|
547
|
+
mcpArgs.push('--extension');
|
|
548
|
+
}
|
|
549
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
550
|
+
console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`);
|
|
551
|
+
if (mode === 'extension') {
|
|
552
|
+
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
|
|
556
|
+
mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
|
|
557
|
+
}
|
|
558
|
+
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
559
|
+
|
|
560
|
+
this._proc = spawn('node', mcpArgs, {
|
|
561
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
562
|
+
env: { ...process.env },
|
|
563
|
+
});
|
|
260
564
|
|
|
261
565
|
// Increase max listeners to avoid warnings
|
|
262
566
|
this._proc.setMaxListeners(20);
|
|
263
567
|
if (this._proc.stdout) this._proc.stdout.setMaxListeners(20);
|
|
264
568
|
|
|
265
|
-
const page = new Page(
|
|
266
|
-
(msg) => { if (this._proc?.stdin?.writable) this._proc.stdin.write(msg); },
|
|
267
|
-
() => new Promise<any>((res) => { this._waiters.push(res); }),
|
|
268
|
-
);
|
|
569
|
+
const page = new Page((method, params = {}) => this._sendRequest(method, params));
|
|
269
570
|
this._page = page;
|
|
270
571
|
|
|
271
572
|
this._proc.stdout?.on('data', (chunk: Buffer) => {
|
|
@@ -274,107 +575,238 @@ export class PlaywrightMCP {
|
|
|
274
575
|
this._buffer = lines.pop() ?? '';
|
|
275
576
|
for (const line of lines) {
|
|
276
577
|
if (!line.trim()) continue;
|
|
578
|
+
debugLog(`RECV: ${line}`);
|
|
277
579
|
try {
|
|
278
580
|
const parsed = JSON.parse(line);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
581
|
+
if (typeof parsed?.id === 'number') {
|
|
582
|
+
const waiter = this._pending.get(parsed.id);
|
|
583
|
+
if (waiter) {
|
|
584
|
+
this._pending.delete(parsed.id);
|
|
585
|
+
waiter.resolve(parsed);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {
|
|
589
|
+
debugLog(`Parse error: ${e}`);
|
|
590
|
+
}
|
|
282
591
|
}
|
|
283
592
|
});
|
|
284
593
|
|
|
285
|
-
this._proc.stderr?.on('data', () => {
|
|
286
|
-
|
|
594
|
+
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
|
595
|
+
const text = chunk.toString();
|
|
596
|
+
stderrBuffer = appendLimited(stderrBuffer, text, STDERR_BUFFER_LIMIT);
|
|
597
|
+
debugLog(`STDERR: ${text}`);
|
|
598
|
+
});
|
|
599
|
+
this._proc.on('error', (err) => {
|
|
600
|
+
debugLog(`Subprocess error: ${err.message}`);
|
|
601
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process error: ${err.message}`));
|
|
602
|
+
settleError('process-exit', { rawMessage: err.message });
|
|
603
|
+
});
|
|
604
|
+
this._proc.on('close', (code) => {
|
|
605
|
+
debugLog(`Subprocess closed with code ${code}`);
|
|
606
|
+
this._rejectPendingRequests(new Error(`Playwright MCP process exited before response${code == null ? '' : ` (code ${code})`}`));
|
|
607
|
+
if (!settled) {
|
|
608
|
+
settleError(inferConnectFailureKind({
|
|
609
|
+
mode,
|
|
610
|
+
hasExtensionToken: !!extensionToken,
|
|
611
|
+
stderr: stderrBuffer,
|
|
612
|
+
exited: true,
|
|
613
|
+
}), { exitCode: code });
|
|
614
|
+
}
|
|
615
|
+
});
|
|
287
616
|
|
|
288
617
|
// Initialize: send initialize request
|
|
289
|
-
|
|
618
|
+
debugLog('Waiting for initialize response...');
|
|
619
|
+
this._sendRequest('initialize', {
|
|
290
620
|
protocolVersion: '2024-11-05',
|
|
291
621
|
capabilities: {},
|
|
292
622
|
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
623
|
+
}).then((resp) => {
|
|
624
|
+
debugLog('Got initialize response');
|
|
625
|
+
if (resp.error) {
|
|
626
|
+
settleError(inferConnectFailureKind({
|
|
627
|
+
mode,
|
|
628
|
+
hasExtensionToken: !!extensionToken,
|
|
629
|
+
stderr: stderrBuffer,
|
|
630
|
+
rawMessage: `MCP init failed: ${resp.error.message}`,
|
|
631
|
+
}), { rawMessage: resp.error.message });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
|
636
|
+
debugLog(`SEND: ${initializedMsg.trim()}`);
|
|
637
|
+
this._proc?.stdin?.write(initializedMsg);
|
|
301
638
|
|
|
302
639
|
// Get initial tab count for cleanup
|
|
640
|
+
debugLog('Fetching initial tabs count...');
|
|
303
641
|
page.tabs().then((tabs: any) => {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
642
|
+
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
|
643
|
+
this._initialTabIdentities = extractTabIdentities(tabs);
|
|
644
|
+
settleSuccess(page);
|
|
645
|
+
}).catch((err) => {
|
|
646
|
+
debugLog(`Tabs fetch error: ${err.message}`);
|
|
647
|
+
settleSuccess(page);
|
|
648
|
+
});
|
|
649
|
+
}).catch((err) => {
|
|
650
|
+
debugLog(`Init promise rejected: ${err.message}`);
|
|
651
|
+
settleError('mcp-init', { rawMessage: err.message });
|
|
652
|
+
});
|
|
313
653
|
});
|
|
314
654
|
}
|
|
315
655
|
|
|
316
656
|
async close(): Promise<void> {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
try { await this._page.closeTab(i); } catch {}
|
|
657
|
+
if (this._closingPromise) return this._closingPromise;
|
|
658
|
+
if (this._state === 'closed') return;
|
|
659
|
+
this._state = 'closing';
|
|
660
|
+
this._closingPromise = (async () => {
|
|
661
|
+
try {
|
|
662
|
+
// Close tabs opened during this session (site tabs + extension tabs)
|
|
663
|
+
if (this._page && this._proc && !this._proc.killed) {
|
|
664
|
+
try {
|
|
665
|
+
const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
|
|
666
|
+
const tabEntries = extractTabEntries(tabs);
|
|
667
|
+
const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
|
|
668
|
+
for (const index of tabsToClose) {
|
|
669
|
+
try { await this._page.closeTab(index); } catch {}
|
|
331
670
|
}
|
|
671
|
+
} catch {}
|
|
672
|
+
}
|
|
673
|
+
if (this._proc && !this._proc.killed) {
|
|
674
|
+
this._proc.kill('SIGTERM');
|
|
675
|
+
const exited = await new Promise<boolean>((res) => {
|
|
676
|
+
let done = false;
|
|
677
|
+
const finish = (value: boolean) => {
|
|
678
|
+
if (done) return;
|
|
679
|
+
done = true;
|
|
680
|
+
res(value);
|
|
681
|
+
};
|
|
682
|
+
this._proc?.once('exit', () => finish(true));
|
|
683
|
+
setTimeout(() => finish(false), 3000);
|
|
684
|
+
});
|
|
685
|
+
if (!exited && this._proc && !this._proc.killed) {
|
|
686
|
+
try { this._proc.kill('SIGKILL'); } catch {}
|
|
332
687
|
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
this.
|
|
337
|
-
|
|
688
|
+
}
|
|
689
|
+
} finally {
|
|
690
|
+
this._rejectPendingRequests(new Error('Playwright MCP session closed'));
|
|
691
|
+
this._page = null;
|
|
692
|
+
this._proc = null;
|
|
693
|
+
this._state = 'closed';
|
|
694
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
338
695
|
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
this._releaseLock();
|
|
342
|
-
}
|
|
696
|
+
})();
|
|
697
|
+
return this._closingPromise;
|
|
343
698
|
}
|
|
699
|
+
}
|
|
344
700
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
701
|
+
function extractTabEntries(raw: any): Array<{ index: number; identity: string }> {
|
|
702
|
+
if (Array.isArray(raw)) {
|
|
703
|
+
return raw.map((tab: any, index: number) => ({
|
|
704
|
+
index,
|
|
705
|
+
identity: [
|
|
706
|
+
tab?.id ?? '',
|
|
707
|
+
tab?.url ?? '',
|
|
708
|
+
tab?.title ?? '',
|
|
709
|
+
tab?.name ?? '',
|
|
710
|
+
].join('|'),
|
|
711
|
+
}));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (typeof raw === 'string') {
|
|
715
|
+
return raw
|
|
716
|
+
.split('\n')
|
|
717
|
+
.map(line => line.trim())
|
|
718
|
+
.filter(Boolean)
|
|
719
|
+
.map(line => {
|
|
720
|
+
const match = line.match(/Tab\s+(\d+)\s*(.*)$/);
|
|
721
|
+
if (!match) return null;
|
|
722
|
+
return {
|
|
723
|
+
index: parseInt(match[1], 10),
|
|
724
|
+
identity: match[2].trim() || `tab-${match[1]}`,
|
|
725
|
+
};
|
|
726
|
+
})
|
|
727
|
+
.filter((entry): entry is { index: number; identity: string } => entry !== null);
|
|
359
728
|
}
|
|
360
729
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function extractTabIdentities(raw: any): string[] {
|
|
734
|
+
return extractTabEntries(raw).map(tab => tab.identity);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function diffTabIndexes(initialIdentities: string[], currentTabs: Array<{ index: number; identity: string }>): number[] {
|
|
738
|
+
if (initialIdentities.length === 0 || currentTabs.length === 0) return [];
|
|
739
|
+
const remaining = new Map<string, number>();
|
|
740
|
+
for (const identity of initialIdentities) {
|
|
741
|
+
remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const tabsToClose: number[] = [];
|
|
745
|
+
for (const tab of currentTabs) {
|
|
746
|
+
const count = remaining.get(tab.identity) ?? 0;
|
|
747
|
+
if (count > 0) {
|
|
748
|
+
remaining.set(tab.identity, count - 1);
|
|
749
|
+
continue;
|
|
365
750
|
}
|
|
751
|
+
tabsToClose.push(tab.index);
|
|
366
752
|
}
|
|
753
|
+
|
|
754
|
+
return tabsToClose.sort((a, b) => b - a);
|
|
367
755
|
}
|
|
368
756
|
|
|
757
|
+
function appendLimited(current: string, chunk: string, limit: number): string {
|
|
758
|
+
const next = current + chunk;
|
|
759
|
+
if (next.length <= limit) return next;
|
|
760
|
+
return next.slice(-limit);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
764
|
+
return new Promise<T>((resolve, reject) => {
|
|
765
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
766
|
+
promise.then(
|
|
767
|
+
(value) => {
|
|
768
|
+
clearTimeout(timer);
|
|
769
|
+
resolve(value);
|
|
770
|
+
},
|
|
771
|
+
(error) => {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
reject(error);
|
|
774
|
+
},
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export const __test__ = {
|
|
780
|
+
createJsonRpcRequest,
|
|
781
|
+
extractTabEntries,
|
|
782
|
+
diffTabIndexes,
|
|
783
|
+
appendLimited,
|
|
784
|
+
withTimeout,
|
|
785
|
+
};
|
|
786
|
+
|
|
369
787
|
function findMcpServerPath(): string | null {
|
|
788
|
+
if (_cachedMcpServerPath !== undefined) return _cachedMcpServerPath;
|
|
789
|
+
|
|
790
|
+
const envMcp = process.env.OPENCLI_MCP_SERVER_PATH;
|
|
791
|
+
if (envMcp && fs.existsSync(envMcp)) {
|
|
792
|
+
_cachedMcpServerPath = envMcp;
|
|
793
|
+
return _cachedMcpServerPath;
|
|
794
|
+
}
|
|
795
|
+
|
|
370
796
|
// Check local node_modules first (@playwright/mcp is the modern package)
|
|
371
797
|
const localMcp = path.resolve('node_modules', '@playwright', 'mcp', 'cli.js');
|
|
372
|
-
if (fs.existsSync(localMcp))
|
|
798
|
+
if (fs.existsSync(localMcp)) {
|
|
799
|
+
_cachedMcpServerPath = localMcp;
|
|
800
|
+
return _cachedMcpServerPath;
|
|
801
|
+
}
|
|
373
802
|
|
|
374
803
|
// Check project-relative path
|
|
375
804
|
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
|
376
805
|
const projectMcp = path.resolve(__dirname2, '..', 'node_modules', '@playwright', 'mcp', 'cli.js');
|
|
377
|
-
if (fs.existsSync(projectMcp))
|
|
806
|
+
if (fs.existsSync(projectMcp)) {
|
|
807
|
+
_cachedMcpServerPath = projectMcp;
|
|
808
|
+
return _cachedMcpServerPath;
|
|
809
|
+
}
|
|
378
810
|
|
|
379
811
|
// Check common locations
|
|
380
812
|
const candidates = [
|
|
@@ -386,13 +818,19 @@ function findMcpServerPath(): string | null {
|
|
|
386
818
|
// Try npx resolution (legacy package name)
|
|
387
819
|
try {
|
|
388
820
|
const result = execSync('npx -y --package=@playwright/mcp which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
389
|
-
if (result && fs.existsSync(result))
|
|
821
|
+
if (result && fs.existsSync(result)) {
|
|
822
|
+
_cachedMcpServerPath = result;
|
|
823
|
+
return _cachedMcpServerPath;
|
|
824
|
+
}
|
|
390
825
|
} catch {}
|
|
391
826
|
|
|
392
827
|
// Try which
|
|
393
828
|
try {
|
|
394
829
|
const result = execSync('which mcp-server-playwright 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
395
|
-
if (result && fs.existsSync(result))
|
|
830
|
+
if (result && fs.existsSync(result)) {
|
|
831
|
+
_cachedMcpServerPath = result;
|
|
832
|
+
return _cachedMcpServerPath;
|
|
833
|
+
}
|
|
396
834
|
} catch {}
|
|
397
835
|
|
|
398
836
|
// Search in common npx cache
|
|
@@ -400,9 +838,13 @@ function findMcpServerPath(): string | null {
|
|
|
400
838
|
if (!fs.existsSync(base)) continue;
|
|
401
839
|
try {
|
|
402
840
|
const found = execSync(`find "${base}" -name "cli.js" -path "*playwright*mcp*" 2>/dev/null | head -1`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
403
|
-
if (found)
|
|
841
|
+
if (found) {
|
|
842
|
+
_cachedMcpServerPath = found;
|
|
843
|
+
return _cachedMcpServerPath;
|
|
844
|
+
}
|
|
404
845
|
} catch {}
|
|
405
846
|
}
|
|
406
847
|
|
|
407
|
-
|
|
848
|
+
_cachedMcpServerPath = null;
|
|
849
|
+
return _cachedMcpServerPath;
|
|
408
850
|
}
|