@jackwener/opencli 0.8.0 → 0.9.1

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 (68) hide show
  1. package/README.md +7 -2
  2. package/README.zh-CN.md +9 -2
  3. package/SKILL.md +11 -2
  4. package/dist/cli-manifest.json +343 -0
  5. package/dist/clis/antigravity/dump.d.ts +1 -0
  6. package/dist/clis/antigravity/dump.js +28 -0
  7. package/dist/clis/antigravity/extract-code.d.ts +1 -0
  8. package/dist/clis/antigravity/extract-code.js +32 -0
  9. package/dist/clis/antigravity/model.d.ts +1 -0
  10. package/dist/clis/antigravity/model.js +44 -0
  11. package/dist/clis/antigravity/new.d.ts +1 -0
  12. package/dist/clis/antigravity/new.js +25 -0
  13. package/dist/clis/antigravity/read.d.ts +1 -0
  14. package/dist/clis/antigravity/read.js +34 -0
  15. package/dist/clis/antigravity/send.d.ts +1 -0
  16. package/dist/clis/antigravity/send.js +35 -0
  17. package/dist/clis/antigravity/status.d.ts +1 -0
  18. package/dist/clis/antigravity/status.js +18 -0
  19. package/dist/clis/antigravity/watch.d.ts +1 -0
  20. package/dist/clis/antigravity/watch.js +41 -0
  21. package/dist/clis/codex/dump.d.ts +1 -0
  22. package/dist/clis/codex/dump.js +25 -0
  23. package/dist/clis/codex/extract-diff.d.ts +1 -0
  24. package/dist/clis/codex/extract-diff.js +44 -0
  25. package/dist/clis/codex/new.d.ts +1 -0
  26. package/dist/clis/codex/new.js +25 -0
  27. package/dist/clis/codex/read.d.ts +1 -0
  28. package/dist/clis/codex/read.js +31 -0
  29. package/dist/clis/codex/send.d.ts +1 -0
  30. package/dist/clis/codex/send.js +44 -0
  31. package/dist/clis/codex/status.d.ts +1 -0
  32. package/dist/clis/codex/status.js +21 -0
  33. package/dist/clis/xiaoyuzhou/episode.d.ts +1 -0
  34. package/dist/clis/xiaoyuzhou/episode.js +28 -0
  35. package/dist/clis/xiaoyuzhou/podcast-episodes.d.ts +1 -0
  36. package/dist/clis/xiaoyuzhou/podcast-episodes.js +36 -0
  37. package/dist/clis/xiaoyuzhou/podcast.d.ts +1 -0
  38. package/dist/clis/xiaoyuzhou/podcast.js +27 -0
  39. package/dist/clis/xiaoyuzhou/utils.d.ts +16 -0
  40. package/dist/clis/xiaoyuzhou/utils.js +55 -0
  41. package/dist/clis/xiaoyuzhou/utils.test.d.ts +1 -0
  42. package/dist/clis/xiaoyuzhou/utils.test.js +99 -0
  43. package/package.json +1 -1
  44. package/src/clis/antigravity/README.md +49 -0
  45. package/src/clis/antigravity/README.zh-CN.md +52 -0
  46. package/src/clis/antigravity/SKILL.md +42 -0
  47. package/src/clis/antigravity/dump.ts +30 -0
  48. package/src/clis/antigravity/extract-code.ts +34 -0
  49. package/src/clis/antigravity/model.ts +47 -0
  50. package/src/clis/antigravity/new.ts +28 -0
  51. package/src/clis/antigravity/read.ts +36 -0
  52. package/src/clis/antigravity/send.ts +40 -0
  53. package/src/clis/antigravity/status.ts +19 -0
  54. package/src/clis/antigravity/watch.ts +45 -0
  55. package/src/clis/codex/README.md +33 -0
  56. package/src/clis/codex/README.zh-CN.md +33 -0
  57. package/src/clis/codex/dump.ts +28 -0
  58. package/src/clis/codex/extract-diff.ts +47 -0
  59. package/src/clis/codex/new.ts +29 -0
  60. package/src/clis/codex/read.ts +33 -0
  61. package/src/clis/codex/send.ts +48 -0
  62. package/src/clis/codex/status.ts +23 -0
  63. package/src/clis/xiaoyuzhou/episode.ts +28 -0
  64. package/src/clis/xiaoyuzhou/podcast-episodes.ts +36 -0
  65. package/src/clis/xiaoyuzhou/podcast.ts +27 -0
  66. package/src/clis/xiaoyuzhou/utils.test.ts +122 -0
  67. package/src/clis/xiaoyuzhou/utils.ts +65 -0
  68. package/tests/e2e/public-commands.test.ts +62 -0
@@ -0,0 +1,35 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const sendCommand = cli({
3
+ site: 'antigravity',
4
+ name: 'send',
5
+ description: 'Send a message to Antigravity AI via the internal Lexical editor',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'message', help: 'The message text to send', required: true, positional: true }
11
+ ],
12
+ columns: ['status', 'message'],
13
+ func: async (page, kwargs) => {
14
+ const text = kwargs.message;
15
+ // We use evaluate to focus and insert text because Lexical editors maintain
16
+ // absolute control over their DOM and don't respond to raw node.textContent.
17
+ // document.execCommand simulates a native paste/typing action perfectly.
18
+ await page.evaluate(`
19
+ async () => {
20
+ const container = document.getElementById('antigravity.agentSidePanelInputBox');
21
+ if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
22
+ const editor = container.querySelector('[data-lexical-editor="true"]');
23
+ if (!editor) throw new Error('Could not find Antigravity input box');
24
+
25
+ editor.focus();
26
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
27
+ }
28
+ `);
29
+ // Wait for the React/Lexical state to flush the new input
30
+ await page.wait(0.5);
31
+ // Press Enter to submit the message
32
+ await page.pressKey('Enter');
33
+ return [{ status: 'Sent successfully', message: text }];
34
+ },
35
+ });
@@ -0,0 +1 @@
1
+ export declare const statusCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,18 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const statusCommand = cli({
3
+ site: 'antigravity',
4
+ name: 'status',
5
+ description: 'Check Antigravity CDP connection and get current page state',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [],
10
+ columns: ['status', 'url', 'title'],
11
+ func: async (page) => {
12
+ return {
13
+ status: 'Connected',
14
+ url: await page.evaluate('window.location.href'),
15
+ title: await page.evaluate('document.title'),
16
+ };
17
+ },
18
+ });
@@ -0,0 +1 @@
1
+ export declare const watchCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,41 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const watchCommand = cli({
3
+ site: 'antigravity',
4
+ name: 'watch',
5
+ description: 'Stream new chat messages from Antigravity in real-time',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [],
10
+ timeoutSeconds: 86400, // Run for up to 24 hours
11
+ columns: [], // We use direct stdout streaming
12
+ func: async (page) => {
13
+ console.log('Watching Antigravity chat... (Press Ctrl+C to stop)');
14
+ let lastLength = 0;
15
+ // Loop until process gets killed
16
+ while (true) {
17
+ const text = await page.evaluate(`
18
+ async () => {
19
+ const container = document.getElementById('conversation');
20
+ return container ? container.innerText : '';
21
+ }
22
+ `);
23
+ const currentLength = text.length;
24
+ if (currentLength > lastLength) {
25
+ // Delta mode
26
+ const newSegment = text.substring(lastLength);
27
+ if (newSegment.trim().length > 0) {
28
+ process.stdout.write(newSegment);
29
+ }
30
+ lastLength = currentLength;
31
+ }
32
+ else if (currentLength < lastLength) {
33
+ // The conversation was cleared or updated significantly
34
+ lastLength = currentLength;
35
+ console.log('\\n--- Conversation Cleared/Changed ---\\n');
36
+ process.stdout.write(text);
37
+ }
38
+ await new Promise(resolve => setTimeout(resolve, 500));
39
+ }
40
+ },
41
+ });
@@ -0,0 +1 @@
1
+ export declare const dumpCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,25 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+ export const dumpCommand = cli({
4
+ site: 'codex',
5
+ name: 'dump',
6
+ description: 'Dump the DOM and Accessibility tree of Codex for reverse-engineering',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ columns: ['action', 'files'],
11
+ func: async (page) => {
12
+ // Extract full HTML
13
+ const dom = await page.evaluate('document.body.innerHTML');
14
+ fs.writeFileSync('/tmp/codex-dom.html', dom);
15
+ // Get accessibility snapshot
16
+ const snap = await page.snapshot({ interactive: false });
17
+ fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
18
+ return [
19
+ {
20
+ action: 'Dom extraction finished',
21
+ files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
22
+ },
23
+ ];
24
+ },
25
+ });
@@ -0,0 +1 @@
1
+ export declare const extractDiffCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,44 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const extractDiffCommand = cli({
3
+ site: 'codex',
4
+ name: 'extract-diff',
5
+ description: 'Extract visual code review diff patches from Codex',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ columns: ['File', 'Diff'],
10
+ func: async (page) => {
11
+ const diffs = await page.evaluate(`
12
+ (function() {
13
+ const results = [];
14
+ // Assuming diffs are rendered with standard diff classes or monaco difference editors
15
+ const diffBlocks = document.querySelectorAll('.diff-editor, .monaco-diff-editor, [data-testid="diff-view"]');
16
+
17
+ diffBlocks.forEach((block, index) => {
18
+ // Very roughly scrape text representing additions/deletions mapped from the inner wrapper
19
+ results.push({
20
+ File: block.getAttribute('data-filename') || \`DiffBlock_\${index+1}\`,
21
+ Diff: block.innerText || block.textContent
22
+ });
23
+ });
24
+
25
+ // If no structured diffs found, try to find any code blocks labeled as patches
26
+ if (results.length === 0) {
27
+ const codeBlocks = document.querySelectorAll('pre code.language-diff, pre code.language-patch');
28
+ codeBlocks.forEach((code, index) => {
29
+ results.push({
30
+ File: \`Patch_\${index+1}\`,
31
+ Diff: code.innerText || code.textContent
32
+ });
33
+ });
34
+ }
35
+
36
+ return results;
37
+ })()
38
+ `);
39
+ if (diffs.length === 0) {
40
+ return [{ File: 'No diffs found', Diff: 'Try running opencli codex send "/review" first' }];
41
+ }
42
+ return diffs;
43
+ },
44
+ });
@@ -0,0 +1 @@
1
+ export declare const newCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,25 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const newCommand = cli({
3
+ site: 'codex',
4
+ name: 'new',
5
+ description: 'Start a new Codex conversation thread / isolated workspace',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ columns: ['Status', 'Action'],
10
+ func: async (page) => {
11
+ // According to research, Cmd+N / Ctrl+N spins up a new thread
12
+ const isMac = process.platform === 'darwin';
13
+ const newThreadKey = isMac ? 'Meta+N' : 'Control+N';
14
+ // Simulate keyboard shortcut
15
+ await page.pressKey(newThreadKey);
16
+ // Wait a brief moment for UI animation
17
+ await page.wait(1);
18
+ return [
19
+ {
20
+ Status: 'Success',
21
+ Action: `Pressed ${newThreadKey} to trigger New Thread`,
22
+ },
23
+ ];
24
+ },
25
+ });
@@ -0,0 +1 @@
1
+ export declare const readCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,31 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const readCommand = cli({
3
+ site: 'codex',
4
+ name: 'read',
5
+ description: 'Read the contents of the current Codex conversation thread',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ columns: ['Thread_Content'],
10
+ func: async (page) => {
11
+ const historyText = await page.evaluate(`
12
+ (function() {
13
+ // Fallback robust scraping heuristic for chat history panes
14
+ // We look for large scrolling areas or generic message lists
15
+ const threadContainer = document.querySelector('[role="log"], [data-testid="conversation"], main, .thread-container, .messages-list');
16
+
17
+ if (threadContainer) {
18
+ return threadContainer.innerText || threadContainer.textContent;
19
+ }
20
+
21
+ // If specific containers fail, just dump the whole body's readable text minus the navigation
22
+ return document.body.innerText;
23
+ })()
24
+ `);
25
+ return [
26
+ {
27
+ Thread_Content: historyText,
28
+ },
29
+ ];
30
+ },
31
+ });
@@ -0,0 +1 @@
1
+ export declare const sendCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,44 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const sendCommand = cli({
3
+ site: 'codex',
4
+ name: 'send',
5
+ description: 'Send text/commands to the Codex AI composer',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [{ name: 'text', required: true, help: 'Text, command (e.g. /review), or skill (e.g. $imagegen)' }],
10
+ columns: ['Status', 'InjectedText'],
11
+ func: async (page, kwargs) => {
12
+ const textToInsert = kwargs.text;
13
+ // We use evaluate to inject text bypassing complex nested shadow roots or contenteditables
14
+ await page.evaluate(`
15
+ (function(text) {
16
+ // Attempt 1: Look for standard textarea/composer input
17
+ let composer = document.querySelector('textarea, [contenteditable="true"]');
18
+
19
+ // Basic heuristic: prioritize elements that are deeply nested, visible, and have 'composer' or 'input' classes
20
+ const editables = Array.from(document.querySelectorAll('[contenteditable="true"]'));
21
+ if (editables.length > 0) {
22
+ composer = editables[editables.length - 1]; // Often the active input is appended near the end
23
+ }
24
+
25
+ if (!composer) {
26
+ throw new Error('Could not find Composer input element in Codex UI');
27
+ }
28
+
29
+ composer.focus();
30
+
31
+ // This handles Lexical/ProseMirror/Monaco rich-text editors effectively by mimicking human paste/type deeply.
32
+ document.execCommand('insertText', false, text);
33
+ })(${JSON.stringify(textToInsert)})
34
+ `);
35
+ // Simulate Enter key to submit
36
+ await page.pressKey('Enter');
37
+ return [
38
+ {
39
+ Status: 'Success',
40
+ InjectedText: textToInsert,
41
+ },
42
+ ];
43
+ },
44
+ });
@@ -0,0 +1 @@
1
+ export declare const statusCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const statusCommand = cli({
3
+ site: 'codex',
4
+ name: 'status',
5
+ description: 'Check active CDP connection to OpenAI Codex App',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI, // Interactive UI manipulation
8
+ browser: true,
9
+ columns: ['Status', 'Url', 'Title'],
10
+ func: async (page) => {
11
+ const url = await page.evaluate('window.location.href');
12
+ const title = await page.evaluate('document.title');
13
+ return [
14
+ {
15
+ Status: 'Connected',
16
+ Url: url,
17
+ Title: title,
18
+ },
19
+ ];
20
+ },
21
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ cli({
5
+ site: 'xiaoyuzhou',
6
+ name: 'episode',
7
+ description: 'View details of a Xiaoyuzhou podcast episode',
8
+ domain: 'www.xiaoyuzhoufm.com',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
12
+ columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
13
+ func: async (_page, args) => {
14
+ const pageProps = await fetchPageProps(`/episode/${args.id}`);
15
+ const ep = pageProps.episode;
16
+ if (!ep)
17
+ 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 @@
1
+ export {};
@@ -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
+ cli({
5
+ site: 'xiaoyuzhou',
6
+ name: 'podcast-episodes',
7
+ description: 'List recent episodes of a Xiaoyuzhou podcast (up to 15, SSR limit)',
8
+ domain: 'www.xiaoyuzhoufm.com',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
13
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show (up to 15, SSR limit)' },
14
+ ],
15
+ columns: ['eid', 'title', 'duration', 'plays', 'date'],
16
+ func: async (_page, args) => {
17
+ const pageProps = await fetchPageProps(`/podcast/${args.id}`);
18
+ const podcast = pageProps.podcast;
19
+ if (!podcast)
20
+ 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) => ({
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 @@
1
+ export {};
@@ -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
+ cli({
5
+ site: 'xiaoyuzhou',
6
+ name: 'podcast',
7
+ description: 'View a Xiaoyuzhou podcast profile',
8
+ domain: 'www.xiaoyuzhoufm.com',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
12
+ columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
13
+ func: async (_page, args) => {
14
+ const pageProps = await fetchPageProps(`/podcast/${args.id}`);
15
+ const p = pageProps.podcast;
16
+ if (!p)
17
+ 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,16 @@
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
+ * Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
10
+ * @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
11
+ */
12
+ export declare function fetchPageProps(path: string): Promise<any>;
13
+ /** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
14
+ export declare function formatDuration(seconds: number): string;
15
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
16
+ export declare function formatDate(iso: string): string;
@@ -0,0 +1,55 @@
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
+ import { CliError } from '../../errors.js';
9
+ /**
10
+ * Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
11
+ * @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
12
+ */
13
+ export async function fetchPageProps(path) {
14
+ const url = `https://www.xiaoyuzhoufm.com${path}`;
15
+ // Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
16
+ const resp = await fetch(url, {
17
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
18
+ });
19
+ if (!resp.ok) {
20
+ throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
21
+ }
22
+ const html = await resp.text();
23
+ // [\s\S]*? for multiline safety (JSON may span lines)
24
+ const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
25
+ if (!match) {
26
+ throw new CliError('PARSE_ERROR', 'Failed to extract __NEXT_DATA__', 'Page structure may have changed');
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(match[1]);
31
+ }
32
+ catch {
33
+ throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed');
34
+ }
35
+ const pageProps = parsed.props?.pageProps;
36
+ if (!pageProps || Object.keys(pageProps).length === 0) {
37
+ throw new CliError('NOT_FOUND', 'Resource not found', 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
38
+ }
39
+ return pageProps;
40
+ }
41
+ /** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
42
+ export function formatDuration(seconds) {
43
+ if (!Number.isFinite(seconds) || seconds < 0)
44
+ return '-';
45
+ seconds = Math.round(seconds);
46
+ const m = Math.floor(seconds / 60);
47
+ const s = seconds % 60;
48
+ return `${m}:${String(s).padStart(2, '0')}`;
49
+ }
50
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
51
+ export function formatDate(iso) {
52
+ if (!iso)
53
+ return '-';
54
+ return iso.slice(0, 10);
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, fetchPageProps } from './utils.js';
3
+ describe('formatDuration', () => {
4
+ it('formats typical duration', () => {
5
+ expect(formatDuration(3890)).toBe('64:50');
6
+ });
7
+ it('formats zero seconds', () => {
8
+ expect(formatDuration(0)).toBe('0:00');
9
+ });
10
+ it('pads single-digit seconds', () => {
11
+ expect(formatDuration(65)).toBe('1:05');
12
+ });
13
+ it('formats exact minutes', () => {
14
+ expect(formatDuration(3600)).toBe('60:00');
15
+ });
16
+ it('rounds floating-point seconds', () => {
17
+ expect(formatDuration(3890.7)).toBe('64:51');
18
+ });
19
+ it('returns dash for NaN', () => {
20
+ expect(formatDuration(NaN)).toBe('-');
21
+ });
22
+ it('returns dash for negative', () => {
23
+ expect(formatDuration(-1)).toBe('-');
24
+ });
25
+ });
26
+ describe('formatDate', () => {
27
+ it('extracts YYYY-MM-DD from ISO string', () => {
28
+ expect(formatDate('2026-03-13T11:00:06.686Z')).toBe('2026-03-13');
29
+ });
30
+ it('handles date-only string', () => {
31
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
32
+ });
33
+ it('returns dash for undefined/empty', () => {
34
+ expect(formatDate('')).toBe('-');
35
+ expect(formatDate(undefined)).toBe('-');
36
+ });
37
+ });
38
+ describe('fetchPageProps', () => {
39
+ beforeEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+ it('extracts pageProps from valid HTML', async () => {
43
+ const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
44
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
45
+ ok: true,
46
+ text: () => Promise.resolve(mockHtml),
47
+ }));
48
+ const result = await fetchPageProps('/podcast/abc123');
49
+ expect(result).toEqual({ podcast: { title: 'Test' } });
50
+ });
51
+ it('throws on HTTP error', async () => {
52
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
+ ok: false,
54
+ status: 404,
55
+ text: () => Promise.resolve('Not Found'),
56
+ }));
57
+ await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
58
+ });
59
+ it('throws when __NEXT_DATA__ is missing', async () => {
60
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
61
+ ok: true,
62
+ text: () => Promise.resolve('<html><body>No data here</body></html>'),
63
+ }));
64
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
65
+ });
66
+ it('throws when pageProps is empty', async () => {
67
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
68
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
69
+ ok: true,
70
+ text: () => Promise.resolve(mockHtml),
71
+ }));
72
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
73
+ });
74
+ it('throws on malformed JSON in __NEXT_DATA__', async () => {
75
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
76
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
77
+ ok: true,
78
+ text: () => Promise.resolve(mockHtml),
79
+ }));
80
+ await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
81
+ });
82
+ it('handles multiline JSON in __NEXT_DATA__', async () => {
83
+ const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
84
+ {
85
+ "props": {
86
+ "pageProps": {
87
+ "episode": {"title": "Multiline Test"}
88
+ }
89
+ }
90
+ }
91
+ </script>`;
92
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
93
+ ok: true,
94
+ text: () => Promise.resolve(mockHtml),
95
+ }));
96
+ const result = await fetchPageProps('/episode/abc');
97
+ expect(result).toEqual({ episode: { title: 'Multiline Test' } });
98
+ });
99
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,49 @@
1
+ # Antigravity CLI Adapter
2
+
3
+ 🔥 **CLI All Electron Apps! The Most Powerful Update Has Arrived!** 🔥
4
+
5
+ Turn ANY Electron application into a CLI tool! Recombine, script, and extend applications like Antigravity Ultra seamlessly. Now AI can control itself natively. Unlimited possibilities await!
6
+
7
+ Turn your local Antigravity desktop application into a programmable AI node via Chrome DevTools Protocol (CDP). This allows you to compose complex LLM workflows entirely through the terminal by manipulating the actual UI natively, bypassing any API restrictions.
8
+
9
+ ## Prerequisites
10
+
11
+ Start the Antigravity desktop app with the Chrome DevTools `remote-debugging-port` flag:
12
+
13
+ \`\`\`bash
14
+ # Start Antigravity in the background
15
+ /Applications/Antigravity.app/Contents/MacOS/Electron \\
16
+ --remote-debugging-port=9224 \\
17
+ --remote-allow-origins="*"
18
+ \`\`\`
19
+
20
+ *(Note: Depending on your installation, the executable might be named differently, e.g., \`Antigravity\` instead of \`Electron\`.)*
21
+
22
+ Next, set the target port in your terminal session to tell OpenCLI where to connect:
23
+
24
+ \`\`\`bash
25
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
26
+ \`\`\`
27
+
28
+ ## Available Commands
29
+
30
+ ### \`opencli antigravity status\`
31
+ Check the Chromium CDP connection. Returns the current window title and active internal URL.
32
+
33
+ ### \`opencli antigravity send <message>\`
34
+ Send a text prompt to the AI. Automatically locates the Lexical editor input box, types the prompt securely, and hits Enter.
35
+
36
+ ### \`opencli antigravity read\`
37
+ Scrape the entire current conversation history block as pure text. Useful for feeding the context to another script.
38
+
39
+ ### \`opencli antigravity new\`
40
+ Click the "New Conversation" button to instantly clear the UI state and start fresh.
41
+
42
+ ### \`opencli antigravity extract-code\`
43
+ Extract any multi-line code blocks from the current conversation view. Ideal for automated script extraction (e.g. \`opencli antigravity extract-code > script.sh\`).
44
+
45
+ ### \`opencli antigravity model <name>\`
46
+ Quickly target and switch the active LLM engine. Example: \`opencli antigravity model claude\` or \`opencli antigravity model gemini\`.
47
+
48
+ ### \`opencli antigravity watch\`
49
+ A long-running, streaming process that continuously polls the Antigravity UI for chat updates and outputs them in real-time to standard output.