@jackwener/opencli 0.4.1 → 0.4.3
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 +103 -142
- package/LICENSE +28 -0
- package/README.md +113 -63
- package/README.zh-CN.md +114 -63
- package/SKILL.md +21 -4
- package/dist/browser.d.ts +21 -2
- package/dist/browser.js +269 -15
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +43 -0
- package/dist/build-manifest.js +66 -2
- package/dist/cli-manifest.json +905 -109
- 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/clis/xiaohongshu/search.d.ts +5 -2
- package/dist/clis/xiaohongshu/search.js +35 -41
- 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 +51 -0
- package/src/browser.ts +318 -22
- package/src/build-manifest.ts +67 -2
- 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/clis/xiaohongshu/search.ts +41 -44
- 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/runtime.d.ts
CHANGED
|
@@ -10,4 +10,6 @@ export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
|
|
|
10
10
|
timeout: number;
|
|
11
11
|
label?: string;
|
|
12
12
|
}): Promise<T>;
|
|
13
|
-
export declare function browserSession<T>(BrowserFactory: new () => any, fn: (page: IPage) => Promise<T
|
|
13
|
+
export declare function browserSession<T>(BrowserFactory: new () => any, fn: (page: IPage) => Promise<T>, opts?: {
|
|
14
|
+
forceExtension?: boolean;
|
|
15
|
+
}): Promise<T>;
|
package/dist/runtime.js
CHANGED
|
@@ -15,10 +15,10 @@ export async function runWithTimeout(promise, opts) {
|
|
|
15
15
|
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
-
export async function browserSession(BrowserFactory, fn) {
|
|
18
|
+
export async function browserSession(BrowserFactory, fn, opts) {
|
|
19
19
|
const mcp = new BrowserFactory();
|
|
20
20
|
try {
|
|
21
|
-
const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT });
|
|
21
|
+
const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, forceExtension: opts?.forceExtension });
|
|
22
22
|
return await fn(page);
|
|
23
23
|
}
|
|
24
24
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"playwright"
|
|
32
32
|
],
|
|
33
33
|
"author": "jackwener",
|
|
34
|
-
"license": "
|
|
34
|
+
"license": "BSD-3-Clause",
|
|
35
35
|
"repository": {
|
|
36
36
|
"type": "git",
|
|
37
37
|
"url": "git+https://github.com/jackwener/opencli.git"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatBrowserConnectError, getTokenFingerprint } from './browser.js';
|
|
3
|
+
|
|
4
|
+
describe('getTokenFingerprint', () => {
|
|
5
|
+
it('returns null for empty token', () => {
|
|
6
|
+
expect(getTokenFingerprint(undefined)).toBeNull();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns stable short fingerprint for token', () => {
|
|
10
|
+
expect(getTokenFingerprint('abc123')).toBe('6ca13d52');
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('formatBrowserConnectError', () => {
|
|
15
|
+
it('explains missing extension token clearly', () => {
|
|
16
|
+
const err = formatBrowserConnectError({
|
|
17
|
+
kind: 'missing-token',
|
|
18
|
+
mode: 'extension',
|
|
19
|
+
timeout: 30,
|
|
20
|
+
hasExtensionToken: false,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(err.message).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set');
|
|
24
|
+
expect(err.message).toContain('manual approval dialog');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('mentions token mismatch as likely cause for extension timeout', () => {
|
|
28
|
+
const err = formatBrowserConnectError({
|
|
29
|
+
kind: 'extension-timeout',
|
|
30
|
+
mode: 'extension',
|
|
31
|
+
timeout: 30,
|
|
32
|
+
hasExtensionToken: true,
|
|
33
|
+
tokenFingerprint: 'deadbeef',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(err.message).toContain('does not match the token currently shown by the browser extension');
|
|
37
|
+
expect(err.message).toContain('deadbeef');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps CDP timeout guidance separate', () => {
|
|
41
|
+
const err = formatBrowserConnectError({
|
|
42
|
+
kind: 'cdp-timeout',
|
|
43
|
+
mode: 'cdp',
|
|
44
|
+
timeout: 30,
|
|
45
|
+
hasExtensionToken: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(err.message).toContain('via CDP');
|
|
49
|
+
expect(err.message).toContain('chrome://inspect#remote-debugging');
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/browser.ts
CHANGED
|
@@ -1,15 +1,81 @@
|
|
|
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'; } })();
|
|
@@ -19,6 +85,106 @@ const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INT
|
|
|
19
85
|
const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
20
86
|
const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock');
|
|
21
87
|
|
|
88
|
+
type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown';
|
|
89
|
+
|
|
90
|
+
type ConnectFailureInput = {
|
|
91
|
+
kind: ConnectFailureKind;
|
|
92
|
+
mode: 'extension' | 'cdp';
|
|
93
|
+
timeout: number;
|
|
94
|
+
hasExtensionToken: boolean;
|
|
95
|
+
tokenFingerprint?: string | null;
|
|
96
|
+
stderr?: string;
|
|
97
|
+
exitCode?: number | null;
|
|
98
|
+
rawMessage?: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function getTokenFingerprint(token: string | undefined): string | null {
|
|
102
|
+
if (!token) return null;
|
|
103
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatBrowserConnectError(input: ConnectFailureInput): Error {
|
|
107
|
+
const stderr = input.stderr?.trim();
|
|
108
|
+
const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : '';
|
|
109
|
+
const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : '';
|
|
110
|
+
|
|
111
|
+
if (input.mode === 'extension') {
|
|
112
|
+
if (input.kind === 'missing-token') {
|
|
113
|
+
return new Error(
|
|
114
|
+
'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' +
|
|
115
|
+
'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' +
|
|
116
|
+
'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' +
|
|
117
|
+
suffix,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (input.kind === 'extension-not-installed') {
|
|
122
|
+
return new Error(
|
|
123
|
+
'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' +
|
|
124
|
+
'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' +
|
|
125
|
+
'If Chrome shows an approval dialog, click Allow.' +
|
|
126
|
+
suffix,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (input.kind === 'extension-timeout') {
|
|
131
|
+
const likelyCause = input.hasExtensionToken
|
|
132
|
+
? `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.`
|
|
133
|
+
: 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.';
|
|
134
|
+
return new Error(
|
|
135
|
+
`Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` +
|
|
136
|
+
`${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.` +
|
|
137
|
+
suffix,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (input.mode === 'cdp' && input.kind === 'cdp-timeout') {
|
|
143
|
+
return new Error(
|
|
144
|
+
`Timed out connecting to browser via CDP (${input.timeout}s).\n\n` +
|
|
145
|
+
'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' +
|
|
146
|
+
suffix,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (input.kind === 'mcp-init') {
|
|
151
|
+
return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (input.kind === 'process-exit') {
|
|
155
|
+
return new Error(
|
|
156
|
+
`Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` +
|
|
157
|
+
suffix,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return new Error(input.rawMessage ?? 'Failed to connect to browser');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function inferConnectFailureKind(args: {
|
|
165
|
+
mode: 'extension' | 'cdp';
|
|
166
|
+
hasExtensionToken: boolean;
|
|
167
|
+
stderr: string;
|
|
168
|
+
rawMessage?: string;
|
|
169
|
+
exited?: boolean;
|
|
170
|
+
}): ConnectFailureKind {
|
|
171
|
+
const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase();
|
|
172
|
+
|
|
173
|
+
if (args.mode === 'extension' && !args.hasExtensionToken)
|
|
174
|
+
return 'missing-token';
|
|
175
|
+
if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge'))
|
|
176
|
+
return 'extension-not-installed';
|
|
177
|
+
if (args.rawMessage?.startsWith('MCP init failed:'))
|
|
178
|
+
return 'mcp-init';
|
|
179
|
+
if (args.exited)
|
|
180
|
+
return 'process-exit';
|
|
181
|
+
if (args.mode === 'extension')
|
|
182
|
+
return 'extension-timeout';
|
|
183
|
+
if (args.mode === 'cdp')
|
|
184
|
+
return 'cdp-timeout';
|
|
185
|
+
return 'unknown';
|
|
186
|
+
}
|
|
187
|
+
|
|
22
188
|
// JSON-RPC helpers
|
|
23
189
|
let _nextId = 1;
|
|
24
190
|
function jsonRpcRequest(method: string, params: Record<string, any> = {}): string {
|
|
@@ -231,6 +397,28 @@ export class Page implements IPage {
|
|
|
231
397
|
* Playwright MCP process manager.
|
|
232
398
|
*/
|
|
233
399
|
export class PlaywrightMCP {
|
|
400
|
+
private static _activeInsts: Set<PlaywrightMCP> = new Set();
|
|
401
|
+
private static _cleanupRegistered = false;
|
|
402
|
+
|
|
403
|
+
private static _registerGlobalCleanup() {
|
|
404
|
+
if (this._cleanupRegistered) return;
|
|
405
|
+
this._cleanupRegistered = true;
|
|
406
|
+
const cleanup = () => {
|
|
407
|
+
for (const inst of this._activeInsts) {
|
|
408
|
+
if (inst._lockAcquired) {
|
|
409
|
+
try { fs.rmdirSync(LOCK_DIR); } catch {}
|
|
410
|
+
inst._lockAcquired = false;
|
|
411
|
+
}
|
|
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
424
|
private _waiters: Array<(data: any) => void> = [];
|
|
@@ -239,24 +427,89 @@ export class PlaywrightMCP {
|
|
|
239
427
|
|
|
240
428
|
private _page: Page | null = null;
|
|
241
429
|
|
|
242
|
-
async connect(opts: { timeout?: number } = {}): Promise<Page> {
|
|
430
|
+
async connect(opts: { timeout?: number; forceExtension?: boolean } = {}): Promise<Page> {
|
|
243
431
|
await this._acquireLock();
|
|
244
432
|
const timeout = opts.timeout ?? CONNECT_TIMEOUT;
|
|
245
433
|
const mcpPath = findMcpServerPath();
|
|
246
434
|
if (!mcpPath) throw new Error('Playwright MCP server not found. Install: npm install -D @playwright/mcp');
|
|
247
435
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
436
|
+
// Connection priority:
|
|
437
|
+
// 1. OPENCLI_CDP_ENDPOINT env var → explicit CDP endpoint
|
|
438
|
+
// 2. OPENCLI_USE_CDP=1 → auto-discover via DevToolsActivePort
|
|
439
|
+
// 3. Default → --extension mode (Playwright MCP Bridge)
|
|
440
|
+
// Some anti-bot sites (e.g. BOSS Zhipin) detect CDP — use forceExtension to bypass.
|
|
441
|
+
const forceExt = opts.forceExtension || process.env.OPENCLI_FORCE_EXTENSION === '1';
|
|
442
|
+
let cdpEndpoint: string | null = null;
|
|
443
|
+
if (!forceExt) {
|
|
444
|
+
if (process.env.OPENCLI_CDP_ENDPOINT) {
|
|
445
|
+
cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
446
|
+
} else if (process.env.OPENCLI_USE_CDP === '1') {
|
|
447
|
+
cdpEndpoint = await discoverChromeEndpoint();
|
|
448
|
+
}
|
|
254
449
|
}
|
|
255
450
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
451
|
+
return new Promise<Page>((resolve, reject) => {
|
|
452
|
+
const isDebug = process.env.DEBUG?.includes('opencli:mcp');
|
|
453
|
+
const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`);
|
|
454
|
+
const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension';
|
|
455
|
+
const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN;
|
|
456
|
+
const tokenFingerprint = getTokenFingerprint(extensionToken);
|
|
457
|
+
let stderrBuffer = '';
|
|
458
|
+
let settled = false;
|
|
459
|
+
|
|
460
|
+
const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => {
|
|
461
|
+
if (settled) return;
|
|
462
|
+
settled = true;
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
reject(formatBrowserConnectError({
|
|
465
|
+
kind,
|
|
466
|
+
mode,
|
|
467
|
+
timeout,
|
|
468
|
+
hasExtensionToken: !!extensionToken,
|
|
469
|
+
tokenFingerprint,
|
|
470
|
+
stderr: stderrBuffer,
|
|
471
|
+
exitCode: extra.exitCode,
|
|
472
|
+
rawMessage: extra.rawMessage,
|
|
473
|
+
}));
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const settleSuccess = (pageToResolve: Page) => {
|
|
477
|
+
if (settled) return;
|
|
478
|
+
settled = true;
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
resolve(pageToResolve);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const timer = setTimeout(() => {
|
|
484
|
+
debugLog('Connection timed out');
|
|
485
|
+
settleError(inferConnectFailureKind({
|
|
486
|
+
mode,
|
|
487
|
+
hasExtensionToken: !!extensionToken,
|
|
488
|
+
stderr: stderrBuffer,
|
|
489
|
+
}));
|
|
490
|
+
}, timeout * 1000);
|
|
491
|
+
|
|
492
|
+
const mcpArgs: string[] = [mcpPath];
|
|
493
|
+
if (cdpEndpoint) {
|
|
494
|
+
mcpArgs.push('--cdp-endpoint', cdpEndpoint);
|
|
495
|
+
} else {
|
|
496
|
+
mcpArgs.push('--extension');
|
|
497
|
+
}
|
|
498
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
499
|
+
console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`);
|
|
500
|
+
if (mode === 'extension') {
|
|
501
|
+
console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
|
|
505
|
+
mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
|
|
506
|
+
}
|
|
507
|
+
debugLog(`Spawning node ${mcpArgs.join(' ')}`);
|
|
508
|
+
|
|
509
|
+
this._proc = spawn('node', mcpArgs, {
|
|
510
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
511
|
+
env: { ...process.env },
|
|
512
|
+
});
|
|
260
513
|
|
|
261
514
|
// Increase max listeners to avoid warnings
|
|
262
515
|
this._proc.setMaxListeners(20);
|
|
@@ -274,16 +527,37 @@ export class PlaywrightMCP {
|
|
|
274
527
|
this._buffer = lines.pop() ?? '';
|
|
275
528
|
for (const line of lines) {
|
|
276
529
|
if (!line.trim()) continue;
|
|
530
|
+
debugLog(`RECV: ${line}`);
|
|
277
531
|
try {
|
|
278
532
|
const parsed = JSON.parse(line);
|
|
279
533
|
const waiter = this._waiters.shift();
|
|
280
534
|
if (waiter) waiter(parsed);
|
|
281
|
-
} catch {
|
|
535
|
+
} catch (e) {
|
|
536
|
+
debugLog(`Parse error: ${e}`);
|
|
537
|
+
}
|
|
282
538
|
}
|
|
283
539
|
});
|
|
284
540
|
|
|
285
|
-
this._proc.stderr?.on('data', () => {
|
|
286
|
-
|
|
541
|
+
this._proc.stderr?.on('data', (chunk: Buffer) => {
|
|
542
|
+
const text = chunk.toString();
|
|
543
|
+
stderrBuffer += text;
|
|
544
|
+
debugLog(`STDERR: ${text}`);
|
|
545
|
+
});
|
|
546
|
+
this._proc.on('error', (err) => {
|
|
547
|
+
debugLog(`Subprocess error: ${err.message}`);
|
|
548
|
+
settleError('process-exit', { rawMessage: err.message });
|
|
549
|
+
});
|
|
550
|
+
this._proc.on('close', (code) => {
|
|
551
|
+
debugLog(`Subprocess closed with code ${code}`);
|
|
552
|
+
if (!settled) {
|
|
553
|
+
settleError(inferConnectFailureKind({
|
|
554
|
+
mode,
|
|
555
|
+
hasExtensionToken: !!extensionToken,
|
|
556
|
+
stderr: stderrBuffer,
|
|
557
|
+
exited: true,
|
|
558
|
+
}), { exitCode: code });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
287
561
|
|
|
288
562
|
// Initialize: send initialize request
|
|
289
563
|
const initMsg = jsonRpcRequest('initialize', {
|
|
@@ -291,25 +565,46 @@ export class PlaywrightMCP {
|
|
|
291
565
|
capabilities: {},
|
|
292
566
|
clientInfo: { name: 'opencli', version: PKG_VERSION },
|
|
293
567
|
});
|
|
568
|
+
debugLog(`SEND: ${initMsg.trim()}`);
|
|
294
569
|
this._proc.stdin?.write(initMsg);
|
|
295
570
|
|
|
296
571
|
// Wait for initialize response, then send initialized notification
|
|
297
572
|
const origRecv = () => new Promise<any>((res) => { this._waiters.push(res); });
|
|
573
|
+
debugLog('Waiting for initialize response...');
|
|
298
574
|
origRecv().then((resp) => {
|
|
299
|
-
|
|
300
|
-
|
|
575
|
+
debugLog('Got initialize response');
|
|
576
|
+
if (resp.error) {
|
|
577
|
+
settleError(inferConnectFailureKind({
|
|
578
|
+
mode,
|
|
579
|
+
hasExtensionToken: !!extensionToken,
|
|
580
|
+
stderr: stderrBuffer,
|
|
581
|
+
rawMessage: `MCP init failed: ${resp.error.message}`,
|
|
582
|
+
}), { rawMessage: resp.error.message });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n';
|
|
587
|
+
debugLog(`SEND: ${initializedMsg.trim()}`);
|
|
588
|
+
this._proc?.stdin?.write(initializedMsg);
|
|
301
589
|
|
|
302
590
|
// Get initial tab count for cleanup
|
|
591
|
+
debugLog('Fetching initial tabs count...');
|
|
303
592
|
page.tabs().then((tabs: any) => {
|
|
593
|
+
debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
|
|
304
594
|
if (typeof tabs === 'string') {
|
|
305
595
|
this._initialTabCount = (tabs.match(/Tab \d+/g) || []).length;
|
|
306
596
|
} else if (Array.isArray(tabs)) {
|
|
307
597
|
this._initialTabCount = tabs.length;
|
|
308
598
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
599
|
+
settleSuccess(page);
|
|
600
|
+
}).catch((err) => {
|
|
601
|
+
debugLog(`Tabs fetch error: ${err.message}`);
|
|
602
|
+
settleSuccess(page);
|
|
603
|
+
});
|
|
604
|
+
}).catch((err) => {
|
|
605
|
+
debugLog(`Init promise rejected: ${err.message}`);
|
|
606
|
+
settleError('mcp-init', { rawMessage: err.message });
|
|
607
|
+
});
|
|
313
608
|
});
|
|
314
609
|
}
|
|
315
610
|
|
|
@@ -339,6 +634,7 @@ export class PlaywrightMCP {
|
|
|
339
634
|
} finally {
|
|
340
635
|
this._page = null;
|
|
341
636
|
this._releaseLock();
|
|
637
|
+
PlaywrightMCP._activeInsts.delete(this);
|
|
342
638
|
}
|
|
343
639
|
}
|
|
344
640
|
|
package/src/build-manifest.ts
CHANGED
|
@@ -87,10 +87,11 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
|
87
87
|
|
|
88
88
|
function scanTs(filePath: string, site: string): ManifestEntry {
|
|
89
89
|
// TS adapters self-register via cli() at import time.
|
|
90
|
-
// We
|
|
90
|
+
// We statically parse the source to extract metadata for the manifest stub.
|
|
91
91
|
const baseName = path.basename(filePath, path.extname(filePath));
|
|
92
92
|
const relativePath = `${site}/${baseName}.js`;
|
|
93
|
-
|
|
93
|
+
|
|
94
|
+
const entry: ManifestEntry = {
|
|
94
95
|
site,
|
|
95
96
|
name: baseName,
|
|
96
97
|
description: '',
|
|
@@ -100,6 +101,70 @@ function scanTs(filePath: string, site: string): ManifestEntry {
|
|
|
100
101
|
type: 'ts',
|
|
101
102
|
modulePath: relativePath,
|
|
102
103
|
};
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const src = fs.readFileSync(filePath, 'utf-8');
|
|
107
|
+
|
|
108
|
+
// Extract description
|
|
109
|
+
const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
110
|
+
if (descMatch) entry.description = descMatch[1];
|
|
111
|
+
|
|
112
|
+
// Extract domain
|
|
113
|
+
const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
114
|
+
if (domainMatch) entry.domain = domainMatch[1];
|
|
115
|
+
|
|
116
|
+
// Extract strategy
|
|
117
|
+
const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
|
|
118
|
+
if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
|
|
119
|
+
|
|
120
|
+
// Extract browser: false (some adapters bypass browser entirely)
|
|
121
|
+
const browserMatch = src.match(/browser\s*:\s*(true|false)/);
|
|
122
|
+
if (browserMatch) entry.browser = browserMatch[1] === 'true';
|
|
123
|
+
|
|
124
|
+
// Extract columns
|
|
125
|
+
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
|
|
126
|
+
if (colMatch) {
|
|
127
|
+
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract args array items: { name: '...', ... }
|
|
131
|
+
const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
|
|
132
|
+
if (argsBlockMatch) {
|
|
133
|
+
const argsBlock = argsBlockMatch[1];
|
|
134
|
+
const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = argRegex.exec(argsBlock)) !== null) {
|
|
137
|
+
const argName = m[1];
|
|
138
|
+
const body = m[2];
|
|
139
|
+
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
|
|
140
|
+
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
|
|
141
|
+
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
|
|
142
|
+
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
|
|
143
|
+
|
|
144
|
+
let defaultVal: any = undefined;
|
|
145
|
+
if (defaultMatch) {
|
|
146
|
+
const raw = defaultMatch[1].trim();
|
|
147
|
+
if (raw === 'true') defaultVal = true;
|
|
148
|
+
else if (raw === 'false') defaultVal = false;
|
|
149
|
+
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
|
|
150
|
+
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
|
|
151
|
+
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
entry.args.push({
|
|
155
|
+
name: argName,
|
|
156
|
+
type: typeMatch?.[1] ?? 'str',
|
|
157
|
+
default: defaultVal,
|
|
158
|
+
required: requiredMatch?.[1] === 'true',
|
|
159
|
+
help: helpMatch?.[1] ?? '',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// If parsing fails, fall back to empty metadata — module will self-register at runtime
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return entry;
|
|
103
168
|
}
|
|
104
169
|
|
|
105
170
|
// Main
|