@jackwener/opencli 0.7.11 → 0.9.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/CDP.md +103 -0
  2. package/CDP.zh-CN.md +103 -0
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +5 -0
  5. package/dist/browser/discover.d.ts +15 -0
  6. package/dist/browser/discover.js +68 -2
  7. package/dist/browser/errors.d.ts +2 -1
  8. package/dist/browser/errors.js +13 -0
  9. package/dist/browser/index.d.ts +1 -0
  10. package/dist/browser/index.js +1 -0
  11. package/dist/browser/mcp.js +8 -3
  12. package/dist/browser/page.js +11 -2
  13. package/dist/cli-manifest.json +246 -0
  14. package/dist/clis/antigravity/dump.d.ts +1 -0
  15. package/dist/clis/antigravity/dump.js +28 -0
  16. package/dist/clis/antigravity/extract-code.d.ts +1 -0
  17. package/dist/clis/antigravity/extract-code.js +32 -0
  18. package/dist/clis/antigravity/model.d.ts +1 -0
  19. package/dist/clis/antigravity/model.js +44 -0
  20. package/dist/clis/antigravity/new.d.ts +1 -0
  21. package/dist/clis/antigravity/new.js +25 -0
  22. package/dist/clis/antigravity/read.d.ts +1 -0
  23. package/dist/clis/antigravity/read.js +34 -0
  24. package/dist/clis/antigravity/send.d.ts +1 -0
  25. package/dist/clis/antigravity/send.js +35 -0
  26. package/dist/clis/antigravity/status.d.ts +1 -0
  27. package/dist/clis/antigravity/status.js +18 -0
  28. package/dist/clis/antigravity/watch.d.ts +1 -0
  29. package/dist/clis/antigravity/watch.js +41 -0
  30. package/dist/clis/barchart/flow.js +56 -58
  31. package/dist/clis/xiaoyuzhou/episode.d.ts +1 -0
  32. package/dist/clis/xiaoyuzhou/episode.js +28 -0
  33. package/dist/clis/xiaoyuzhou/podcast-episodes.d.ts +1 -0
  34. package/dist/clis/xiaoyuzhou/podcast-episodes.js +36 -0
  35. package/dist/clis/xiaoyuzhou/podcast.d.ts +1 -0
  36. package/dist/clis/xiaoyuzhou/podcast.js +27 -0
  37. package/dist/clis/xiaoyuzhou/utils.d.ts +16 -0
  38. package/dist/clis/xiaoyuzhou/utils.js +55 -0
  39. package/dist/clis/xiaoyuzhou/utils.test.d.ts +1 -0
  40. package/dist/clis/xiaoyuzhou/utils.test.js +99 -0
  41. package/dist/doctor.js +8 -0
  42. package/dist/engine.d.ts +1 -1
  43. package/dist/engine.js +59 -1
  44. package/dist/main.js +2 -15
  45. package/dist/pipeline/executor.js +2 -24
  46. package/dist/pipeline/registry.d.ts +19 -0
  47. package/dist/pipeline/registry.js +41 -0
  48. package/package.json +1 -1
  49. package/src/browser/discover.ts +79 -5
  50. package/src/browser/errors.ts +17 -1
  51. package/src/browser/index.ts +1 -0
  52. package/src/browser/mcp.ts +8 -3
  53. package/src/browser/page.ts +21 -2
  54. package/src/clis/antigravity/README.md +49 -0
  55. package/src/clis/antigravity/README.zh-CN.md +52 -0
  56. package/src/clis/antigravity/SKILL.md +42 -0
  57. package/src/clis/antigravity/dump.ts +30 -0
  58. package/src/clis/antigravity/extract-code.ts +34 -0
  59. package/src/clis/antigravity/model.ts +47 -0
  60. package/src/clis/antigravity/new.ts +28 -0
  61. package/src/clis/antigravity/read.ts +36 -0
  62. package/src/clis/antigravity/send.ts +40 -0
  63. package/src/clis/antigravity/status.ts +19 -0
  64. package/src/clis/antigravity/watch.ts +45 -0
  65. package/src/clis/barchart/flow.ts +57 -58
  66. package/src/clis/xiaoyuzhou/episode.ts +28 -0
  67. package/src/clis/xiaoyuzhou/podcast-episodes.ts +36 -0
  68. package/src/clis/xiaoyuzhou/podcast.ts +27 -0
  69. package/src/clis/xiaoyuzhou/utils.test.ts +122 -0
  70. package/src/clis/xiaoyuzhou/utils.ts +65 -0
  71. package/src/doctor.ts +9 -0
  72. package/src/engine.ts +58 -1
  73. package/src/main.ts +6 -11
  74. package/src/pipeline/executor.ts +2 -28
  75. package/src/pipeline/registry.ts +60 -0
  76. package/tests/e2e/public-commands.test.ts +62 -0
@@ -0,0 +1,45 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const watchCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'watch',
6
+ description: 'Stream new chat messages from Antigravity in real-time',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [],
11
+ timeoutSeconds: 86400, // Run for up to 24 hours
12
+ columns: [], // We use direct stdout streaming
13
+ func: async (page) => {
14
+ console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
15
+
16
+ let lastLength = 0;
17
+
18
+ // Loop until process gets killed
19
+ while (true) {
20
+ const text = await page.evaluate(`
21
+ async () => {
22
+ const container = document.getElementById('conversation');
23
+ return container ? container.innerText : '';
24
+ }
25
+ `);
26
+
27
+ const currentLength = text.length;
28
+ if (currentLength > lastLength) {
29
+ // Delta mode
30
+ const newSegment = text.substring(lastLength);
31
+ if (newSegment.trim().length > 0) {
32
+ process.stdout.write(newSegment);
33
+ }
34
+ lastLength = currentLength;
35
+ } else if (currentLength < lastLength) {
36
+ // The conversation was cleared or updated significantly
37
+ lastLength = currentLength;
38
+ console.log('\\n--- Conversation Cleared/Changed ---\\n');
39
+ process.stdout.write(text);
40
+ }
41
+
42
+ await new Promise(resolve => setTimeout(resolve, 500));
43
+ }
44
+ },
45
+ });
@@ -30,81 +30,80 @@ cli({
30
30
  (async () => {
31
31
  const limit = ${limit};
32
32
  const typeFilter = '${optionType}'.toLowerCase();
33
- const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
34
- const headers = { 'X-CSRF-TOKEN': csrf };
35
33
 
34
+ // Wait for CSRF token to appear (Angular may inject it after initial render)
35
+ let csrf = '';
36
+ for (let i = 0; i < 10; i++) {
37
+ csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
38
+ if (csrf) break;
39
+ await new Promise(r => setTimeout(r, 500));
40
+ }
41
+ if (!csrf) return { error: 'no-csrf' };
42
+
43
+ const headers = { 'X-CSRF-TOKEN': csrf };
36
44
  const fields = [
37
45
  'baseSymbol','strikePrice','expirationDate','optionType',
38
46
  'lastPrice','volume','openInterest','volumeOpenInterestRatio','volatility',
39
47
  ].join(',');
40
48
 
41
- // Fetch extra rows when filtering by type since server-side filter may not work
49
+ // Fetch extra rows when filtering by type since server-side filter doesn't work
42
50
  const fetchLimit = typeFilter !== 'all' ? limit * 3 : limit;
43
- try {
44
- const url = '/proxies/core-api/v1/options/get?list=options.unusual_activity.stocks.us'
45
- + '&fields=' + fields
46
- + '&orderBy=volumeOpenInterestRatio&orderDir=desc'
47
- + '&raw=1&limit=' + fetchLimit;
48
51
 
49
- const resp = await fetch(url, { credentials: 'include', headers });
50
- if (resp.ok) {
52
+ // Try unusual_activity first, fall back to mostActive (unusual_activity is
53
+ // empty outside market hours)
54
+ const lists = [
55
+ 'options.unusual_activity.stocks.us',
56
+ 'options.mostActive.us',
57
+ ];
58
+
59
+ for (const list of lists) {
60
+ try {
61
+ const url = '/proxies/core-api/v1/options/get?list=' + list
62
+ + '&fields=' + fields
63
+ + '&orderBy=volumeOpenInterestRatio&orderDir=desc'
64
+ + '&raw=1&limit=' + fetchLimit;
65
+
66
+ const resp = await fetch(url, { credentials: 'include', headers });
67
+ if (!resp.ok) continue;
51
68
  const d = await resp.json();
52
69
  let items = d?.data || [];
53
- if (items.length > 0) {
54
- // Apply client-side type filter
55
- if (typeFilter !== 'all') {
56
- items = items.filter(i => {
57
- const t = ((i.raw || i).optionType || '').toLowerCase();
58
- return t === typeFilter;
59
- });
60
- }
61
- return items.slice(0, limit).map(i => {
62
- const r = i.raw || i;
63
- return {
64
- symbol: r.baseSymbol || r.symbol,
65
- type: r.optionType,
66
- strike: r.strikePrice,
67
- expiration: r.expirationDate,
68
- last: r.lastPrice,
69
- volume: r.volume,
70
- openInterest: r.openInterest,
71
- volOiRatio: r.volumeOpenInterestRatio,
72
- iv: r.volatility,
73
- };
70
+ if (items.length === 0) continue;
71
+
72
+ // Apply client-side type filter
73
+ if (typeFilter !== 'all') {
74
+ items = items.filter(i => {
75
+ const t = ((i.raw || i).optionType || '').toLowerCase();
76
+ return t === typeFilter;
74
77
  });
75
78
  }
76
- }
77
- } catch(e) {}
78
-
79
- // Fallback: parse from DOM table
80
- try {
81
- const rows = document.querySelectorAll('tr[data-ng-repeat], tbody tr');
82
- const results = [];
83
- for (const row of rows) {
84
- const cells = row.querySelectorAll('td');
85
- if (cells.length < 6) continue;
86
- const getText = (idx) => cells[idx]?.textContent?.trim() || null;
87
- results.push({
88
- symbol: getText(0),
89
- type: getText(1),
90
- strike: getText(2),
91
- expiration: getText(3),
92
- last: getText(4),
93
- volume: getText(5),
94
- openInterest: cells.length > 6 ? getText(6) : null,
95
- volOiRatio: cells.length > 7 ? getText(7) : null,
96
- iv: cells.length > 8 ? getText(8) : null,
79
+ return items.slice(0, limit).map(i => {
80
+ const r = i.raw || i;
81
+ return {
82
+ symbol: r.baseSymbol || r.symbol,
83
+ type: r.optionType,
84
+ strike: r.strikePrice,
85
+ expiration: r.expirationDate,
86
+ last: r.lastPrice,
87
+ volume: r.volume,
88
+ openInterest: r.openInterest,
89
+ volOiRatio: r.volumeOpenInterestRatio,
90
+ iv: r.volatility,
91
+ };
97
92
  });
98
- if (results.length >= limit) break;
99
- }
100
- return results;
101
- } catch(e) {
102
- return [];
93
+ } catch(e) {}
103
94
  }
95
+
96
+ return [];
104
97
  })()
105
98
  `);
106
99
 
107
- if (!data || !Array.isArray(data)) return [];
100
+ if (!data) return [];
101
+
102
+ if (data.error === 'no-csrf') {
103
+ throw new Error('Could not extract CSRF token from barchart.com. Make sure you are logged in.');
104
+ }
105
+
106
+ if (!Array.isArray(data)) return [];
108
107
 
109
108
  return data.slice(0, limit).map(r => ({
110
109
  symbol: r.symbol || '',
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { fetchPageProps, formatDuration, formatDate } from './utils.js';
4
+
5
+ cli({
6
+ site: 'xiaoyuzhou',
7
+ name: 'episode',
8
+ description: 'View details of a Xiaoyuzhou podcast episode',
9
+ domain: 'www.xiaoyuzhoufm.com',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
13
+ columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
14
+ func: async (_page, args) => {
15
+ const pageProps = await fetchPageProps(`/episode/${args.id}`);
16
+ const ep = pageProps.episode;
17
+ if (!ep) throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
18
+ return [{
19
+ title: ep.title,
20
+ podcast: ep.podcast?.title,
21
+ duration: formatDuration(ep.duration),
22
+ plays: ep.playCount,
23
+ comments: ep.commentCount,
24
+ likes: ep.clapCount,
25
+ date: formatDate(ep.pubDate),
26
+ }];
27
+ },
28
+ });
@@ -0,0 +1,36 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { fetchPageProps, formatDuration, formatDate } from './utils.js';
4
+
5
+ cli({
6
+ site: 'xiaoyuzhou',
7
+ name: 'podcast-episodes',
8
+ description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)',
9
+ domain: 'www.xiaoyuzhoufm.com',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [
13
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
14
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' },
15
+ ],
16
+ columns: ['eid', 'title', 'duration', 'plays', 'date'],
17
+ func: async (_page, args) => {
18
+ const pageProps = await fetchPageProps(`/podcast/${args.id}`);
19
+ const podcast = pageProps.podcast;
20
+ if (!podcast) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
21
+ const allEpisodes = podcast.episodes ?? [];
22
+ const requestedLimit = Number(args.limit);
23
+ if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
24
+ throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
25
+ }
26
+ const limit = Math.min(requestedLimit, allEpisodes.length);
27
+ const episodes = allEpisodes.slice(0, limit);
28
+ return episodes.map((ep: any) => ({
29
+ eid: ep.eid,
30
+ title: ep.title,
31
+ duration: formatDuration(ep.duration),
32
+ plays: ep.playCount,
33
+ date: formatDate(ep.pubDate),
34
+ }));
35
+ },
36
+ });
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { fetchPageProps, formatDate } from './utils.js';
4
+
5
+ cli({
6
+ site: 'xiaoyuzhou',
7
+ name: 'podcast',
8
+ description: 'View a Xiaoyuzhou podcast profile',
9
+ domain: 'www.xiaoyuzhoufm.com',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: false,
12
+ args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
13
+ columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
14
+ func: async (_page, args) => {
15
+ const pageProps = await fetchPageProps(`/podcast/${args.id}`);
16
+ const p = pageProps.podcast;
17
+ if (!p) throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
18
+ return [{
19
+ title: p.title,
20
+ author: p.author,
21
+ description: p.brief,
22
+ subscribers: p.subscriptionCount,
23
+ episodes: p.episodeCount,
24
+ updated: formatDate(p.latestEpisodePubDate),
25
+ }];
26
+ },
27
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, fetchPageProps } from './utils.js';
3
+
4
+ describe('formatDuration', () => {
5
+ it('formats typical duration', () => {
6
+ expect(formatDuration(3890)).toBe('64:50');
7
+ });
8
+
9
+ it('formats zero seconds', () => {
10
+ expect(formatDuration(0)).toBe('0:00');
11
+ });
12
+
13
+ it('pads single-digit seconds', () => {
14
+ expect(formatDuration(65)).toBe('1:05');
15
+ });
16
+
17
+ it('formats exact minutes', () => {
18
+ expect(formatDuration(3600)).toBe('60:00');
19
+ });
20
+
21
+ it('rounds floating-point seconds', () => {
22
+ expect(formatDuration(3890.7)).toBe('64:51');
23
+ });
24
+
25
+ it('returns dash for NaN', () => {
26
+ expect(formatDuration(NaN)).toBe('-');
27
+ });
28
+
29
+ it('returns dash for negative', () => {
30
+ expect(formatDuration(-1)).toBe('-');
31
+ });
32
+ });
33
+
34
+ describe('formatDate', () => {
35
+ it('extracts YYYY-MM-DD from ISO string', () => {
36
+ expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13');
37
+ });
38
+
39
+ it('handles date-only string', () => {
40
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
41
+ });
42
+
43
+ it('returns dash for undefined/empty', () => {
44
+ expect(formatDate('')).toBe('-');
45
+ expect(formatDate(undefined as any)).toBe('-');
46
+ });
47
+ });
48
+
49
+ describe('fetchPageProps', () => {
50
+ beforeEach(() => {
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it('extracts pageProps from valid HTML', async () => {
55
+ const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
56
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
57
+ ok: true,
58
+ text: () => Promise.resolve(mockHtml),
59
+ }));
60
+
61
+ const result = await fetchPageProps('/podcast/abc123');
62
+ expect(result).toEqual({ podcast: { title: 'Test' } });
63
+ });
64
+
65
+ it('throws on HTTP error', async () => {
66
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
67
+ ok: false,
68
+ status: 404,
69
+ text: () => Promise.resolve('Not Found'),
70
+ }));
71
+
72
+ await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
73
+ });
74
+
75
+ it('throws when __NEXT_DATA__ is missing', async () => {
76
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
77
+ ok: true,
78
+ text: () => Promise.resolve('<html><body>No data here</body></html>'),
79
+ }));
80
+
81
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
82
+ });
83
+
84
+ it('throws when pageProps is empty', async () => {
85
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
86
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
87
+ ok: true,
88
+ text: () => Promise.resolve(mockHtml),
89
+ }));
90
+
91
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
92
+ });
93
+
94
+ it('throws on malformed JSON in __NEXT_DATA__', async () => {
95
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
96
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
97
+ ok: true,
98
+ text: () => Promise.resolve(mockHtml),
99
+ }));
100
+
101
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
102
+ });
103
+
104
+ it('handles multiline JSON in __NEXT_DATA__', async () => {
105
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
106
+ {
107
+ "props": {
108
+ "pageProps": {
109
+ "episode": {"title": "Multiline Test"}
110
+ }
111
+ }
112
+ }
113
+ </script>`;
114
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
115
+ ok: true,
116
+ text: () => Promise.resolve(mockHtml),
117
+ }));
118
+
119
+ const result = await fetchPageProps('/episode/abc');
120
+ expect(result).toEqual({ episode: { title: 'Multiline Test' } });
121
+ });
122
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared Xiaoyuzhou utilities — page data extraction and formatting.
3
+ *
4
+ * Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
5
+ * <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
6
+ * instead of using their authenticated API.
7
+ */
8
+
9
+ import { CliError } from '../../errors.js';
10
+
11
+ /**
12
+ * Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
13
+ * @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
14
+ */
15
+ export async function fetchPageProps(path: string): Promise<any> {
16
+ const url = `https://www.xiaoyuzhoufm.com${path}`;
17
+ // Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
18
+ const resp = await fetch(url, {
19
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
20
+ });
21
+ if (!resp.ok) {
22
+ throw new CliError(
23
+ 'FETCH_ERROR',
24
+ `HTTP ${resp.status} for ${path}`,
25
+ 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
26
+ );
27
+ }
28
+ const html = await resp.text();
29
+ // [\s\S]*? for multiline safety (JSON may span lines)
30
+ const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
31
+ if (!match) {
32
+ throw new CliError(
33
+ 'PARSE_ERROR',
34
+ 'Failed to extract __NEXT_DATA__',
35
+ 'Page structure may have changed',
36
+ );
37
+ }
38
+ let parsed: any;
39
+ try { parsed = JSON.parse(match[1]); }
40
+ catch { throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed'); }
41
+ const pageProps = parsed.props?.pageProps;
42
+ if (!pageProps || Object.keys(pageProps).length === 0) {
43
+ throw new CliError(
44
+ 'NOT_FOUND',
45
+ 'Resource not found',
46
+ 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs',
47
+ );
48
+ }
49
+ return pageProps;
50
+ }
51
+
52
+ /** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
53
+ export function formatDuration(seconds: number): string {
54
+ if (!Number.isFinite(seconds) || seconds < 0) return '-';
55
+ seconds = Math.round(seconds);
56
+ const m = Math.floor(seconds / 60);
57
+ const s = seconds % 60;
58
+ return `${m}:${String(s).padStart(2, '0')}`;
59
+ }
60
+
61
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
62
+ export function formatDate(iso: string): string {
63
+ if (!iso) return '-';
64
+ return iso.slice(0, 10);
65
+ }
package/src/doctor.ts CHANGED
@@ -591,6 +591,15 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
591
591
  const hasMismatch = uniqueFingerprints.length > 1;
592
592
  const lines = [chalk.bold(`opencli v${report.cliVersion ?? 'unknown'} doctor`), ''];
593
593
 
594
+ // CDP endpoint mode (for remote/server environments)
595
+ const cdpEndpoint = process.env.OPENCLI_CDP_ENDPOINT;
596
+ if (cdpEndpoint) {
597
+ lines.push(statusLine('OK', `CDP endpoint: ${chalk.cyan(cdpEndpoint)}`));
598
+ lines.push(chalk.dim(' → Remote Chrome mode: extension token not required'));
599
+ lines.push('');
600
+ return lines.join('\n');
601
+ }
602
+
594
603
  const installStatus: ReportStatus = report.extensionInstalled ? 'OK' : 'MISSING';
595
604
  const installDetail = report.extensionInstalled
596
605
  ? `Extension installed (${report.extensionBrowsers.join(', ')})`
package/src/engine.ts CHANGED
@@ -164,15 +164,72 @@ function registerYamlCli(filePath: string, defaultSite: string): void {
164
164
  }
165
165
  }
166
166
 
167
+ /**
168
+ * Validates and coerces arguments based on the command's Arg definitions.
169
+ */
170
+ function coerceAndValidateArgs(cmdArgs: Arg[], kwargs: Record<string, any>): Record<string, any> {
171
+ const result: Record<string, any> = { ...kwargs };
172
+
173
+ for (const argDef of cmdArgs) {
174
+ const val = result[argDef.name];
175
+
176
+ // 1. Check required
177
+ if (argDef.required && (val === undefined || val === null || val === '')) {
178
+ throw new Error(`Argument "${argDef.name}" is required.\n${argDef.help ? `Hint: ${argDef.help}` : ''}`);
179
+ }
180
+
181
+ if (val !== undefined && val !== null) {
182
+ // 2. Type coercion
183
+ if (argDef.type === 'int' || argDef.type === 'number') {
184
+ const num = Number(val);
185
+ if (Number.isNaN(num)) {
186
+ throw new Error(`Argument "${argDef.name}" must be a valid number. Received: "${val}"`);
187
+ }
188
+ result[argDef.name] = num;
189
+ } else if (argDef.type === 'boolean' || argDef.type === 'bool') {
190
+ if (typeof val === 'string') {
191
+ const lower = val.toLowerCase();
192
+ if (lower === 'true' || lower === '1') result[argDef.name] = true;
193
+ else if (lower === 'false' || lower === '0') result[argDef.name] = false;
194
+ else throw new Error(`Argument "${argDef.name}" must be a boolean (true/false). Received: "${val}"`);
195
+ } else {
196
+ result[argDef.name] = Boolean(val);
197
+ }
198
+ }
199
+
200
+ // 3. Choices validation
201
+ const coercedVal = result[argDef.name];
202
+ if (argDef.choices && argDef.choices.length > 0) {
203
+ // Only stringent check for string/number types against choices array
204
+ if (!argDef.choices.map(String).includes(String(coercedVal))) {
205
+ throw new Error(`Argument "${argDef.name}" must be one of: ${argDef.choices.join(', ')}. Received: "${coercedVal}"`);
206
+ }
207
+ }
208
+ } else if (argDef.default !== undefined) {
209
+ // Set default if value is missing
210
+ result[argDef.name] = argDef.default;
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+
167
216
  /**
168
217
  * Execute a CLI command. Handles lazy-loading of TS modules.
169
218
  */
170
219
  export async function executeCommand(
171
220
  cmd: CliCommand,
172
221
  page: IPage | null,
173
- kwargs: Record<string, any>,
222
+ rawKwargs: Record<string, any>,
174
223
  debug: boolean = false,
175
224
  ): Promise<any> {
225
+ let kwargs: Record<string, any>;
226
+ try {
227
+ kwargs = coerceAndValidateArgs(cmd.args, rawKwargs);
228
+ } catch (err: any) {
229
+ // Re-throw validation errors clearly
230
+ throw new Error(`[Argument Validation Error]\n${err.message}`);
231
+ }
232
+
176
233
  // Lazy-load TS module on first execution
177
234
  const internal = cmd as InternalCliCommand;
178
235
  if (internal._lazy && internal._modulePath) {
package/src/main.ts CHANGED
@@ -189,19 +189,21 @@ for (const [, cmd] of registry) {
189
189
  const actionOpts = actionArgs[positionalArgs.length] ?? {};
190
190
  const startTime = Date.now();
191
191
  const kwargs: Record<string, any> = {};
192
+
192
193
  // Collect positional args
193
194
  for (let i = 0; i < positionalArgs.length; i++) {
194
195
  const arg = positionalArgs[i];
195
196
  const v = actionArgs[i];
196
- if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
197
- else if (arg.default != null) kwargs[arg.name] = arg.default;
197
+ if (v !== undefined) kwargs[arg.name] = v;
198
198
  }
199
+
199
200
  // Collect named options
200
201
  for (const arg of cmd.args) {
201
202
  if (arg.positional) continue;
202
- const v = actionOpts[arg.name]; if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
203
- else if (arg.default != null) kwargs[arg.name] = arg.default;
203
+ const v = actionOpts[arg.name];
204
+ if (v !== undefined) kwargs[arg.name] = v;
204
205
  }
206
+
205
207
  try {
206
208
  if (actionOpts.verbose) process.env.OPENCLI_VERBOSE = '1';
207
209
  let result: any;
@@ -226,11 +228,4 @@ for (const [, cmd] of registry) {
226
228
  });
227
229
  }
228
230
 
229
- function coerce(v: any, t: string): any {
230
- if (t === 'bool') return ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase());
231
- if (t === 'int') return parseInt(String(v), 10);
232
- if (t === 'float') return parseFloat(String(v));
233
- return String(v);
234
- }
235
-
236
231
  program.parse();
@@ -4,11 +4,7 @@
4
4
 
5
5
  import chalk from 'chalk';
6
6
  import type { IPage } from '../types.js';
7
- import { stepNavigate, stepClick, stepType, stepWait, stepPress, stepSnapshot, stepEvaluate } from './steps/browser.js';
8
- import { stepFetch } from './steps/fetch.js';
9
- import { stepSelect, stepMap, stepFilter, stepSort, stepLimit } from './steps/transform.js';
10
- import { stepIntercept } from './steps/intercept.js';
11
- import { stepTap } from './steps/tap.js';
7
+ import { getStep, type StepHandler } from './registry.js';
12
8
  import { log } from '../logger.js';
13
9
 
14
10
  export interface PipelineContext {
@@ -16,28 +12,6 @@ export interface PipelineContext {
16
12
  debug?: boolean;
17
13
  }
18
14
 
19
- /** Step handler: all steps conform to (page, params, data, args) => Promise<any> */
20
- type StepHandler = (page: IPage | null, params: any, data: any, args: Record<string, any>) => Promise<any>;
21
-
22
- /** Registry of all available step handlers */
23
- const STEP_HANDLERS: Record<string, StepHandler> = {
24
- navigate: stepNavigate,
25
- fetch: stepFetch,
26
- select: stepSelect,
27
- evaluate: stepEvaluate,
28
- snapshot: stepSnapshot,
29
- click: stepClick,
30
- type: stepType,
31
- wait: stepWait,
32
- press: stepPress,
33
- map: stepMap,
34
- filter: stepFilter,
35
- sort: stepSort,
36
- limit: stepLimit,
37
- intercept: stepIntercept,
38
- tap: stepTap,
39
- };
40
-
41
15
  export async function executePipeline(
42
16
  page: IPage | null,
43
17
  pipeline: any[],
@@ -54,7 +28,7 @@ export async function executePipeline(
54
28
  for (const [op, params] of Object.entries(step)) {
55
29
  if (debug) debugStepStart(i + 1, total, op, params);
56
30
 
57
- const handler = STEP_HANDLERS[op];
31
+ const handler = getStep(op);
58
32
  if (handler) {
59
33
  data = await handler(page, params, data, args);
60
34
  } else {