@jackwener/opencli 0.9.8 → 1.0.0

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.
Files changed (97) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +15 -57
  6. package/README.zh-CN.md +16 -59
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -11
  16. package/dist/browser/index.js +5 -11
  17. package/dist/browser/mcp.d.ts +9 -18
  18. package/dist/browser/mcp.js +70 -284
  19. package/dist/browser/page.d.ts +28 -6
  20. package/dist/browser/page.js +210 -85
  21. package/dist/browser.test.js +4 -225
  22. package/dist/cli-manifest.json +167 -0
  23. package/dist/clis/neteasemusic/like.d.ts +1 -0
  24. package/dist/clis/neteasemusic/like.js +25 -0
  25. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  26. package/dist/clis/neteasemusic/lyrics.js +47 -0
  27. package/dist/clis/neteasemusic/next.d.ts +1 -0
  28. package/dist/clis/neteasemusic/next.js +26 -0
  29. package/dist/clis/neteasemusic/play.d.ts +1 -0
  30. package/dist/clis/neteasemusic/play.js +26 -0
  31. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  32. package/dist/clis/neteasemusic/playing.js +59 -0
  33. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  34. package/dist/clis/neteasemusic/playlist.js +46 -0
  35. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  36. package/dist/clis/neteasemusic/prev.js +25 -0
  37. package/dist/clis/neteasemusic/search.d.ts +1 -0
  38. package/dist/clis/neteasemusic/search.js +52 -0
  39. package/dist/clis/neteasemusic/status.d.ts +1 -0
  40. package/dist/clis/neteasemusic/status.js +16 -0
  41. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  42. package/dist/clis/neteasemusic/volume.js +54 -0
  43. package/dist/daemon.d.ts +13 -0
  44. package/dist/daemon.js +187 -0
  45. package/dist/doctor.d.ts +27 -61
  46. package/dist/doctor.js +70 -601
  47. package/dist/doctor.test.js +30 -170
  48. package/dist/main.js +6 -25
  49. package/dist/pipeline/executor.test.js +1 -0
  50. package/dist/pipeline/steps/browser.js +2 -2
  51. package/dist/pipeline/steps/intercept.js +1 -2
  52. package/dist/setup.d.ts +6 -0
  53. package/dist/setup.js +46 -160
  54. package/dist/types.d.ts +6 -0
  55. package/extension/icons/icon-128.png +0 -0
  56. package/extension/icons/icon-16.png +0 -0
  57. package/extension/icons/icon-32.png +0 -0
  58. package/extension/icons/icon-48.png +0 -0
  59. package/extension/manifest.json +31 -0
  60. package/extension/package.json +16 -0
  61. package/extension/src/background.ts +293 -0
  62. package/extension/src/cdp.ts +125 -0
  63. package/extension/src/protocol.ts +57 -0
  64. package/extension/store-assets/screenshot-1280x800.png +0 -0
  65. package/extension/tsconfig.json +15 -0
  66. package/extension/vite.config.ts +18 -0
  67. package/package.json +5 -5
  68. package/src/browser/daemon-client.ts +113 -0
  69. package/src/browser/discover.ts +18 -232
  70. package/src/browser/errors.ts +30 -100
  71. package/src/browser/index.ts +6 -12
  72. package/src/browser/mcp.ts +78 -278
  73. package/src/browser/page.ts +222 -88
  74. package/src/browser.test.ts +3 -233
  75. package/src/clis/chatgpt/README.md +1 -1
  76. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  77. package/src/clis/neteasemusic/README.md +31 -0
  78. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  79. package/src/clis/neteasemusic/like.ts +28 -0
  80. package/src/clis/neteasemusic/lyrics.ts +53 -0
  81. package/src/clis/neteasemusic/next.ts +30 -0
  82. package/src/clis/neteasemusic/play.ts +30 -0
  83. package/src/clis/neteasemusic/playing.ts +62 -0
  84. package/src/clis/neteasemusic/playlist.ts +51 -0
  85. package/src/clis/neteasemusic/prev.ts +29 -0
  86. package/src/clis/neteasemusic/search.ts +58 -0
  87. package/src/clis/neteasemusic/status.ts +18 -0
  88. package/src/clis/neteasemusic/volume.ts +61 -0
  89. package/src/daemon.ts +217 -0
  90. package/src/doctor.test.ts +32 -193
  91. package/src/doctor.ts +74 -668
  92. package/src/main.ts +6 -23
  93. package/src/pipeline/executor.test.ts +1 -0
  94. package/src/pipeline/steps/browser.ts +2 -2
  95. package/src/pipeline/steps/intercept.ts +1 -2
  96. package/src/setup.ts +47 -183
  97. package/src/types.ts +1 -0
package/src/doctor.ts CHANGED
@@ -1,47 +1,22 @@
1
- import * as fs from 'node:fs';
2
- import * as os from 'node:os';
3
- import * as path from 'node:path';
1
+ /**
2
+ * opencli doctor diagnose and fix browser connectivity.
3
+ *
4
+ * Simplified for the daemon-based architecture. No more token management,
5
+ * MCP path discovery, or config file scanning.
6
+ */
4
7
 
5
- import { createInterface } from 'node:readline/promises';
6
- import { stdin as input, stdout as output } from 'node:process';
7
8
  import chalk from 'chalk';
8
- import type { IPage } from './types.js';
9
- import { PlaywrightMCP, getTokenFingerprint } from './browser/index.js';
9
+ import { checkDaemonStatus } from './browser/discover.js';
10
+ import { PlaywrightMCP } from './browser/index.js';
10
11
  import { browserSession } from './runtime.js';
11
12
 
12
- const PLAYWRIGHT_SERVER_NAME = 'playwright';
13
- export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
14
- const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
15
- const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
16
13
  export type DoctorOptions = {
17
14
  fix?: boolean;
18
15
  yes?: boolean;
19
16
  live?: boolean;
20
- shellRc?: string;
21
- configPaths?: string[];
22
- token?: string;
23
17
  cliVersion?: string;
24
18
  };
25
19
 
26
- export type ShellFileStatus = {
27
- path: string;
28
- exists: boolean;
29
- token: string | null;
30
- fingerprint: string | null;
31
- };
32
-
33
- export type McpConfigFormat = 'json' | 'toml';
34
-
35
- export type McpConfigStatus = {
36
- path: string;
37
- exists: boolean;
38
- format: McpConfigFormat;
39
- token: string | null;
40
- fingerprint: string | null;
41
- writable: boolean;
42
- parseError?: string;
43
- };
44
-
45
20
  export type ConnectivityResult = {
46
21
  ok: boolean;
47
22
  error?: string;
@@ -50,463 +25,22 @@ export type ConnectivityResult = {
50
25
 
51
26
  export type DoctorReport = {
52
27
  cliVersion?: string;
53
- envToken: string | null;
54
- envFingerprint: string | null;
55
- extensionToken: string | null;
56
- extensionFingerprint: string | null;
57
- extensionInstalled: boolean;
58
- extensionBrowsers: string[];
59
- shellFiles: ShellFileStatus[];
60
- configs: McpConfigStatus[];
61
- recommendedToken: string | null;
62
- recommendedFingerprint: string | null;
28
+ daemonRunning: boolean;
29
+ extensionConnected: boolean;
63
30
  connectivity?: ConnectivityResult;
64
- warnings: string[];
65
31
  issues: string[];
66
32
  };
67
33
 
68
- type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
69
-
70
- function colorLabel(status: ReportStatus): string {
71
- switch (status) {
72
- case 'OK': return chalk.green('[OK]');
73
- case 'MISSING': return chalk.red('[MISSING]');
74
- case 'MISMATCH': return chalk.yellow('[MISMATCH]');
75
- case 'WARN': return chalk.yellow('[WARN]');
76
- }
77
- }
78
-
79
- function statusLine(status: ReportStatus, text: string): string {
80
- return `${colorLabel(status)} ${text}`;
81
- }
82
-
83
- function tokenSummary(token: string | null, fingerprint: string | null): string {
84
- if (!token) return chalk.dim('missing');
85
- return `configured ${chalk.dim(`(${fingerprint})`)}`;
86
- }
87
-
88
- export function shortenPath(p: string): string {
89
- const home = os.homedir();
90
- return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
91
- }
92
-
93
- export function toolName(p: string): string {
94
- if (p.includes('.codex/')) return 'Codex';
95
- if (p.includes('.cursor/')) return 'Cursor';
96
- if (p.includes('.claude.json')) return 'Claude Code';
97
- if (p.includes('antigravity')) return 'Antigravity';
98
- if (p.includes('.gemini/settings')) return 'Gemini CLI';
99
- if (p.includes('opencode')) return 'OpenCode';
100
- if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
101
- if (p.includes('.vscode/')) return 'VS Code';
102
- if (p.includes('.mcp.json')) return 'Project MCP';
103
- if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
104
- return '';
105
- }
106
-
107
- export function getDefaultShellRcPath(): string {
108
- const shell = process.env.SHELL ?? '';
109
- if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc');
110
- if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish');
111
- return path.join(os.homedir(), '.zshrc');
112
- }
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
-
123
- export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] {
124
- const home = os.homedir();
125
- const candidates = [
126
- path.join(home, '.codex', 'config.toml'),
127
- path.join(home, '.codex', 'mcp.json'),
128
- path.join(home, '.cursor', 'mcp.json'),
129
- path.join(home, '.claude.json'),
130
- path.join(home, '.gemini', 'settings.json'),
131
- path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
132
- path.join(home, '.config', 'opencode', 'opencode.json'),
133
- path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
134
- path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
135
- path.join(cwd, '.cursor', 'mcp.json'),
136
- path.join(cwd, '.vscode', 'mcp.json'),
137
- path.join(cwd, '.opencode', 'opencode.json'),
138
- path.join(cwd, '.mcp.json'),
139
- ];
140
- return [...new Set(candidates)];
141
- }
142
-
143
- export function readTokenFromShellContent(content: string): string | null {
144
- const m = content.match(TOKEN_LINE_RE);
145
- return m?.[3] ?? null;
146
- }
147
-
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
- }
157
- const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`;
158
- if (!content.trim()) return `${nextLine}\n`;
159
- if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${
160
- token
161
- }"`);
162
- return `${content.replace(/\s*$/, '')}\n${nextLine}\n`;
163
- }
164
-
165
- function readJsonConfigToken(content: string): string | null {
166
- try {
167
- const parsed = JSON.parse(content);
168
- return readTokenFromJsonObject(parsed);
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- function readTokenFromJsonObject(parsed: any): string | null {
175
- const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV];
176
- if (typeof direct === 'string' && direct) return direct;
177
- const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.environment?.[PLAYWRIGHT_TOKEN_ENV];
178
- if (typeof opencode === 'string' && opencode) return opencode;
179
- return null;
180
- }
181
-
182
- export function upsertJsonConfigToken(content: string, token: string, filePath?: string): string {
183
- const parsed = content.trim() ? JSON.parse(content) : {};
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) {
192
- parsed.mcp = parsed.mcp ?? {};
193
- parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? {
194
- command: ['npx', '-y', '@playwright/mcp@latest', '--extension'],
195
- enabled: true,
196
- type: 'local',
197
- };
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;
208
- }
209
- return `${JSON.stringify(parsed, null, 2)}\n`;
210
- }
211
-
212
- export function readTomlConfigToken(content: string): string | null {
213
- const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/);
214
- if (!sectionMatch) return null;
215
- const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m);
216
- return tokenMatch?.[1] ?? null;
217
- }
218
-
219
- export function upsertTomlConfigToken(content: string, token: string): string {
220
- const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/;
221
- const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`;
222
- if (envSectionRe.test(content)) {
223
- return content.replace(envSectionRe, (section) => {
224
- if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) {
225
- return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine);
226
- }
227
- return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`;
228
- });
229
- }
230
-
231
- const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/;
232
- if (baseSectionRe.test(content)) {
233
- return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`);
234
- }
235
-
236
- const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
237
- return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
238
- }
239
-
240
- export function fileExists(filePath: string): boolean {
241
- try {
242
- return fs.existsSync(filePath);
243
- } catch {
244
- return false;
245
- }
246
- }
247
-
248
- function canWrite(filePath: string): boolean {
249
- try {
250
- if (fileExists(filePath)) {
251
- fs.accessSync(filePath, fs.constants.W_OK);
252
- return true;
253
- }
254
- fs.accessSync(path.dirname(filePath), fs.constants.W_OK);
255
- return true;
256
- } catch {
257
- return false;
258
- }
259
- }
260
-
261
- function readConfigStatus(filePath: string): McpConfigStatus {
262
- const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json';
263
- if (!fileExists(filePath)) {
264
- return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) };
265
- }
266
- try {
267
- const content = fs.readFileSync(filePath, 'utf-8');
268
- const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content);
269
- return {
270
- path: filePath,
271
- exists: true,
272
- format,
273
- token,
274
- fingerprint: getTokenFingerprint(token ?? undefined),
275
- writable: canWrite(filePath),
276
- };
277
- } catch (error: any) {
278
- return {
279
- path: filePath,
280
- exists: true,
281
- format,
282
- token: null,
283
- fingerprint: null,
284
- writable: canWrite(filePath),
285
- parseError: error?.message ?? String(error),
286
- };
287
- }
288
- }
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
-
310
- /**
311
- * Discover the auth token stored by the Playwright MCP Bridge extension
312
- * by scanning Chrome's LevelDB localStorage files directly.
313
- *
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.
319
- */
320
- export function discoverExtensionToken(): string | null {
321
- const home = os.homedir();
322
- const platform = os.platform();
323
- const bases: string[] = [];
324
-
325
- if (platform === 'darwin') {
326
- bases.push(
327
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
328
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev'),
329
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta'),
330
- path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
331
- path.join(home, 'Library', 'Application Support', 'Chromium'),
332
- path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
333
- );
334
- } else if (platform === 'linux') {
335
- bases.push(
336
- path.join(home, '.config', 'google-chrome'),
337
- path.join(home, '.config', 'google-chrome-unstable'),
338
- path.join(home, '.config', 'google-chrome-beta'),
339
- path.join(home, '.config', 'chromium'),
340
- path.join(home, '.config', 'microsoft-edge'),
341
- );
342
- } else if (platform === 'win32') {
343
- const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
344
- bases.push(
345
- path.join(appData, 'Google', 'Chrome', 'User Data'),
346
- path.join(appData, 'Google', 'Chrome Dev', 'User Data'),
347
- path.join(appData, 'Google', 'Chrome Beta', 'User Data'),
348
- path.join(appData, 'Microsoft', 'Edge', 'User Data'),
349
- );
350
- }
351
-
352
- const profiles = enumerateProfiles(bases);
353
- const tokenRe = /([A-Za-z0-9_-]{40,50})/;
354
-
355
- for (const base of bases) {
356
- for (const profile of profiles) {
357
- const dir = path.join(base, profile, 'Local Storage', 'leveldb');
358
- if (!fileExists(dir)) continue;
359
-
360
- const token = extractTokenViaBinaryRead(dir, tokenRe);
361
- if (token) return token;
362
- }
363
- }
364
-
365
- return null;
366
- }
367
-
368
- function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
369
- // LevelDB fragments strings across byte boundaries, so we can't search
370
- // for the full extension ID or "auth-token" as contiguous ASCII. Instead,
371
- // search for a short prefix of the extension ID that reliably appears as
372
- // contiguous bytes, then scan a window around each match for a base64url
373
- // token value.
374
- //
375
- // Observed LevelDB layout near the auth-token entry:
376
- // ... auth-t<binary> ... 4,mmlmfjh<binary>Pocbjadbfplnigmagldckm.7 ...
377
- // <binary> hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA <binary> ...
378
- //
379
- // The extension ID prefix "mmlmfjh" appears ~44 bytes before the token.
380
- const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
381
- const extIdPrefix = Buffer.from(PLAYWRIGHT_EXTENSION_ID.slice(0, 7)); // "mmlmfjh"
382
-
383
- let files: string[];
384
- try {
385
- files = fs.readdirSync(dir)
386
- .filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
387
- .map(f => path.join(dir, f));
388
- } catch { return null; }
389
-
390
- // Sort by mtime descending so we find the freshest token first
391
- files.sort((a, b) => {
392
- try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
393
- });
394
-
395
- for (const file of files) {
396
- let data: Buffer;
397
- try { data = fs.readFileSync(file); } catch { continue; }
398
-
399
- // Quick check: file must contain at least the prefix
400
- if (data.indexOf(extIdPrefix) === -1) continue;
401
-
402
- // Strategy 1: scan after each occurrence of the extension ID prefix
403
- // for base64url tokens within a 500-byte window
404
- let idx = 0;
405
- while (true) {
406
- const pos = data.indexOf(extIdPrefix, idx);
407
- if (pos === -1) break;
408
-
409
- const scanStart = pos;
410
- const scanEnd = Math.min(data.length, pos + 500);
411
- const window = data.subarray(scanStart, scanEnd).toString('latin1');
412
- const m = window.match(tokenRe);
413
- if (m && validateBase64urlToken(m[1])) {
414
- // Make sure this isn't another extension ID that happens to match
415
- if (m[1] !== PLAYWRIGHT_EXTENSION_ID) return m[1];
416
- }
417
- idx = pos + 1;
418
- }
419
-
420
- // Strategy 2 (fallback): original approach using full extension ID + auth-token key
421
- const keyBuf = Buffer.from('auth-token');
422
- idx = 0;
423
- while (true) {
424
- const kp = data.indexOf(keyBuf, idx);
425
- if (kp === -1) break;
426
-
427
- const contextStart = Math.max(0, kp - 500);
428
- if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
429
- const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
430
- const m = after.match(tokenRe);
431
- if (m && validateBase64urlToken(m[1])) return m[1];
432
- }
433
- idx = kp + 1;
434
- }
435
- }
436
- return null;
437
- }
438
-
439
- function validateBase64urlToken(token: string): boolean {
440
- try {
441
- const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
442
- const decoded = Buffer.from(b64, 'base64');
443
- return decoded.length >= 28 && decoded.length <= 36;
444
- } catch { return false; }
445
- }
446
-
447
-
448
- /**
449
- * Check whether the Playwright MCP Bridge extension is installed in any browser.
450
- * Scans Chrome/Chromium/Edge Extensions directories for the known extension ID.
451
- */
452
- export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } {
453
- const home = os.homedir();
454
- const platform = os.platform();
455
- const browserDirs: Array<{ name: string; base: string }> = [];
456
-
457
- if (platform === 'darwin') {
458
- browserDirs.push(
459
- { name: 'Chrome', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') },
460
- { name: 'Chrome Dev', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Dev') },
461
- { name: 'Chrome Beta', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Beta') },
462
- { name: 'Chrome Canary', base: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary') },
463
- { name: 'Chromium', base: path.join(home, 'Library', 'Application Support', 'Chromium') },
464
- { name: 'Edge', base: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') },
465
- );
466
- } else if (platform === 'linux') {
467
- browserDirs.push(
468
- { name: 'Chrome', base: path.join(home, '.config', 'google-chrome') },
469
- { name: 'Chrome Dev', base: path.join(home, '.config', 'google-chrome-unstable') },
470
- { name: 'Chrome Beta', base: path.join(home, '.config', 'google-chrome-beta') },
471
- { name: 'Chromium', base: path.join(home, '.config', 'chromium') },
472
- { name: 'Edge', base: path.join(home, '.config', 'microsoft-edge') },
473
- );
474
- } else if (platform === 'win32') {
475
- const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
476
- browserDirs.push(
477
- { name: 'Chrome', base: path.join(appData, 'Google', 'Chrome', 'User Data') },
478
- { name: 'Chrome Dev', base: path.join(appData, 'Google', 'Chrome Dev', 'User Data') },
479
- { name: 'Chrome Beta', base: path.join(appData, 'Google', 'Chrome Beta', 'User Data') },
480
- { name: 'Edge', base: path.join(appData, 'Microsoft', 'Edge', 'User Data') },
481
- );
482
- }
483
-
484
- const profiles = enumerateProfiles(browserDirs.map(d => d.base));
485
- const foundBrowsers: string[] = [];
486
-
487
- for (const { name, base } of browserDirs) {
488
- for (const profile of profiles) {
489
- const extDir = path.join(base, profile, 'Extensions', PLAYWRIGHT_EXTENSION_ID);
490
- if (fileExists(extDir)) {
491
- foundBrowsers.push(name);
492
- break; // one match per browser is enough
493
- }
494
- }
495
- }
496
-
497
- return { installed: foundBrowsers.length > 0, browsers: [...new Set(foundBrowsers)] };
498
- }
499
-
500
34
  /**
501
- * Test token connectivity by attempting a real MCP connection.
502
- * Connects, does the JSON-RPC handshake, and immediately closes.
35
+ * Test connectivity by attempting a real browser command.
503
36
  */
504
- export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
505
- const timeout = opts?.timeout ?? 8;
37
+ export async function checkConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> {
506
38
  const start = Date.now();
507
39
  try {
508
40
  const mcp = new PlaywrightMCP();
509
- await mcp.connect({ timeout });
41
+ const page = await mcp.connect({ timeout: opts?.timeout ?? 8 });
42
+ // Try a simple eval to verify end-to-end connectivity
43
+ await page.evaluate('1 + 1');
510
44
  await mcp.close();
511
45
  return { ok: true, durationMs: Date.now() - start };
512
46
  } catch (err: any) {
@@ -515,215 +49,87 @@ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promi
515
49
  }
516
50
 
517
51
  export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
518
- const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
519
- const shellPath = opts.shellRc ?? getDefaultShellRcPath();
520
- const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => {
521
- if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null };
522
- const content = fs.readFileSync(filePath, 'utf-8');
523
- const token = readTokenFromShellContent(content);
524
- return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) };
525
- });
526
- const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
527
- const configs = configPaths.map(readConfigStatus);
528
-
529
- // Try to discover the token directly from the Chrome extension's localStorage
530
- const extensionToken = discoverExtensionToken();
52
+ const status = await checkDaemonStatus();
531
53
 
532
- const allTokens = [
533
- opts.token ?? null,
534
- extensionToken,
535
- envToken,
536
- ...shellFiles.map(s => s.token),
537
- ...configs.map(c => c.token),
538
- ].filter((v): v is string => !!v);
539
- const uniqueTokens = [...new Set(allTokens)];
540
- const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
541
-
542
- // Check extension installation
543
- const extInstall = checkExtensionInstalled();
544
-
545
- // Connectivity test (only when --live)
546
54
  let connectivity: ConnectivityResult | undefined;
547
55
  if (opts.live) {
548
- connectivity = await checkTokenConnectivity();
56
+ connectivity = await checkConnectivity();
57
+ }
58
+
59
+ const issues: string[] = [];
60
+ if (!status.running) {
61
+ issues.push('Daemon is not running. It should start automatically when you run an opencli browser command.');
62
+ }
63
+ if (status.running && !status.extensionConnected) {
64
+ issues.push(
65
+ 'Daemon is running but the Chrome extension is not connected.\n' +
66
+ 'Please install the opencli Browser Bridge extension:\n' +
67
+ ' 1. Download from GitHub Releases\n' +
68
+ ' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
69
+ ' 3. Click "Load unpacked" → select the extension folder',
70
+ );
71
+ }
72
+ if (connectivity && !connectivity.ok) {
73
+ issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
549
74
  }
550
75
 
551
- const report: DoctorReport = {
76
+ return {
552
77
  cliVersion: opts.cliVersion,
553
- envToken,
554
- envFingerprint: getTokenFingerprint(envToken ?? undefined),
555
- extensionToken,
556
- extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
557
- extensionInstalled: extInstall.installed,
558
- extensionBrowsers: extInstall.browsers,
559
- shellFiles,
560
- configs,
561
- recommendedToken,
562
- recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined),
78
+ daemonRunning: status.running,
79
+ extensionConnected: status.extensionConnected,
563
80
  connectivity,
564
- warnings: [],
565
- issues: [],
81
+ issues,
566
82
  };
567
-
568
- if (!extInstall.installed) report.issues.push('Playwright MCP Bridge extension is not installed in any browser.');
569
- if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`);
570
- if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.');
571
- if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.');
572
- if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.');
573
- if (connectivity && !connectivity.ok) report.issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
574
- for (const config of configs) {
575
- if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`);
576
- }
577
- if (!recommendedToken) {
578
- report.warnings.push('No token source found.');
579
- }
580
- return report;
581
83
  }
582
84
 
583
85
  export function renderBrowserDoctorReport(report: DoctorReport): string {
584
- const tokenFingerprints = [
585
- report.extensionFingerprint,
586
- report.envFingerprint,
587
- ...report.shellFiles.map(shell => shell.fingerprint),
588
- ...report.configs.filter(config => config.exists).map(config => config.fingerprint),
589
- ].filter((value): value is string => !!value);
590
- const uniqueFingerprints = [...new Set(tokenFingerprints)];
591
- const hasMismatch = uniqueFingerprints.length > 1;
592
86
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
593
87
 
594
- // CDP endpoint mode (for remote/server environments)
595
- const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
596
- if (cdpEndpoint) {
597
- lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
598
- lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
599
- lines.push('');
600
- return lines.join('\n');
601
- }
602
-
603
- const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
604
- const installDetail = report.extensionInstalled
605
- ? `Extension installed (${report.extensionBrowsers.join(', ')})`
606
- : 'Extension not installed in any browser';
607
- lines.push(statusLine(installStatus, installDetail));
608
-
609
- const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
610
- lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
88
+ // Daemon status
89
+ const daemonIcon = report.daemonRunning ? chalk.green('[OK]') : chalk.red('[MISSING]');
90
+ lines.push(`${daemonIcon} Daemon: ${report.daemonRunning ? 'running on port 19825' : 'not running'}`);
611
91
 
612
- const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
613
- lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
614
-
615
- for (const shell of report.shellFiles) {
616
- const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
617
- const tool = toolName(shell.path);
618
- const suffix = tool ? chalk.dim(` [${tool}]`) : '';
619
- lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
620
- }
621
- const existingConfigs = report.configs.filter(config => config.exists);
622
- const missingConfigCount = report.configs.length - existingConfigs.length;
623
- if (existingConfigs.length > 0) {
624
- for (const config of existingConfigs) {
625
- const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
626
- const configStatus: ReportStatus = config.parseError
627
- ? 'WARN'
628
- : !config.token
629
- ? 'MISSING'
630
- : hasMismatch
631
- ? 'MISMATCH'
632
- : 'OK';
633
- const tool = toolName(config.path);
634
- const suffix = tool ? chalk.dim(` [${tool}]`) : '';
635
- lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
636
- }
637
- } else {
638
- lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
639
- }
640
- if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
641
- lines.push('');
92
+ // Extension status
93
+ const extIcon = report.extensionConnected ? chalk.green('[OK]') : chalk.yellow('[MISSING]');
94
+ lines.push(`${extIcon} Extension: ${report.extensionConnected ? 'connected' : 'not connected'}`);
642
95
 
643
- // Connectivity result
96
+ // Connectivity
644
97
  if (report.connectivity) {
645
- const connStatus: ReportStatus = report.connectivity.ok ? 'OK' : 'WARN';
646
- const connDetail = report.connectivity.ok
647
- ? `Browser connectivity: connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
648
- : `Browser connectivity: failed (${report.connectivity.error ?? 'unknown'})`;
649
- lines.push(statusLine(connStatus, connDetail));
98
+ const connIcon = report.connectivity.ok ? chalk.green('[OK]') : chalk.red('[FAIL]');
99
+ const detail = report.connectivity.ok
100
+ ? `connected in ${(report.connectivity.durationMs / 1000).toFixed(1)}s`
101
+ : `failed (${report.connectivity.error ?? 'unknown'})`;
102
+ lines.push(`${connIcon} Connectivity: ${detail}`);
650
103
  } else {
651
- lines.push(statusLine('WARN', 'Browser connectivity: not tested (use --live)'));
104
+ lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
652
105
  }
653
106
 
654
- lines.push(statusLine(
655
- hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
656
- `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
657
- ));
658
107
  if (report.issues.length) {
659
108
  lines.push('', chalk.yellow('Issues:'));
660
- for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`));
661
- }
662
- if (report.warnings.length) {
663
- lines.push('', chalk.yellow('Warnings:'));
664
- for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`));
665
- }
666
- return lines.join('\n');
667
- }
668
-
669
- async function confirmPrompt(question: string): Promise<boolean> {
670
- const rl = createInterface({ input, output });
671
- try {
672
- const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
673
- return answer === 'y' || answer === 'yes';
674
- } finally {
675
- rl.close();
109
+ for (const issue of report.issues) {
110
+ lines.push(chalk.dim(` • ${issue}`));
111
+ }
112
+ } else if (report.daemonRunning && report.extensionConnected) {
113
+ lines.push('', chalk.green('Everything looks good!'));
676
114
  }
677
- }
678
115
 
679
- export function writeFileWithMkdir(filePath: string, content: string): void {
680
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
681
- fs.writeFileSync(filePath, content, 'utf-8');
116
+ return lines.join('\n');
682
117
  }
683
118
 
684
- export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
685
- const token = opts.token ?? report.recommendedToken;
686
- if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
687
- const fp = getTokenFingerprint(token);
688
-
689
- const plannedWrites: string[] = [];
690
- const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
691
- const shellStatus = report.shellFiles.find(s => s.path === shellPath);
692
- if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
693
- for (const config of report.configs) {
694
- if (!config.writable) continue;
695
- if (config.fingerprint === fp) continue; // already correct
696
- plannedWrites.push(config.path);
697
- }
698
-
699
- if (plannedWrites.length === 0) {
700
- console.log(chalk.green('All config files are already up to date.'));
701
- return [];
702
- }
703
-
704
- if (!opts.yes) {
705
- const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
706
- if (!ok) return [];
707
- }
708
-
709
- const written: string[] = [];
710
- if (plannedWrites.includes(shellPath)) {
711
- const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
712
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token, shellPath));
713
- written.push(shellPath);
714
- }
715
-
716
- for (const config of report.configs) {
717
- if (!plannedWrites.includes(config.path)) continue;
718
- if (config.parseError) continue;
719
- const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
720
- const next = config.format === 'toml'
721
- ? upsertTomlConfigToken(before, token)
722
- : upsertJsonConfigToken(before, token, config.path);
723
- writeFileWithMkdir(config.path, next);
724
- written.push(config.path);
725
- }
726
-
727
- process.env[PLAYWRIGHT_TOKEN_ENV] = token;
728
- return written;
729
- }
119
+ // Backward compatibility exports (no-ops for things that no longer exist)
120
+ export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
121
+ export function discoverExtensionToken(): string | null { return null; }
122
+ export function checkExtensionInstalled(): { installed: boolean; browsers: string[] } { return { installed: false, browsers: [] }; }
123
+ export function applyBrowserDoctorFix(): Promise<string[]> { return Promise.resolve([]); }
124
+ export function getDefaultShellRcPath(): string { return ''; }
125
+ export function getDefaultMcpConfigPaths(): string[] { return []; }
126
+ export function readTokenFromShellContent(_content: string): string | null { return null; }
127
+ export function upsertShellToken(content: string): string { return content; }
128
+ export function upsertJsonConfigToken(content: string): string { return content; }
129
+ export function readTomlConfigToken(_content: string): string | null { return null; }
130
+ export function upsertTomlConfigToken(content: string): string { return content; }
131
+ export function shortenPath(p: string): string { return p; }
132
+ export function toolName(_p: string): string { return ''; }
133
+ export function fileExists(filePath: string): boolean { try { return require('node:fs').existsSync(filePath); } catch { return false; } }
134
+ export function writeFileWithMkdir(_p: string, _c: string): void {}
135
+ export async function checkTokenConnectivity(opts?: { timeout?: number }): Promise<ConnectivityResult> { return checkConnectivity(opts); }