@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
@@ -5,11 +5,11 @@
5
5
  import * as fs from 'node:fs';
6
6
  import * as path from 'node:path';
7
7
  import yaml from 'js-yaml';
8
- /** Volatile params to strip from generated URLs */
9
- const VOLATILE_PARAMS = new Set(['w_rid', 'wts', 'callback', '_', 'timestamp', 't', 'nonce', 'sign']);
10
- const SEARCH_PARAM_NAMES = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'w', 'search_query']);
11
- const LIMIT_PARAM_NAMES = new Set(['ps', 'page_size', 'limit', 'count', 'per_page', 'size', 'num']);
12
- const PAGE_PARAM_NAMES = new Set(['pn', 'page', 'page_num', 'offset', 'cursor']);
8
+ import { VOLATILE_PARAMS, SEARCH_PARAMS, LIMIT_PARAMS, PAGINATION_PARAMS } from './constants.js';
9
+ /** Renamed aliases for backward compatibility with local references */
10
+ const SEARCH_PARAM_NAMES = SEARCH_PARAMS;
11
+ const LIMIT_PARAM_NAMES = LIMIT_PARAMS;
12
+ const PAGE_PARAM_NAMES = PAGINATION_PARAMS;
13
13
  export function synthesizeFromExplore(target, opts = {}) {
14
14
  const exploreDir = resolveExploreDir(target);
15
15
  const bundle = loadExploreBundle(exploreDir);
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[]>;
package/dist/tui.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * tui.ts — Zero-dependency interactive TUI components
3
+ *
4
+ * Uses raw stdin mode + ANSI escape codes for interactive prompts.
5
+ */
6
+ import chalk from 'chalk';
7
+ /**
8
+ * Interactive multi-select checkbox prompt.
9
+ *
10
+ * Controls:
11
+ * ↑/↓ or j/k — navigate
12
+ * Space — toggle selection
13
+ * a — toggle all
14
+ * Enter — confirm
15
+ * q/Esc — cancel (returns empty)
16
+ */
17
+ export async function checkboxPrompt(items, opts = {}) {
18
+ if (items.length === 0)
19
+ return [];
20
+ const { stdin, stdout } = process;
21
+ if (!stdin.isTTY) {
22
+ // Non-interactive: return all checked items
23
+ return items.filter(i => i.checked).map(i => i.value);
24
+ }
25
+ let cursor = 0;
26
+ const state = items.map(i => ({ ...i }));
27
+ function colorStatus(status, color) {
28
+ if (!status)
29
+ return '';
30
+ switch (color) {
31
+ case 'green': return chalk.green(status);
32
+ case 'yellow': return chalk.yellow(status);
33
+ case 'red': return chalk.red(status);
34
+ case 'dim': return chalk.dim(status);
35
+ default: return chalk.dim(status);
36
+ }
37
+ }
38
+ function render() {
39
+ // Move cursor to start and clear
40
+ let out = '';
41
+ if (opts.title) {
42
+ out += `\n${chalk.bold(opts.title)}\n\n`;
43
+ }
44
+ for (let i = 0; i < state.length; i++) {
45
+ const item = state[i];
46
+ const pointer = i === cursor ? chalk.cyan('❯') : ' ';
47
+ const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
48
+ const label = i === cursor ? chalk.bold(item.label) : item.label;
49
+ const status = colorStatus(item.status, item.statusColor);
50
+ out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
51
+ }
52
+ out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
53
+ return out;
54
+ }
55
+ return new Promise((resolve) => {
56
+ const wasRaw = stdin.isRaw;
57
+ stdin.setRawMode(true);
58
+ stdin.resume();
59
+ stdout.write('\x1b[?25l'); // Hide cursor
60
+ let firstDraw = true;
61
+ function draw() {
62
+ // Clear previous render (skip on first draw)
63
+ if (!firstDraw) {
64
+ const lines = render().split('\n').length;
65
+ stdout.write(`\x1b[${lines}A\x1b[J`);
66
+ }
67
+ firstDraw = false;
68
+ stdout.write(render());
69
+ }
70
+ function cleanup() {
71
+ stdin.setRawMode(wasRaw ?? false);
72
+ stdin.pause();
73
+ stdin.removeListener('data', onData);
74
+ // Clear the TUI and restore cursor
75
+ const lines = render().split('\n').length;
76
+ stdout.write(`\x1b[${lines}A\x1b[J`);
77
+ stdout.write('\x1b[?25h'); // Show cursor
78
+ }
79
+ function onData(data) {
80
+ const key = data.toString();
81
+ // Arrow up / k
82
+ if (key === '\x1b[A' || key === 'k') {
83
+ cursor = (cursor - 1 + state.length) % state.length;
84
+ draw();
85
+ return;
86
+ }
87
+ // Arrow down / j
88
+ if (key === '\x1b[B' || key === 'j') {
89
+ cursor = (cursor + 1) % state.length;
90
+ draw();
91
+ return;
92
+ }
93
+ // Space — toggle
94
+ if (key === ' ') {
95
+ state[cursor].checked = !state[cursor].checked;
96
+ draw();
97
+ return;
98
+ }
99
+ // Tab — toggle and move down
100
+ if (key === '\t') {
101
+ state[cursor].checked = !state[cursor].checked;
102
+ cursor = (cursor + 1) % state.length;
103
+ draw();
104
+ return;
105
+ }
106
+ // 'a' — toggle all
107
+ if (key === 'a') {
108
+ const allChecked = state.every(i => i.checked);
109
+ for (const item of state)
110
+ item.checked = !allChecked;
111
+ draw();
112
+ return;
113
+ }
114
+ // Enter — confirm
115
+ if (key === '\r' || key === '\n') {
116
+ cleanup();
117
+ const selected = state.filter(i => i.checked).map(i => i.value);
118
+ // Show summary
119
+ stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
120
+ resolve(selected);
121
+ return;
122
+ }
123
+ // q / Esc — cancel
124
+ if (key === 'q' || key === '\x1b') {
125
+ cleanup();
126
+ stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
127
+ resolve([]);
128
+ return;
129
+ }
130
+ // Ctrl+C — exit process
131
+ if (key === '\x03') {
132
+ cleanup();
133
+ process.exit(130);
134
+ }
135
+ }
136
+ stdin.on('data', onData);
137
+ draw();
138
+ });
139
+ }
package/dist/validate.js CHANGED
@@ -2,6 +2,13 @@
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import yaml from 'js-yaml';
5
+ /** All recognized pipeline step names */
6
+ const KNOWN_STEP_NAMES = new Set([
7
+ 'navigate', 'click', 'type', 'wait', 'press', 'snapshot', 'scroll',
8
+ 'fetch', 'evaluate',
9
+ 'select', 'map', 'filter', 'sort', 'limit',
10
+ 'intercept', 'tap',
11
+ ]);
5
12
  export function validateClisWithTarget(dirs, target) {
6
13
  const results = [];
7
14
  let errors = 0;
@@ -52,6 +59,20 @@ function validateYamlFile(filePath) {
52
59
  errors.push('"columns" must be an array');
53
60
  if (def.args && typeof def.args !== 'object')
54
61
  errors.push('"args" must be an object');
62
+ // Validate pipeline step names (catch typos like 'navaigate')
63
+ if (Array.isArray(def.pipeline)) {
64
+ for (let i = 0; i < def.pipeline.length; i++) {
65
+ const step = def.pipeline[i];
66
+ if (step && typeof step === 'object') {
67
+ const stepKeys = Object.keys(step);
68
+ for (const key of stepKeys) {
69
+ if (!KNOWN_STEP_NAMES.has(key)) {
70
+ warnings.push(`Pipeline step ${i}: unknown step name "${key}" (did you mean one of: ${[...KNOWN_STEP_NAMES].join(', ')}?)`);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
55
76
  }
56
77
  catch (e) {
57
78
  errors.push(`YAML parse error: ${e.message}`);
package/dist/verify.d.ts CHANGED
@@ -1,2 +1,9 @@
1
+ /**
2
+ * Verification: runs validation and optional smoke test.
3
+ *
4
+ * The smoke test is intentionally kept as a stub — full browser-based
5
+ * smoke testing requires a running browser session and is better suited
6
+ * to the `opencli test` command or CI pipelines.
7
+ */
1
8
  export declare function verifyClis(opts: any): Promise<any>;
2
9
  export declare function renderVerifyReport(report: any): string;
package/dist/verify.js CHANGED
@@ -1,4 +1,10 @@
1
- /** Verification: validate + smoke. */
1
+ /**
2
+ * Verification: runs validation and optional smoke test.
3
+ *
4
+ * The smoke test is intentionally kept as a stub — full browser-based
5
+ * smoke testing requires a running browser session and is better suited
6
+ * to the `opencli test` command or CI pipelines.
7
+ */
2
8
  import { validateClisWithTarget, renderValidationReport } from './validate.js';
3
9
  export async function verifyClis(opts) {
4
10
  const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Single source of truth for package version.
3
+ */
4
+ export declare const PKG_VERSION: string;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Single source of truth for package version.
3
+ */
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
9
+ export const PKG_VERSION = (() => {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version;
12
+ }
13
+ catch {
14
+ return '0.0.0';
15
+ }
16
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -49,8 +49,27 @@ describe('browser helpers', () => {
49
49
  expect(__test__.appendLimited('12345', '67890', 8)).toBe('34567890');
50
50
  });
51
51
 
52
+ it('builds Playwright MCP args with kebab-case executable path', () => {
53
+ expect(__test__.buildMcpArgs({
54
+ mcpPath: '/tmp/cli.js',
55
+ executablePath: '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
56
+ })).toEqual([
57
+ '/tmp/cli.js',
58
+ '--extension',
59
+ '--executable-path',
60
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
61
+ ]);
62
+
63
+ expect(__test__.buildMcpArgs({
64
+ mcpPath: '/tmp/cli.js',
65
+ })).toEqual([
66
+ '/tmp/cli.js',
67
+ '--extension',
68
+ ]);
69
+ });
70
+
52
71
  it('times out slow promises', async () => {
53
- await expect(__test__.withTimeout(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
72
+ await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
54
73
  });
55
74
  });
56
75
 
package/src/browser.ts CHANGED
@@ -10,10 +10,10 @@ import * as fs from 'node:fs';
10
10
  import * as os from 'node:os';
11
11
  import * as path from 'node:path';
12
12
  import { formatSnapshot } from './snapshotFormatter.js';
13
-
14
- // Read version from package.json (single source of truth)
15
- const __browser_dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.resolve(__browser_dirname, '..', 'package.json'), 'utf-8')).version; } catch { return '0.0.0'; } })();
13
+ import { PKG_VERSION } from './version.js';
14
+ import { normalizeEvaluateSource } from './pipeline/template.js';
15
+ import { generateInterceptorJs, generateReadInterceptedJs } from './interceptor.js';
16
+ import { withTimeoutMs } from './runtime.js';
17
17
 
18
18
  const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
19
19
  const STDERR_BUFFER_LIMIT = 16 * 1024;
@@ -158,23 +158,10 @@ export class Page implements IPage {
158
158
 
159
159
  async evaluate(js: string): Promise<any> {
160
160
  // Normalize IIFE format to function format expected by MCP browser_evaluate
161
- const normalized = this.normalizeEval(js);
161
+ const normalized = normalizeEvaluateSource(js);
162
162
  return this.call('tools/call', { name: 'browser_evaluate', arguments: { function: normalized } });
163
163
  }
164
164
 
165
- private normalizeEval(source: string): string {
166
- const s = source.trim();
167
- if (!s) return '() => undefined';
168
- // IIFE: (async () => {...})() → wrap as () => (...)
169
- if (s.startsWith('(') && s.endsWith(')()')) return `() => (${s})`;
170
- // Already a function/arrow
171
- if (/^(async\s+)?\([^)]*\)\s*=>/.test(s)) return s;
172
- if (/^(async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=>/.test(s)) return s;
173
- if (s.startsWith('function ') || s.startsWith('async function ')) return s;
174
- // Raw expression → wrap
175
- return `() => (${s})`;
176
- }
177
-
178
165
  async snapshot(opts: { interactive?: boolean; compact?: boolean; maxDepth?: number; raw?: boolean } = {}): Promise<any> {
179
166
  const raw = await this.call('tools/call', { name: 'browser_snapshot', arguments: {} });
180
167
  if (opts.raw) return raw;
@@ -263,57 +250,15 @@ export class Page implements IPage {
263
250
  }
264
251
 
265
252
  async installInterceptor(pattern: string): Promise<void> {
266
- const js = `
267
- () => {
268
- window.__opencli_xhr = window.__opencli_xhr || [];
269
- window.__opencli_patterns = window.__opencli_patterns || [];
270
- if (!window.__opencli_patterns.includes('${pattern}')) {
271
- window.__opencli_patterns.push('${pattern}');
272
- }
273
-
274
- if (!window.__patched_xhr) {
275
- const checkMatch = (url) => window.__opencli_patterns.some(p => url.includes(p));
276
-
277
- const XHR = XMLHttpRequest.prototype;
278
- const open = XHR.open;
279
- const send = XHR.send;
280
- XHR.open = function(method, url) {
281
- this._url = url;
282
- return open.call(this, method, url, ...Array.prototype.slice.call(arguments, 2));
283
- };
284
- XHR.send = function() {
285
- this.addEventListener('load', function() {
286
- if (checkMatch(this._url)) {
287
- try { window.__opencli_xhr.push({url: this._url, data: JSON.parse(this.responseText)}); } catch(e){}
288
- }
289
- });
290
- return send.apply(this, arguments);
291
- };
292
-
293
- const origFetch = window.fetch;
294
- window.fetch = async function(...args) {
295
- let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
296
- const res = await origFetch.apply(this, args);
297
- setTimeout(async () => {
298
- try {
299
- if (checkMatch(u)) {
300
- const clone = res.clone();
301
- const j = await clone.json();
302
- window.__opencli_xhr.push({url: u, data: j});
303
- }
304
- } catch(e) {}
305
- }, 0);
306
- return res;
307
- };
308
- window.__patched_xhr = true;
309
- }
310
- }
311
- `;
312
- await this.evaluate(js);
253
+ await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
254
+ arrayName: '__opencli_xhr',
255
+ patchGuard: '__opencli_interceptor_patched',
256
+ }));
313
257
  }
314
258
 
315
259
  async getInterceptedRequests(): Promise<any[]> {
316
- return (await this.evaluate('() => window.__opencli_xhr')) || [];
260
+ const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
261
+ return result || [];
317
262
  }
318
263
  }
319
264
 
@@ -442,13 +387,13 @@ export class PlaywrightMCP {
442
387
  }));
443
388
  }, timeout * 1000);
444
389
 
445
- const mcpArgs: string[] = [mcpPath, '--extension'];
390
+ const mcpArgs = buildMcpArgs({
391
+ mcpPath,
392
+ executablePath: process.env.OPENCLI_BROWSER_EXECUTABLE_PATH,
393
+ });
446
394
  if (process.env.OPENCLI_VERBOSE) {
447
395
  console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`);
448
396
  }
449
- if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) {
450
- mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH);
451
- }
452
397
  debugLog(`Spawning node ${mcpArgs.join(' ')}`);
453
398
 
454
399
  this._proc = spawn('node', mcpArgs, {
@@ -530,7 +475,7 @@ export class PlaywrightMCP {
530
475
 
531
476
  // Use tabs as a readiness probe and for tab cleanup bookkeeping.
532
477
  debugLog('Fetching initial tabs count...');
533
- withTimeout(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
478
+ withTimeoutMs(page.tabs(), INITIAL_TABS_TIMEOUT_MS, 'Timed out fetching initial tabs').then((tabs: any) => {
534
479
  debugLog(`Tabs response: ${typeof tabs === 'string' ? tabs : JSON.stringify(tabs)}`);
535
480
  this._initialTabIdentities = extractTabIdentities(tabs);
536
481
  settleSuccess(page);
@@ -555,7 +500,7 @@ export class PlaywrightMCP {
555
500
  // Extension mode opens bridge/session tabs that we can clean up best-effort.
556
501
  if (this._page && this._proc && !this._proc.killed) {
557
502
  try {
558
- const tabs = await withTimeout(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
503
+ const tabs = await withTimeoutMs(this._page.tabs(), TAB_CLEANUP_TIMEOUT_MS, 'Timed out fetching tabs during cleanup');
559
504
  const tabEntries = extractTabEntries(tabs);
560
505
  const tabsToClose = diffTabIndexes(this._initialTabIdentities, tabEntries);
561
506
  for (const index of tabsToClose) {
@@ -664,20 +609,12 @@ function appendLimited(current: string, chunk: string, limit: number): string {
664
609
  return next.slice(-limit);
665
610
  }
666
611
 
667
- function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
668
- return new Promise<T>((resolve, reject) => {
669
- const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
670
- promise.then(
671
- (value) => {
672
- clearTimeout(timer);
673
- resolve(value);
674
- },
675
- (error) => {
676
- clearTimeout(timer);
677
- reject(error);
678
- },
679
- );
680
- });
612
+ function buildMcpArgs(input: { mcpPath: string; executablePath?: string | null }): string[] {
613
+ const args = [input.mcpPath, '--extension'];
614
+ if (input.executablePath) {
615
+ args.push('--executable-path', input.executablePath);
616
+ }
617
+ return args;
681
618
  }
682
619
 
683
620
  export const __test__ = {
@@ -685,7 +622,8 @@ export const __test__ = {
685
622
  extractTabEntries,
686
623
  diffTabIndexes,
687
624
  appendLimited,
688
- withTimeout,
625
+ buildMcpArgs,
626
+ withTimeoutMs,
689
627
  };
690
628
 
691
629
  function findMcpServerPath(): string | null {
package/src/cascade.ts CHANGED
@@ -37,6 +37,49 @@ interface CascadeResult {
37
37
  confidence: number;
38
38
  }
39
39
 
40
+ /**
41
+ * Build the JavaScript source for a fetch probe.
42
+ * Shared logic for PUBLIC, COOKIE, and HEADER strategies.
43
+ */
44
+ function buildFetchProbeJs(url: string, opts: {
45
+ credentials?: boolean;
46
+ extractCsrf?: boolean;
47
+ }): string {
48
+ const credentialsLine = opts.credentials ? `credentials: 'include',` : '';
49
+ const headerSetup = opts.extractCsrf
50
+ ? `
51
+ const cookies = document.cookie.split(';').map(c => c.trim());
52
+ const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
53
+ const headers = {};
54
+ if (csrf) { headers['X-Csrf-Token'] = csrf; headers['X-XSRF-Token'] = csrf; }
55
+ `
56
+ : 'const headers = {};';
57
+
58
+ return `
59
+ async () => {
60
+ try {
61
+ ${headerSetup}
62
+ const resp = await fetch(${JSON.stringify(url)}, {
63
+ ${credentialsLine}
64
+ headers
65
+ });
66
+ const status = resp.status;
67
+ if (!resp.ok) return { status, ok: false };
68
+ const text = await resp.text();
69
+ let hasData = false;
70
+ try {
71
+ const json = JSON.parse(text);
72
+ hasData = !!json && (Array.isArray(json) ? json.length > 0 :
73
+ typeof json === 'object' && Object.keys(json).length > 0);
74
+ // Check for API-level error codes (common in Chinese sites)
75
+ if (json.code !== undefined && json.code !== 0) hasData = false;
76
+ } catch {}
77
+ return { status, ok: true, hasData, preview: text.slice(0, 200) };
78
+ } catch (e) { return { ok: false, error: e.message }; }
79
+ }
80
+ `;
81
+ }
82
+
40
83
  /**
41
84
  * Probe an endpoint with a specific strategy.
42
85
  * Returns whether the probe succeeded and basic response info.
@@ -45,32 +88,14 @@ export async function probeEndpoint(
45
88
  page: IPage,
46
89
  url: string,
47
90
  strategy: Strategy,
48
- opts: { timeout?: number } = {},
91
+ _opts: { timeout?: number } = {},
49
92
  ): Promise<ProbeResult> {
50
93
  const result: ProbeResult = { strategy, success: false };
51
94
 
52
95
  try {
53
96
  switch (strategy) {
54
97
  case Strategy.PUBLIC: {
55
- // Try direct fetch without browser (no credentials)
56
- const js = `
57
- async () => {
58
- try {
59
- const resp = await fetch(${JSON.stringify(url)});
60
- const status = resp.status;
61
- if (!resp.ok) return { status, ok: false };
62
- const text = await resp.text();
63
- let hasData = false;
64
- try {
65
- const json = JSON.parse(text);
66
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
67
- typeof json === 'object' && Object.keys(json).length > 0);
68
- } catch {}
69
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
70
- } catch (e) { return { ok: false, error: e.message }; }
71
- }
72
- `;
73
- const resp = await page.evaluate(js);
98
+ const resp = await page.evaluate(buildFetchProbeJs(url, {}));
74
99
  result.statusCode = resp?.status;
75
100
  result.success = resp?.ok && resp?.hasData;
76
101
  result.hasData = resp?.hasData;
@@ -79,27 +104,7 @@ export async function probeEndpoint(
79
104
  }
80
105
 
81
106
  case Strategy.COOKIE: {
82
- // Fetch with credentials: 'include' (uses browser cookies)
83
- const js = `
84
- async () => {
85
- try {
86
- const resp = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
87
- const status = resp.status;
88
- if (!resp.ok) return { status, ok: false };
89
- const text = await resp.text();
90
- let hasData = false;
91
- try {
92
- const json = JSON.parse(text);
93
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
94
- typeof json === 'object' && Object.keys(json).length > 0);
95
- // Check for API-level error codes (common in Chinese sites)
96
- if (json.code !== undefined && json.code !== 0) hasData = false;
97
- } catch {}
98
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
99
- } catch (e) { return { ok: false, error: e.message }; }
100
- }
101
- `;
102
- const resp = await page.evaluate(js);
107
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true }));
103
108
  result.statusCode = resp?.status;
104
109
  result.success = resp?.ok && resp?.hasData;
105
110
  result.hasData = resp?.hasData;
@@ -108,39 +113,7 @@ export async function probeEndpoint(
108
113
  }
109
114
 
110
115
  case Strategy.HEADER: {
111
- // Fetch with credentials + try to extract common auth headers
112
- const js = `
113
- async () => {
114
- try {
115
- // Try to extract CSRF tokens from cookies
116
- const cookies = document.cookie.split(';').map(c => c.trim());
117
- const csrf = cookies.find(c => c.startsWith('ct0=') || c.startsWith('csrf_token=') || c.startsWith('_csrf='))?.split('=').slice(1).join('=');
118
-
119
- const headers = {};
120
- if (csrf) {
121
- headers['X-Csrf-Token'] = csrf;
122
- headers['X-XSRF-Token'] = csrf;
123
- }
124
-
125
- const resp = await fetch(${JSON.stringify(url)}, {
126
- credentials: 'include',
127
- headers
128
- });
129
- const status = resp.status;
130
- if (!resp.ok) return { status, ok: false };
131
- const text = await resp.text();
132
- let hasData = false;
133
- try {
134
- const json = JSON.parse(text);
135
- hasData = !!json && (Array.isArray(json) ? json.length > 0 :
136
- typeof json === 'object' && Object.keys(json).length > 0);
137
- if (json.code !== undefined && json.code !== 0) hasData = false;
138
- } catch {}
139
- return { status, ok: true, hasData, preview: text.slice(0, 200) };
140
- } catch (e) { return { ok: false, error: e.message }; }
141
- }
142
- `;
143
- const resp = await page.evaluate(js);
116
+ const resp = await page.evaluate(buildFetchProbeJs(url, { credentials: true, extractCsrf: true }));
144
117
  result.statusCode = resp?.status;
145
118
  result.success = resp?.ok && resp?.hasData;
146
119
  result.hasData = resp?.hasData;
@@ -151,7 +124,6 @@ export async function probeEndpoint(
151
124
  case Strategy.INTERCEPT:
152
125
  case Strategy.UI:
153
126
  // These require specific implementation per-site
154
- // Mark as needing manual implementation
155
127
  result.success = false;
156
128
  result.error = `Strategy ${strategy} requires site-specific implementation`;
157
129
  break;