@jackwener/opencli 0.5.1 → 0.5.2

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 (52) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  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/constants.d.ts +13 -0
  10. package/dist/constants.js +30 -0
  11. package/dist/engine.js +3 -3
  12. package/dist/engine.test.d.ts +4 -0
  13. package/dist/engine.test.js +67 -0
  14. package/dist/explore.js +1 -15
  15. package/dist/interceptor.d.ts +42 -0
  16. package/dist/interceptor.js +138 -0
  17. package/dist/main.js +1 -4
  18. package/dist/output.js +0 -5
  19. package/dist/pipeline/steps/intercept.js +4 -54
  20. package/dist/pipeline/steps/tap.js +11 -51
  21. package/dist/registry.d.ts +3 -1
  22. package/dist/registry.test.d.ts +4 -0
  23. package/dist/registry.test.js +90 -0
  24. package/dist/runtime.d.ts +15 -1
  25. package/dist/runtime.js +11 -6
  26. package/dist/synthesize.js +5 -5
  27. package/dist/validate.js +21 -0
  28. package/dist/verify.d.ts +7 -0
  29. package/dist/verify.js +7 -1
  30. package/dist/version.d.ts +4 -0
  31. package/dist/version.js +16 -0
  32. package/package.json +1 -1
  33. package/src/browser.test.ts +20 -1
  34. package/src/browser.ts +25 -87
  35. package/src/cascade.ts +47 -75
  36. package/src/constants.ts +35 -0
  37. package/src/engine.test.ts +77 -0
  38. package/src/engine.ts +5 -5
  39. package/src/explore.ts +2 -15
  40. package/src/interceptor.ts +153 -0
  41. package/src/main.ts +1 -5
  42. package/src/output.ts +0 -4
  43. package/src/pipeline/executor.ts +15 -15
  44. package/src/pipeline/steps/intercept.ts +4 -55
  45. package/src/pipeline/steps/tap.ts +12 -51
  46. package/src/registry.test.ts +106 -0
  47. package/src/registry.ts +4 -1
  48. package/src/runtime.ts +22 -8
  49. package/src/synthesize.ts +5 -5
  50. package/src/validate.ts +22 -0
  51. package/src/verify.ts +10 -1
  52. package/src/version.ts +18 -0
@@ -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
+ };
@@ -0,0 +1,138 @@
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 function generateInterceptorJs(patternExpr, opts = {}) {
20
+ const arr = opts.arrayName ?? '__opencli_intercepted';
21
+ const guard = opts.patchGuard ?? '__opencli_interceptor_patched';
22
+ return `
23
+ () => {
24
+ window.${arr} = window.${arr} || [];
25
+ const __pattern = ${patternExpr};
26
+
27
+ if (!window.${guard}) {
28
+ const __checkMatch = (url) => __pattern && url.includes(__pattern);
29
+
30
+ // ── Patch fetch ──
31
+ const __origFetch = window.fetch;
32
+ window.fetch = async function(...args) {
33
+ const reqUrl = typeof args[0] === 'string' ? args[0]
34
+ : (args[0] && args[0].url) || '';
35
+ const response = await __origFetch.apply(this, args);
36
+ if (__checkMatch(reqUrl)) {
37
+ try {
38
+ const clone = response.clone();
39
+ const json = await clone.json();
40
+ window.${arr}.push(json);
41
+ } catch(e) {}
42
+ }
43
+ return response;
44
+ };
45
+
46
+ // ── Patch XMLHttpRequest ──
47
+ const __XHR = XMLHttpRequest.prototype;
48
+ const __origOpen = __XHR.open;
49
+ const __origSend = __XHR.send;
50
+ __XHR.open = function(method, url) {
51
+ this.__opencli_url = String(url);
52
+ return __origOpen.apply(this, arguments);
53
+ };
54
+ __XHR.send = function() {
55
+ if (__checkMatch(this.__opencli_url)) {
56
+ this.addEventListener('load', function() {
57
+ try {
58
+ window.${arr}.push(JSON.parse(this.responseText));
59
+ } catch(e) {}
60
+ });
61
+ }
62
+ return __origSend.apply(this, arguments);
63
+ };
64
+
65
+ window.${guard} = true;
66
+ }
67
+ }
68
+ `;
69
+ }
70
+ /**
71
+ * Generate JavaScript source to read and clear intercepted data.
72
+ */
73
+ export function generateReadInterceptedJs(arrayName = '__opencli_intercepted') {
74
+ return `
75
+ () => {
76
+ const data = window.${arrayName} || [];
77
+ window.${arrayName} = [];
78
+ return data;
79
+ }
80
+ `;
81
+ }
82
+ /**
83
+ * Generate a self-contained tap interceptor for store-action bridge.
84
+ * Unlike the global interceptor, this one:
85
+ * - Installs temporarily, restores originals in finally block
86
+ * - Resolves a promise on first capture (for immediate await)
87
+ * - Returns captured data directly
88
+ */
89
+ export function generateTapInterceptorJs(patternExpr) {
90
+ return {
91
+ setupVar: `
92
+ let captured = null;
93
+ let captureResolve;
94
+ const capturePromise = new Promise(r => { captureResolve = r; });
95
+ const capturePattern = ${patternExpr};
96
+ `,
97
+ capturedVar: 'captured',
98
+ promiseVar: 'capturePromise',
99
+ resolveVar: 'captureResolve',
100
+ fetchPatch: `
101
+ const origFetch = window.fetch;
102
+ window.fetch = async function(...fetchArgs) {
103
+ const resp = await origFetch.apply(this, fetchArgs);
104
+ try {
105
+ const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
106
+ : fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
107
+ if (capturePattern && url.includes(capturePattern) && !captured) {
108
+ try { captured = await resp.clone().json(); captureResolve(); } catch {}
109
+ }
110
+ } catch {}
111
+ return resp;
112
+ };
113
+ `,
114
+ xhrPatch: `
115
+ const origXhrOpen = XMLHttpRequest.prototype.open;
116
+ const origXhrSend = XMLHttpRequest.prototype.send;
117
+ XMLHttpRequest.prototype.open = function(method, url) {
118
+ this.__tapUrl = String(url);
119
+ return origXhrOpen.apply(this, arguments);
120
+ };
121
+ XMLHttpRequest.prototype.send = function(body) {
122
+ if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
123
+ this.addEventListener('load', function() {
124
+ if (!captured) {
125
+ try { captured = JSON.parse(this.responseText); captureResolve(); } catch {}
126
+ }
127
+ });
128
+ }
129
+ return origXhrSend.apply(this, arguments);
130
+ };
131
+ `,
132
+ restorePatch: `
133
+ window.fetch = origFetch;
134
+ XMLHttpRequest.prototype.open = origXhrOpen;
135
+ XMLHttpRequest.prototype.send = origXhrSend;
136
+ `,
137
+ };
138
+ }
package/dist/main.js CHANGED
@@ -2,7 +2,6 @@
2
2
  /**
3
3
  * opencli — Make any website your CLI. AI-powered.
4
4
  */
5
- import * as fs from 'node:fs';
6
5
  import * as os from 'node:os';
7
6
  import * as path from 'node:path';
8
7
  import { fileURLToPath } from 'node:url';
@@ -13,13 +12,11 @@ import { fullName, getRegistry, strategyLabel } from './registry.js';
13
12
  import { render as renderOutput } from './output.js';
14
13
  import { PlaywrightMCP } from './browser.js';
15
14
  import { browserSession, DEFAULT_BROWSER_COMMAND_TIMEOUT, runWithTimeout } from './runtime.js';
15
+ import { PKG_VERSION } from './version.js';
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
  const BUILTIN_CLIS = path.resolve(__dirname, 'clis');
19
19
  const USER_CLIS = path.join(os.homedir(), '.opencli', 'clis');
20
- // Read version from package.json (single source of truth)
21
- const pkgJsonPath = path.resolve(__dirname, '..', 'package.json');
22
- const PKG_VERSION = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version ?? '0.0.0';
23
20
  await discoverClis(BUILTIN_CLIS, USER_CLIS);
24
21
  const program = new Command();
25
22
  program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
package/dist/output.js CHANGED
@@ -43,11 +43,6 @@ function renderTable(data, opts) {
43
43
  style: { head: [], border: [] },
44
44
  wordWrap: true,
45
45
  wrapOnWordBoundary: true,
46
- colWidths: columns.map((_c, i) => {
47
- if (i === 0)
48
- return 6;
49
- return null;
50
- }).filter(() => true),
51
46
  });
52
47
  for (const row of rows) {
53
48
  table.push(columns.map(c => {
@@ -2,6 +2,7 @@
2
2
  * Pipeline step: intercept — declarative XHR interception.
3
3
  */
4
4
  import { render } from '../template.js';
5
+ import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
5
6
  export async function stepIntercept(page, params, data, args) {
6
7
  const cfg = typeof params === 'object' ? params : {};
7
8
  const trigger = cfg.trigger ?? '';
@@ -11,52 +12,7 @@ export async function stepIntercept(page, params, data, args) {
11
12
  if (!capturePattern)
12
13
  return data;
13
14
  // Step 1: Inject fetch/XHR interceptor BEFORE trigger
14
- await page.evaluate(`
15
- () => {
16
- window.__opencli_intercepted = window.__opencli_intercepted || [];
17
- const pattern = ${JSON.stringify(capturePattern)};
18
-
19
- if (!window.__opencli_fetch_patched) {
20
- const origFetch = window.fetch;
21
- window.fetch = async function(...args) {
22
- const reqUrl = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
23
- const response = await origFetch.apply(this, args);
24
- setTimeout(async () => {
25
- try {
26
- if (reqUrl.includes(pattern)) {
27
- const clone = response.clone();
28
- const json = await clone.json();
29
- window.__opencli_intercepted.push(json);
30
- }
31
- } catch(e) {}
32
- }, 0);
33
- return response;
34
- };
35
- window.__opencli_fetch_patched = true;
36
- }
37
-
38
- if (!window.__opencli_xhr_patched) {
39
- const XHR = XMLHttpRequest.prototype;
40
- const open = XHR.open;
41
- const send = XHR.send;
42
- XHR.open = function(method, url, ...args) {
43
- this._reqUrl = url;
44
- return open.call(this, method, url, ...args);
45
- };
46
- XHR.send = function(...args) {
47
- this.addEventListener('load', function() {
48
- try {
49
- if (this._reqUrl && this._reqUrl.includes(pattern)) {
50
- window.__opencli_intercepted.push(JSON.parse(this.responseText));
51
- }
52
- } catch(e) {}
53
- });
54
- return send.apply(this, args);
55
- };
56
- window.__opencli_xhr_patched = true;
57
- }
58
- }
59
- `);
15
+ await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern)));
60
16
  // Step 2: Execute the trigger action
61
17
  if (trigger.startsWith('navigate:')) {
62
18
  const url = render(trigger.slice('navigate:'.length), { args, data });
@@ -77,14 +33,8 @@ export async function stepIntercept(page, params, data, args) {
77
33
  // Step 3: Wait a bit for network requests to fire
78
34
  await page.wait(Math.min(timeout, 3));
79
35
  // Step 4: Retrieve captured data
80
- const matchingResponses = await page.evaluate(`
81
- () => {
82
- const data = window.__opencli_intercepted || [];
83
- window.__opencli_intercepted = []; // clear after reading
84
- return data;
85
- }
86
- `);
87
- // Step 4: Select from response if specified
36
+ const matchingResponses = await page.evaluate(generateReadInterceptedJs());
37
+ // Step 5: Select from response if specified
88
38
  let result = matchingResponses.length === 1 ? matchingResponses[0] :
89
39
  matchingResponses.length > 1 ? matchingResponses : data;
90
40
  if (selectPath && result) {
@@ -9,6 +9,7 @@
9
9
  * 5. Returns the captured data (optionally sub-selected)
10
10
  */
11
11
  import { render } from '../template.js';
12
+ import { generateTapInterceptorJs } from '../../interceptor.js';
12
13
  export async function stepTap(page, params, data, args) {
13
14
  const cfg = typeof params === 'object' ? params : {};
14
15
  const storeName = String(render(cfg.store ?? '', { args, data }));
@@ -32,53 +33,14 @@ export async function stepTap(page, params, data, args) {
32
33
  const actionCall = actionArgsRendered.length
33
34
  ? `store[${JSON.stringify(actionName)}](${actionArgsRendered.join(', ')})`
34
35
  : `store[${JSON.stringify(actionName)}]()`;
36
+ // Use shared interceptor generator for fetch/XHR patching
37
+ const tap = generateTapInterceptorJs(JSON.stringify(capturePattern));
35
38
  const js = `
36
39
  async () => {
37
40
  // ── 1. Setup capture proxy (fetch + XHR dual interception) ──
38
- let captured = null;
39
- let captureResolve;
40
- const capturePromise = new Promise(r => { captureResolve = r; });
41
- const capturePattern = ${JSON.stringify(capturePattern)};
42
-
43
- // Intercept fetch API
44
- const origFetch = window.fetch;
45
- window.fetch = async function(...fetchArgs) {
46
- const resp = await origFetch.apply(this, fetchArgs);
47
- try {
48
- const url = typeof fetchArgs[0] === 'string' ? fetchArgs[0]
49
- : fetchArgs[0] instanceof Request ? fetchArgs[0].url : String(fetchArgs[0]);
50
- if (capturePattern && url.includes(capturePattern) && !captured) {
51
- try { captured = await resp.clone().json(); captureResolve(); } catch {}
52
- }
53
- } catch {}
54
- return resp;
55
- };
56
-
57
- // Intercept XMLHttpRequest
58
- const origXhrOpen = XMLHttpRequest.prototype.open;
59
- const origXhrSend = XMLHttpRequest.prototype.send;
60
- XMLHttpRequest.prototype.open = function(method, url) {
61
- this.__tapUrl = String(url);
62
- return origXhrOpen.apply(this, arguments);
63
- };
64
- XMLHttpRequest.prototype.send = function(body) {
65
- if (capturePattern && this.__tapUrl?.includes(capturePattern)) {
66
- const xhr = this;
67
- const origHandler = xhr.onreadystatechange;
68
- xhr.onreadystatechange = function() {
69
- if (xhr.readyState === 4 && !captured) {
70
- try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {}
71
- }
72
- if (origHandler) origHandler.apply(this, arguments);
73
- };
74
- const origOnload = xhr.onload;
75
- xhr.onload = function() {
76
- if (!captured) { try { captured = JSON.parse(xhr.responseText); captureResolve(); } catch {} }
77
- if (origOnload) origOnload.apply(this, arguments);
78
- };
79
- }
80
- return origXhrSend.apply(this, arguments);
81
- };
41
+ ${tap.setupVar}
42
+ ${tap.fetchPatch}
43
+ ${tap.xhrPatch}
82
44
 
83
45
  try {
84
46
  // ── 2. Find store ──
@@ -113,19 +75,17 @@ export async function stepTap(page, params, data, args) {
113
75
  await ${actionCall};
114
76
 
115
77
  // ── 4. Wait for network response ──
116
- if (!captured) {
78
+ if (!${tap.capturedVar}) {
117
79
  const timeoutPromise = new Promise(r => setTimeout(r, ${timeout} * 1000));
118
- await Promise.race([capturePromise, timeoutPromise]);
80
+ await Promise.race([${tap.promiseVar}, timeoutPromise]);
119
81
  }
120
82
  } finally {
121
83
  // ── 5. Always restore originals ──
122
- window.fetch = origFetch;
123
- XMLHttpRequest.prototype.open = origXhrOpen;
124
- XMLHttpRequest.prototype.send = origXhrSend;
84
+ ${tap.restorePatch}
125
85
  }
126
86
 
127
- if (!captured) return { error: 'No matching response captured for pattern: ' + capturePattern };
128
- return captured${selectChain} ?? captured;
87
+ if (!${tap.capturedVar}) return { error: 'No matching response captured for pattern: ' + capturePattern };
88
+ return ${tap.capturedVar}${selectChain} ?? ${tap.capturedVar};
129
89
  }
130
90
  `;
131
91
  return page.evaluate(js);
@@ -30,7 +30,9 @@ export interface CliCommand {
30
30
  pipeline?: any[];
31
31
  timeoutSeconds?: number;
32
32
  source?: string;
33
- /** Internal: lazy-loaded TS module support */
33
+ }
34
+ /** Internal extension for lazy-loaded TS modules (not exposed in public API) */
35
+ export interface InternalCliCommand extends CliCommand {
34
36
  _lazy?: boolean;
35
37
  _modulePath?: string;
36
38
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for registry.ts: Strategy enum, cli() registration, helpers.
3
+ */
4
+ export {};
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests for registry.ts: Strategy enum, cli() registration, helpers.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { cli, getRegistry, fullName, strategyLabel, registerCommand, Strategy } from './registry.js';
6
+ describe('cli() registration', () => {
7
+ it('registers a command and returns it', () => {
8
+ const cmd = cli({
9
+ site: 'test-registry',
10
+ name: 'hello',
11
+ description: 'A test command',
12
+ strategy: Strategy.PUBLIC,
13
+ browser: false,
14
+ });
15
+ expect(cmd.site).toBe('test-registry');
16
+ expect(cmd.name).toBe('hello');
17
+ expect(cmd.strategy).toBe(Strategy.PUBLIC);
18
+ expect(cmd.browser).toBe(false);
19
+ expect(cmd.args).toEqual([]);
20
+ });
21
+ it('puts registered command in the registry', () => {
22
+ cli({
23
+ site: 'test-registry',
24
+ name: 'registered',
25
+ description: 'test',
26
+ });
27
+ const registry = getRegistry();
28
+ expect(registry.has('test-registry/registered')).toBe(true);
29
+ });
30
+ it('defaults strategy to COOKIE when browser is true', () => {
31
+ const cmd = cli({
32
+ site: 'test-registry',
33
+ name: 'default-strategy',
34
+ });
35
+ expect(cmd.strategy).toBe(Strategy.COOKIE);
36
+ expect(cmd.browser).toBe(true);
37
+ });
38
+ it('defaults strategy to PUBLIC when browser is false', () => {
39
+ const cmd = cli({
40
+ site: 'test-registry',
41
+ name: 'no-browser',
42
+ browser: false,
43
+ });
44
+ expect(cmd.strategy).toBe(Strategy.PUBLIC);
45
+ });
46
+ it('overwrites existing command on re-registration', () => {
47
+ cli({ site: 'test-registry', name: 'overwrite', description: 'v1' });
48
+ cli({ site: 'test-registry', name: 'overwrite', description: 'v2' });
49
+ const reg = getRegistry();
50
+ expect(reg.get('test-registry/overwrite')?.description).toBe('v2');
51
+ });
52
+ });
53
+ describe('fullName', () => {
54
+ it('returns site/name', () => {
55
+ const cmd = {
56
+ site: 'bilibili', name: 'hot', description: '', args: [],
57
+ };
58
+ expect(fullName(cmd)).toBe('bilibili/hot');
59
+ });
60
+ });
61
+ describe('strategyLabel', () => {
62
+ it('returns strategy string', () => {
63
+ const cmd = {
64
+ site: 'test', name: 'test', description: '', args: [],
65
+ strategy: Strategy.INTERCEPT,
66
+ };
67
+ expect(strategyLabel(cmd)).toBe('intercept');
68
+ });
69
+ it('returns public when no strategy set', () => {
70
+ const cmd = {
71
+ site: 'test', name: 'test', description: '', args: [],
72
+ };
73
+ expect(strategyLabel(cmd)).toBe('public');
74
+ });
75
+ });
76
+ describe('registerCommand', () => {
77
+ it('registers a pre-built command', () => {
78
+ const cmd = {
79
+ site: 'test-registry',
80
+ name: 'direct-reg',
81
+ description: 'directly registered',
82
+ args: [],
83
+ strategy: Strategy.HEADER,
84
+ browser: true,
85
+ };
86
+ registerCommand(cmd);
87
+ const reg = getRegistry();
88
+ expect(reg.get('test-registry/direct-reg')?.strategy).toBe(Strategy.HEADER);
89
+ });
90
+ });
package/dist/runtime.d.ts CHANGED
@@ -6,8 +6,22 @@ export declare const DEFAULT_BROWSER_CONNECT_TIMEOUT: number;
6
6
  export declare const DEFAULT_BROWSER_COMMAND_TIMEOUT: number;
7
7
  export declare const DEFAULT_BROWSER_EXPLORE_TIMEOUT: number;
8
8
  export declare const DEFAULT_BROWSER_SMOKE_TIMEOUT: number;
9
+ /**
10
+ * Timeout with seconds unit. Used for high-level command timeouts.
11
+ */
9
12
  export declare function runWithTimeout<T>(promise: Promise<T>, opts: {
10
13
  timeout: number;
11
14
  label?: string;
12
15
  }): Promise<T>;
13
- export declare function browserSession<T>(BrowserFactory: new () => any, fn: (page: IPage) => Promise<T>): Promise<T>;
16
+ /**
17
+ * Timeout with milliseconds unit. Used for low-level internal timeouts.
18
+ */
19
+ export declare function withTimeoutMs<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
20
+ /** Interface for browser factory (PlaywrightMCP or test mocks) */
21
+ export interface IBrowserFactory {
22
+ connect(opts?: {
23
+ timeout?: number;
24
+ }): Promise<IPage>;
25
+ close(): Promise<void>;
26
+ }
27
+ export declare function browserSession<T>(BrowserFactory: new () => IBrowserFactory, fn: (page: IPage) => Promise<T>): Promise<T>;
package/dist/runtime.js CHANGED
@@ -5,14 +5,19 @@ export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROW
5
5
  export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_COMMAND_TIMEOUT ?? '45', 10);
6
6
  export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_EXPLORE_TIMEOUT ?? '120', 10);
7
7
  export const DEFAULT_BROWSER_SMOKE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_SMOKE_TIMEOUT ?? '60', 10);
8
+ /**
9
+ * Timeout with seconds unit. Used for high-level command timeouts.
10
+ */
8
11
  export async function runWithTimeout(promise, opts) {
12
+ return withTimeoutMs(promise, opts.timeout * 1000, `${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`);
13
+ }
14
+ /**
15
+ * Timeout with milliseconds unit. Used for low-level internal timeouts.
16
+ */
17
+ export function withTimeoutMs(promise, timeoutMs, message) {
9
18
  return new Promise((resolve, reject) => {
10
- const timer = setTimeout(() => {
11
- reject(new Error(`${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`));
12
- }, opts.timeout * 1000);
13
- promise
14
- .then((result) => { clearTimeout(timer); resolve(result); })
15
- .catch((err) => { clearTimeout(timer); reject(err); });
19
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
20
+ promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
16
21
  });
17
22
  }
18
23
  export async function browserSession(BrowserFactory, fn) {
@@ -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/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.5.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },