@jackwener/opencli 0.7.3 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # OpenCLI
2
2
 
3
3
  > **Make any website your CLI.**
4
- > Zero risk · Reuse Chrome login · AI-powered discovery
4
+ > Zero risk · Reuse Chrome login · AI-powered discovery · 80+ commands · 19 sites
5
5
 
6
6
  [中文文档](./README.zh-CN.md)
7
7
 
@@ -9,7 +9,7 @@
9
9
  [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org)
10
10
  [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE)
11
11
 
12
- A CLI tool that turns **any website** into a command-line interface — bilibili, zhihu, xiaohongshu, twitter, reddit, and many more — powered by browser session reuse and AI-native discovery.
12
+ A CLI tool that turns **any website** into a command-line interface — Bilibili, Zhihu, 小红书, Twitter/X, Reddit, YouTube, and [many more](#built-in-commands) — powered by browser session reuse and AI-native discovery.
13
13
 
14
14
  ---
15
15
 
@@ -32,8 +32,9 @@ A CLI tool that turns **any website** into a command-line interface — bilibili
32
32
 
33
33
  - **Account-safe** — Reuses Chrome's logged-in state; your credentials never leave the browser.
34
34
  - **AI Agent ready** — `explore` discovers APIs, `synthesize` generates adapters, `cascade` finds auth strategies.
35
+ - **Self-healing setup** — `opencli setup` auto-discovers tokens; `opencli doctor` diagnoses config across 10+ tools; `--fix` repairs them all.
35
36
  - **Dynamic Loader** — Simply drop `.ts` or `.yaml` adapters into the `clis/` folder for auto-registration.
36
- - **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime typescript injections.
37
+ - **Dual-Engine Architecture** — Supports both YAML declarative data pipelines and robust browser runtime TypeScript injections.
37
38
 
38
39
  ## Prerequisites
39
40
 
@@ -85,10 +86,12 @@ export PLAYWRIGHT_MCP_EXTENSION_TOKEN="<your-token-here>"
85
86
 
86
87
  </details>
87
88
 
88
- Verify with `opencli doctor` — shows colored status for all config locations:
89
+ Verify with `opencli doctor` — shows colored status for extension install, token consistency, and all config locations:
89
90
 
90
91
  ```bash
91
- opencli doctor
92
+ opencli doctor # Token & config diagnosis
93
+ opencli doctor --live # Also test live browser connectivity
94
+ opencli doctor --fix -y # Auto-fix all mismatched configs
92
95
  ```
93
96
 
94
97
  ## Quick Start
@@ -130,27 +133,29 @@ npm install -g @jackwener/opencli@latest
130
133
 
131
134
  ## Built-in Commands
132
135
 
133
- | Site | Commands | Mode |
134
- |------|----------|------|
135
- | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 🔐 Browser |
136
- | **zhihu** | `hot` `search` `question` | 🔐 Browser |
137
- | **xiaohongshu** | `search` `notifications` `feed` `user` | 🔐 Browser |
138
- | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser |
139
- | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 🔐 Browser |
140
- | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
141
- | **weibo** | `hot` | 🔐 Browser |
142
- | **boss** | `search` `detail` | 🔐 Browser |
143
- | **coupang** | `search` `add-to-cart` | 🔐 Browser |
144
- | **youtube** | `search` `video` `transcript` | 🔐 Browser |
145
- | **linkedin** | `search` | 🔐 Browser |
146
- | **yahoo-finance** | `quote` | 🔐 Browser |
147
- | **reuters** | `search` | 🔐 Browser |
148
- | **smzdm** | `search` | 🔐 Browser |
149
- | **ctrip** | `search` | 🔐 Browser |
150
- | **github** | `search` | 🌐 Public |
151
- | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 🌐 Public / 🔐 Browser |
152
- | **hackernews** | `top` | 🌐 Public |
153
- | **bbc** | `news` | 🌐 Public |
136
+ **19 sites · 80+ commands** run `opencli list` for the live registry.
137
+
138
+ | Site | Commands | Count | Mode |
139
+ |------|----------|:-----:|------|
140
+ | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser |
141
+ | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser |
142
+ | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
143
+ | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
144
+ | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser |
145
+ | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser |
146
+ | **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser |
147
+ | **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser |
148
+ | **boss** | `search` `detail` | 2 | 🔐 Browser |
149
+ | **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
150
+ | **bbc** | `news` | 1 | 🌐 Public |
151
+ | **ctrip** | `search` | 1 | 🔐 Browser |
152
+ | **github** | `search` | 1 | 🌐 Public |
153
+ | **hackernews** | `top` | 1 | 🌐 Public |
154
+ | **linkedin** | `search` | 1 | 🔐 Browser |
155
+ | **reuters** | `search` | 1 | 🔐 Browser |
156
+ | **smzdm** | `search` | 1 | 🔐 Browser |
157
+ | **weibo** | `hot` | 1 | 🔐 Browser |
158
+ | **yahoo-finance** | `quote` | 1 | 🔐 Browser |
154
159
 
155
160
  ## Output Formats
156
161
 
@@ -195,7 +200,7 @@ Explore outputs to `.opencli/explore/<site>/` (manifest.json, endpoints.json, ca
195
200
 
196
201
  See **[TESTING.md](./TESTING.md)** for the full testing guide, including:
197
202
 
198
- - Current test coverage (unit + ~52 E2E tests across all 18 sites)
203
+ - Current test coverage (unit + E2E tests across 19 sites)
199
204
  - How to run tests locally
200
205
  - How to add tests when creating new adapters
201
206
  - CI/CD pipeline with sharding
package/SKILL.md CHANGED
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: opencli
3
- description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login."
4
- version: 0.7.0
3
+ description: "OpenCLI — Make any website your CLI. Zero risk, AI-powered, reuse Chrome login. 80+ commands across 19 sites."
4
+ version: 0.7.3
5
5
  author: jackwener
6
- tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, AI, agent]
6
+ tags: [cli, browser, web, mcp, playwright, bilibili, zhihu, twitter, github, v2ex, hackernews, reddit, xiaohongshu, xueqiu, youtube, boss, coupang, AI, agent]
7
7
  ---
8
8
 
9
9
  # OpenCLI
@@ -68,6 +68,7 @@ opencli zhihu question --id 34816524 # 问题详情和回答
68
68
  opencli xiaohongshu search --keyword "美食" # 搜索笔记
69
69
  opencli xiaohongshu notifications # 通知(mentions/likes/connections)
70
70
  opencli xiaohongshu feed --limit 10 # 推荐 Feed
71
+ opencli xiaohongshu me # 我的信息
71
72
  opencli xiaohongshu user --uid xxx # 用户主页
72
73
 
73
74
  # 雪球 Xueqiu (browser)
@@ -131,6 +132,7 @@ opencli weibo hot --limit 10 # 微博热搜
131
132
 
132
133
  # BOSS直聘 (browser)
133
134
  opencli boss search --query "AI agent" # 搜索职位
135
+ opencli boss detail --securityId xxx # 职位详情
134
136
 
135
137
  # YouTube (browser)
136
138
  opencli youtube search --query "rust" # 搜索视频
@@ -160,7 +162,8 @@ opencli list -f yaml # YAML output
160
162
  opencli validate # Validate all CLI definitions
161
163
  opencli validate bilibili # Validate specific site
162
164
  opencli setup # Interactive token setup (auto-discover + TUI checkbox)
163
- opencli doctor # Diagnose token config across all tools
165
+ opencli doctor # Diagnose token & extension config across all tools
166
+ opencli doctor --live # Also test live browser connectivity
164
167
  opencli doctor --fix -y # Auto-fix all config files (non-interactive)
165
168
  ```
166
169
 
package/dist/doctor.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const PLAYWRIGHT_TOKEN_ENV = "PLAYWRIGHT_MCP_EXTENSION_TOKEN";
2
2
  export type DoctorOptions = {
3
3
  fix?: boolean;
4
4
  yes?: boolean;
5
+ live?: boolean;
5
6
  shellRc?: string;
6
7
  configPaths?: string[];
7
8
  token?: string;
@@ -23,16 +24,24 @@ export type McpConfigStatus = {
23
24
  writable: boolean;
24
25
  parseError?: string;
25
26
  };
27
+ export type ConnectivityResult = {
28
+ ok: boolean;
29
+ error?: string;
30
+ durationMs: number;
31
+ };
26
32
  export type DoctorReport = {
27
33
  cliVersion?: string;
28
34
  envToken: string | null;
29
35
  envFingerprint: string | null;
30
36
  extensionToken: string | null;
31
37
  extensionFingerprint: string | null;
38
+ extensionInstalled: boolean;
39
+ extensionBrowsers: string[];
32
40
  shellFiles: ShellFileStatus[];
33
41
  configs: McpConfigStatus[];
34
42
  recommendedToken: string | null;
35
43
  recommendedFingerprint: string | null;
44
+ connectivity?: ConnectivityResult;
36
45
  warnings: string[];
37
46
  issues: string[];
38
47
  };
@@ -50,10 +59,28 @@ export declare function fileExists(filePath: string): boolean;
50
59
  * Discover the auth token stored by the Playwright MCP Bridge extension
51
60
  * by scanning Chrome's LevelDB localStorage files directly.
52
61
  *
53
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
54
- * with a pure-Node fallback on Windows.
62
+ * Reads LevelDB .ldb/.log files as raw binary and searches for the
63
+ * extension ID near base64url token values. This works reliably across
64
+ * platforms because LevelDB's internal encoding can split ASCII strings
65
+ * like "auth-token" and the extension ID across byte boundaries, making
66
+ * text-based tools like `strings` + `grep` unreliable.
55
67
  */
56
68
  export declare function discoverExtensionToken(): string | null;
69
+ /**
70
+ * Check whether the Playwright MCP Bridge extension is installed in any browser.
71
+ * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
72
+ */
73
+ export declare function checkExtensionInstalled(): {
74
+ installed: boolean;
75
+ browsers: string[];
76
+ };
77
+ /**
78
+ * Test token connectivity by attempting a real MCP connection.
79
+ * Connects, does the JSON-RPC handshake, and immediately closes.
80
+ */
81
+ export declare function checkTokenConnectivity(opts?: {
82
+ timeout?: number;
83
+ }): Promise<ConnectivityResult>;
57
84
  export declare function runBrowserDoctor(opts?: DoctorOptions): Promise<DoctorReport>;
58
85
  export declare function renderBrowserDoctorReport(report: DoctorReport): string;
59
86
  export declare function writeFileWithMkdir(filePath: string, content: string): void;
package/dist/doctor.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
- import { execSync } from 'node:child_process';
5
4
  import { createInterface } from 'node:readline/promises';
6
5
  import { stdin as input, stdout as output } from 'node:process';
7
6
  import chalk from 'chalk';
8
- import { getTokenFingerprint } from './browser.js';
7
+ import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
9
8
  const PLAYWRIGHT_SERVER_NAME = 'playwright';
10
9
  export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
11
10
  const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
@@ -105,7 +104,7 @@ function readTokenFromJsonObject(parsed) {
105
104
  const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
106
105
  if (typeof direct === 'string' && direct)
107
106
  return direct;
108
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
107
+ const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
109
108
  if (typeof opencode === 'string' && opencode)
110
109
  return opencode;
111
110
  return null;
@@ -127,8 +126,8 @@ export function upsertJsonConfigToken(content, token) {
127
126
  enabled: true,
128
127
  type: 'local',
129
128
  };
130
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {};
131
- parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token;
129
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment = parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment ?? {};
130
+ parsed.mcp[PLAYWRIGHT_SERVER_NAME].environment[PLAYWRIGHT_TOKEN_ENV] = token;
132
131
  }
133
132
  return `${JSON.stringify(parsed, null, 2)}\n`;
134
133
  }
@@ -211,8 +210,11 @@ function readConfigStatus(filePath) {
211
210
  * Discover the auth token stored by the Playwright MCP Bridge extension
212
211
  * by scanning Chrome's LevelDB localStorage files directly.
213
212
  *
214
- * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
215
- * with a pure-Node fallback on Windows.
213
+ * Reads LevelDB .ldb/.log files as raw binary and searches for the
214
+ * extension ID near base64url token values. This works reliably across
215
+ * platforms because LevelDB's internal encoding can split ASCII strings
216
+ * like "auth-token" and the extension ID across byte boundaries, making
217
+ * text-based tools like `strings` + `grep` unreliable.
216
218
  */
217
219
  export function discoverExtensionToken() {
218
220
  const home = os.homedir();
@@ -229,21 +231,12 @@ export function discoverExtensionToken() {
229
231
  bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
230
232
  }
231
233
  const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
232
- // Token is 43 chars of base64url (from 32 random bytes)
233
234
  const tokenRe = /([A-Za-z0-9_-]{40,50})/;
234
235
  for (const base of bases) {
235
236
  for (const profile of profiles) {
236
237
  const dir = path.join(base, profile, 'Local Storage', 'leveldb');
237
238
  if (!fileExists(dir))
238
239
  continue;
239
- // Fast path: use strings + grep to find candidate files and extract token
240
- if (platform !== 'win32') {
241
- const token = extractTokenViaStrings(dir, tokenRe);
242
- if (token)
243
- return token;
244
- continue;
245
- }
246
- // Slow path (Windows): read binary files directly
247
240
  const token = extractTokenViaBinaryRead(dir, tokenRe);
248
241
  if (token)
249
242
  return token;
@@ -251,39 +244,20 @@ export function discoverExtensionToken() {
251
244
  }
252
245
  return null;
253
246
  }
254
- function extractTokenViaStrings(dir, tokenRe) {
255
- try {
256
- // Single shell pipeline: for each LevelDB file, extract strings, find lines
257
- // after the extension ID, and filter for base64url token pattern.
258
- //
259
- // LevelDB `strings` output for the extension's auth-token entry:
260
- // auth-token ← key name
261
- // 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
262
- // hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
263
- //
264
- // We get the line immediately after any EXTENSION_ID mention and check
265
- // if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
266
- const shellDir = dir.replace(/'/g, "'\\''");
267
- const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
268
- `[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
269
- `grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
270
- `grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
271
- `grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
272
- `done 2>/dev/null`;
273
- const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
274
- // Take the first non-empty line
275
- for (const line of result.split('\n')) {
276
- const token = line.trim();
277
- if (token && validateBase64urlToken(token))
278
- return token;
279
- }
280
- }
281
- catch { }
282
- return null;
283
- }
284
247
  function extractTokenViaBinaryRead(dir, tokenRe) {
248
+ // LevelDB fragments strings across byte boundaries, so we can't search
249
+ // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
250
+ // search for a short prefix of the extension ID that reliably appears as
251
+ // contiguous bytes, then scan a window around each match for a base64url
252
+ // token value.
253
+ //
254
+ // Observed LevelDB layout near the auth-token entry:
255
+ // ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
256
+ // <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
257
+ //
258
+ // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
285
259
  const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
286
- const keyBuf = Buffer.from('auth-token');
260
+ const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
287
261
  let files;
288
262
  try {
289
263
  files = fs.readdirSync(dir)
@@ -293,7 +267,7 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
293
267
  catch {
294
268
  return null;
295
269
  }
296
- // Sort by mtime descending
270
+ // Sort by mtime descending so we find the freshest token first
297
271
  files.sort((a, b) => {
298
272
  try {
299
273
  return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
@@ -310,15 +284,30 @@ function extractTokenViaBinaryRead(dir, tokenRe) {
310
284
  catch {
311
285
  continue;
312
286
  }
313
- // Quick check: does file contain both the extension ID and auth-token key?
314
- const extPos = data.indexOf(extIdBuf);
315
- if (extPos === -1)
316
- continue;
317
- const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
318
- if (keyPos === -1)
287
+ // Quick check: file must contain at least the prefix
288
+ if (data.indexOf(extIdPrefix) === -1)
319
289
  continue;
320
- // Scan for token value after auth-token key
290
+ // Strategy 1: scan after each occurrence of the extension ID prefix
291
+ // for base64url tokens within a 500-byte window
321
292
  let idx = 0;
293
+ while (true) {
294
+ const pos = data.indexOf(extIdPrefix, idx);
295
+ if (pos === -1)
296
+ break;
297
+ const scanStart = pos;
298
+ const scanEnd = Math.min(data.length, pos + 500);
299
+ const window = data.subarray(scanStart, scanEnd).toString('latin1');
300
+ const m = window.match(tokenRe);
301
+ if (m && validateBase64urlToken(m[1])) {
302
+ // Make sure this isn't another extension ID that happens to match
303
+ if (m[1] !== PLAYWRIGHT_EXTENSION_ID)
304
+ return m[1];
305
+ }
306
+ idx = pos + 1;
307
+ }
308
+ // Strategy 2 (fallback): original approach using full extension ID + auth-token key
309
+ const keyBuf = Buffer.from('auth-token');
310
+ idx = 0;
322
311
  while (true) {
323
312
  const kp = data.indexOf(keyBuf, idx);
324
313
  if (kp === -1)
@@ -345,6 +334,54 @@ function validateBase64urlToken(token) {
345
334
  return false;
346
335
  }
347
336
  }
337
+ /**
338
+ * Check whether the Playwright MCP Bridge extension is installed in any browser.
339
+ * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
340
+ */
341
+ export function checkExtensionInstalled() {
342
+ const home = os.homedir();
343
+ const platform = os.platform();
344
+ const browserDirs = [];
345
+ if (platform === 'darwin') {
346
+ browserDirs.push({ name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') }, { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') }, { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') }, { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') });
347
+ }
348
+ else if (platform === 'linux') {
349
+ browserDirs.push({ name: 'Chrome', base: path.join(home, '.config', 'google-chrome') }, { name: 'Chromium', base: path.join(home, '.config', 'chromium') }, { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') });
350
+ }
351
+ else if (platform === 'win32') {
352
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
353
+ browserDirs.push({ name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') }, { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') });
354
+ }
355
+ const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
356
+ const foundBrowsers = [];
357
+ for (const { name, base } of browserDirs) {
358
+ for (const profile of profiles) {
359
+ const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
360
+ if (fileExists(extDir)) {
361
+ foundBrowsers.push(name);
362
+ break; // one match per browser is enough
363
+ }
364
+ }
365
+ }
366
+ return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
367
+ }
368
+ /**
369
+ * Test token connectivity by attempting a real MCP connection.
370
+ * Connects, does the JSON-RPC handshake, and immediately closes.
371
+ */
372
+ export async function checkTokenConnectivity(opts) {
373
+ const timeout = opts?.timeout ?? 8;
374
+ const start = Date.now();
375
+ try {
376
+ const mcp = new PlaywrightMCP();
377
+ await mcp.connect({ timeout });
378
+ await mcp.close();
379
+ return { ok: true, durationMs: Date.now() - start };
380
+ }
381
+ catch (err) {
382
+ return { ok: false, error: err?.message ?? String(err), durationMs: Date.now() - start };
383
+ }
384
+ }
348
385
  export async function runBrowserDoctor(opts = {}) {
349
386
  const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
350
387
  const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -368,19 +405,31 @@ export async function runBrowserDoctor(opts = {}) {
368
405
  ].filter((v) => !!v);
369
406
  const uniqueTokens = [...new Set(allTokens)];
370
407
  const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
408
+ // Check extension installation
409
+ const extInstall = checkExtensionInstalled();
410
+ // Connectivity test (only when --live)
411
+ let connectivity;
412
+ if (opts.live) {
413
+ connectivity = await checkTokenConnectivity();
414
+ }
371
415
  const report = {
372
416
  cliVersion: opts.cliVersion,
373
417
  envToken,
374
418
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
375
419
  extensionToken,
376
420
  extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
421
+ extensionInstalled: extInstall.installed,
422
+ extensionBrowsers: extInstall.browsers,
377
423
  shellFiles,
378
424
  configs,
379
425
  recommendedToken,
380
426
  recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
427
+ connectivity,
381
428
  warnings: [],
382
429
  issues: [],
383
430
  };
431
+ if (!extInstall.installed)
432
+ report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
384
433
  if (!envToken)
385
434
  report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
386
435
  if (!shellFiles.some(s => s.token))
@@ -389,6 +438,8 @@ export async function runBrowserDoctor(opts = {}) {
389
438
  report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
390
439
  if (uniqueTokens.length > 1)
391
440
  report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
441
+ if (connectivity && !connectivity.ok)
442
+ report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
392
443
  for (const config of configs) {
393
444
  if (config.parseError)
394
445
  report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
@@ -408,6 +459,11 @@ export function renderBrowserDoctorReport(report) {
408
459
  const uniqueFingerprints = [...new Set(tokenFingerprints)];
409
460
  const hasMismatch = uniqueFingerprints.length > 1;
410
461
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
462
+ const installStatus = report.extensionInstalled ? 'OK' : 'MISSING';
463
+ const installDetail = report.extensionInstalled
464
+ ? `Extension installed (${report.extensionBrowsers.join(', ')})`
465
+ : 'Extension not installed in any browser';
466
+ lines.push(statusLine(installStatus, installDetail));
411
467
  const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
412
468
  lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
413
469
  const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
@@ -441,6 +497,17 @@ export function renderBrowserDoctorReport(report) {
441
497
  if (missingConfigCount > 0)
442
498
  lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
443
499
  lines.push('');
500
+ // Connectivity result
501
+ if (report.connectivity) {
502
+ const connStatus = report.connectivity.ok ? 'OK' : 'WARN';
503
+ const connDetail = report.connectivity.ok
504
+ ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
505
+ : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
506
+ lines.push(statusLine(connStatus, connDetail));
507
+ }
508
+ else {
509
+ lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
510
+ }
444
511
  lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
445
512
  if (report.issues.length) {
446
513
  lines.push('', chalk.yellow('Issues:'));
@@ -66,7 +66,7 @@ describe('json token helpers', () => {
66
66
  },
67
67
  }), 'abc123');
68
68
  const parsed = JSON.parse(next);
69
- expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
69
+ expect(parsed.mcp.playwright.environment.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123');
70
70
  });
71
71
  });
72
72
  describe('doctor report rendering', () => {
@@ -77,6 +77,8 @@ describe('doctor report rendering', () => {
77
77
  envFingerprint: 'fp1',
78
78
  extensionToken: 'abc123',
79
79
  extensionFingerprint: 'fp1',
80
+ extensionInstalled: true,
81
+ extensionBrowsers: ['Chrome'],
80
82
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
81
83
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
82
84
  recommendedToken: 'abc123',
@@ -84,6 +86,7 @@ describe('doctor report rendering', () => {
84
86
  warnings: [],
85
87
  issues: [],
86
88
  }));
89
+ expect(text).toContain('[OK] Extension installed (Chrome)');
87
90
  expect(text).toContain('[OK] Environment token: configured (fp1)');
88
91
  expect(text).toContain('[OK] /tmp/mcp.json');
89
92
  expect(text).toContain('configured (fp1)');
@@ -94,6 +97,8 @@ describe('doctor report rendering', () => {
94
97
  envFingerprint: 'fp1',
95
98
  extensionToken: null,
96
99
  extensionFingerprint: null,
100
+ extensionInstalled: false,
101
+ extensionBrowsers: [],
97
102
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
98
103
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
99
104
  recommendedToken: 'abc123',
@@ -101,9 +106,45 @@ describe('doctor report rendering', () => {
101
106
  warnings: [],
102
107
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
103
108
  }));
109
+ expect(text).toContain('[MISSING] Extension not installed in any browser');
104
110
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
105
111
  expect(text).toContain('[MISMATCH] /tmp/.zshrc');
106
112
  expect(text).toContain('configured (fp2)');
107
113
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
108
114
  });
115
+ it('renders connectivity OK when live test succeeds', () => {
116
+ const text = strip(renderBrowserDoctorReport({
117
+ envToken: 'abc123',
118
+ envFingerprint: 'fp1',
119
+ extensionToken: 'abc123',
120
+ extensionFingerprint: 'fp1',
121
+ extensionInstalled: true,
122
+ extensionBrowsers: ['Chrome'],
123
+ shellFiles: [],
124
+ configs: [],
125
+ recommendedToken: 'abc123',
126
+ recommendedFingerprint: 'fp1',
127
+ connectivity: { ok: true, durationMs: 1234 },
128
+ warnings: [],
129
+ issues: [],
130
+ }));
131
+ expect(text).toContain('[OK] Browser connectivity: connected in 1.2s');
132
+ });
133
+ it('renders connectivity WARN when not tested', () => {
134
+ const text = strip(renderBrowserDoctorReport({
135
+ envToken: 'abc123',
136
+ envFingerprint: 'fp1',
137
+ extensionToken: 'abc123',
138
+ extensionFingerprint: 'fp1',
139
+ extensionInstalled: true,
140
+ extensionBrowsers: ['Chrome'],
141
+ shellFiles: [],
142
+ configs: [],
143
+ recommendedToken: 'abc123',
144
+ recommendedFingerprint: 'fp1',
145
+ warnings: [],
146
+ issues: [],
147
+ }));
148
+ expect(text).toContain('[WARN] Browser connectivity: not tested (use --live)');
149
+ });
109
150
  });
package/dist/main.js CHANGED
@@ -102,12 +102,13 @@ program.command('doctor')
102
102
  .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false)
103
103
  .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false)
104
104
  .option('--token <token>', 'Override token to write instead of auto-detecting')
105
+ .option('--live', 'Test browser connectivity (requires Chrome running)', false)
105
106
  .option('--shell-rc <path>', 'Shell startup file to update')
106
107
  .option('--mcp-config <paths>', 'Comma-separated MCP config paths to scan/update')
107
108
  .action(async (opts) => {
108
109
  const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js');
109
110
  const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s) => s.trim()).filter(Boolean) : undefined;
110
- const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
111
+ const report = await runBrowserDoctor({ token: opts.token, live: opts.live, shellRc: opts.shellRc, configPaths, cliVersion: PKG_VERSION });
111
112
  console.log(renderBrowserDoctorReport(report));
112
113
  if (opts.fix) {
113
114
  const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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 });