@jackwener/opencli 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -1,23 +1,57 @@
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
  import { createInterface } from 'node:readline/promises';
5
6
  import { stdin as input, stdout as output } from 'node:process';
7
+ import chalk from 'chalk';
6
8
  import { getTokenFingerprint } from './browser.js';
7
9
  const PLAYWRIGHT_SERVER_NAME = 'playwright';
8
- const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
10
+ export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
9
11
  const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
10
12
  const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
11
- function label(status) {
12
- return `[${status}]`;
13
+ function colorLabel(status) {
14
+ switch (status) {
15
+ case 'OK': return chalk.green('[OK]');
16
+ case 'MISSING': return chalk.red('[MISSING]');
17
+ case 'MISMATCH': return chalk.yellow('[MISMATCH]');
18
+ case 'WARN': return chalk.yellow('[WARN]');
19
+ }
13
20
  }
14
21
  function statusLine(status, text) {
15
- return `${label(status)} ${text}`;
22
+ return `${colorLabel(status)} ${text}`;
16
23
  }
17
24
  function tokenSummary(token, fingerprint) {
18
25
  if (!token)
19
- return 'missing';
20
- return `configured (${fingerprint})`;
26
+ return chalk.dim('missing');
27
+ return `configured ${chalk.dim(`(${fingerprint})`)}`;
28
+ }
29
+ export function shortenPath(p) {
30
+ const home = os.homedir();
31
+ return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
32
+ }
33
+ export function toolName(p) {
34
+ if (p.includes('.codex/'))
35
+ return 'Codex';
36
+ if (p.includes('.cursor/'))
37
+ return 'Cursor';
38
+ if (p.includes('.claude.json'))
39
+ return 'Claude Code';
40
+ if (p.includes('antigravity'))
41
+ return 'Antigravity';
42
+ if (p.includes('.gemini/settings'))
43
+ return 'Gemini CLI';
44
+ if (p.includes('opencode'))
45
+ return 'OpenCode';
46
+ if (p.includes('Claude/claude_desktop'))
47
+ return 'Claude Desktop';
48
+ if (p.includes('.vscode/'))
49
+ return 'VS Code';
50
+ if (p.includes('.mcp.json'))
51
+ return 'Project MCP';
52
+ if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile'))
53
+ return 'Shell';
54
+ return '';
21
55
  }
22
56
  export function getDefaultShellRcPath() {
23
57
  const shell = process.env.SHELL ?? '';
@@ -33,12 +67,16 @@ export function getDefaultMcpConfigPaths(cwd = process.cwd()) {
33
67
  path.join(home, '.codex', 'config.toml'),
34
68
  path.join(home, '.codex', 'mcp.json'),
35
69
  path.join(home, '.cursor', 'mcp.json'),
70
+ path.join(home, '.claude.json'),
71
+ path.join(home, '.gemini', 'settings.json'),
72
+ path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
36
73
  path.join(home, '.config', 'opencode', 'opencode.json'),
37
74
  path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
38
75
  path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
39
76
  path.join(cwd, '.cursor', 'mcp.json'),
40
77
  path.join(cwd, '.vscode', 'mcp.json'),
41
78
  path.join(cwd, '.opencode', 'opencode.json'),
79
+ path.join(cwd, '.mcp.json'),
42
80
  ];
43
81
  return [...new Set(candidates)];
44
82
  }
@@ -119,7 +157,7 @@ export function upsertTomlConfigToken(content, token) {
119
157
  const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : '';
120
158
  return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
121
159
  }
122
- function fileExists(filePath) {
160
+ export function fileExists(filePath) {
123
161
  try {
124
162
  return fs.existsSync(filePath);
125
163
  }
@@ -169,6 +207,144 @@ function readConfigStatus(filePath) {
169
207
  };
170
208
  }
171
209
  }
210
+ /**
211
+ * Discover the auth token stored by the Playwright MCP Bridge extension
212
+ * by scanning Chrome's LevelDB localStorage files directly.
213
+ *
214
+ * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
215
+ * with a pure-Node fallback on Windows.
216
+ */
217
+ export function discoverExtensionToken() {
218
+ const home = os.homedir();
219
+ const platform = os.platform();
220
+ const bases = [];
221
+ if (platform === 'darwin') {
222
+ bases.push(path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'), path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'), path.join(home, 'Library', 'Application Support', 'Chromium'), path.join(home, 'Library', 'Application Support', 'Microsoft Edge'));
223
+ }
224
+ else if (platform === 'linux') {
225
+ bases.push(path.join(home, '.config', 'google-chrome'), path.join(home, '.config', 'chromium'), path.join(home, '.config', 'microsoft-edge'));
226
+ }
227
+ else if (platform === 'win32') {
228
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
229
+ bases.push(path.join(appData, 'Google', 'Chrome', 'User Data'), path.join(appData, 'Microsoft', 'Edge', 'User Data'));
230
+ }
231
+ const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
232
+ // Token is 43 chars of base64url (from 32 random bytes)
233
+ const tokenRe = /([A-Za-z0-9_-]{40,50})/;
234
+ for (const base of bases) {
235
+ for (const profile of profiles) {
236
+ const dir = path.join(base, profile, 'Local Storage', 'leveldb');
237
+ if (!fileExists(dir))
238
+ 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
+ const token = extractTokenViaBinaryRead(dir, tokenRe);
248
+ if (token)
249
+ return token;
250
+ }
251
+ }
252
+ return null;
253
+ }
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
+ function extractTokenViaBinaryRead(dir, tokenRe) {
285
+ const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
286
+ const keyBuf = Buffer.from('auth-token');
287
+ let files;
288
+ try {
289
+ files = fs.readdirSync(dir)
290
+ .filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
291
+ .map(f => path.join(dir, f));
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ // Sort by mtime descending
297
+ files.sort((a, b) => {
298
+ try {
299
+ return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
300
+ }
301
+ catch {
302
+ return 0;
303
+ }
304
+ });
305
+ for (const file of files) {
306
+ let data;
307
+ try {
308
+ data = fs.readFileSync(file);
309
+ }
310
+ catch {
311
+ continue;
312
+ }
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)
319
+ continue;
320
+ // Scan for token value after auth-token key
321
+ let idx = 0;
322
+ while (true) {
323
+ const kp = data.indexOf(keyBuf, idx);
324
+ if (kp === -1)
325
+ break;
326
+ const contextStart = Math.max(0, kp - 500);
327
+ if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
328
+ const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
329
+ const m = after.match(tokenRe);
330
+ if (m && validateBase64urlToken(m[1]))
331
+ return m[1];
332
+ }
333
+ idx = kp + 1;
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+ function validateBase64urlToken(token) {
339
+ try {
340
+ const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
341
+ const decoded = Buffer.from(b64, 'base64');
342
+ return decoded.length >= 28 && decoded.length <= 36;
343
+ }
344
+ catch {
345
+ return false;
346
+ }
347
+ }
172
348
  export async function runBrowserDoctor(opts = {}) {
173
349
  const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
174
350
  const shellPath = opts.shellRc ?? getDefaultShellRcPath();
@@ -181,18 +357,23 @@ export async function runBrowserDoctor(opts = {}) {
181
357
  });
182
358
  const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
183
359
  const configs = configPaths.map(readConfigStatus);
360
+ // Try to discover the token directly from the Chrome extension's localStorage
361
+ const extensionToken = discoverExtensionToken();
184
362
  const allTokens = [
185
363
  opts.token ?? null,
364
+ extensionToken,
186
365
  envToken,
187
366
  ...shellFiles.map(s => s.token),
188
367
  ...configs.map(c => c.token),
189
368
  ].filter((v) => !!v);
190
369
  const uniqueTokens = [...new Set(allTokens)];
191
- const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
370
+ const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
192
371
  const report = {
193
372
  cliVersion: opts.cliVersion,
194
373
  envToken,
195
374
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
375
+ extensionToken,
376
+ extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
196
377
  shellFiles,
197
378
  configs,
198
379
  recommendedToken,
@@ -219,24 +400,29 @@ export async function runBrowserDoctor(opts = {}) {
219
400
  }
220
401
  export function renderBrowserDoctorReport(report) {
221
402
  const tokenFingerprints = [
403
+ report.extensionFingerprint,
222
404
  report.envFingerprint,
223
405
  ...report.shellFiles.map(shell => shell.fingerprint),
224
406
  ...report.configs.filter(config => config.exists).map(config => config.fingerprint),
225
407
  ].filter((value) => !!value);
226
408
  const uniqueFingerprints = [...new Set(tokenFingerprints)];
227
409
  const hasMismatch = uniqueFingerprints.length > 1;
228
- const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor`, ''];
410
+ const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
411
+ const extStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
412
+ lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
229
413
  const envStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
230
414
  lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
231
415
  for (const shell of report.shellFiles) {
232
416
  const shellStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
233
- lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`));
417
+ const tool = toolName(shell.path);
418
+ const suffix = tool ? chalk.dim(` [${tool}]`) : '';
419
+ lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
234
420
  }
235
421
  const existingConfigs = report.configs.filter(config => config.exists);
236
422
  const missingConfigCount = report.configs.length - existingConfigs.length;
237
423
  if (existingConfigs.length > 0) {
238
424
  for (const config of existingConfigs) {
239
- const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : '';
425
+ const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
240
426
  const configStatus = config.parseError
241
427
  ? 'WARN'
242
428
  : !config.token
@@ -244,25 +430,27 @@ export function renderBrowserDoctorReport(report) {
244
430
  : hasMismatch
245
431
  ? 'MISMATCH'
246
432
  : 'OK';
247
- lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
433
+ const tool = toolName(config.path);
434
+ const suffix = tool ? chalk.dim(` [${tool}]`) : '';
435
+ lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
248
436
  }
249
437
  }
250
438
  else {
251
- lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations'));
439
+ lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
252
440
  }
253
441
  if (missingConfigCount > 0)
254
- lines.push(` Other scanned config locations not present: ${missingConfigCount}`);
442
+ lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
255
443
  lines.push('');
256
444
  lines.push(statusLine(hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`));
257
445
  if (report.issues.length) {
258
- lines.push('', 'Issues:');
446
+ lines.push('', chalk.yellow('Issues:'));
259
447
  for (const issue of report.issues)
260
- lines.push(`- ${issue}`);
448
+ lines.push(chalk.dim(` • ${issue}`));
261
449
  }
262
450
  if (report.warnings.length) {
263
- lines.push('', 'Warnings:');
451
+ lines.push('', chalk.yellow('Warnings:'));
264
452
  for (const warning of report.warnings)
265
- lines.push(`- ${warning}`);
453
+ lines.push(chalk.dim(` • ${warning}`));
266
454
  }
267
455
  return lines.join('\n');
268
456
  }
@@ -276,7 +464,7 @@ async function confirmPrompt(question) {
276
464
  rl.close();
277
465
  }
278
466
  }
279
- function writeFileWithMkdir(filePath, content) {
467
+ export function writeFileWithMkdir(filePath, content) {
280
468
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
281
469
  fs.writeFileSync(filePath, content, 'utf-8');
282
470
  }
@@ -284,25 +472,38 @@ export async function applyBrowserDoctorFix(report, opts = {}) {
284
472
  const token = opts.token ?? report.recommendedToken;
285
473
  if (!token)
286
474
  throw new Error('No Playwright MCP token is available to write. Provide --token first.');
475
+ const fp = getTokenFingerprint(token);
287
476
  const plannedWrites = [];
288
477
  const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
289
- plannedWrites.push(shellPath);
478
+ const shellStatus = report.shellFiles.find(s => s.path === shellPath);
479
+ if (shellStatus?.fingerprint !== fp)
480
+ plannedWrites.push(shellPath);
290
481
  for (const config of report.configs) {
291
482
  if (!config.writable)
292
483
  continue;
484
+ if (config.fingerprint === fp)
485
+ continue; // already correct
293
486
  plannedWrites.push(config.path);
294
487
  }
488
+ if (plannedWrites.length === 0) {
489
+ console.log(chalk.green('All config files are already up to date.'));
490
+ return [];
491
+ }
295
492
  if (!opts.yes) {
296
- const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`);
493
+ const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
297
494
  if (!ok)
298
495
  return [];
299
496
  }
300
497
  const written = [];
301
- const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
302
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
303
- written.push(shellPath);
498
+ if (plannedWrites.includes(shellPath)) {
499
+ const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
500
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
501
+ written.push(shellPath);
502
+ }
304
503
  for (const config of report.configs) {
305
- if (!config.writable || config.parseError)
504
+ if (!plannedWrites.includes(config.path))
505
+ continue;
506
+ if (config.parseError)
306
507
  continue;
307
508
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
308
509
  const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
@@ -70,33 +70,40 @@ describe('json token helpers', () => {
70
70
  });
71
71
  });
72
72
  describe('doctor report rendering', () => {
73
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
73
74
  it('renders OK-style report when tokens match', () => {
74
- const text = renderBrowserDoctorReport({
75
+ const text = strip(renderBrowserDoctorReport({
75
76
  envToken: 'abc123',
76
77
  envFingerprint: 'fp1',
78
+ extensionToken: 'abc123',
79
+ extensionFingerprint: 'fp1',
77
80
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }],
78
81
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
79
82
  recommendedToken: 'abc123',
80
83
  recommendedFingerprint: 'fp1',
81
84
  warnings: [],
82
85
  issues: [],
83
- });
86
+ }));
84
87
  expect(text).toContain('[OK] Environment token: configured (fp1)');
85
- expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)');
88
+ expect(text).toContain('[OK] /tmp/mcp.json');
89
+ expect(text).toContain('configured (fp1)');
86
90
  });
87
91
  it('renders MISMATCH-style report when fingerprints differ', () => {
88
- const text = renderBrowserDoctorReport({
92
+ const text = strip(renderBrowserDoctorReport({
89
93
  envToken: 'abc123',
90
94
  envFingerprint: 'fp1',
95
+ extensionToken: null,
96
+ extensionFingerprint: null,
91
97
  shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }],
92
98
  configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }],
93
99
  recommendedToken: 'abc123',
94
100
  recommendedFingerprint: 'fp1',
95
101
  warnings: [],
96
102
  issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'],
97
- });
103
+ }));
98
104
  expect(text).toContain('[MISMATCH] Environment token: configured (fp1)');
99
- expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)');
105
+ expect(text).toContain('[MISMATCH] /tmp/.zshrc');
106
+ expect(text).toContain('configured (fp2)');
100
107
  expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1');
101
108
  });
102
109
  });
package/dist/main.js CHANGED
@@ -114,6 +114,13 @@ program.command('doctor')
114
114
  }
115
115
  }
116
116
  });
117
+ program.command('setup')
118
+ .description('Interactive setup: configure Playwright MCP token across all detected tools')
119
+ .option('--token <token>', 'Provide token directly instead of auto-detecting')
120
+ .action(async (opts) => {
121
+ const { runSetup } = await import('./setup.js');
122
+ await runSetup({ cliVersion: PKG_VERSION, token: opts.token });
123
+ });
117
124
  // ── Dynamic site commands ──────────────────────────────────────────────────
118
125
  const registry = getRegistry();
119
126
  const siteGroups = new Map();
@@ -0,0 +1,4 @@
1
+ export declare function runSetup(opts?: {
2
+ cliVersion?: string;
3
+ token?: string;
4
+ }): Promise<void>;
package/dist/setup.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * setup.ts — Interactive Playwright MCP token setup
3
+ *
4
+ * Discovers the extension token, shows an interactive checkbox
5
+ * for selecting which config files to update, and applies changes.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import chalk from 'chalk';
9
+ import { createInterface } from 'node:readline/promises';
10
+ import { stdin as input, stdout as output } from 'node:process';
11
+ import { PLAYWRIGHT_TOKEN_ENV, discoverExtensionToken, fileExists, getDefaultShellRcPath, runBrowserDoctor, shortenPath, toolName, upsertJsonConfigToken, upsertShellToken, upsertTomlConfigToken, writeFileWithMkdir, } from './doctor.js';
12
+ import { getTokenFingerprint } from './browser.js';
13
+ import { checkboxPrompt } from './tui.js';
14
+ export async function runSetup(opts = {}) {
15
+ console.log();
16
+ console.log(chalk.bold(' opencli setup') + chalk.dim(' — Playwright MCP token configuration'));
17
+ console.log();
18
+ // Step 1: Discover token
19
+ let token = opts.token ?? null;
20
+ if (!token) {
21
+ const extensionToken = discoverExtensionToken();
22
+ const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null;
23
+ if (extensionToken && envToken && extensionToken === envToken) {
24
+ token = extensionToken;
25
+ console.log(` ${chalk.green('✓')} Token auto-discovered from Chrome extension`);
26
+ console.log(` Fingerprint: ${chalk.bold(getTokenFingerprint(token) ?? 'unknown')}`);
27
+ }
28
+ else if (extensionToken) {
29
+ token = extensionToken;
30
+ console.log(` ${chalk.green('✓')} Token discovered from Chrome extension ` +
31
+ chalk.dim(`(${getTokenFingerprint(token)})`));
32
+ if (envToken && envToken !== extensionToken) {
33
+ console.log(` ${chalk.yellow('!')} Environment has different token ` +
34
+ chalk.dim(`(${getTokenFingerprint(envToken)})`));
35
+ }
36
+ }
37
+ else if (envToken) {
38
+ token = envToken;
39
+ console.log(` ${chalk.green('✓')} Token from environment variable ` +
40
+ chalk.dim(`(${getTokenFingerprint(token)})`));
41
+ }
42
+ }
43
+ else {
44
+ console.log(` ${chalk.green('✓')} Using provided token ` +
45
+ chalk.dim(`(${getTokenFingerprint(token)})`));
46
+ }
47
+ if (!token) {
48
+ console.log(` ${chalk.yellow('!')} No token found. Please enter it manually.`);
49
+ console.log(chalk.dim(' (Find it in the Playwright MCP Bridge extension → Status page)'));
50
+ console.log();
51
+ const rl = createInterface({ input, output });
52
+ const answer = await rl.question(' Token: ');
53
+ rl.close();
54
+ token = answer.trim();
55
+ if (!token) {
56
+ console.log(chalk.red('\n No token provided. Aborting.\n'));
57
+ return;
58
+ }
59
+ }
60
+ const fingerprint = getTokenFingerprint(token) ?? 'unknown';
61
+ console.log();
62
+ // Step 2: Scan all config locations
63
+ const report = await runBrowserDoctor({ token, cliVersion: opts.cliVersion });
64
+ // Step 3: Build checkbox items
65
+ const items = [];
66
+ // Shell file
67
+ const shellPath = report.shellFiles[0]?.path ?? getDefaultShellRcPath();
68
+ const shellStatus = report.shellFiles[0];
69
+ const shellFp = shellStatus?.fingerprint;
70
+ const shellOk = shellFp === fingerprint;
71
+ const shellTool = toolName(shellPath) || 'Shell';
72
+ items.push({
73
+ label: padRight(shortenPath(shellPath), 50) + chalk.dim(` [${shellTool}]`),
74
+ value: `shell:${shellPath}`,
75
+ checked: !shellOk,
76
+ status: shellOk ? `configured (${shellFp})` : shellFp ? `mismatch (${shellFp})` : 'missing',
77
+ statusColor: shellOk ? 'green' : shellFp ? 'yellow' : 'red',
78
+ });
79
+ // Config files
80
+ for (const config of report.configs) {
81
+ const fp = config.fingerprint;
82
+ const ok = fp === fingerprint;
83
+ const tool = toolName(config.path);
84
+ items.push({
85
+ label: padRight(shortenPath(config.path), 50) + chalk.dim(tool ? ` [${tool}]` : ''),
86
+ value: `config:${config.path}`,
87
+ checked: false, // let user explicitly select which tools to configure
88
+ status: ok ? `configured (${fp})` : !config.exists ? 'will create' : fp ? `mismatch (${fp})` : 'missing',
89
+ statusColor: ok ? 'green' : 'yellow',
90
+ });
91
+ }
92
+ // Step 4: Show interactive checkbox
93
+ console.clear();
94
+ const selected = await checkboxPrompt(items, {
95
+ title: ` ${chalk.bold('opencli setup')} — token ${chalk.cyan(fingerprint)}`,
96
+ });
97
+ if (selected.length === 0) {
98
+ console.log(chalk.dim(' No changes made.\n'));
99
+ return;
100
+ }
101
+ // Step 5: Apply changes
102
+ const written = [];
103
+ let wroteShell = false;
104
+ for (const sel of selected) {
105
+ if (sel.startsWith('shell:')) {
106
+ const p = sel.slice('shell:'.length);
107
+ const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
108
+ writeFileWithMkdir(p, upsertShellToken(before, token));
109
+ written.push(p);
110
+ wroteShell = true;
111
+ }
112
+ else if (sel.startsWith('config:')) {
113
+ const p = sel.slice('config:'.length);
114
+ const config = report.configs.find(c => c.path === p);
115
+ if (config && config.parseError)
116
+ continue;
117
+ const before = fileExists(p) ? fs.readFileSync(p, 'utf-8') : '';
118
+ const format = config?.format ?? (p.endsWith('.toml') ? 'toml' : 'json');
119
+ const next = format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
120
+ writeFileWithMkdir(p, next);
121
+ written.push(p);
122
+ }
123
+ }
124
+ process.env[PLAYWRIGHT_TOKEN_ENV] = token;
125
+ // Step 6: Summary
126
+ if (written.length > 0) {
127
+ console.log(chalk.green.bold(` ✓ Updated ${written.length} file(s):`));
128
+ for (const p of written) {
129
+ const tool = toolName(p);
130
+ console.log(` ${chalk.dim('•')} ${shortenPath(p)}${tool ? chalk.dim(` [${tool}]`) : ''}`);
131
+ }
132
+ if (wroteShell) {
133
+ console.log();
134
+ console.log(chalk.cyan(` 💡 Run ${chalk.bold(`source ${shortenPath(shellPath)}`)} to apply token to current shell.`));
135
+ }
136
+ }
137
+ else {
138
+ console.log(chalk.yellow(' No files were changed.'));
139
+ }
140
+ console.log();
141
+ }
142
+ function padRight(s, n) {
143
+ const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
144
+ return visible.length >= n ? s : s + ' '.repeat(n - visible.length);
145
+ }
package/dist/tui.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export interface CheckboxItem {
2
+ label: string;
3
+ value: string;
4
+ checked: boolean;
5
+ /** Optional status to display after the label */
6
+ status?: string;
7
+ statusColor?: 'green' | 'yellow' | 'red' | 'dim';
8
+ }
9
+ /**
10
+ * Interactive multi-select checkbox prompt.
11
+ *
12
+ * Controls:
13
+ * ↑/↓ or j/k — navigate
14
+ * Space — toggle selection
15
+ * a — toggle all
16
+ * Enter — confirm
17
+ * q/Esc — cancel (returns empty)
18
+ */
19
+ export declare function checkboxPrompt(items: CheckboxItem[], opts?: {
20
+ title?: string;
21
+ hint?: string;
22
+ }): Promise<string[]>;