@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.
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';
@@ -61,6 +60,13 @@ export function getDefaultShellRcPath() {
61
60
  return path.join(os.homedir(), '.config', 'fish', 'config.fish');
62
61
  return path.join(os.homedir(), '.zshrc');
63
62
  }
63
+ function isFishConfig(filePath) {
64
+ return filePath.endsWith('config.fish') || filePath.includes('/fish/');
65
+ }
66
+ /** Detect if a JSON config file uses OpenCode's `mcp` format vs standard `mcpServers` */
67
+ function isOpenCodeConfig(filePath) {
68
+ return filePath.includes('opencode');
69
+ }
64
70
  export function getDefaultMcpConfigPaths(cwd = process.cwd()) {
65
71
  const home = os.homedir();
66
72
  const candidates = [
@@ -84,7 +90,17 @@ export function readTokenFromShellContent(content) {
84
90
  const m = content.match(TOKEN_LINE_RE);
85
91
  return m?.[3] ?? null;
86
92
  }
87
- export function upsertShellToken(content, token) {
93
+ export function upsertShellToken(content, token, filePath) {
94
+ if (filePath && isFishConfig(filePath)) {
95
+ // Fish shell uses `set -gx` instead of `export`
96
+ const fishLine = `set -gx ${PLAYWRIGHT_TOKEN_ENV} "${token}"`;
97
+ const fishRe = /^\s*set\s+(-gx\s+)?PLAYWRIGHT_MCP_EXTENSION_TOKEN\s+.*/m;
98
+ if (!content.trim())
99
+ return `${fishLine}\n`;
100
+ if (fishRe.test(content))
101
+ return content.replace(fishRe, fishLine);
102
+ return `${content.replace(/\s*$/, '')}\n${fishLine}\n`;
103
+ }
88
104
  const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
89
105
  if (!content.trim())
90
106
  return `${nextLine}\n`;
@@ -105,30 +121,36 @@ function readTokenFromJsonObject(parsed) {
105
121
  const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
106
122
  if (typeof direct === 'string' && direct)
107
123
  return direct;
108
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
124
+ const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
109
125
  if (typeof opencode === 'string' && opencode)
110
126
  return opencode;
111
127
  return null;
112
128
  }
113
- export function upsertJsonConfigToken(content, token) {
129
+ export function upsertJsonConfigToken(content, token, filePath) {
114
130
  const parsed = content.trim() ? JSON.parse(content) : {};
115
- if (parsed?.mcpServers) {
116
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
117
- command: 'npx',
118
- args: ['-y', '@playwright/mcp@latest', '--extension'],
119
- };
120
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
121
- parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
122
- }
123
- else {
131
+ // Determine format: use OpenCode format only if explicitly an opencode config,
132
+ // or if the existing content already uses `mcp` key (not `mcpServers`)
133
+ const useOpenCodeFormat = filePath
134
+ ? isOpenCodeConfig(filePath)
135
+ : (!parsed.mcpServers && parsed.mcp);
136
+ if (useOpenCodeFormat) {
124
137
  parsed.mcp = parsed.mcp ?? {};
125
138
  parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
126
139
  command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
127
140
  enabled: true,
128
141
  type: 'local',
129
142
  };
130
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
131
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
143
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
144
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
145
+ }
146
+ else {
147
+ parsed.mcpServers = parsed.mcpServers ?? {};
148
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? {
149
+ command: 'npx',
150
+ args: ['-y', '@playwright/mcp@latest', '--extension'],
151
+ };
152
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {};
153
+ parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
132
154
  }
133
155
  return `${JSON.stringify(parsed, null, 2)}\n`;
134
156
  }
@@ -207,12 +229,37 @@ function readConfigStatus(filePath) {
207
229
  };
208
230
  }
209
231
  }
232
+ /**
233
+ * Dynamically enumerate Chrome profiles by scanning for 'Default' and 'Profile *'
234
+ * directories across all browser base paths. Falls back to ['Default'] if none found.
235
+ */
236
+ function enumerateProfiles(baseDirs) {
237
+ const profiles = new Set();
238
+ for (const base of baseDirs) {
239
+ if (!fileExists(base))
240
+ continue;
241
+ try {
242
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
243
+ if (!entry.isDirectory())
244
+ continue;
245
+ if (entry.name === 'Default' || /^Profile \d+$/.test(entry.name)) {
246
+ profiles.add(entry.name);
247
+ }
248
+ }
249
+ }
250
+ catch { /* permission denied, etc. */ }
251
+ }
252
+ return profiles.size > 0 ? [...profiles].sort() : ['Default'];
253
+ }
210
254
  /**
211
255
  * Discover the auth token stored by the Playwright MCP Bridge extension
212
256
  * by scanning Chrome's LevelDB localStorage files directly.
213
257
  *
214
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
215
- * with a pure-Node fallback on Windows.
258
+ * Reads LevelDB .ldb/.log files as raw binary and searches for the
259
+ * extension ID near base64url token values. This works reliably across
260
+ * platforms because LevelDB's internal encoding can split ASCII strings
261
+ * like "auth-token" and the extension ID across byte boundaries, making
262
+ * text-based tools like `strings` + `grep` unreliable.
216
263
  */
217
264
  export function discoverExtensionToken() {
218
265
  const home = os.homedir();
@@ -228,22 +275,13 @@ export function discoverExtensionToken() {
228
275
  const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
229
276
  bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
230
277
  }
231
- const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
232
- // Token is 43 chars of base64url (from 32 random bytes)
278
+ const profiles = enumerateProfiles(bases);
233
279
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
234
280
  for (const base of bases) {
235
281
  for (const profile of profiles) {
236
282
  const dir = path.join(base, profile, 'Local Storage', 'leveldb');
237
283
  if (!fileExists(dir))
238
284
  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
285
  const token = extractTokenViaBinaryRead(dir, tokenRe);
248
286
  if (token)
249
287
  return token;
@@ -251,39 +289,20 @@ export function discoverExtensionToken() {
251
289
  }
252
290
  return null;
253
291
  }
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
292
  function extractTokenViaBinaryRead(dir, tokenRe) {
293
+ // LevelDB fragments strings across byte boundaries, so we can't search
294
+ // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
295
+ // search for a short prefix of the extension ID that reliably appears as
296
+ // contiguous bytes, then scan a window around each match for a base64url
297
+ // token value.
298
+ //
299
+ // Observed LevelDB layout near the auth-token entry:
300
+ // ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
301
+ // <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
302
+ //
303
+ // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
285
304
  const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
286
- const keyBuf = Buffer.from('auth-token');
305
+ const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
287
306
  let files;
288
307
  try {
289
308
  files = fs.readdirSync(dir)
@@ -293,7 +312,7 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
293
312
  catch {
294
313
  return null;
295
314
  }
296
- // Sort by mtime descending
315
+ // Sort by mtime descending so we find the freshest token first
297
316
  files.sort((a, b) => {
298
317
  try {
299
318
  return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
@@ -310,15 +329,30 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
310
329
  catch {
311
330
  continue;
312
331
  }
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)
332
+ // Quick check: file must contain at least the prefix
333
+ if (data.indexOf(extIdPrefix) === -1)
319
334
  continue;
320
- // Scan for token value after auth-token key
335
+ // Strategy 1: scan after each occurrence of the extension ID prefix
336
+ // for base64url tokens within a 500-byte window
321
337
  let idx = 0;
338
+ while (true) {
339
+ const pos = data.indexOf(extIdPrefix, idx);
340
+ if (pos === -1)
341
+ break;
342
+ const scanStart = pos;
343
+ const scanEnd = Math.min(data.length, pos + 500);
344
+ const window = data.subarray(scanStart, scanEnd).toString('latin1');
345
+ const m = window.match(tokenRe);
346
+ if (m && validateBase64urlToken(m[1])) {
347
+ // Make sure this isn't another extension ID that happens to match
348
+ if (m[1] !== PLAYWRIGHT_EXTENSION_ID)
349
+ return m[1];
350
+ }
351
+ idx = pos + 1;
352
+ }
353
+ // Strategy 2 (fallback): original approach using full extension ID + auth-token key
354
+ const keyBuf = Buffer.from('auth-token');
355
+ idx = 0;
322
356
  while (true) {
323
357
  const kp = data.indexOf(keyBuf, idx);
324
358
  if (kp === -1)
@@ -345,6 +379,54 @@ function validateBase64urlToken(token) {
345
379
  return false;
346
380
  }
347
381
  }
382
+ /**
383
+ * Check whether the Playwright MCP Bridge extension is installed in any browser.
384
+ * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
385
+ */
386
+ export function checkExtensionInstalled() {
387
+ const home = os.homedir();
388
+ const platform = os.platform();
389
+ const browserDirs = [];
390
+ if (platform === 'darwin') {
391
+ 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') });
392
+ }
393
+ else if (platform === 'linux') {
394
+ 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') });
395
+ }
396
+ else if (platform === 'win32') {
397
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
398
+ browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
399
+ }
400
+ const profiles = enumerateProfiles(browserDirs.map(d => d.base));
401
+ const foundBrowsers = [];
402
+ for (const { name, base } of browserDirs) {
403
+ for (const profile of profiles) {
404
+ const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
405
+ if (fileExists(extDir)) {
406
+ foundBrowsers.push(name);
407
+ break; // one match per browser is enough
408
+ }
409
+ }
410
+ }
411
+ return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
412
+ }
413
+ /**
414
+ * Test token connectivity by attempting a real MCP connection.
415
+ * Connects, does the JSON-RPC handshake, and immediately closes.
416
+ */
417
+ export async function checkTokenConnectivity(opts) {
418
+ const timeout = opts?.timeout ?? 8;
419
+ const start = Date.now();
420
+ try {
421
+ const mcp = new PlaywrightMCP();
422
+ await mcp.connect({ timeout });
423
+ await mcp.close();
424
+ return { ok: true, durationMs: Date.now() - start };
425
+ }
426
+ catch (err) {
427
+ return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
428
+ }
429
+ }
348
430
  export async function runBrowserDoctor(opts = {}) {
349
431
  const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
350
432
  const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -368,19 +450,31 @@ export async function runBrowserDoctor(opts = {}) {
368
450
  ].filter((v) => !!v);
369
451
  const uniqueTokens = [...new Set(allTokens)];
370
452
  const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
453
+ // Check extension installation
454
+ const extInstall = checkExtensionInstalled();
455
+ // Connectivity test (only when --live)
456
+ let connectivity;
457
+ if (opts.live) {
458
+ connectivity = await checkTokenConnectivity();
459
+ }
371
460
  const report = {
372
461
  cliVersion: opts.cliVersion,
373
462
  envToken,
374
463
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
375
464
  extensionToken,
376
465
  extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
466
+ extensionInstalled: extInstall.installed,
467
+ extensionBrowsers: extInstall.browsers,
377
468
  shellFiles,
378
469
  configs,
379
470
  recommendedToken,
380
471
  recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
472
+ connectivity,
381
473
  warnings: [],
382
474
  issues: [],
383
475
  };
476
+ if (!extInstall.installed)
477
+ report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
384
478
  if (!envToken)
385
479
  report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
386
480
  if (!shellFiles.some(s => s.token))
@@ -389,6 +483,8 @@ export async function runBrowserDoctor(opts = {}) {
389
483
  report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
390
484
  if (uniqueTokens.length > 1)
391
485
  report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
486
+ if (connectivity && !connectivity.ok)
487
+ report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
392
488
  for (const config of configs) {
393
489
  if (config.parseError)
394
490
  report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
@@ -408,6 +504,11 @@ export function renderBrowserDoctorReport(report) {
408
504
  const uniqueFingerprints = [...new Set(tokenFingerprints)];
409
505
  const hasMismatch = uniqueFingerprints.length > 1;
410
506
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
507
+ const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
508
+ const installDetail = report.extensionInstalled
509
+ ? `Extension installed (${report.extensionBrowsers.join(', ')})`
510
+ : 'Extension not installed in any browser';
511
+ lines.push(statusLine(installStatus, installDetail));
411
512
  const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
412
513
  lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
413
514
  const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
@@ -441,6 +542,17 @@ export function renderBrowserDoctorReport(report) {
441
542
  if (missingConfigCount > 0)
442
543
  lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
443
544
  lines.push('');
545
+ // Connectivity result
546
+ if (report.connectivity) {
547
+ const connStatus = report.connectivity.ok ? 'OK' : 'WARN';
548
+ const connDetail = report.connectivity.ok
549
+ ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
550
+ : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
551
+ lines.push(statusLine(connStatus, connDetail));
552
+ }
553
+ else {
554
+ lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
555
+ }
444
556
  lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
445
557
  if (report.issues.length) {
446
558
  lines.push('', chalk.yellow('Issues:'));
@@ -497,7 +609,7 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
497
609
  const written = [];
498
610
  if (plannedWrites.includes(shellPath)) {
499
611
  const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
500
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
612
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
501
613
  written.push(shellPath);
502
614
  }
503
615
  for (const config of report.configs) {
@@ -506,7 +618,9 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
506
618
  if (config.parseError)
507
619
  continue;
508
620
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
509
- const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
621
+ const next = config.format === 'toml'
622
+ ? upsertTomlConfigToken(before, token)
623
+ : upsertJsonConfigToken(before, token, config.path);
510
624
  writeFileWithMkdir(config.path, next);
511
625
  written.push(config.path);
512
626
  }
@@ -66,7 +66,48 @@ 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
+ });
71
+ it('creates standard mcpServers format for empty file (not OpenCode)', () => {
72
+ const next = upsertJsonConfigToken('', 'abc123');
73
+ const parsed = JSON.parse(next);
74
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
75
+ expect(parsed.mcp).toBeUndefined();
76
+ });
77
+ it('creates OpenCode format when filePath contains opencode', () => {
78
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.config/opencode/opencode.json');
79
+ const parsed = JSON.parse(next);
80
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
81
+ expect(parsed.mcpServers).toBeUndefined();
82
+ });
83
+ it('creates standard format when filePath is claude.json', () => {
84
+ const next = upsertJsonConfigToken('', 'abc123', '/home/user/.claude.json');
85
+ const parsed = JSON.parse(next);
86
+ expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
87
+ });
88
+ });
89
+ describe('fish shell support', () => {
90
+ it('generates fish set -gx syntax for fish config path', () => {
91
+ const next = upsertShellToken('', 'abc123', '/home/user/.config/fish/config.fish');
92
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
93
+ expect(next).not.toContain('export');
94
+ });
95
+ it('replaces existing fish set line', () => {
96
+ const content = 'set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "old"\n';
97
+ const next = upsertShellToken(content, 'new', '/home/user/.config/fish/config.fish');
98
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "new"');
99
+ expect(next).not.toContain('"old"');
100
+ });
101
+ it('appends fish syntax to existing fish config', () => {
102
+ const content = 'set -gx PATH /usr/bin\n';
103
+ const next = upsertShellToken(content, 'abc123', '/home/user/.config/fish/config.fish');
104
+ expect(next).toContain('set -gx PLAYWRIGHT_MCP_EXTENSION_TOKEN "abc123"');
105
+ expect(next).toContain('set -gx PATH /usr/bin');
106
+ });
107
+ it('uses export syntax for zshrc even with filePath', () => {
108
+ const next = upsertShellToken('', 'abc123', '/home/user/.zshrc');
109
+ expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"');
110
+ expect(next).not.toContain('set -gx');
70
111
  });
71
112
  });
72
113
  describe('doctor report rendering', () => {
@@ -77,6 +118,8 @@ describe('doctor report rendering', () => {
77
118
  envFingerprint: 'fp1',
78
119
  extensionToken: 'abc123',
79
120
  extensionFingerprint: 'fp1',
121
+ extensionInstalled: true,
122
+ extensionBrowsers: ['Chrome'],
80
123
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
81
124
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
82
125
  recommendedToken: 'abc123',
@@ -84,6 +127,7 @@ describe('doctor report rendering', () => {
84
127
  warnings: [],
85
128
  issues: [],
86
129
  }));
130
+ expect(text).toContain('[OK] Extension installed (Chrome)');
87
131
  expect(text).toContain('[OK] Environment token: configured (fp1)');
88
132
  expect(text).toContain('[OK] /tmp/mcp.json');
89
133
  expect(text).toContain('configured (fp1)');
@@ -94,6 +138,8 @@ describe('doctor report rendering', () => {
94
138
  envFingerprint: 'fp1',
95
139
  extensionToken: null,
96
140
  extensionFingerprint: null,
141
+ extensionInstalled: false,
142
+ extensionBrowsers: [],
97
143
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
98
144
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
99
145
  recommendedToken: 'abc123',
@@ -101,9 +147,45 @@ describe('doctor report rendering', () => {
101
147
  warnings: [],
102
148
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
103
149
  }));
150
+ expect(text).toContain('[MISSING] Extension not installed in any browser');
104
151
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
105
152
  expect(text).toContain('[MISMATCH] /tmp/.zshrc');
106
153
  expect(text).toContain('configured (fp2)');
107
154
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
108
155
  });
156
+ it('renders connectivity OK when live test succeeds', () => {
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
+ connectivity: { ok: true, durationMs: 1234 },
169
+ warnings: [],
170
+ issues: [],
171
+ }));
172
+ expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
173
+ });
174
+ it('renders connectivity WARN when not tested', () => {
175
+ const text = strip(renderBrowserDoctorReport({
176
+ envToken: 'abc123',
177
+ envFingerprint: 'fp1',
178
+ extensionToken: 'abc123',
179
+ extensionFingerprint: 'fp1',
180
+ extensionInstalled: true,
181
+ extensionBrowsers: ['Chrome'],
182
+ shellFiles: [],
183
+ configs: [],
184
+ recommendedToken: 'abc123',
185
+ recommendedFingerprint: 'fp1',
186
+ warnings: [],
187
+ issues: [],
188
+ }));
189
+ expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
190
+ });
109
191
  });
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/dist/setup.js CHANGED
@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
8
8
  import chalk from 'chalk';
9
9
  import { createInterface } from 'node:readline/promises';
10
10
  import { stdin as input, stdout as output } from 'node:process';
11
- import { PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
11
+ import { PLAYWRIGHT_TOKEN_ENV, checkExtensionInstalled, checkTokenConnectivity, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
12
12
  import { getTokenFingerprint } from './browser.js';
13
13
  import { checkboxPrompt } from './tui.js';
14
14
  export async function runSetup(opts = {}) {
@@ -45,11 +45,24 @@ export async function runSetup(opts = {}) {
45
45
  chalk.dim(`(${getTokenFingerprint(token)})`));
46
46
  }
47
47
  if (!token) {
48
- console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
49
- console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
48
+ // Give precise diagnosis of why token scan failed
49
+ const extInstall = checkExtensionInstalled();
50
+ console.log(` ${chalk.red('✗')} Browser token scan failed\n`);
51
+ if (!extInstall.installed) {
52
+ console.log(chalk.dim(' Cause: Playwright MCP Bridge extension is not installed'));
53
+ console.log(chalk.dim(' Fix: Install from https://chromewebstore.google.com/detail/'));
54
+ console.log(chalk.dim(' playwright-mcp-bridge/mmlmfjhmonkocbjadbfplnigmagldckm'));
55
+ }
56
+ else {
57
+ console.log(chalk.dim(` Cause: Extension is installed (${extInstall.browsers.join(', ')}) but token not found in LevelDB`));
58
+ console.log(chalk.dim(' Fix: 1) Open the extension popup and verify the token is generated'));
59
+ console.log(chalk.dim(' 2) Close Chrome completely, then re-run setup'));
60
+ }
61
+ console.log();
62
+ console.log(` You can enter the token manually, or fix the above and re-run ${chalk.bold('opencli setup')}.`);
50
63
  console.log();
51
64
  const rl = createInterface({ input, output });
52
- const answer = await rl.question(' Token: ');
65
+ const answer = await rl.question(' Token (press Enter to abort): ');
53
66
  rl.close();
54
67
  token = answer.trim();
55
68
  if (!token) {
@@ -105,7 +118,7 @@ export async function runSetup(opts = {}) {
105
118
  if (sel.startsWith('shell:')) {
106
119
  const p = sel.slice('shell:'.length);
107
120
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
108
- writeFileWithMkdir(p, upsertShellToken(before, token));
121
+ writeFileWithMkdir(p, upsertShellToken(before, token, p));
109
122
  written.push(p);
110
123
  wroteShell = true;
111
124
  }
@@ -116,7 +129,7 @@ export async function runSetup(opts = {}) {
116
129
  continue;
117
130
  const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
118
131
  const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
119
- const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
132
+ const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token, p);
120
133
  writeFileWithMkdir(p, next);
121
134
  written.push(p);
122
135
  }
@@ -138,6 +151,22 @@ export async function runSetup(opts = {}) {
138
151
  console.log(chalk.yellow(' No files were changed.'));
139
152
  }
140
153
  console.log();
154
+ // Step 7: Auto-verify browser connectivity
155
+ console.log(chalk.dim(' Verifying browser connectivity...'));
156
+ try {
157
+ const result = await checkTokenConnectivity({ timeout: 5 });
158
+ if (result.ok) {
159
+ console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
160
+ }
161
+ else {
162
+ console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
163
+ console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
164
+ }
165
+ }
166
+ catch {
167
+ console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
168
+ }
169
+ console.log();
141
170
  }
142
171
  function padRight(s, n) {
143
172
  const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },