@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/src/doctor.ts CHANGED
@@ -1,14 +1,16 @@
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 type { IPage } from './types.js';
7
9
  import { PlaywrightMCP, getTokenFingerprint } from './browser.js';
8
10
  import { browserSession } from './runtime.js';
9
11
 
10
12
  const PLAYWRIGHT_SERVER_NAME = 'playwright';
11
- const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
13
+ export const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN';
12
14
  const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm';
13
15
  const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m;
14
16
  export type DoctorOptions = {
@@ -43,6 +45,8 @@ export type DoctorReport = {
43
45
  cliVersion?: string;
44
46
  envToken: string | null;
45
47
  envFingerprint: string | null;
48
+ extensionToken: string | null;
49
+ extensionFingerprint: string | null;
46
50
  shellFiles: ShellFileStatus[];
47
51
  configs: McpConfigStatus[];
48
52
  recommendedToken: string | null;
@@ -53,17 +57,41 @@ export type DoctorReport = {
53
57
 
54
58
  type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN';
55
59
 
56
- function label(status: ReportStatus): string {
57
- return `[${status}]`;
60
+ function colorLabel(status: ReportStatus): string {
61
+ switch (status) {
62
+ case 'OK': return chalk.green('[OK]');
63
+ case 'MISSING': return chalk.red('[MISSING]');
64
+ case 'MISMATCH': return chalk.yellow('[MISMATCH]');
65
+ case 'WARN': return chalk.yellow('[WARN]');
66
+ }
58
67
  }
59
68
 
60
69
  function statusLine(status: ReportStatus, text: string): string {
61
- return `${label(status)} ${text}`;
70
+ return `${colorLabel(status)} ${text}`;
62
71
  }
63
72
 
64
73
  function tokenSummary(token: string | null, fingerprint: string | null): string {
65
- if (!token) return 'missing';
66
- return `configured (${fingerprint})`;
74
+ if (!token) return chalk.dim('missing');
75
+ return `configured ${chalk.dim(`(${fingerprint})`)}`;
76
+ }
77
+
78
+ export function shortenPath(p: string): string {
79
+ const home = os.homedir();
80
+ return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
81
+ }
82
+
83
+ export function toolName(p: string): string {
84
+ if (p.includes('.codex/')) return 'Codex';
85
+ if (p.includes('.cursor/')) return 'Cursor';
86
+ if (p.includes('.claude.json')) return 'Claude Code';
87
+ if (p.includes('antigravity')) return 'Antigravity';
88
+ if (p.includes('.gemini/settings')) return 'Gemini CLI';
89
+ if (p.includes('opencode')) return 'OpenCode';
90
+ if (p.includes('Claude/claude_desktop')) return 'Claude Desktop';
91
+ if (p.includes('.vscode/')) return 'VS Code';
92
+ if (p.includes('.mcp.json')) return 'Project MCP';
93
+ if (p.includes('.zshrc') || p.includes('.bashrc') || p.includes('.profile')) return 'Shell';
94
+ return '';
67
95
  }
68
96
 
69
97
  export function getDefaultShellRcPath(): string {
@@ -79,12 +107,16 @@ export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[]
79
107
  path.join(home, '.codex', 'config.toml'),
80
108
  path.join(home, '.codex', 'mcp.json'),
81
109
  path.join(home, '.cursor', 'mcp.json'),
110
+ path.join(home, '.claude.json'),
111
+ path.join(home, '.gemini', 'settings.json'),
112
+ path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
82
113
  path.join(home, '.config', 'opencode', 'opencode.json'),
83
114
  path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
84
115
  path.join(home, '.config', 'Claude', 'claude_desktop_config.json'),
85
116
  path.join(cwd, '.cursor', 'mcp.json'),
86
117
  path.join(cwd, '.vscode', 'mcp.json'),
87
118
  path.join(cwd, '.opencode', 'opencode.json'),
119
+ path.join(cwd, '.mcp.json'),
88
120
  ];
89
121
  return [...new Set(candidates)];
90
122
  }
@@ -170,7 +202,7 @@ export function upsertTomlConfigToken(content: string, token: string): string {
170
202
  return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`;
171
203
  }
172
204
 
173
- function fileExists(filePath: string): boolean {
205
+ export function fileExists(filePath: string): boolean {
174
206
  try {
175
207
  return fs.existsSync(filePath);
176
208
  } catch {
@@ -220,6 +252,145 @@ function readConfigStatus(filePath: string): McpConfigStatus {
220
252
  }
221
253
  }
222
254
 
255
+ /**
256
+ * Discover the auth token stored by the Playwright MCP Bridge extension
257
+ * by scanning Chrome's LevelDB localStorage files directly.
258
+ *
259
+ * Uses `strings` + `grep` for fast binary scanning on macOS/Linux,
260
+ * with a pure-Node fallback on Windows.
261
+ */
262
+ export function discoverExtensionToken(): string | null {
263
+ const home = os.homedir();
264
+ const platform = os.platform();
265
+ const bases: string[] = [];
266
+
267
+ if (platform === 'darwin') {
268
+ bases.push(
269
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome'),
270
+ path.join(home, 'Library', 'Application Support', 'Google', 'Chrome Canary'),
271
+ path.join(home, 'Library', 'Application Support', 'Chromium'),
272
+ path.join(home, 'Library', 'Application Support', 'Microsoft Edge'),
273
+ );
274
+ } else if (platform === 'linux') {
275
+ bases.push(
276
+ path.join(home, '.config', 'google-chrome'),
277
+ path.join(home, '.config', 'chromium'),
278
+ path.join(home, '.config', 'microsoft-edge'),
279
+ );
280
+ } else if (platform === 'win32') {
281
+ const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
282
+ bases.push(
283
+ path.join(appData, 'Google', 'Chrome', 'User Data'),
284
+ path.join(appData, 'Microsoft', 'Edge', 'User Data'),
285
+ );
286
+ }
287
+
288
+ const profiles = ['Default', 'Profile 1', 'Profile 2', 'Profile 3'];
289
+ // Token is 43 chars of base64url (from 32 random bytes)
290
+ const tokenRe = /([A-Za-z0-9_-]{40,50})/;
291
+
292
+ for (const base of bases) {
293
+ for (const profile of profiles) {
294
+ const dir = path.join(base, profile, 'Local Storage', 'leveldb');
295
+ if (!fileExists(dir)) continue;
296
+
297
+ // Fast path: use strings + grep to find candidate files and extract token
298
+ if (platform !== 'win32') {
299
+ const token = extractTokenViaStrings(dir, tokenRe);
300
+ if (token) return token;
301
+ continue;
302
+ }
303
+
304
+ // Slow path (Windows): read binary files directly
305
+ const token = extractTokenViaBinaryRead(dir, tokenRe);
306
+ if (token) return token;
307
+ }
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ function extractTokenViaStrings(dir: string, tokenRe: RegExp): string | null {
314
+ try {
315
+ // Single shell pipeline: for each LevelDB file, extract strings, find lines
316
+ // after the extension ID, and filter for base64url token pattern.
317
+ //
318
+ // LevelDB `strings` output for the extension's auth-token entry:
319
+ // auth-token ← key name
320
+ // 4,mmlmfjhmonkocbjadbfplnigmagldckm.7 ← LevelDB internal key
321
+ // hqI86ncsD1QpcVcj-k9CyzTF-ieCQd_4KreZ_wy1WHA ← token value
322
+ //
323
+ // We get the line immediately after any EXTENSION_ID mention and check
324
+ // if it looks like a base64url token (40-50 chars, [A-Za-z0-9_-]).
325
+ const shellDir = dir.replace(/'/g, "'\\''");
326
+ const cmd = `for f in '${shellDir}'/*.ldb '${shellDir}'/*.log; do ` +
327
+ `[ -f "$f" ] && strings "$f" 2>/dev/null | ` +
328
+ `grep -A1 '${PLAYWRIGHT_EXTENSION_ID}' | ` +
329
+ `grep -v '${PLAYWRIGHT_EXTENSION_ID}' | ` +
330
+ `grep -E '^[A-Za-z0-9_-]{40,50}$' | head -1; ` +
331
+ `done 2>/dev/null`;
332
+ const result = execSync(cmd, { encoding: 'utf-8', timeout: 10000 }).trim();
333
+
334
+ // Take the first non-empty line
335
+ for (const line of result.split('\n')) {
336
+ const token = line.trim();
337
+ if (token && validateBase64urlToken(token)) return token;
338
+ }
339
+ } catch {}
340
+ return null;
341
+ }
342
+
343
+ function extractTokenViaBinaryRead(dir: string, tokenRe: RegExp): string | null {
344
+ const extIdBuf = Buffer.from(PLAYWRIGHT_EXTENSION_ID);
345
+ const keyBuf = Buffer.from('auth-token');
346
+
347
+ let files: string[];
348
+ try {
349
+ files = fs.readdirSync(dir)
350
+ .filter(f => f.endsWith('.ldb') || f.endsWith('.log'))
351
+ .map(f => path.join(dir, f));
352
+ } catch { return null; }
353
+
354
+ // Sort by mtime descending
355
+ files.sort((a, b) => {
356
+ try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; }
357
+ });
358
+
359
+ for (const file of files) {
360
+ let data: Buffer;
361
+ try { data = fs.readFileSync(file); } catch { continue; }
362
+
363
+ // Quick check: does file contain both the extension ID and auth-token key?
364
+ const extPos = data.indexOf(extIdBuf);
365
+ if (extPos === -1) continue;
366
+ const keyPos = data.indexOf(keyBuf, Math.max(0, extPos - 500));
367
+ if (keyPos === -1) continue;
368
+
369
+ // Scan for token value after auth-token key
370
+ let idx = 0;
371
+ while (true) {
372
+ const kp = data.indexOf(keyBuf, idx);
373
+ if (kp === -1) break;
374
+
375
+ const contextStart = Math.max(0, kp - 500);
376
+ if (data.indexOf(extIdBuf, contextStart) !== -1 && data.indexOf(extIdBuf, contextStart) < kp) {
377
+ const after = data.subarray(kp + keyBuf.length, kp + keyBuf.length + 200).toString('latin1');
378
+ const m = after.match(tokenRe);
379
+ if (m && validateBase64urlToken(m[1])) return m[1];
380
+ }
381
+ idx = kp + 1;
382
+ }
383
+ }
384
+ return null;
385
+ }
386
+
387
+ function validateBase64urlToken(token: string): boolean {
388
+ try {
389
+ const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
390
+ const decoded = Buffer.from(b64, 'base64');
391
+ return decoded.length >= 28 && decoded.length <= 36;
392
+ } catch { return false; }
393
+ }
223
394
 
224
395
 
225
396
  export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
@@ -234,19 +405,25 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
234
405
  const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths();
235
406
  const configs = configPaths.map(readConfigStatus);
236
407
 
408
+ // Try to discover the token directly from the Chrome extension's localStorage
409
+ const extensionToken = discoverExtensionToken();
410
+
237
411
  const allTokens = [
238
412
  opts.token ?? null,
413
+ extensionToken,
239
414
  envToken,
240
415
  ...shellFiles.map(s => s.token),
241
416
  ...configs.map(c => c.token),
242
417
  ].filter((v): v is string => !!v);
243
418
  const uniqueTokens = [...new Set(allTokens)];
244
- const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
419
+ const recommendedToken = opts.token ?? extensionToken ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : null) ?? null;
245
420
 
246
421
  const report: DoctorReport = {
247
422
  cliVersion: opts.cliVersion,
248
423
  envToken,
249
424
  envFingerprint: getTokenFingerprint(envToken ?? undefined),
425
+ extensionToken,
426
+ extensionFingerprint: getTokenFingerprint(extensionToken ?? undefined),
250
427
  shellFiles,
251
428
  configs,
252
429
  recommendedToken,
@@ -270,26 +447,32 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
270
447
 
271
448
  export function renderBrowserDoctorReport(report: DoctorReport): string {
272
449
  const tokenFingerprints = [
450
+ report.extensionFingerprint,
273
451
  report.envFingerprint,
274
452
  ...report.shellFiles.map(shell => shell.fingerprint),
275
453
  ...report.configs.filter(config => config.exists).map(config => config.fingerprint),
276
454
  ].filter((value): value is string => !!value);
277
455
  const uniqueFingerprints = [...new Set(tokenFingerprints)];
278
456
  const hasMismatch = uniqueFingerprints.length > 1;
279
- const lines = [`opencli v${report.cliVersion ?? 'unknown'} doctor`, ''];
457
+ const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
458
+
459
+ const extStatus: ReportStatus = !report.extensionToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
460
+ lines.push(statusLine(extStatus, `Extension token (Chrome LevelDB): ${tokenSummary(report.extensionToken, report.extensionFingerprint)}`));
280
461
 
281
462
  const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
282
463
  lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`));
283
464
 
284
465
  for (const shell of report.shellFiles) {
285
466
  const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK';
286
- lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`));
467
+ const tool = toolName(shell.path);
468
+ const suffix = tool ? chalk.dim(` [${tool}]`) : '';
469
+ lines.push(statusLine(shellStatus, `${shortenPath(shell.path)}${suffix}: ${tokenSummary(shell.token, shell.fingerprint)}`));
287
470
  }
288
471
  const existingConfigs = report.configs.filter(config => config.exists);
289
472
  const missingConfigCount = report.configs.length - existingConfigs.length;
290
473
  if (existingConfigs.length > 0) {
291
474
  for (const config of existingConfigs) {
292
- const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : '';
475
+ const parseSuffix = config.parseError ? chalk.red(` (parse error)`) : '';
293
476
  const configStatus: ReportStatus = config.parseError
294
477
  ? 'WARN'
295
478
  : !config.token
@@ -297,24 +480,26 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
297
480
  : hasMismatch
298
481
  ? 'MISMATCH'
299
482
  : 'OK';
300
- lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
483
+ const tool = toolName(config.path);
484
+ const suffix = tool ? chalk.dim(` [${tool}]`) : '';
485
+ lines.push(statusLine(configStatus, `${shortenPath(config.path)}${suffix}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`));
301
486
  }
302
487
  } else {
303
- lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations'));
488
+ lines.push(statusLine('MISSING', 'MCP config: no existing config files found'));
304
489
  }
305
- if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`);
490
+ if (missingConfigCount > 0) lines.push(chalk.dim(` Other scanned config locations not present: ${missingConfigCount}`));
306
491
  lines.push('');
307
492
  lines.push(statusLine(
308
493
  hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN',
309
494
  `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`,
310
495
  ));
311
496
  if (report.issues.length) {
312
- lines.push('', 'Issues:');
313
- for (const issue of report.issues) lines.push(`- ${issue}`);
497
+ lines.push('', chalk.yellow('Issues:'));
498
+ for (const issue of report.issues) lines.push(chalk.dim(` • ${issue}`));
314
499
  }
315
500
  if (report.warnings.length) {
316
- lines.push('', 'Warnings:');
317
- for (const warning of report.warnings) lines.push(`- ${warning}`);
501
+ lines.push('', chalk.yellow('Warnings:'));
502
+ for (const warning of report.warnings) lines.push(chalk.dim(` • ${warning}`));
318
503
  }
319
504
  return lines.join('\n');
320
505
  }
@@ -329,7 +514,7 @@ async function confirmPrompt(question: string): Promise<boolean> {
329
514
  }
330
515
  }
331
516
 
332
- function writeFileWithMkdir(filePath: string, content: string): void {
517
+ export function writeFileWithMkdir(filePath: string, content: string): void {
333
518
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
334
519
  fs.writeFileSync(filePath, content, 'utf-8');
335
520
  }
@@ -337,27 +522,38 @@ function writeFileWithMkdir(filePath: string, content: string): void {
337
522
  export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise<string[]> {
338
523
  const token = opts.token ?? report.recommendedToken;
339
524
  if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token first.');
525
+ const fp = getTokenFingerprint(token);
340
526
 
341
527
  const plannedWrites: string[] = [];
342
528
  const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath();
343
- plannedWrites.push(shellPath);
529
+ const shellStatus = report.shellFiles.find(s => s.path === shellPath);
530
+ if (shellStatus?.fingerprint !== fp) plannedWrites.push(shellPath);
344
531
  for (const config of report.configs) {
345
532
  if (!config.writable) continue;
533
+ if (config.fingerprint === fp) continue; // already correct
346
534
  plannedWrites.push(config.path);
347
535
  }
348
536
 
537
+ if (plannedWrites.length === 0) {
538
+ console.log(chalk.green('All config files are already up to date.'));
539
+ return [];
540
+ }
541
+
349
542
  if (!opts.yes) {
350
- const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`);
543
+ const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${fp}?`);
351
544
  if (!ok) return [];
352
545
  }
353
546
 
354
547
  const written: string[] = [];
355
- const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
356
- writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
357
- written.push(shellPath);
548
+ if (plannedWrites.includes(shellPath)) {
549
+ const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : '';
550
+ writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token));
551
+ written.push(shellPath);
552
+ }
358
553
 
359
554
  for (const config of report.configs) {
360
- if (!config.writable || config.parseError) continue;
555
+ if (!plannedWrites.includes(config.path)) continue;
556
+ if (config.parseError) continue;
361
557
  const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : '';
362
558
  const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token);
363
559
  writeFileWithMkdir(config.path, next);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Tests for engine.ts: CLI discovery and command execution.
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { discoverClis, executeCommand } from './engine.js';
7
+ import { getRegistry, cli, Strategy } from './registry.js';
8
+
9
+ describe('discoverClis', () => {
10
+ it('handles non-existent directories gracefully', async () => {
11
+ // Should not throw for missing directories
12
+ await expect(discoverClis('/tmp/nonexistent-opencli-test-dir')).resolves.not.toThrow();
13
+ });
14
+ });
15
+
16
+ describe('executeCommand', () => {
17
+ it('executes a command with func', async () => {
18
+ const cmd = cli({
19
+ site: 'test-engine',
20
+ name: 'func-test',
21
+ description: 'test command with func',
22
+ browser: false,
23
+ strategy: Strategy.PUBLIC,
24
+ func: async (_page, kwargs) => {
25
+ return [{ title: kwargs.query ?? 'default' }];
26
+ },
27
+ });
28
+
29
+ const result = await executeCommand(cmd, null, { query: 'hello' });
30
+ expect(result).toEqual([{ title: 'hello' }]);
31
+ });
32
+
33
+ it('executes a command with pipeline', async () => {
34
+ const cmd = cli({
35
+ site: 'test-engine',
36
+ name: 'pipe-test',
37
+ description: 'test command with pipeline',
38
+ browser: false,
39
+ strategy: Strategy.PUBLIC,
40
+ pipeline: [
41
+ { evaluate: '() => [{ n: 1 }, { n: 2 }, { n: 3 }]' },
42
+ { limit: '2' },
43
+ ],
44
+ });
45
+
46
+ // Pipeline commands require page for evaluate step, so we'll test the error path
47
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow();
48
+ });
49
+
50
+ it('throws for command with no func or pipeline', async () => {
51
+ const cmd = cli({
52
+ site: 'test-engine',
53
+ name: 'empty-test',
54
+ description: 'empty command',
55
+ browser: false,
56
+ });
57
+
58
+ await expect(executeCommand(cmd, null, {})).rejects.toThrow('has no func or pipeline');
59
+ });
60
+
61
+ it('passes debug flag to func', async () => {
62
+ let receivedDebug = false;
63
+ const cmd = cli({
64
+ site: 'test-engine',
65
+ name: 'debug-test',
66
+ description: 'debug test',
67
+ browser: false,
68
+ func: async (_page, _kwargs, debug) => {
69
+ receivedDebug = debug ?? false;
70
+ return [];
71
+ },
72
+ });
73
+
74
+ await executeCommand(cmd, null, {}, true);
75
+ expect(receivedDebug).toBe(true);
76
+ });
77
+ });
package/src/engine.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
  import yaml from 'js-yaml';
14
- import { type CliCommand, type Arg, Strategy, registerCommand } from './registry.js';
14
+ import { type CliCommand, type InternalCliCommand, type Arg, Strategy, registerCommand } from './registry.js';
15
15
  import type { IPage } from './types.js';
16
16
  import { executePipeline } from './pipeline.js';
17
17
 
@@ -66,7 +66,7 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
66
66
  // The actual module is loaded lazily on first executeCommand().
67
67
  const strategy = (Strategy as any)[(entry.strategy ?? 'cookie').toUpperCase()] ?? Strategy.COOKIE;
68
68
  const modulePath = path.resolve(clisDir, entry.modulePath);
69
- const cmd: CliCommand = {
69
+ const cmd: InternalCliCommand = {
70
70
  site: entry.site,
71
71
  name: entry.name,
72
72
  description: entry.description ?? '',
@@ -77,7 +77,6 @@ function loadFromManifest(manifestPath: string, clisDir: string): void {
77
77
  columns: entry.columns,
78
78
  timeoutSeconds: entry.timeout,
79
79
  source: modulePath,
80
- // Mark as lazy — executeCommand will load the module before running
81
80
  _lazy: true,
82
81
  _modulePath: modulePath,
83
82
  };
@@ -170,8 +169,9 @@ export async function executeCommand(
170
169
  debug: boolean = false,
171
170
  ): Promise<any> {
172
171
  // Lazy-load TS module on first execution
173
- if ((cmd as any)._lazy && (cmd as any)._modulePath) {
174
- const modulePath = (cmd as any)._modulePath;
172
+ const internal = cmd as InternalCliCommand;
173
+ if (internal._lazy && internal._modulePath) {
174
+ const modulePath = internal._modulePath;
175
175
  if (!_loadedModules.has(modulePath)) {
176
176
  try {
177
177
  await import(`file://${modulePath}`);
package/src/explore.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'node:fs';
10
10
  import * as path from 'node:path';
11
11
  import { DEFAULT_BROWSER_EXPLORE_TIMEOUT, browserSession, runWithTimeout } from './runtime.js';
12
+ import { VOLATILE_PARAMS, SEARCH_PARAMS, PAGINATION_PARAMS, LIMIT_PARAMS, FIELD_ROLES } from './constants.js';
12
13
 
13
14
  // ── Site name detection ────────────────────────────────────────────────────
14
15
 
@@ -43,21 +44,7 @@ export function slugify(value: string): string {
43
44
 
44
45
  // ── Field & capability inference ───────────────────────────────────────────
45
46
 
46
- const FIELD_ROLES: Record<string, string[]> = {
47
- title: ['title', 'name', 'text', 'content', 'desc', 'description', 'headline', 'subject'],
48
- url: ['url', 'uri', 'link', 'href', 'permalink', 'jump_url', 'web_url', 'share_url'],
49
- author: ['author', 'username', 'user_name', 'nickname', 'nick', 'owner', 'creator', 'up_name', 'uname'],
50
- score: ['score', 'hot', 'heat', 'likes', 'like_count', 'view_count', 'views', 'play', 'favorite_count', 'reply_count'],
51
- time: ['time', 'created_at', 'publish_time', 'pub_time', 'date', 'ctime', 'mtime', 'pubdate', 'created'],
52
- id: ['id', 'aid', 'bvid', 'mid', 'uid', 'oid', 'note_id', 'item_id'],
53
- cover: ['cover', 'pic', 'image', 'thumbnail', 'poster', 'avatar'],
54
- category: ['category', 'tag', 'type', 'tname', 'channel', 'section'],
55
- };
56
-
57
- const SEARCH_PARAMS = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'search_query', 'w']);
58
- const PAGINATION_PARAMS = new Set(['page', 'pn', 'offset', 'cursor', 'next', 'page_num']);
59
- const LIMIT_PARAMS = new Set(['limit', 'count', 'size', 'per_page', 'page_size', 'ps', 'num']);
60
- const VOLATILE_PARAMS = new Set(['w_rid', 'wts', '_', 'callback', 'timestamp', 't', 'nonce', 'sign']);
47
+ // (constants now imported from constants.ts)
61
48
 
62
49
  // ── Network analysis ───────────────────────────────────────────────────────
63
50