@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.
@@ -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
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
54
- * with a pure-Node fallback on Windows.
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]?.env?.[PLAYWRIGHT_TOKEN_ENV];
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].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
131
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
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
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
215
- * with a pure-Node fallback on Windows.
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 keyBuf = Buffer.from('auth-token');
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: does file contain both the extension ID and auth-token key?
314
- const extPos = data.indexOf(extIdBuf);
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
- // Scan for token value after auth-token key
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:'));
@@ -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.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },