@jackwener/opencli 0.8.0 → 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 (47) hide show
  1. package/README.md +1 -0
  2. package/README.zh-CN.md +1 -0
  3. package/dist/cli-manifest.json +246 -0
  4. package/dist/clis/antigravity/dump.d.ts +1 -0
  5. package/dist/clis/antigravity/dump.js +28 -0
  6. package/dist/clis/antigravity/extract-code.d.ts +1 -0
  7. package/dist/clis/antigravity/extract-code.js +32 -0
  8. package/dist/clis/antigravity/model.d.ts +1 -0
  9. package/dist/clis/antigravity/model.js +44 -0
  10. package/dist/clis/antigravity/new.d.ts +1 -0
  11. package/dist/clis/antigravity/new.js +25 -0
  12. package/dist/clis/antigravity/read.d.ts +1 -0
  13. package/dist/clis/antigravity/read.js +34 -0
  14. package/dist/clis/antigravity/send.d.ts +1 -0
  15. package/dist/clis/antigravity/send.js +35 -0
  16. package/dist/clis/antigravity/status.d.ts +1 -0
  17. package/dist/clis/antigravity/status.js +18 -0
  18. package/dist/clis/antigravity/watch.d.ts +1 -0
  19. package/dist/clis/antigravity/watch.js +41 -0
  20. package/dist/clis/xiaoyuzhou/episode.d.ts +1 -0
  21. package/dist/clis/xiaoyuzhou/episode.js +28 -0
  22. package/dist/clis/xiaoyuzhou/podcast-episodes.d.ts +1 -0
  23. package/dist/clis/xiaoyuzhou/podcast-episodes.js +36 -0
  24. package/dist/clis/xiaoyuzhou/podcast.d.ts +1 -0
  25. package/dist/clis/xiaoyuzhou/podcast.js +27 -0
  26. package/dist/clis/xiaoyuzhou/utils.d.ts +16 -0
  27. package/dist/clis/xiaoyuzhou/utils.js +55 -0
  28. package/dist/clis/xiaoyuzhou/utils.test.d.ts +1 -0
  29. package/dist/clis/xiaoyuzhou/utils.test.js +99 -0
  30. package/package.json +1 -1
  31. package/src/clis/antigravity/README.md +49 -0
  32. package/src/clis/antigravity/README.zh-CN.md +52 -0
  33. package/src/clis/antigravity/SKILL.md +42 -0
  34. package/src/clis/antigravity/dump.ts +30 -0
  35. package/src/clis/antigravity/extract-code.ts +34 -0
  36. package/src/clis/antigravity/model.ts +47 -0
  37. package/src/clis/antigravity/new.ts +28 -0
  38. package/src/clis/antigravity/read.ts +36 -0
  39. package/src/clis/antigravity/send.ts +40 -0
  40. package/src/clis/antigravity/status.ts +19 -0
  41. package/src/clis/antigravity/watch.ts +45 -0
  42. package/src/clis/xiaoyuzhou/episode.ts +28 -0
  43. package/src/clis/xiaoyuzhou/podcast-episodes.ts +36 -0
  44. package/src/clis/xiaoyuzhou/podcast.ts +27 -0
  45. package/src/clis/xiaoyuzhou/utils.test.ts +122 -0
  46. package/src/clis/xiaoyuzhou/utils.ts +65 -0
  47. package/tests/e2e/public-commands.test.ts +62 -0
@@ -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
+ }
@@ -6,6 +6,11 @@
6
6
  import { describe, it, expect } from 'vitest';
7
7
  import { runCli, parseJsonOutput } from './helpers.js';
8
8
 
9
+ function isExpectedXiaoyuzhouRestriction(code: number, stderr: string): boolean {
10
+ if (code === 0) return false;
11
+ return /Error \[FETCH_ERROR\]: HTTP (403|429|451|503)\b/.test(stderr);
12
+ }
13
+
9
14
  describe('public commands E2E', () => {
10
15
  // ── hackernews ──
11
16
  it('hackernews top returns structured data', async () => {
@@ -53,4 +58,61 @@ describe('public commands E2E', () => {
53
58
  expect(data).toBeDefined();
54
59
  }
55
60
  }, 30_000);
61
+
62
+ // ── xiaoyuzhou (Chinese site — may return empty on overseas CI runners) ──
63
+ it('xiaoyuzhou podcast returns podcast profile', async () => {
64
+ const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
65
+ if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
66
+ console.warn(`xiaoyuzhou podcast skipped: ${stderr.trim()}`);
67
+ return;
68
+ }
69
+ expect(code).toBe(0);
70
+ const data = parseJsonOutput(stdout);
71
+ expect(Array.isArray(data)).toBe(true);
72
+ expect(data.length).toBe(1);
73
+ expect(data[0]).toHaveProperty('title');
74
+ expect(data[0]).toHaveProperty('subscribers');
75
+ expect(data[0]).toHaveProperty('episodes');
76
+ }, 30_000);
77
+
78
+ it('xiaoyuzhou podcast-episodes returns episode list', async () => {
79
+ const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '-f', 'json']);
80
+ if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
81
+ console.warn(`xiaoyuzhou podcast-episodes skipped: ${stderr.trim()}`);
82
+ return;
83
+ }
84
+ expect(code).toBe(0);
85
+ const data = parseJsonOutput(stdout);
86
+ expect(Array.isArray(data)).toBe(true);
87
+ expect(data.length).toBeGreaterThanOrEqual(1);
88
+ expect(data[0]).toHaveProperty('eid');
89
+ expect(data[0]).toHaveProperty('title');
90
+ expect(data[0]).toHaveProperty('duration');
91
+ }, 30_000);
92
+
93
+ it('xiaoyuzhou episode returns episode detail', async () => {
94
+ const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'episode', '69b3b675772ac2295bfc01d0', '-f', 'json']);
95
+ if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
96
+ console.warn(`xiaoyuzhou episode skipped: ${stderr.trim()}`);
97
+ return;
98
+ }
99
+ expect(code).toBe(0);
100
+ const data = parseJsonOutput(stdout);
101
+ expect(Array.isArray(data)).toBe(true);
102
+ expect(data.length).toBe(1);
103
+ expect(data[0]).toHaveProperty('title');
104
+ expect(data[0]).toHaveProperty('podcast');
105
+ expect(data[0]).toHaveProperty('plays');
106
+ expect(data[0]).toHaveProperty('comments');
107
+ }, 30_000);
108
+
109
+ it('xiaoyuzhou podcast-episodes rejects invalid limit', async () => {
110
+ const { stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '--limit', 'abc', '-f', 'json']);
111
+ if (isExpectedXiaoyuzhouRestriction(code, stderr)) {
112
+ console.warn(`xiaoyuzhou invalid-limit skipped: ${stderr.trim()}`);
113
+ return;
114
+ }
115
+ expect(code).not.toBe(0);
116
+ expect(stderr).toMatch(/limit must be a positive integer|Argument "limit" must be a valid number/);
117
+ }, 30_000);
56
118
  });