@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,28 @@
1
+ /**
2
+ * Shared YouTube utilities — URL parsing, video ID extraction, etc.
3
+ */
4
+
5
+ /**
6
+ * Extract a YouTube video ID from a URL or bare video ID string.
7
+ * Supports: watch?v=, youtu.be/, /shorts/, /embed/, /live/, /v/
8
+ */
9
+ export function parseVideoId(input: string): string {
10
+ if (!input.startsWith('http')) return input;
11
+
12
+ try {
13
+ const parsed = new URL(input);
14
+ if (parsed.searchParams.has('v')) {
15
+ return parsed.searchParams.get('v')!;
16
+ }
17
+ if (parsed.hostname === 'youtu.be') {
18
+ return parsed.pathname.slice(1).split('/')[0];
19
+ }
20
+ // Handle /shorts/xxx, /embed/xxx, /live/xxx, /v/xxx
21
+ const pathMatch = parsed.pathname.match(/^\/(shorts|embed|live|v)\/([^/?]+)/);
22
+ if (pathMatch) return pathMatch[2];
23
+ } catch {
24
+ // Not a valid URL — treat entire input as video ID
25
+ }
26
+
27
+ return input;
28
+ }
@@ -0,0 +1,116 @@
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
+
7
+ cli({
8
+ site: 'youtube',
9
+ name: 'video',
10
+ description: 'Get YouTube video metadata (title, views, description, etc.)',
11
+ domain: 'www.youtube.com',
12
+ strategy: Strategy.COOKIE,
13
+ args: [
14
+ { name: 'url', required: true, help: 'YouTube video URL or video ID' },
15
+ ],
16
+ columns: ['field', 'value'],
17
+ func: async (page, kwargs) => {
18
+ const videoId = parseVideoId(kwargs.url);
19
+ const videoUrl = `https://www.youtube.com/watch?v=${videoId}`;
20
+ await page.goto(videoUrl);
21
+ await page.wait(3);
22
+
23
+ const data = await page.evaluate(`
24
+ (async () => {
25
+ const player = window.ytInitialPlayerResponse;
26
+ const yt = window.ytInitialData;
27
+ if (!player) return { error: 'ytInitialPlayerResponse not found' };
28
+
29
+ const details = player.videoDetails || {};
30
+ const microformat = player.microformat?.playerMicroformatRenderer || {};
31
+
32
+ // Try to get full description from ytInitialData
33
+ let fullDescription = details.shortDescription || '';
34
+ try {
35
+ const contents = yt?.contents?.twoColumnWatchNextResults
36
+ ?.results?.results?.contents;
37
+ if (contents) {
38
+ for (const c of contents) {
39
+ const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content;
40
+ if (desc) { fullDescription = desc; break; }
41
+ }
42
+ }
43
+ } catch {}
44
+
45
+ // Get like count if available
46
+ let likes = '';
47
+ try {
48
+ const contents = yt?.contents?.twoColumnWatchNextResults
49
+ ?.results?.results?.contents;
50
+ if (contents) {
51
+ for (const c of contents) {
52
+ const buttons = c.videoPrimaryInfoRenderer?.videoActions
53
+ ?.menuRenderer?.topLevelButtons;
54
+ if (buttons) {
55
+ for (const b of buttons) {
56
+ const toggle = b.segmentedLikeDislikeButtonViewModel
57
+ ?.likeButtonViewModel?.likeButtonViewModel?.toggleButtonViewModel
58
+ ?.toggleButtonViewModel?.defaultButtonViewModel?.buttonViewModel;
59
+ if (toggle?.title) { likes = toggle.title; break; }
60
+ }
61
+ }
62
+ }
63
+ }
64
+ } catch {}
65
+
66
+ // Get publish date
67
+ const publishDate = microformat.publishDate
68
+ || microformat.uploadDate
69
+ || details.publishDate || '';
70
+
71
+ // Get category
72
+ const category = microformat.category || '';
73
+
74
+ // Get channel subscriber count if available
75
+ let subscribers = '';
76
+ try {
77
+ const contents = yt?.contents?.twoColumnWatchNextResults
78
+ ?.results?.results?.contents;
79
+ if (contents) {
80
+ for (const c of contents) {
81
+ const owner = c.videoSecondaryInfoRenderer?.owner
82
+ ?.videoOwnerRenderer?.subscriberCountText?.simpleText;
83
+ if (owner) { subscribers = owner; break; }
84
+ }
85
+ }
86
+ } catch {}
87
+
88
+ return {
89
+ title: details.title || '',
90
+ channel: details.author || '',
91
+ channelId: details.channelId || '',
92
+ videoId: details.videoId || '',
93
+ views: details.viewCount || '',
94
+ likes,
95
+ subscribers,
96
+ duration: details.lengthSeconds ? details.lengthSeconds + 's' : '',
97
+ publishDate,
98
+ category,
99
+ description: fullDescription,
100
+ keywords: (details.keywords || []).join(', '),
101
+ isLive: details.isLiveContent || false,
102
+ thumbnail: details.thumbnail?.thumbnails?.slice(-1)?.[0]?.url || '',
103
+ };
104
+ })()
105
+ `);
106
+
107
+ if (!data || typeof data !== 'object') throw new Error('Failed to extract video metadata from page');
108
+ if (data.error) throw new Error(data.error);
109
+
110
+ // Return as field/value pairs for table display
111
+ return Object.entries(data).map(([field, value]) => ({
112
+ field,
113
+ value: String(value),
114
+ }));
115
+ },
116
+ });
@@ -81,7 +81,7 @@ describe('json token helpers', () => {
81
81
  },
82
82
  }), 'abc123');
83
83
  const parsed = JSON.parse(next);
84
- expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
84
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
85
85
  });
86
86
  });
87
87
 
@@ -94,6 +94,8 @@ describe('doctor report rendering', () => {
94
94
  envFingerprint: 'fp1',
95
95
  extensionToken: 'abc123',
96
96
  extensionFingerprint: 'fp1',
97
+ extensionInstalled: true,
98
+ extensionBrowsers: ['Chrome'],
97
99
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
98
100
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
99
101
  recommendedToken: 'abc123',
@@ -102,6 +104,7 @@ describe('doctor report rendering', () => {
102
104
  issues: [],
103
105
  }));
104
106
 
107
+ expect(text).toContain('[OK] Extension installed (Chrome)');
105
108
  expect(text).toContain('[OK] Environment token: configured (fp1)');
106
109
  expect(text).toContain('[OK] /tmp/mcp.json');
107
110
  expect(text).toContain('configured (fp1)');
@@ -113,6 +116,8 @@ describe('doctor report rendering', () => {
113
116
  envFingerprint: 'fp1',
114
117
  extensionToken: null,
115
118
  extensionFingerprint: null,
119
+ extensionInstalled: false,
120
+ extensionBrowsers: [],
116
121
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
117
122
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
118
123
  recommendedToken: 'abc123',
@@ -121,10 +126,50 @@ describe('doctor report rendering', () => {
121
126
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
122
127
  }));
123
128
 
129
+ expect(text).toContain('[MISSING] Extension not installed in any browser');
124
130
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
125
131
  expect(text).toContain('[MISMATCH] /tmp/.zshrc');
126
132
  expect(text).toContain('configured (fp2)');
127
133
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
128
134
  });
135
+
136
+ it('renders connectivity OK when live test succeeds', () => {
137
+ const text = strip(renderBrowserDoctorReport({
138
+ envToken: 'abc123',
139
+ envFingerprint: 'fp1',
140
+ extensionToken: 'abc123',
141
+ extensionFingerprint: 'fp1',
142
+ extensionInstalled: true,
143
+ extensionBrowsers: ['Chrome'],
144
+ shellFiles: [],
145
+ configs: [],
146
+ recommendedToken: 'abc123',
147
+ recommendedFingerprint: 'fp1',
148
+ connectivity: { ok: true, durationMs: 1234 },
149
+ warnings: [],
150
+ issues: [],
151
+ }));
152
+
153
+ expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
154
+ });
155
+
156
+ it('renders connectivity WARN when not tested', () => {
157
+ const text = strip(renderBrowserDoctorReport({
158
+ envToken: 'abc123',
159
+ envFingerprint: 'fp1',
160
+ extensionToken: 'abc123',
161
+ extensionFingerprint: 'fp1',
162
+ extensionInstalled: true,
163
+ extensionBrowsers: ['Chrome'],
164
+ shellFiles: [],
165
+ configs: [],
166
+ recommendedToken: 'abc123',
167
+ recommendedFingerprint: 'fp1',
168
+ warnings: [],
169
+ issues: [],
170
+ }));
171
+
172
+ expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
173
+ });
129
174
  });
130
175
 
package/src/doctor.ts CHANGED
@@ -1,7 +1,7 @@
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';
4
+
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin as input, stdout as output } from 'node:process';
7
7
  import chalk from 'chalk';
@@ -16,6 +16,7 @@ const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'
16
16
  export type DoctorOptions = {
17
17
  fix?: boolean;
18
18
  yes?: boolean;
19
+ live?: boolean;
19
20
  shellRc?: string;
20
21
  configPaths?: string[];
21
22
  token?: string;
@@ -41,16 +42,25 @@ export type McpConfigStatus = {
41
42
  parseError?: string;
42
43
  };
43
44
 
45
+ export type ConnectivityResult = {
46
+ ok: boolean;
47
+ error?: string;
48
+ durationMs: number;
49
+ };
50
+
44
51
  export type DoctorReport = {
45
52
  cliVersion?: string;
46
53
  envToken: string | null;
47
54
  envFingerprint: string | null;
48
55
  extensionToken: string | null;
49
56
  extensionFingerprint: string | null;
57
+ extensionInstalled: boolean;
58
+ extensionBrowsers: string[];
50
59
  shellFiles: ShellFileStatus[];
51
60
  configs: McpConfigStatus[];
52
61
  recommendedToken: string | null;
53
62
  recommendedFingerprint: string | null;
63
+ connectivity?: ConnectivityResult;
54
64
  warnings: string[];
55
65
  issues: string[];
56
66
  };
@@ -147,7 +157,7 @@ function readJsonConfigToken(content: string): string | null {
147
157
  function readTokenFromJsonObject(parsed: any): string | null {
148
158
  const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
149
159
  if (typeof direct === 'string' && direct) return direct;
150
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
160
+ const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
151
161
  if (typeof opencode === 'string' && opencode) return opencode;
152
162
  return null;
153
163
  }
@@ -168,8 +178,8 @@ export function upsertJsonConfigToken(content: string, token: string): string {
168
178
  enabled: true,
169
179
  type: 'local',
170
180
  };
171
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
172
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
181
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
182
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
173
183
  }
174
184
  return `${JSON.stringify(parsed, null, 2)}\n`;
175
185
  }
@@ -256,8 +266,11 @@ function readConfigStatus(filePath: string): McpConfigStatus {
256
266
  * Discover the auth token stored by the Playwright MCP Bridge extension
257
267
  * by scanning Chrome's LevelDB localStorage files directly.
258
268
  *
259
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
260
- * with a pure-Node fallback on Windows.
269
+ * Reads LevelDB .ldb/.log files as raw binary and searches for the
270
+ * extension ID near base64url token values. This works reliably across
271
+ * platforms because LevelDB's internal encoding can split ASCII strings
272
+ * like "auth-token" and the extension ID across byte boundaries, making
273
+ * text-based tools like `strings` + `grep` unreliable.
261
274
  */
262
275
  export function discoverExtensionToken(): string | null {
263
276
  const home = os.homedir();
@@ -286,7 +299,6 @@ export function discoverExtensionToken(): string | null {
286
299
  }
287
300
 
288
301
  const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
289
- // Token is 43 chars of base64url (from 32 random bytes)
290
302
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
291
303
 
292
304
  for (const base of bases) {
@@ -294,14 +306,6 @@ export function discoverExtensionToken(): string | null {
294
306
  const dir = path.join(base, profile, 'Local Storage', 'leveldb');
295
307
  if (!fileExists(dir)) continue;
296
308
 
297
- // Fast path: use strings + grep to find candidate files and extract token
298
- if (platform !== 'win32') {
299
- const token = extractTokenViaStrings(dir, tokenRe);
300
- if (token) return token;
301
- continue;
302
- }
303
-
304
- // Slow path (Windows): read binary files directly
305
309
  const token = extractTokenViaBinaryRead(dir, tokenRe);
306
310
  if (token) return token;
307
311
  }
@@ -310,39 +314,20 @@ export function discoverExtensionToken(): string | null {
310
314
  return null;
311
315
  }
312
316
 
313
- function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
314
- try {
315
- // Single shell pipeline: for each LevelDB file, extract strings, find lines
316
- // after the extension ID, and filter for base64url token pattern.
317
- //
318
- // LevelDB `strings` output for the extension's auth-token entry:
319
- // auth-token ← key name
320
- // 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
321
- // hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
322
- //
323
- // We get the line immediately after any EXTENSION_ID mention and check
324
- // if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
325
- const shellDir = dir.replace(/'/g, "'\\''");
326
- const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
327
- `[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
328
- `grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
329
- `grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
330
- `grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
331
- `done 2>/dev/null`;
332
- const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
333
-
334
- // Take the first non-empty line
335
- for (const line of result.split('\n')) {
336
- const token = line.trim();
337
- if (token && validateBase64urlToken(token)) return token;
338
- }
339
- } catch {}
340
- return null;
341
- }
342
-
343
317
  function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
318
+ // LevelDB fragments strings across byte boundaries, so we can't search
319
+ // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
320
+ // search for a short prefix of the extension ID that reliably appears as
321
+ // contiguous bytes, then scan a window around each match for a base64url
322
+ // token value.
323
+ //
324
+ // Observed LevelDB layout near the auth-token entry:
325
+ // ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
326
+ // <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
327
+ //
328
+ // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
344
329
  const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
345
- const keyBuf = Buffer.from('auth-token');
330
+ const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
346
331
 
347
332
  let files: string[];
348
333
  try {
@@ -351,7 +336,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
351
336
  .map(f => path.join(dir, f));
352
337
  } catch { return null; }
353
338
 
354
- // Sort by mtime descending
339
+ // Sort by mtime descending so we find the freshest token first
355
340
  files.sort((a, b) => {
356
341
  try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
357
342
  });
@@ -360,14 +345,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
360
345
  let data: Buffer;
361
346
  try { data = fs.readFileSync(file); } catch { continue; }
362
347
 
363
- // Quick check: does file contain both the extension ID and auth-token key?
364
- const extPos = data.indexOf(extIdBuf);
365
- if (extPos === -1) continue;
366
- const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
367
- if (keyPos === -1) continue;
348
+ // Quick check: file must contain at least the prefix
349
+ if (data.indexOf(extIdPrefix) === -1) continue;
368
350
 
369
- // Scan for token value after auth-token key
351
+ // Strategy 1: scan after each occurrence of the extension ID prefix
352
+ // for base64url tokens within a 500-byte window
370
353
  let idx = 0;
354
+ while (true) {
355
+ const pos = data.indexOf(extIdPrefix, idx);
356
+ if (pos === -1) break;
357
+
358
+ const scanStart = pos;
359
+ const scanEnd = Math.min(data.length, pos + 500);
360
+ const window = data.subarray(scanStart, scanEnd).toString('latin1');
361
+ const m = window.match(tokenRe);
362
+ if (m && validateBase64urlToken(m[1])) {
363
+ // Make sure this isn't another extension ID that happens to match
364
+ if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
365
+ }
366
+ idx = pos + 1;
367
+ }
368
+
369
+ // Strategy 2 (fallback): original approach using full extension ID + auth-token key
370
+ const keyBuf = Buffer.from('auth-token');
371
+ idx = 0;
371
372
  while (true) {
372
373
  const kp = data.indexOf(keyBuf, idx);
373
374
  if (kp === -1) break;
@@ -393,6 +394,69 @@ function validateBase64urlToken(token: string): boolean {
393
394
  }
394
395
 
395
396
 
397
+ /**
398
+ * Check whether the Playwright MCP Bridge extension is installed in any browser.
399
+ * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
400
+ */
401
+ export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
402
+ const home = os.homedir();
403
+ const platform = os.platform();
404
+ const browserDirs: Array<{ name: string; base: string }> = [];
405
+
406
+ if (platform === 'darwin') {
407
+ browserDirs.push(
408
+ { name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
409
+ { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
410
+ { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
411
+ { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
412
+ );
413
+ } else if (platform === 'linux') {
414
+ browserDirs.push(
415
+ { name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
416
+ { name: 'Chromium', base: path.join(home, '.config', 'chromium') },
417
+ { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
418
+ );
419
+ } else if (platform === 'win32') {
420
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
421
+ browserDirs.push(
422
+ { name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
423
+ { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
424
+ );
425
+ }
426
+
427
+ const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
428
+ const foundBrowsers: string[] = [];
429
+
430
+ for (const { name, base } of browserDirs) {
431
+ for (const profile of profiles) {
432
+ const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
433
+ if (fileExists(extDir)) {
434
+ foundBrowsers.push(name);
435
+ break; // one match per browser is enough
436
+ }
437
+ }
438
+ }
439
+
440
+ return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
441
+ }
442
+
443
+ /**
444
+ * Test token connectivity by attempting a real MCP connection.
445
+ * Connects, does the JSON-RPC handshake, and immediately closes.
446
+ */
447
+ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
448
+ const timeout = opts?.timeout ?? 8;
449
+ const start = Date.now();
450
+ try {
451
+ const mcp = new PlaywrightMCP();
452
+ await mcp.connect({ timeout });
453
+ await mcp.close();
454
+ return { ok: true, durationMs: Date.now() - start };
455
+ } catch (err: any) {
456
+ return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
457
+ }
458
+ }
459
+
396
460
  export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
397
461
  const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
398
462
  const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -418,24 +482,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
418
482
  const uniqueTokens = [...new Set(allTokens)];
419
483
  const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
420
484
 
485
+ // Check extension installation
486
+ const extInstall = checkExtensionInstalled();
487
+
488
+ // Connectivity test (only when --live)
489
+ let connectivity: ConnectivityResult | undefined;
490
+ if (opts.live) {
491
+ connectivity = await checkTokenConnectivity();
492
+ }
493
+
421
494
  const report: DoctorReport = {
422
495
  cliVersion: opts.cliVersion,
423
496
  envToken,
424
497
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
425
498
  extensionToken,
426
499
  extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
500
+ extensionInstalled: extInstall.installed,
501
+ extensionBrowsers: extInstall.browsers,
427
502
  shellFiles,
428
503
  configs,
429
504
  recommendedToken,
430
505
  recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
506
+ connectivity,
431
507
  warnings: [],
432
508
  issues: [],
433
509
  };
434
510
 
511
+ if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
435
512
  if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
436
513
  if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
437
514
  if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
438
515
  if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
516
+ if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
439
517
  for (const config of configs) {
440
518
  if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
441
519
  }
@@ -456,6 +534,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
456
534
  const hasMismatch = uniqueFingerprints.length > 1;
457
535
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
458
536
 
537
+ const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
538
+ const installDetail = report.extensionInstalled
539
+ ? `Extension installed (${report.extensionBrowsers.join(', ')})`
540
+ : 'Extension not installed in any browser';
541
+ lines.push(statusLine(installStatus, installDetail));
542
+
459
543
  const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
460
544
  lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
461
545
 
@@ -489,6 +573,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
489
573
  }
490
574
  if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
491
575
  lines.push('');
576
+
577
+ // Connectivity result
578
+ if (report.connectivity) {
579
+ const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
580
+ const connDetail = report.connectivity.ok
581
+ ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
582
+ : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
583
+ lines.push(statusLine(connStatus, connDetail));
584
+ } else {
585
+ lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
586
+ }
587
+
492
588
  lines.push(statusLine(
493
589
  hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
494
590
  `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
package/src/main.ts CHANGED
@@ -100,12 +100,13 @@ program.command('doctor')
100
100
  .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
101
101
  .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
102
102
  .option('--token <token>', 'Override token to write instead of auto-detecting')
103
+ .option('--live', 'Test browser connectivity (requires Chrome running)', false)
103
104
  .option('--shell-rc <path>', 'Shell startup file to update')
104
105
  .option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
105
106
  .action(async (opts) => {
106
107
  const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
107
108
  const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined;
108
- const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
109
+ const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
109
110
  console.log(renderBrowserDoctorReport(report));
110
111
  if (opts.fix) {
111
112
  const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
@@ -1,76 +0,0 @@
1
- site: reddit
2
- name: read
3
- description: Read a Reddit post and its comments
4
- domain: reddit.com
5
- strategy: cookie
6
- browser: true
7
-
8
- args:
9
- post_id:
10
- type: string
11
- required: true
12
- description: "Post ID (e.g. 1abc123) or full URL"
13
- sort:
14
- type: string
15
- default: best
16
- description: "Comment sort: best, top, new, controversial, old, qa"
17
- limit:
18
- type: int
19
- default: 25
20
- description: Number of top-level comments to fetch
21
-
22
- columns: [type, author, score, text]
23
-
24
- pipeline:
25
- - navigate: https://www.reddit.com
26
- - evaluate: |
27
- (async () => {
28
- let postId = ${{ args.post_id | json }};
29
- const urlMatch = postId.match(/comments\/([a-z0-9]+)/);
30
- if (urlMatch) postId = urlMatch[1];
31
-
32
- const sort = ${{ args.sort | json }};
33
- const limit = ${{ args.limit }};
34
- const res = await fetch('/comments/' + postId + '.json?sort=' + sort + '&limit=' + limit + '&raw_json=1', {
35
- credentials: 'include'
36
- });
37
- const data = await res.json();
38
- if (!Array.isArray(data) || data.length < 1) return [];
39
-
40
- const results = [];
41
-
42
- // First element: post itself
43
- const post = data[0]?.data?.children?.[0]?.data;
44
- if (post) {
45
- let body = post.selftext || '';
46
- if (body.length > 2000) body = body.slice(0, 2000) + '\n... [truncated]';
47
- results.push({
48
- type: '📰 POST',
49
- author: post.author,
50
- score: post.score,
51
- text: post.title + (body ? '\n\n' + body : '') + (post.url && !post.is_self ? '\n🔗 ' + post.url : ''),
52
- });
53
- }
54
-
55
- // Second element: comments
56
- const comments = data[1]?.data?.children || [];
57
- for (const c of comments) {
58
- if (c.kind !== 't1') continue;
59
- const d = c.data;
60
- let body = d.body || '';
61
- if (body.length > 500) body = body.slice(0, 500) + '...';
62
- results.push({
63
- type: '💬 COMMENT',
64
- author: d.author || '[deleted]',
65
- score: d.score || 0,
66
- text: body,
67
- });
68
- }
69
-
70
- return results;
71
- })()
72
- - map:
73
- type: ${{ item.type }}
74
- author: ${{ item.author }}
75
- score: ${{ item.score }}
76
- text: ${{ item.text }}