@jackwener/opencli 0.5.1 → 0.6.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 (76) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +4 -3
  3. package/SKILL.md +7 -4
  4. package/dist/browser.d.ts +7 -3
  5. package/dist/browser.js +25 -92
  6. package/dist/browser.test.js +18 -1
  7. package/dist/cascade.d.ts +1 -1
  8. package/dist/cascade.js +42 -75
  9. package/dist/cli-manifest.json +80 -0
  10. package/dist/clis/coupang/add-to-cart.d.ts +1 -0
  11. package/dist/clis/coupang/add-to-cart.js +141 -0
  12. package/dist/clis/coupang/search.d.ts +1 -0
  13. package/dist/clis/coupang/search.js +453 -0
  14. package/dist/constants.d.ts +13 -0
  15. package/dist/constants.js +30 -0
  16. package/dist/coupang.d.ts +24 -0
  17. package/dist/coupang.js +262 -0
  18. package/dist/coupang.test.d.ts +1 -0
  19. package/dist/coupang.test.js +62 -0
  20. package/dist/doctor.d.ts +15 -0
  21. package/dist/doctor.js +226 -25
  22. package/dist/doctor.test.js +13 -6
  23. package/dist/engine.js +3 -3
  24. package/dist/engine.test.d.ts +4 -0
  25. package/dist/engine.test.js +67 -0
  26. package/dist/explore.js +1 -15
  27. package/dist/interceptor.d.ts +42 -0
  28. package/dist/interceptor.js +138 -0
  29. package/dist/main.js +8 -4
  30. package/dist/output.js +0 -5
  31. package/dist/pipeline/steps/intercept.js +4 -54
  32. package/dist/pipeline/steps/tap.js +11 -51
  33. package/dist/registry.d.ts +3 -1
  34. package/dist/registry.test.d.ts +4 -0
  35. package/dist/registry.test.js +90 -0
  36. package/dist/runtime.d.ts +15 -1
  37. package/dist/runtime.js +11 -6
  38. package/dist/setup.d.ts +4 -0
  39. package/dist/setup.js +145 -0
  40. package/dist/synthesize.js +5 -5
  41. package/dist/tui.d.ts +22 -0
  42. package/dist/tui.js +139 -0
  43. package/dist/validate.js +21 -0
  44. package/dist/verify.d.ts +7 -0
  45. package/dist/verify.js +7 -1
  46. package/dist/version.d.ts +4 -0
  47. package/dist/version.js +16 -0
  48. package/package.json +1 -1
  49. package/src/browser.test.ts +20 -1
  50. package/src/browser.ts +25 -87
  51. package/src/cascade.ts +47 -75
  52. package/src/clis/coupang/add-to-cart.ts +149 -0
  53. package/src/clis/coupang/search.ts +466 -0
  54. package/src/constants.ts +35 -0
  55. package/src/coupang.test.ts +78 -0
  56. package/src/coupang.ts +302 -0
  57. package/src/doctor.test.ts +15 -6
  58. package/src/doctor.ts +221 -25
  59. package/src/engine.test.ts +77 -0
  60. package/src/engine.ts +5 -5
  61. package/src/explore.ts +2 -15
  62. package/src/interceptor.ts +153 -0
  63. package/src/main.ts +9 -5
  64. package/src/output.ts +0 -4
  65. package/src/pipeline/executor.ts +15 -15
  66. package/src/pipeline/steps/intercept.ts +4 -55
  67. package/src/pipeline/steps/tap.ts +12 -51
  68. package/src/registry.test.ts +106 -0
  69. package/src/registry.ts +4 -1
  70. package/src/runtime.ts +22 -8
  71. package/src/setup.ts +169 -0
  72. package/src/synthesize.ts +5 -5
  73. package/src/tui.ts +171 -0
  74. package/src/validate.ts +22 -0
  75. package/src/verify.ts +10 -1
  76. package/src/version.ts +18 -0
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/engine.js CHANGED
@@ -73,7 +73,6 @@ function loadFromManifest(manifestPath, clisDir) {
73
73
  columns: entry.columns,
74
74
  timeoutSeconds: entry.timeout,
75
75
  source: modulePath,
76
- // Mark as lazy — executeCommand will load the module before running
77
76
  _lazy: true,
78
77
  _modulePath: modulePath,
79
78
  };
@@ -158,8 +157,9 @@ function registerYamlCli(filePath, defaultSite) {
158
157
  */
159
158
  export async function executeCommand(cmd, page, kwargs, debug = false) {
160
159
  // Lazy-load TS module on first execution
161
- if (cmd._lazy && cmd._modulePath) {
162
- const modulePath = cmd._modulePath;
160
+ const internal = cmd;
161
+ if (internal._lazy && internal._modulePath) {
162
+ const modulePath = internal._modulePath;
163
163
  if (!_loadedModules.has(modulePath)) {
164
164
  try {
165
165
  await import(`file://${modulePath}`);
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for engine.ts: CLI discovery and command execution.
3
+ */
4
+ export {};
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Tests for engine.ts: CLI discovery and command execution.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { discoverClis, executeCommand } from './engine.js';
6
+ import { cli, Strategy } from './registry.js';
7
+ describe('discoverClis', () => {
8
+ it('handles non-existent directories gracefully', async () => {
9
+ // Should not throw for missing directories
10
+ await expect(discoverClis('/tmp/nonexistent-opencli-test-dir')).resolves.not.toThrow();
11
+ });
12
+ });
13
+ describe('executeCommand', () => {
14
+ it('executes a command with func', async () => {
15
+ const cmd = cli({
16
+ site: 'test-engine',
17
+ name: 'func-test',
18
+ description: 'test command with func',
19
+ browser: false,
20
+ strategy: Strategy.PUBLIC,
21
+ func: async (_page, kwargs) => {
22
+ return [{ title: kwargs.query ?? 'default' }];
23
+ },
24
+ });
25
+ const result = await executeCommand(cmd, null, { query: 'hello' });
26
+ expect(result).toEqual([{ title: 'hello' }]);
27
+ });
28
+ it('executes a command with pipeline', async () => {
29
+ const cmd = cli({
30
+ site: 'test-engine',
31
+ name: 'pipe-test',
32
+ description: 'test command with pipeline',
33
+ browser: false,
34
+ strategy: Strategy.PUBLIC,
35
+ pipeline: [
36
+ { evaluate: '() => [{ n: 1 }, { n: 2 }, { n: 3 }]' },
37
+ { limit: '2' },
38
+ ],
39
+ });
40
+ // Pipeline commands require page for evaluate step, so we'll test the error path
41
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow();
42
+ });
43
+ it('throws for command with no func or pipeline', async () => {
44
+ const cmd = cli({
45
+ site: 'test-engine',
46
+ name: 'empty-test',
47
+ description: 'empty command',
48
+ browser: false,
49
+ });
50
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow('has no func or pipeline');
51
+ });
52
+ it('passes debug flag to func', async () => {
53
+ let receivedDebug = false;
54
+ const cmd = cli({
55
+ site: 'test-engine',
56
+ name: 'debug-test',
57
+ description: 'debug test',
58
+ browser: false,
59
+ func: async (_page, _kwargs, debug) => {
60
+ receivedDebug = debug ?? false;
61
+ return [];
62
+ },
63
+ });
64
+ await executeCommand(cmd, null, {}, true);
65
+ expect(receivedDebug).toBe(true);
66
+ });
67
+ });
package/dist/explore.js CHANGED
@@ -8,6 +8,7 @@
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
10
  import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
11
+ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_ROLES } from './constants.js';
11
12
  // ── Site name detection ────────────────────────────────────────────────────
12
13
  const KNOWN_SITE_ALIASES = {
13
14
  'x.com': 'twitter', 'twitter.com': 'twitter',
@@ -39,21 +40,6 @@ export function detectSiteName(url) {
39
40
  export function slugify(value) {
40
41
  return value.trim().toLowerCase().replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '') || 'site';
41
42
  }
42
- // ── Field & capability inference ───────────────────────────────────────────
43
- const FIELD_ROLES = {
44
- title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
45
- url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
46
- author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
47
- score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
48
- time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
49
- id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
50
- cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
51
- category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
52
- };
53
- const SEARCH_PARAMS = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w']);
54
- const PAGINATION_PARAMS = new Set(['page', 'pn', 'offset', 'cursor', 'next', 'page_num']);
55
- const LIMIT_PARAMS = new Set(['limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num']);
56
- const VOLATILE_PARAMS = new Set(['w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign']);
57
43
  /**
58
44
  * Parse raw network output from Playwright MCP.
59
45
  * Handles text format: [GET] url => [200]
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Shared XHR/Fetch interceptor JavaScript generators.
3
+ *
4
+ * Provides a single source of truth for monkey-patching browser
5
+ * fetch() and XMLHttpRequest to capture API responses matching
6
+ * a URL pattern. Used by:
7
+ * - Page.installInterceptor() (browser.ts)
8
+ * - stepIntercept (pipeline/steps/intercept.ts)
9
+ * - stepTap (pipeline/steps/tap.ts)
10
+ */
11
+ /**
12
+ * Generate JavaScript source that installs a fetch/XHR interceptor.
13
+ * Captured responses are pushed to `window.__opencli_intercepted`.
14
+ *
15
+ * @param patternExpr - JS expression resolving to a URL substring to match (e.g. a JSON.stringify'd string)
16
+ * @param opts.arrayName - Global array name for captured data (default: '__opencli_intercepted')
17
+ * @param opts.patchGuard - Global boolean name to prevent double-patching (default: '__opencli_interceptor_patched')
18
+ */
19
+ export declare function generateInterceptorJs(patternExpr: string, opts?: {
20
+ arrayName?: string;
21
+ patchGuard?: string;
22
+ }): string;
23
+ /**
24
+ * Generate JavaScript source to read and clear intercepted data.
25
+ */
26
+ export declare function generateReadInterceptedJs(arrayName?: string): string;
27
+ /**
28
+ * Generate a self-contained tap interceptor for store-action bridge.
29
+ * Unlike the global interceptor, this one:
30
+ * - Installs temporarily, restores originals in finally block
31
+ * - Resolves a promise on first capture (for immediate await)
32
+ * - Returns captured data directly
33
+ */
34
+ export declare function generateTapInterceptorJs(patternExpr: string): {
35
+ setupVar: string;
36
+ capturedVar: string;
37
+ promiseVar: string;
38
+ resolveVar: string;
39
+ fetchPatch: string;
40
+ xhrPatch: string;
41
+ restorePatch: string;
42
+ };