@jackwener/opencli 0.7.2 → 0.7.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/README.md +32 -26
- package/README.zh-CN.md +1 -0
- package/SKILL.md +10 -4
- package/dist/cli-manifest.json +195 -22
- package/dist/clis/linkedin/search.d.ts +1 -0
- package/dist/clis/linkedin/search.js +366 -0
- package/dist/clis/reddit/read.d.ts +1 -0
- package/dist/clis/reddit/read.js +184 -0
- package/dist/clis/youtube/transcript-group.d.ts +44 -0
- package/dist/clis/youtube/transcript-group.js +226 -0
- package/dist/clis/youtube/transcript-group.test.d.ts +1 -0
- package/dist/clis/youtube/transcript-group.test.js +99 -0
- package/dist/clis/youtube/transcript.d.ts +1 -0
- package/dist/clis/youtube/transcript.js +264 -0
- package/dist/clis/youtube/utils.d.ts +8 -0
- package/dist/clis/youtube/utils.js +28 -0
- package/dist/clis/youtube/video.d.ts +1 -0
- package/dist/clis/youtube/video.js +114 -0
- package/dist/doctor.d.ts +29 -2
- package/dist/doctor.js +122 -55
- package/dist/doctor.test.js +42 -1
- package/dist/main.js +2 -1
- package/package.json +1 -1
- package/src/clis/linkedin/search.ts +416 -0
- package/src/clis/reddit/read.ts +186 -0
- package/src/clis/youtube/transcript-group.test.ts +108 -0
- package/src/clis/youtube/transcript-group.ts +287 -0
- package/src/clis/youtube/transcript.ts +280 -0
- package/src/clis/youtube/utils.ts +28 -0
- package/src/clis/youtube/video.ts +116 -0
- package/src/doctor.test.ts +46 -1
- package/src/doctor.ts +149 -53
- package/src/main.ts +2 -1
- package/dist/clis/reddit/read.yaml +0 -76
- package/src/clis/reddit/read.yaml +0 -76
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
import { parseVideoId } from './utils.js';
|
|
6
|
+
cli({
|
|
7
|
+
site: 'youtube',
|
|
8
|
+
name: 'video',
|
|
9
|
+
description: 'Get YouTube video metadata (title, views, description, etc.)',
|
|
10
|
+
domain: 'www.youtube.com',
|
|
11
|
+
strategy: Strategy.COOKIE,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'url', required: true, help: 'YouTube video URL or video ID' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['field', 'value'],
|
|
16
|
+
func: async (page, kwargs) => {
|
|
17
|
+
const videoId = parseVideoId(kwargs.url);
|
|
18
|
+
const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
|
19
|
+
await page.goto(videoUrl);
|
|
20
|
+
await page.wait(3);
|
|
21
|
+
const data = await page.evaluate(`
|
|
22
|
+
(async () => {
|
|
23
|
+
const player = window.ytInitialPlayerResponse;
|
|
24
|
+
const yt = window.ytInitialData;
|
|
25
|
+
if (!player) return { error: 'ytInitialPlayerResponse not found' };
|
|
26
|
+
|
|
27
|
+
const details = player.videoDetails || {};
|
|
28
|
+
const microformat = player.microformat?.playerMicroformatRenderer || {};
|
|
29
|
+
|
|
30
|
+
// Try to get full description from ytInitialData
|
|
31
|
+
let fullDescription = details.shortDescription || '';
|
|
32
|
+
try {
|
|
33
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
34
|
+
?.results?.results?.contents;
|
|
35
|
+
if (contents) {
|
|
36
|
+
for (const c of contents) {
|
|
37
|
+
const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
|
|
38
|
+
if (desc) { fullDescription = desc; break; }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
// Get like count if available
|
|
44
|
+
let likes = '';
|
|
45
|
+
try {
|
|
46
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
47
|
+
?.results?.results?.contents;
|
|
48
|
+
if (contents) {
|
|
49
|
+
for (const c of contents) {
|
|
50
|
+
const buttons = c.videoPrimaryInfoRenderer?.videoActions
|
|
51
|
+
?.menuRenderer?.topLevelButtons;
|
|
52
|
+
if (buttons) {
|
|
53
|
+
for (const b of buttons) {
|
|
54
|
+
const toggle = b.segmentedLikeDislikeButtonViewModel
|
|
55
|
+
?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
|
|
56
|
+
?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
|
|
57
|
+
if (toggle?.title) { likes = toggle.title; break; }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
64
|
+
// Get publish date
|
|
65
|
+
const publishDate = microformat.publishDate
|
|
66
|
+
|| microformat.uploadDate
|
|
67
|
+
|| details.publishDate || '';
|
|
68
|
+
|
|
69
|
+
// Get category
|
|
70
|
+
const category = microformat.category || '';
|
|
71
|
+
|
|
72
|
+
// Get channel subscriber count if available
|
|
73
|
+
let subscribers = '';
|
|
74
|
+
try {
|
|
75
|
+
const contents = yt?.contents?.twoColumnWatchNextResults
|
|
76
|
+
?.results?.results?.contents;
|
|
77
|
+
if (contents) {
|
|
78
|
+
for (const c of contents) {
|
|
79
|
+
const owner = c.videoSecondaryInfoRenderer?.owner
|
|
80
|
+
?.videoOwnerRenderer?.subscriberCountText?.simpleText;
|
|
81
|
+
if (owner) { subscribers = owner; break; }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
title: details.title || '',
|
|
88
|
+
channel: details.author || '',
|
|
89
|
+
channelId: details.channelId || '',
|
|
90
|
+
videoId: details.videoId || '',
|
|
91
|
+
views: details.viewCount || '',
|
|
92
|
+
likes,
|
|
93
|
+
subscribers,
|
|
94
|
+
duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
|
|
95
|
+
publishDate,
|
|
96
|
+
category,
|
|
97
|
+
description: fullDescription,
|
|
98
|
+
keywords: (details.keywords || []).join(', '),
|
|
99
|
+
isLive: details.isLiveContent || false,
|
|
100
|
+
thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
|
|
101
|
+
};
|
|
102
|
+
})()
|
|
103
|
+
`);
|
|
104
|
+
if (!data || typeof data !== 'object')
|
|
105
|
+
throw new Error('Failed to extract video metadata from page');
|
|
106
|
+
if (data.error)
|
|
107
|
+
throw new Error(data.error);
|
|
108
|
+
// Return as field/value pairs for table display
|
|
109
|
+
return Object.entries(data).map(([field, value]) => ({
|
|
110
|
+
field,
|
|
111
|
+
value: String(value),
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
});
|
package/dist/doctor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export declare const PLAYWRIGHT_TOKEN_ENV = "PLAYWRIGHT_MCP_EXTENSION_TOKEN";
|
|
|
2
2
|
export type DoctorOptions = {
|
|
3
3
|
fix?: boolean;
|
|
4
4
|
yes?: boolean;
|
|
5
|
+
live?: boolean;
|
|
5
6
|
shellRc?: string;
|
|
6
7
|
configPaths?: string[];
|
|
7
8
|
token?: string;
|
|
@@ -23,16 +24,24 @@ export type McpConfigStatus = {
|
|
|
23
24
|
writable: boolean;
|
|
24
25
|
parseError?: string;
|
|
25
26
|
};
|
|
27
|
+
export type ConnectivityResult = {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
durationMs: number;
|
|
31
|
+
};
|
|
26
32
|
export type DoctorReport = {
|
|
27
33
|
cliVersion?: string;
|
|
28
34
|
envToken: string | null;
|
|
29
35
|
envFingerprint: string | null;
|
|
30
36
|
extensionToken: string | null;
|
|
31
37
|
extensionFingerprint: string | null;
|
|
38
|
+
extensionInstalled: boolean;
|
|
39
|
+
extensionBrowsers: string[];
|
|
32
40
|
shellFiles: ShellFileStatus[];
|
|
33
41
|
configs: McpConfigStatus[];
|
|
34
42
|
recommendedToken: string | null;
|
|
35
43
|
recommendedFingerprint: string | null;
|
|
44
|
+
connectivity?: ConnectivityResult;
|
|
36
45
|
warnings: string[];
|
|
37
46
|
issues: string[];
|
|
38
47
|
};
|
|
@@ -50,10 +59,28 @@ export declare function fileExists(filePath: string): boolean;
|
|
|
50
59
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
51
60
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
52
61
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
62
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
63
|
+
* extension ID near base64url token values. This works reliably across
|
|
64
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
65
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
66
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
55
67
|
*/
|
|
56
68
|
export declare function discoverExtensionToken(): string | null;
|
|
69
|
+
/**
|
|
70
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
71
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
72
|
+
*/
|
|
73
|
+
export declare function checkExtensionInstalled(): {
|
|
74
|
+
installed: boolean;
|
|
75
|
+
browsers: string[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
79
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkTokenConnectivity(opts?: {
|
|
82
|
+
timeout?: number;
|
|
83
|
+
}): Promise<ConnectivityResult>;
|
|
57
84
|
export declare function runBrowserDoctor(opts?: DoctorOptions): Promise<DoctorReport>;
|
|
58
85
|
export declare function renderBrowserDoctorReport(report: DoctorReport): string;
|
|
59
86
|
export declare function writeFileWithMkdir(filePath: string, content: string): void;
|
package/dist/doctor.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
|
-
import { execSync } from 'node:child_process';
|
|
5
4
|
import { createInterface } from 'node:readline/promises';
|
|
6
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
|
-
import { getTokenFingerprint } from './browser.js';
|
|
7
|
+
import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
|
|
9
8
|
const PLAYWRIGHT_SERVER_NAME = 'playwright';
|
|
10
9
|
export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
|
|
11
10
|
const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
|
|
@@ -105,7 +104,7 @@ function readTokenFromJsonObject(parsed) {
|
|
|
105
104
|
const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
|
|
106
105
|
if (typeof direct === 'string' && direct)
|
|
107
106
|
return direct;
|
|
108
|
-
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.
|
|
107
|
+
const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
|
|
109
108
|
if (typeof opencode === 'string' && opencode)
|
|
110
109
|
return opencode;
|
|
111
110
|
return null;
|
|
@@ -127,8 +126,8 @@ export function upsertJsonConfigToken(content, token) {
|
|
|
127
126
|
enabled: true,
|
|
128
127
|
type: 'local',
|
|
129
128
|
};
|
|
130
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
131
|
-
parsed.mcp[PLAYWRIGHT_SERVER_NAME].
|
|
129
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
|
|
130
|
+
parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
|
|
132
131
|
}
|
|
133
132
|
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
134
133
|
}
|
|
@@ -211,8 +210,11 @@ function readConfigStatus(filePath) {
|
|
|
211
210
|
* Discover the auth token stored by the Playwright MCP Bridge extension
|
|
212
211
|
* by scanning Chrome's LevelDB localStorage files directly.
|
|
213
212
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
213
|
+
* Reads LevelDB .ldb/.log files as raw binary and searches for the
|
|
214
|
+
* extension ID near base64url token values. This works reliably across
|
|
215
|
+
* platforms because LevelDB's internal encoding can split ASCII strings
|
|
216
|
+
* like "auth-token" and the extension ID across byte boundaries, making
|
|
217
|
+
* text-based tools like `strings` + `grep` unreliable.
|
|
216
218
|
*/
|
|
217
219
|
export function discoverExtensionToken() {
|
|
218
220
|
const home = os.homedir();
|
|
@@ -229,21 +231,12 @@ export function discoverExtensionToken() {
|
|
|
229
231
|
bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
|
|
230
232
|
}
|
|
231
233
|
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
232
|
-
// Token is 43 chars of base64url (from 32 random bytes)
|
|
233
234
|
const tokenRe = /([A-Za-z0-9_-]{40,50})/;
|
|
234
235
|
for (const base of bases) {
|
|
235
236
|
for (const profile of profiles) {
|
|
236
237
|
const dir = path.join(base, profile, 'Local Storage', 'leveldb');
|
|
237
238
|
if (!fileExists(dir))
|
|
238
239
|
continue;
|
|
239
|
-
// Fast path: use strings + grep to find candidate files and extract token
|
|
240
|
-
if (platform !== 'win32') {
|
|
241
|
-
const token = extractTokenViaStrings(dir, tokenRe);
|
|
242
|
-
if (token)
|
|
243
|
-
return token;
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
// Slow path (Windows): read binary files directly
|
|
247
240
|
const token = extractTokenViaBinaryRead(dir, tokenRe);
|
|
248
241
|
if (token)
|
|
249
242
|
return token;
|
|
@@ -251,39 +244,20 @@ export function discoverExtensionToken() {
|
|
|
251
244
|
}
|
|
252
245
|
return null;
|
|
253
246
|
}
|
|
254
|
-
function extractTokenViaStrings(dir, tokenRe) {
|
|
255
|
-
try {
|
|
256
|
-
// Single shell pipeline: for each LevelDB file, extract strings, find lines
|
|
257
|
-
// after the extension ID, and filter for base64url token pattern.
|
|
258
|
-
//
|
|
259
|
-
// LevelDB `strings` output for the extension's auth-token entry:
|
|
260
|
-
// auth-token ← key name
|
|
261
|
-
// 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
|
|
262
|
-
// hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
|
|
263
|
-
//
|
|
264
|
-
// We get the line immediately after any EXTENSION_ID mention and check
|
|
265
|
-
// if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
|
|
266
|
-
const shellDir = dir.replace(/'/g, "'\\''");
|
|
267
|
-
const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
|
|
268
|
-
`[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
|
|
269
|
-
`grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
270
|
-
`grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
|
|
271
|
-
`grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
|
|
272
|
-
`done 2>/dev/null`;
|
|
273
|
-
const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
|
|
274
|
-
// Take the first non-empty line
|
|
275
|
-
for (const line of result.split('\n')) {
|
|
276
|
-
const token = line.trim();
|
|
277
|
-
if (token && validateBase64urlToken(token))
|
|
278
|
-
return token;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
catch { }
|
|
282
|
-
return null;
|
|
283
|
-
}
|
|
284
247
|
function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
248
|
+
// LevelDB fragments strings across byte boundaries, so we can't search
|
|
249
|
+
// for the full extension ID or "auth-token" as contiguous ASCII. Instead,
|
|
250
|
+
// search for a short prefix of the extension ID that reliably appears as
|
|
251
|
+
// contiguous bytes, then scan a window around each match for a base64url
|
|
252
|
+
// token value.
|
|
253
|
+
//
|
|
254
|
+
// Observed LevelDB layout near the auth-token entry:
|
|
255
|
+
// ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
|
|
256
|
+
// <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
|
|
257
|
+
//
|
|
258
|
+
// The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
|
|
285
259
|
const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
|
|
286
|
-
const
|
|
260
|
+
const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
|
|
287
261
|
let files;
|
|
288
262
|
try {
|
|
289
263
|
files = fs.readdirSync(dir)
|
|
@@ -293,7 +267,7 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
293
267
|
catch {
|
|
294
268
|
return null;
|
|
295
269
|
}
|
|
296
|
-
// Sort by mtime descending
|
|
270
|
+
// Sort by mtime descending so we find the freshest token first
|
|
297
271
|
files.sort((a, b) => {
|
|
298
272
|
try {
|
|
299
273
|
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
|
@@ -310,15 +284,30 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
|
|
|
310
284
|
catch {
|
|
311
285
|
continue;
|
|
312
286
|
}
|
|
313
|
-
// Quick check:
|
|
314
|
-
|
|
315
|
-
if (extPos === -1)
|
|
316
|
-
continue;
|
|
317
|
-
const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
|
|
318
|
-
if (keyPos === -1)
|
|
287
|
+
// Quick check: file must contain at least the prefix
|
|
288
|
+
if (data.indexOf(extIdPrefix) === -1)
|
|
319
289
|
continue;
|
|
320
|
-
//
|
|
290
|
+
// Strategy 1: scan after each occurrence of the extension ID prefix
|
|
291
|
+
// for base64url tokens within a 500-byte window
|
|
321
292
|
let idx = 0;
|
|
293
|
+
while (true) {
|
|
294
|
+
const pos = data.indexOf(extIdPrefix, idx);
|
|
295
|
+
if (pos === -1)
|
|
296
|
+
break;
|
|
297
|
+
const scanStart = pos;
|
|
298
|
+
const scanEnd = Math.min(data.length, pos + 500);
|
|
299
|
+
const window = data.subarray(scanStart, scanEnd).toString('latin1');
|
|
300
|
+
const m = window.match(tokenRe);
|
|
301
|
+
if (m && validateBase64urlToken(m[1])) {
|
|
302
|
+
// Make sure this isn't another extension ID that happens to match
|
|
303
|
+
if (m[1] !== PLAYWRIGHT_EXTENSION_ID)
|
|
304
|
+
return m[1];
|
|
305
|
+
}
|
|
306
|
+
idx = pos + 1;
|
|
307
|
+
}
|
|
308
|
+
// Strategy 2 (fallback): original approach using full extension ID + auth-token key
|
|
309
|
+
const keyBuf = Buffer.from('auth-token');
|
|
310
|
+
idx = 0;
|
|
322
311
|
while (true) {
|
|
323
312
|
const kp = data.indexOf(keyBuf, idx);
|
|
324
313
|
if (kp === -1)
|
|
@@ -345,6 +334,54 @@ function validateBase64urlToken(token) {
|
|
|
345
334
|
return false;
|
|
346
335
|
}
|
|
347
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Check whether the Playwright MCP Bridge extension is installed in any browser.
|
|
339
|
+
* Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
|
|
340
|
+
*/
|
|
341
|
+
export function checkExtensionInstalled() {
|
|
342
|
+
const home = os.homedir();
|
|
343
|
+
const platform = os.platform();
|
|
344
|
+
const browserDirs = [];
|
|
345
|
+
if (platform === 'darwin') {
|
|
346
|
+
browserDirs.push({ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') });
|
|
347
|
+
}
|
|
348
|
+
else if (platform === 'linux') {
|
|
349
|
+
browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
|
|
350
|
+
}
|
|
351
|
+
else if (platform === 'win32') {
|
|
352
|
+
const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
353
|
+
browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
|
|
354
|
+
}
|
|
355
|
+
const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
|
|
356
|
+
const foundBrowsers = [];
|
|
357
|
+
for (const { name, base } of browserDirs) {
|
|
358
|
+
for (const profile of profiles) {
|
|
359
|
+
const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
|
|
360
|
+
if (fileExists(extDir)) {
|
|
361
|
+
foundBrowsers.push(name);
|
|
362
|
+
break; // one match per browser is enough
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Test token connectivity by attempting a real MCP connection.
|
|
370
|
+
* Connects, does the JSON-RPC handshake, and immediately closes.
|
|
371
|
+
*/
|
|
372
|
+
export async function checkTokenConnectivity(opts) {
|
|
373
|
+
const timeout = opts?.timeout ?? 8;
|
|
374
|
+
const start = Date.now();
|
|
375
|
+
try {
|
|
376
|
+
const mcp = new PlaywrightMCP();
|
|
377
|
+
await mcp.connect({ timeout });
|
|
378
|
+
await mcp.close();
|
|
379
|
+
return { ok: true, durationMs: Date.now() - start };
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
348
385
|
export async function runBrowserDoctor(opts = {}) {
|
|
349
386
|
const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
|
|
350
387
|
const shellPath = opts.shellRc ?? getDefaultShellRcPath();
|
|
@@ -368,19 +405,31 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
368
405
|
].filter((v) => !!v);
|
|
369
406
|
const uniqueTokens = [...new Set(allTokens)];
|
|
370
407
|
const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
|
|
408
|
+
// Check extension installation
|
|
409
|
+
const extInstall = checkExtensionInstalled();
|
|
410
|
+
// Connectivity test (only when --live)
|
|
411
|
+
let connectivity;
|
|
412
|
+
if (opts.live) {
|
|
413
|
+
connectivity = await checkTokenConnectivity();
|
|
414
|
+
}
|
|
371
415
|
const report = {
|
|
372
416
|
cliVersion: opts.cliVersion,
|
|
373
417
|
envToken,
|
|
374
418
|
envFingerprint: getTokenFingerprint(envToken ?? undefined),
|
|
375
419
|
extensionToken,
|
|
376
420
|
extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
|
|
421
|
+
extensionInstalled: extInstall.installed,
|
|
422
|
+
extensionBrowsers: extInstall.browsers,
|
|
377
423
|
shellFiles,
|
|
378
424
|
configs,
|
|
379
425
|
recommendedToken,
|
|
380
426
|
recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
|
|
427
|
+
connectivity,
|
|
381
428
|
warnings: [],
|
|
382
429
|
issues: [],
|
|
383
430
|
};
|
|
431
|
+
if (!extInstall.installed)
|
|
432
|
+
report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
|
|
384
433
|
if (!envToken)
|
|
385
434
|
report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
|
|
386
435
|
if (!shellFiles.some(s => s.token))
|
|
@@ -389,6 +438,8 @@ export async function runBrowserDoctor(opts = {}) {
|
|
|
389
438
|
report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
|
|
390
439
|
if (uniqueTokens.length > 1)
|
|
391
440
|
report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
|
|
441
|
+
if (connectivity && !connectivity.ok)
|
|
442
|
+
report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
|
|
392
443
|
for (const config of configs) {
|
|
393
444
|
if (config.parseError)
|
|
394
445
|
report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
|
|
@@ -408,6 +459,11 @@ export function renderBrowserDoctorReport(report) {
|
|
|
408
459
|
const uniqueFingerprints = [...new Set(tokenFingerprints)];
|
|
409
460
|
const hasMismatch = uniqueFingerprints.length > 1;
|
|
410
461
|
const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
|
|
462
|
+
const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
|
|
463
|
+
const installDetail = report.extensionInstalled
|
|
464
|
+
? `Extension installed (${report.extensionBrowsers.join(', ')})`
|
|
465
|
+
: 'Extension not installed in any browser';
|
|
466
|
+
lines.push(statusLine(installStatus, installDetail));
|
|
411
467
|
const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
412
468
|
lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
|
|
413
469
|
const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
|
|
@@ -441,6 +497,17 @@ export function renderBrowserDoctorReport(report) {
|
|
|
441
497
|
if (missingConfigCount > 0)
|
|
442
498
|
lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
|
|
443
499
|
lines.push('');
|
|
500
|
+
// Connectivity result
|
|
501
|
+
if (report.connectivity) {
|
|
502
|
+
const connStatus = report.connectivity.ok ? 'OK' : 'WARN';
|
|
503
|
+
const connDetail = report.connectivity.ok
|
|
504
|
+
? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
|
|
505
|
+
: `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
|
|
506
|
+
lines.push(statusLine(connStatus, connDetail));
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
|
|
510
|
+
}
|
|
444
511
|
lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
|
|
445
512
|
if (report.issues.length) {
|
|
446
513
|
lines.push('', chalk.yellow('Issues:'));
|
package/dist/doctor.test.js
CHANGED
|
@@ -66,7 +66,7 @@ describe('json token helpers', () => {
|
|
|
66
66
|
},
|
|
67
67
|
}), 'abc123');
|
|
68
68
|
const parsed = JSON.parse(next);
|
|
69
|
-
expect(parsed.mcp.playwright.
|
|
69
|
+
expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
72
|
describe('doctor report rendering', () => {
|
|
@@ -77,6 +77,8 @@ describe('doctor report rendering', () => {
|
|
|
77
77
|
envFingerprint: 'fp1',
|
|
78
78
|
extensionToken: 'abc123',
|
|
79
79
|
extensionFingerprint: 'fp1',
|
|
80
|
+
extensionInstalled: true,
|
|
81
|
+
extensionBrowsers: ['Chrome'],
|
|
80
82
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
|
|
81
83
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
82
84
|
recommendedToken: 'abc123',
|
|
@@ -84,6 +86,7 @@ describe('doctor report rendering', () => {
|
|
|
84
86
|
warnings: [],
|
|
85
87
|
issues: [],
|
|
86
88
|
}));
|
|
89
|
+
expect(text).toContain('[OK] Extension installed (Chrome)');
|
|
87
90
|
expect(text).toContain('[OK] Environment token: configured (fp1)');
|
|
88
91
|
expect(text).toContain('[OK] /tmp/mcp.json');
|
|
89
92
|
expect(text).toContain('configured (fp1)');
|
|
@@ -94,6 +97,8 @@ describe('doctor report rendering', () => {
|
|
|
94
97
|
envFingerprint: 'fp1',
|
|
95
98
|
extensionToken: null,
|
|
96
99
|
extensionFingerprint: null,
|
|
100
|
+
extensionInstalled: false,
|
|
101
|
+
extensionBrowsers: [],
|
|
97
102
|
shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
|
|
98
103
|
configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
|
|
99
104
|
recommendedToken: 'abc123',
|
|
@@ -101,9 +106,45 @@ describe('doctor report rendering', () => {
|
|
|
101
106
|
warnings: [],
|
|
102
107
|
issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
|
|
103
108
|
}));
|
|
109
|
+
expect(text).toContain('[MISSING] Extension not installed in any browser');
|
|
104
110
|
expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
|
|
105
111
|
expect(text).toContain('[MISMATCH] /tmp/.zshrc');
|
|
106
112
|
expect(text).toContain('configured (fp2)');
|
|
107
113
|
expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
|
|
108
114
|
});
|
|
115
|
+
it('renders connectivity OK when live test succeeds', () => {
|
|
116
|
+
const text = strip(renderBrowserDoctorReport({
|
|
117
|
+
envToken: 'abc123',
|
|
118
|
+
envFingerprint: 'fp1',
|
|
119
|
+
extensionToken: 'abc123',
|
|
120
|
+
extensionFingerprint: 'fp1',
|
|
121
|
+
extensionInstalled: true,
|
|
122
|
+
extensionBrowsers: ['Chrome'],
|
|
123
|
+
shellFiles: [],
|
|
124
|
+
configs: [],
|
|
125
|
+
recommendedToken: 'abc123',
|
|
126
|
+
recommendedFingerprint: 'fp1',
|
|
127
|
+
connectivity: { ok: true, durationMs: 1234 },
|
|
128
|
+
warnings: [],
|
|
129
|
+
issues: [],
|
|
130
|
+
}));
|
|
131
|
+
expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
|
|
132
|
+
});
|
|
133
|
+
it('renders connectivity WARN when not tested', () => {
|
|
134
|
+
const text = strip(renderBrowserDoctorReport({
|
|
135
|
+
envToken: 'abc123',
|
|
136
|
+
envFingerprint: 'fp1',
|
|
137
|
+
extensionToken: 'abc123',
|
|
138
|
+
extensionFingerprint: 'fp1',
|
|
139
|
+
extensionInstalled: true,
|
|
140
|
+
extensionBrowsers: ['Chrome'],
|
|
141
|
+
shellFiles: [],
|
|
142
|
+
configs: [],
|
|
143
|
+
recommendedToken: 'abc123',
|
|
144
|
+
recommendedFingerprint: 'fp1',
|
|
145
|
+
warnings: [],
|
|
146
|
+
issues: [],
|
|
147
|
+
}));
|
|
148
|
+
expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
|
|
149
|
+
});
|
|
109
150
|
});
|
package/dist/main.js
CHANGED
|
@@ -102,12 +102,13 @@ program.command('doctor')
|
|
|
102
102
|
.option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
|
|
103
103
|
.option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
|
|
104
104
|
.option('--token <token>', 'Override token to write instead of auto-detecting')
|
|
105
|
+
.option('--live', 'Test browser connectivity (requires Chrome running)', false)
|
|
105
106
|
.option('--shell-rc <path>', 'Shell startup file to update')
|
|
106
107
|
.option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
|
|
107
108
|
.action(async (opts) => {
|
|
108
109
|
const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
|
|
109
110
|
const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
110
|
-
const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
111
|
+
const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
|
|
111
112
|
console.log(renderBrowserDoctorReport(report));
|
|
112
113
|
if (opts.fix) {
|
|
113
114
|
const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
|