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