@jackwener/opencli 0.7.3 → 0.7.5

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.
@@ -81,7 +81,55 @@ 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
+ });
86
+
87
+ it('creates standard mcpServers format for empty file (not OpenCode)', () => {
88
+ const next = upsertJsonConfigToken('', 'abc123');
89
+ const parsed = JSON.parse(next);
90
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
91
+ expect(parsed.mcp).toBeUndefined();
92
+ });
93
+
94
+ it('creates OpenCode format when filePath contains opencode', () => {
95
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
96
+ const parsed = JSON.parse(next);
97
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
98
+ expect(parsed.mcpServers).toBeUndefined();
99
+ });
100
+
101
+ it('creates standard format when filePath is claude.json', () => {
102
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
103
+ const parsed = JSON.parse(next);
104
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
105
+ });
106
+ });
107
+
108
+ describe('fish shell support', () => {
109
+ it('generates fish set -gx syntax for fish config path', () => {
110
+ const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
111
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
112
+ expect(next).not.toContain('export');
113
+ });
114
+
115
+ it('replaces existing fish set line', () => {
116
+ const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
117
+ const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
118
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
119
+ expect(next).not.toContain('"old"');
120
+ });
121
+
122
+ it('appends fish syntax to existing fish config', () => {
123
+ const content = 'set -gx PATH /usr/bin\n';
124
+ const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
125
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
126
+ expect(next).toContain('set -gx PATH /usr/bin');
127
+ });
128
+
129
+ it('uses export syntax for zshrc even with filePath', () => {
130
+ const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
131
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
132
+ expect(next).not.toContain('set -gx');
85
133
  });
86
134
  });
87
135
 
@@ -94,6 +142,8 @@ describe('doctor report rendering', () => {
94
142
  envFingerprint: 'fp1',
95
143
  extensionToken: 'abc123',
96
144
  extensionFingerprint: 'fp1',
145
+ extensionInstalled: true,
146
+ extensionBrowsers: ['Chrome'],
97
147
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
98
148
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
99
149
  recommendedToken: 'abc123',
@@ -102,6 +152,7 @@ describe('doctor report rendering', () => {
102
152
  issues: [],
103
153
  }));
104
154
 
155
+ expect(text).toContain('[OK] Extension installed (Chrome)');
105
156
  expect(text).toContain('[OK] Environment token: configured (fp1)');
106
157
  expect(text).toContain('[OK] /tmp/mcp.json');
107
158
  expect(text).toContain('configured (fp1)');
@@ -113,6 +164,8 @@ describe('doctor report rendering', () => {
113
164
  envFingerprint: 'fp1',
114
165
  extensionToken: null,
115
166
  extensionFingerprint: null,
167
+ extensionInstalled: false,
168
+ extensionBrowsers: [],
116
169
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
117
170
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
118
171
  recommendedToken: 'abc123',
@@ -121,10 +174,50 @@ describe('doctor report rendering', () => {
121
174
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
122
175
  }));
123
176
 
177
+ expect(text).toContain('[MISSING] Extension not installed in any browser');
124
178
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
125
179
  expect(text).toContain('[MISMATCH] /tmp/.zshrc');
126
180
  expect(text).toContain('configured (fp2)');
127
181
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
128
182
  });
183
+
184
+ it('renders connectivity OK when live test succeeds', () => {
185
+ const text = strip(renderBrowserDoctorReport({
186
+ envToken: 'abc123',
187
+ envFingerprint: 'fp1',
188
+ extensionToken: 'abc123',
189
+ extensionFingerprint: 'fp1',
190
+ extensionInstalled: true,
191
+ extensionBrowsers: ['Chrome'],
192
+ shellFiles: [],
193
+ configs: [],
194
+ recommendedToken: 'abc123',
195
+ recommendedFingerprint: 'fp1',
196
+ connectivity: { ok: true, durationMs: 1234 },
197
+ warnings: [],
198
+ issues: [],
199
+ }));
200
+
201
+ expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
202
+ });
203
+
204
+ it('renders connectivity WARN when not tested', () => {
205
+ const text = strip(renderBrowserDoctorReport({
206
+ envToken: 'abc123',
207
+ envFingerprint: 'fp1',
208
+ extensionToken: 'abc123',
209
+ extensionFingerprint: 'fp1',
210
+ extensionInstalled: true,
211
+ extensionBrowsers: ['Chrome'],
212
+ shellFiles: [],
213
+ configs: [],
214
+ recommendedToken: 'abc123',
215
+ recommendedFingerprint: 'fp1',
216
+ warnings: [],
217
+ issues: [],
218
+ }));
219
+
220
+ expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
221
+ });
129
222
  });
130
223
 
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
  };
@@ -101,6 +111,15 @@ export function getDefaultShellRcPath(): string {
101
111
  return path.join(os.homedir(), '.zshrc');
102
112
  }
103
113
 
114
+ function isFishConfig(filePath: string): boolean {
115
+ return filePath.endsWith('config.fish') || filePath.includes('/fish/');
116
+ }
117
+
118
+ /** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
119
+ function isOpenCodeConfig(filePath: string): boolean {
120
+ return filePath.includes('opencode');
121
+ }
122
+
104
123
  export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
105
124
  const home = os.homedir();
106
125
  const candidates = [
@@ -126,7 +145,15 @@ export function readTokenFromShellContent(content: string): string | null {
126
145
  return m?.[3] ?? null;
127
146
  }
128
147
 
129
- export function upsertShellToken(content: string, token: string): string {
148
+ export function upsertShellToken(content: string, token: string, filePath?: string): string {
149
+ if (filePath && isFishConfig(filePath)) {
150
+ // Fish shell uses `set -gx` instead of `export`
151
+ const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
152
+ const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
153
+ if (!content.trim()) return `${fishLine}\n`;
154
+ if (fishRe.test(content)) return content.replace(fishRe, fishLine);
155
+ return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
156
+ }
130
157
  const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
131
158
  if (!content.trim()) return `${nextLine}\n`;
132
159
  if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
@@ -147,29 +174,37 @@ function readJsonConfigToken(content: string): string | null {
147
174
  function readTokenFromJsonObject(parsed: any): string | null {
148
175
  const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
149
176
  if (typeof direct === 'string' && direct) return direct;
150
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
177
+ const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
151
178
  if (typeof opencode === 'string' && opencode) return opencode;
152
179
  return null;
153
180
  }
154
181
 
155
- export function upsertJsonConfigToken(content: string, token: string): string {
182
+ export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
156
183
  const parsed = content.trim() ? JSON.parse(content) : {};
157
- if (parsed?.mcpServers) {
158
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
159
- command: 'npx',
160
- args: ['-y', '@playwright/mcp@latest', '--extension'],
161
- };
162
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
163
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
164
- } else {
184
+
185
+ // Determine format: use OpenCode format only if explicitly an opencode config,
186
+ // or if the existing content already uses `mcp` key (not `mcpServers`)
187
+ const useOpenCodeFormat = filePath
188
+ ? isOpenCodeConfig(filePath)
189
+ : (!parsed.mcpServers && parsed.mcp);
190
+
191
+ if (useOpenCodeFormat) {
165
192
  parsed.mcp = parsed.mcp ?? {};
166
193
  parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
167
194
  command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
168
195
  enabled: true,
169
196
  type: 'local',
170
197
  };
171
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
172
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
198
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
199
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
200
+ } else {
201
+ parsed.mcpServers = parsed.mcpServers ?? {};
202
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
203
+ command: 'npx',
204
+ args: ['-y', '@playwright/mcp@latest', '--extension'],
205
+ };
206
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
207
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
173
208
  }
174
209
  return `${JSON.stringify(parsed, null, 2)}\n`;
175
210
  }
@@ -252,12 +287,35 @@ function readConfigStatus(filePath: string): McpConfigStatus {
252
287
  }
253
288
  }
254
289
 
290
+ /**
291
+ * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
292
+ * directories across all browser base paths. Falls back to ['Default'] if none found.
293
+ */
294
+ function enumerateProfiles(baseDirs: string[]): string[] {
295
+ const profiles = new Set<string>();
296
+ for (const base of baseDirs) {
297
+ if (!fileExists(base)) continue;
298
+ try {
299
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
300
+ if (!entry.isDirectory()) continue;
301
+ if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
302
+ profiles.add(entry.name);
303
+ }
304
+ }
305
+ } catch { /* permission denied, etc. */ }
306
+ }
307
+ return profiles.size > 0 ? [...profiles].sort() : ['Default'];
308
+ }
309
+
255
310
  /**
256
311
  * Discover the auth token stored by the Playwright MCP Bridge extension
257
312
  * by scanning Chrome's LevelDB localStorage files directly.
258
313
  *
259
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
260
- * with a pure-Node fallback on Windows.
314
+ * Reads LevelDB .ldb/.log files as raw binary and searches for the
315
+ * extension ID near base64url token values. This works reliably across
316
+ * platforms because LevelDB's internal encoding can split ASCII strings
317
+ * like "auth-token" and the extension ID across byte boundaries, making
318
+ * text-based tools like `strings` + `grep` unreliable.
261
319
  */
262
320
  export function discoverExtensionToken(): string | null {
263
321
  const home = os.homedir();
@@ -285,8 +343,7 @@ export function discoverExtensionToken(): string | null {
285
343
  );
286
344
  }
287
345
 
288
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
289
- // Token is 43 chars of base64url (from 32 random bytes)
346
+ const profiles = enumerateProfiles(bases);
290
347
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
291
348
 
292
349
  for (const base of bases) {
@@ -294,14 +351,6 @@ export function discoverExtensionToken(): string | null {
294
351
  const dir = path.join(base, profile, 'Local Storage', 'leveldb');
295
352
  if (!fileExists(dir)) continue;
296
353
 
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
354
  const token = extractTokenViaBinaryRead(dir, tokenRe);
306
355
  if (token) return token;
307
356
  }
@@ -310,39 +359,20 @@ export function discoverExtensionToken(): string | null {
310
359
  return null;
311
360
  }
312
361
 
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
362
  function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
363
+ // LevelDB fragments strings across byte boundaries, so we can't search
364
+ // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
365
+ // search for a short prefix of the extension ID that reliably appears as
366
+ // contiguous bytes, then scan a window around each match for a base64url
367
+ // token value.
368
+ //
369
+ // Observed LevelDB layout near the auth-token entry:
370
+ // ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
371
+ // <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
372
+ //
373
+ // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
344
374
  const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
345
- const keyBuf = Buffer.from('auth-token');
375
+ const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
346
376
 
347
377
  let files: string[];
348
378
  try {
@@ -351,7 +381,7 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
351
381
  .map(f => path.join(dir, f));
352
382
  } catch { return null; }
353
383
 
354
- // Sort by mtime descending
384
+ // Sort by mtime descending so we find the freshest token first
355
385
  files.sort((a, b) => {
356
386
  try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
357
387
  });
@@ -360,14 +390,30 @@ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null
360
390
  let data: Buffer;
361
391
  try { data = fs.readFileSync(file); } catch { continue; }
362
392
 
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;
393
+ // Quick check: file must contain at least the prefix
394
+ if (data.indexOf(extIdPrefix) === -1) continue;
368
395
 
369
- // Scan for token value after auth-token key
396
+ // Strategy 1: scan after each occurrence of the extension ID prefix
397
+ // for base64url tokens within a 500-byte window
370
398
  let idx = 0;
399
+ while (true) {
400
+ const pos = data.indexOf(extIdPrefix, idx);
401
+ if (pos === -1) break;
402
+
403
+ const scanStart = pos;
404
+ const scanEnd = Math.min(data.length, pos + 500);
405
+ const window = data.subarray(scanStart, scanEnd).toString('latin1');
406
+ const m = window.match(tokenRe);
407
+ if (m && validateBase64urlToken(m[1])) {
408
+ // Make sure this isn't another extension ID that happens to match
409
+ if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
410
+ }
411
+ idx = pos + 1;
412
+ }
413
+
414
+ // Strategy 2 (fallback): original approach using full extension ID + auth-token key
415
+ const keyBuf = Buffer.from('auth-token');
416
+ idx = 0;
371
417
  while (true) {
372
418
  const kp = data.indexOf(keyBuf, idx);
373
419
  if (kp === -1) break;
@@ -393,6 +439,69 @@ function validateBase64urlToken(token: string): boolean {
393
439
  }
394
440
 
395
441
 
442
+ /**
443
+ * Check whether the Playwright MCP Bridge extension is installed in any browser.
444
+ * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
445
+ */
446
+ export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
447
+ const home = os.homedir();
448
+ const platform = os.platform();
449
+ const browserDirs: Array<{ name: string; base: string }> = [];
450
+
451
+ if (platform === 'darwin') {
452
+ browserDirs.push(
453
+ { name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
454
+ { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
455
+ { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
456
+ { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
457
+ );
458
+ } else if (platform === 'linux') {
459
+ browserDirs.push(
460
+ { name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
461
+ { name: 'Chromium', base: path.join(home, '.config', 'chromium') },
462
+ { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
463
+ );
464
+ } else if (platform === 'win32') {
465
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
466
+ browserDirs.push(
467
+ { name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
468
+ { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
469
+ );
470
+ }
471
+
472
+ const profiles = enumerateProfiles(browserDirs.map(d => d.base));
473
+ const foundBrowsers: string[] = [];
474
+
475
+ for (const { name, base } of browserDirs) {
476
+ for (const profile of profiles) {
477
+ const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
478
+ if (fileExists(extDir)) {
479
+ foundBrowsers.push(name);
480
+ break; // one match per browser is enough
481
+ }
482
+ }
483
+ }
484
+
485
+ return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
486
+ }
487
+
488
+ /**
489
+ * Test token connectivity by attempting a real MCP connection.
490
+ * Connects, does the JSON-RPC handshake, and immediately closes.
491
+ */
492
+ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
493
+ const timeout = opts?.timeout ?? 8;
494
+ const start = Date.now();
495
+ try {
496
+ const mcp = new PlaywrightMCP();
497
+ await mcp.connect({ timeout });
498
+ await mcp.close();
499
+ return { ok: true, durationMs: Date.now() - start };
500
+ } catch (err: any) {
501
+ return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
502
+ }
503
+ }
504
+
396
505
  export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
397
506
  const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
398
507
  const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -418,24 +527,38 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
418
527
  const uniqueTokens = [...new Set(allTokens)];
419
528
  const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
420
529
 
530
+ // Check extension installation
531
+ const extInstall = checkExtensionInstalled();
532
+
533
+ // Connectivity test (only when --live)
534
+ let connectivity: ConnectivityResult | undefined;
535
+ if (opts.live) {
536
+ connectivity = await checkTokenConnectivity();
537
+ }
538
+
421
539
  const report: DoctorReport = {
422
540
  cliVersion: opts.cliVersion,
423
541
  envToken,
424
542
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
425
543
  extensionToken,
426
544
  extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
545
+ extensionInstalled: extInstall.installed,
546
+ extensionBrowsers: extInstall.browsers,
427
547
  shellFiles,
428
548
  configs,
429
549
  recommendedToken,
430
550
  recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
551
+ connectivity,
431
552
  warnings: [],
432
553
  issues: [],
433
554
  };
434
555
 
556
+ if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
435
557
  if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
436
558
  if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
437
559
  if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
438
560
  if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
561
+ if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
439
562
  for (const config of configs) {
440
563
  if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
441
564
  }
@@ -456,6 +579,12 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
456
579
  const hasMismatch = uniqueFingerprints.length > 1;
457
580
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
458
581
 
582
+ const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
583
+ const installDetail = report.extensionInstalled
584
+ ? `Extension installed (${report.extensionBrowsers.join(', ')})`
585
+ : 'Extension not installed in any browser';
586
+ lines.push(statusLine(installStatus, installDetail));
587
+
459
588
  const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
460
589
  lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
461
590
 
@@ -489,6 +618,18 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
489
618
  }
490
619
  if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
491
620
  lines.push('');
621
+
622
+ // Connectivity result
623
+ if (report.connectivity) {
624
+ const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
625
+ const connDetail = report.connectivity.ok
626
+ ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
627
+ : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
628
+ lines.push(statusLine(connStatus, connDetail));
629
+ } else {
630
+ lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
631
+ }
632
+
492
633
  lines.push(statusLine(
493
634
  hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
494
635
  `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
@@ -547,7 +688,7 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
547
688
  const written: string[] = [];
548
689
  if (plannedWrites.includes(shellPath)) {
549
690
  const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
550
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
691
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
551
692
  written.push(shellPath);
552
693
  }
553
694
 
@@ -555,7 +696,9 @@ export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOp
555
696
  if (!plannedWrites.includes(config.path)) continue;
556
697
  if (config.parseError) continue;
557
698
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
558
- const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
699
+ const next = config.format === 'toml'
700
+ ? upsertTomlConfigToken(before, token)
701
+ : upsertJsonConfigToken(before, token, config.path);
559
702
  writeFileWithMkdir(config.path, next);
560
703
  written.push(config.path);
561
704
  }
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 });
package/src/setup.ts CHANGED
@@ -11,6 +11,8 @@ import { stdin as input, stdout as output } from 'node:process';
11
11
  import {
12
12
  type DoctorReport,
13
13
  PLAYWRIGHT_TOKEN_ENV,
14
+ checkExtensionInstalled,
15
+ checkTokenConnectivity,
14
16
  discoverExtensionToken,
15
17
  fileExists,
16
18
  getDefaultShellRcPath,
@@ -60,11 +62,24 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
60
62
  }
61
63
 
62
64
  if (!token) {
63
- console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
64
- console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
65
+ // Give precise diagnosis of why token scan failed
66
+ const extInstall = checkExtensionInstalled();
67
+
68
+ console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
69
+ if (!extInstall.installed) {
70
+ console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
71
+ console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
72
+ console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
73
+ } else {
74
+ console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
75
+ console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
76
+ console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
77
+ }
78
+ console.log();
79
+ console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
65
80
  console.log();
66
81
  const rl = createInterface({ input, output });
67
- const answer = await rl.question(' Token: ');
82
+ const answer = await rl.question(' Token (press Enter to abort): ');
68
83
  rl.close();
69
84
  token = answer.trim();
70
85
  if (!token) {
@@ -129,7 +144,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
129
144
  if (sel.startsWith('shell:')) {
130
145
  const p = sel.slice('shell:'.length);
131
146
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
132
- writeFileWithMkdir(p, upsertShellToken(before, token));
147
+ writeFileWithMkdir(p, upsertShellToken(before, token, p));
133
148
  written.push(p);
134
149
  wroteShell = true;
135
150
  } else if (sel.startsWith('config:')) {
@@ -138,7 +153,7 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
138
153
  if (config && config.parseError) continue;
139
154
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
140
155
  const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
141
- const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
156
+ const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
142
157
  writeFileWithMkdir(p, next);
143
158
  written.push(p);
144
159
  }
@@ -161,6 +176,21 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
161
176
  console.log(chalk.yellow(' No files were changed.'));
162
177
  }
163
178
  console.log();
179
+
180
+ // Step 7: Auto-verify browser connectivity
181
+ console.log(chalk.dim(' Verifying browser connectivity...'));
182
+ try {
183
+ const result = await checkTokenConnectivity({ timeout: 5 });
184
+ if (result.ok) {
185
+ console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
186
+ } else {
187
+ console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
188
+ console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
189
+ }
190
+ } catch {
191
+ console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
192
+ }
193
+ console.log();
164
194
  }
165
195
 
166
196
  function padRight(s: string, n: number): string {