@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,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.0",
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.
@@ -0,0 +1,52 @@
1
+ # Antigravity CLI Adapter (探针插件)
2
+
3
+ 🔥 **opencli 支持 CLI 化所有 electron 应用!最强大更新来袭!** 🔥
4
+
5
+ CLI all electron!现在支持把所有 electron 应用 CLI 化,从而组合出各种神奇的能力。
6
+ 如果你在使用诸如 Antigravity Ultra 等工具时觉得不够灵活或难以扩展,现在通过 OpenCLI 把他 CLI 化,轻松打破界限。
7
+
8
+ 现在,**AI 可以自己控制自己**!结合 cc/openclaw 就可以远程控制任何 electron 应用!无限玩法!!
9
+
10
+ 通过 Chrome DevTools Protocol (CDP),将你本地运行的 Antigravity 桌面客户端转变为一个完全可编程的 AI 节点。这让你可以在命令行终端中直接操控它的 UI 界面,实现真正的“零 API 限制”本地自动化大模型工作流调度。
11
+
12
+ ## 开发准备
13
+
14
+ 首先,**请在终端启动 Antigravity 桌面版**,并附加上允许远程调试(CDP)的内核启动参数:
15
+
16
+ \`\`\`bash
17
+ # 在后台启动并驻留
18
+ /Applications/Antigravity.app/Contents/MacOS/Electron \\
19
+ --remote-debugging-port=9224 \\
20
+ --remote-allow-origins="*"
21
+ \`\`\`
22
+
23
+ *(注意:如果你打包的应用重命名过主构建,可能需要把 `Electron` 换成实际的可执行文件名,如 `Antigravity`)*
24
+
25
+ 接下来,在你想执行 CLI 命令的另一个新终端板块里,声明要连入的本地调试端口环境变量:
26
+
27
+ \`\`\`bash
28
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
29
+ \`\`\`
30
+
31
+ ## 全部指令一览
32
+
33
+ ### \`opencli antigravity status\`
34
+ 快速检查当前探针与内核 CDP 的连接状态。会返回底层的当前 URL 和网页 Title。
35
+
36
+ ### \`opencli antigravity send <message>\`
37
+ 给 Agent 发送消息。它会自动定位到底部的 Lexical 输入框,安全地注入你的指定文本然后模拟回车发送。
38
+
39
+ ### \`opencli antigravity read\`
40
+ 全量抓取当前的对话面板,将所有历史聊天记录作为一整块纯文本取回。
41
+
42
+ ### \`opencli antigravity new\`
43
+ 模拟点击侧边栏顶部的“开启新对话”按钮,瞬间清空并重置 Agent 的上下文状态。
44
+
45
+ ### \`opencli antigravity extract-code\`
46
+ 从当前的 Agent 聊天记录中单独提取所有的多行代码块。非常适合自动化脚手架开发(例如直接重定向输出写入本地文件:\`opencli antigravity extract-code > script.sh\`)。
47
+
48
+ ### \`opencli antigravity model <name>\`
49
+ 切换大模型引擎。只需传入关键词(比如:\`opencli antigravity model claude\` 或 \`model gemini\`),它会自动帮你点开模型选择菜单并模拟点击。
50
+
51
+ ### \`opencli antigravity watch\`
52
+ 开启一个长连接流式监听。通过持续轮询 DOM 的变化量,它能像流式 API 一样,在终端实时向你推送 Agent 刚刚打出的那一行最新回复,直到你按 Ctrl+C 中止。
@@ -0,0 +1,42 @@
1
+ ---
2
+ description: How to automate Antigravity using OpenCLI
3
+ ---
4
+
5
+ # Antigravity Automation Skill
6
+
7
+ This skill allows AI agents to control the [Antigravity](https://github.com/chengazhen/Antigravity) desktop app (and any Electron app with CDP enabled) programmatically via OpenCLI.
8
+
9
+ ## Requirements
10
+ The target Electron application MUST be launched with the remote-debugging-port flag:
11
+ \`\`\`bash
12
+ /Applications/Antigravity.app/Contents/MacOS/Electron --remote-debugging-port=9224 --remote-allow-origins="*"
13
+ \`\`\`
14
+
15
+ The agent must configure the endpoint environment variable locally before invoking standard commands:
16
+ \`\`\`bash
17
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
18
+ \`\`\`
19
+
20
+ ## High-Level Capabilities
21
+ 1. **Send Messages (`opencli antigravity send <message>`)**: Type and send a message directly into the chat UI.
22
+ 2. **Read History (`opencli antigravity read`)**: Scrape the raw chat transcript from the main UI container.
23
+ 3. **Extract Code (`opencli antigravity extract-code`)**: Automatically isolate and extract source code text blocks from the AI's recent answers.
24
+ 4. **Switch Models (`opencli antigravity model <name>`)**: Instantly toggle the active LLM (e.g., \`gemini\`, \`claude\`).
25
+ 5. **Clear Context (`opencli antigravity new`)**: Start a fresh conversation.
26
+
27
+ ## Examples for Automated Workflows
28
+
29
+ ### Generating and Saving Code
30
+ \`\`\`bash
31
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
32
+ opencli antigravity send "Write a python script to fetch HN top stories"
33
+ # wait ~10-15 seconds for output to render
34
+ opencli antigravity extract-code > hn_fetcher.py
35
+ \`\`\`
36
+
37
+ ### Reading Real-time Logs
38
+ Agents can run long-running streaming watch instances:
39
+ \`\`\`bash
40
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9224"
41
+ opencli antigravity watch
42
+ \`\`\`
@@ -0,0 +1,30 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'node:fs';
3
+
4
+ export const dumpCommand = cli({
5
+ site: 'antigravity',
6
+ name: 'dump',
7
+ description: 'Dump the DOM to help AI understand the UI',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['htmlFile', 'snapFile'],
13
+ func: async (page) => {
14
+ // Extract HTML
15
+ const html = await page.evaluate('document.body.innerHTML');
16
+ fs.writeFileSync('/tmp/antigravity-dom.html', html);
17
+
18
+ // Extract Snapshot
19
+ let snapFile = '';
20
+ try {
21
+ const snap = await page.snapshot({ raw: true });
22
+ snapFile = '/tmp/antigravity-snapshot.json';
23
+ fs.writeFileSync(snapFile, JSON.stringify(snap, null, 2));
24
+ } catch (e) {
25
+ snapFile = 'Failed';
26
+ }
27
+
28
+ return [{ htmlFile: '/tmp/antigravity-dom.html', snapFile }];
29
+ },
30
+ });
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const extractCodeCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'extract-code',
6
+ description: 'Extract multi-line code blocks from the current Antigravity conversation',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [],
11
+ columns: ['code'],
12
+ func: async (page) => {
13
+ const blocks = await page.evaluate(`
14
+ async () => {
15
+ // Find standard pre/code blocks
16
+ let elements = Array.from(document.querySelectorAll('pre code'));
17
+
18
+ // Fallback to Monaco editor content inside the UI
19
+ if (elements.length === 0) {
20
+ elements = Array.from(document.querySelectorAll('.monaco-editor'));
21
+ }
22
+
23
+ // Generic fallback to any code tag that spans multiple lines
24
+ if (elements.length === 0) {
25
+ elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
26
+ }
27
+
28
+ return elements.map(el => el.innerText).filter(text => text.trim().length > 0);
29
+ }
30
+ `);
31
+
32
+ return blocks.map((code: string) => ({ code }));
33
+ },
34
+ });
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const modelCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'model',
6
+ description: 'Switch the active LLM model in Antigravity',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [
11
+ { name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true }
12
+ ],
13
+ columns: ['status'],
14
+ func: async (page, kwargs) => {
15
+ const targetName = kwargs.name.toLowerCase();
16
+
17
+ await page.evaluate(`
18
+ async () => {
19
+ const targetModelName = ${JSON.stringify(targetName)};
20
+
21
+ // 1. Locate the model selector dropdown trigger
22
+ const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
23
+ if (!trigger) throw new Error('Could not find the model selector trigger in the UI');
24
+ trigger.click();
25
+
26
+ // 2. Wait a brief moment for React to mount the Portal/Dialog
27
+ await new Promise(r => setTimeout(r, 200));
28
+
29
+ // 3. Find the option spanning target text
30
+ const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
31
+ const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
32
+ if (!target) {
33
+ // If not found, click the trigger again to close it safely
34
+ trigger.click();
35
+ throw new Error('Model matching "' + targetModelName + '" was not found in the dropdown list.');
36
+ }
37
+
38
+ // 4. Click the closest parent that handles the row action
39
+ const optionNode = target.closest('.cursor-pointer') || target;
40
+ optionNode.click();
41
+ }
42
+ `);
43
+
44
+ await page.wait(0.5);
45
+ return [{ status: `Model switched to: ${kwargs.name}` }];
46
+ },
47
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const newCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'new',
6
+ description: 'Start a new conversation / clear context in Antigravity',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [],
11
+ columns: ['status'],
12
+ func: async (page) => {
13
+ await page.evaluate(`
14
+ async () => {
15
+ const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
16
+ if (!btn) throw new Error('Could not find New Conversation button');
17
+
18
+ // In case it's disabled, we must check, but we'll try to click it anyway
19
+ btn.click();
20
+ }
21
+ `);
22
+
23
+ // Give it a moment to reset the UI
24
+ await page.wait(0.5);
25
+
26
+ return [{ status: 'Successfully started a new conversation' }];
27
+ },
28
+ });
@@ -0,0 +1,36 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const readCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'read',
6
+ description: 'Read the latest chat messages from Antigravity AI',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [
11
+ { name: 'last', help: 'Number of recent messages to read (not fully implemented due to generic structure, currently returns full history text or latest chunk)' }
12
+ ],
13
+ columns: ['role', 'content'],
14
+ func: async (page, kwargs) => {
15
+ // We execute a script inside Antigravity's Chromium environment to extract the text
16
+ // of the entire conversation pane.
17
+ const rawText = await page.evaluate(`
18
+ async () => {
19
+ const container = document.getElementById('conversation');
20
+ if (!container) throw new Error('Could not find conversation container');
21
+
22
+ // Extract the full visible text of the conversation
23
+ // In Electron/Chromium, innerText preserves basic visual line breaks nicely
24
+ return container.innerText;
25
+ }
26
+ `);
27
+
28
+ // We can do simple heuristic parsing based on typical visual markers if needed.
29
+ // For now, we return the entire text blob, or just the last 2000 characters if it's too long.
30
+ const cleanText = String(rawText).trim();
31
+ return [{
32
+ role: 'history',
33
+ content: cleanText
34
+ }];
35
+ },
36
+ });
@@ -0,0 +1,40 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const sendCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'send',
6
+ description: 'Send a message to Antigravity AI via the internal Lexical editor',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [
11
+ { name: 'message', help: 'The message text to send', required: true, positional: true }
12
+ ],
13
+ columns: ['status', 'message'],
14
+ func: async (page, kwargs) => {
15
+ const text = kwargs.message;
16
+
17
+ // We use evaluate to focus and insert text because Lexical editors maintain
18
+ // absolute control over their DOM and don't respond to raw node.textContent.
19
+ // document.execCommand simulates a native paste/typing action perfectly.
20
+ await page.evaluate(`
21
+ async () => {
22
+ const container = document.getElementById('antigravity.agentSidePanelInputBox');
23
+ if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
24
+ const editor = container.querySelector('[data-lexical-editor="true"]');
25
+ if (!editor) throw new Error('Could not find Antigravity input box');
26
+
27
+ editor.focus();
28
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
29
+ }
30
+ `);
31
+
32
+ // Wait for the React/Lexical state to flush the new input
33
+ await page.wait(0.5);
34
+
35
+ // Press Enter to submit the message
36
+ await page.pressKey('Enter');
37
+
38
+ return [{ status: 'Sent successfully', message: text }];
39
+ },
40
+ });
@@ -0,0 +1,19 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const statusCommand = cli({
4
+ site: 'antigravity',
5
+ name: 'status',
6
+ description: 'Check Antigravity CDP connection and get current page state',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ args: [],
11
+ columns: ['status', 'url', 'title'],
12
+ func: async (page) => {
13
+ return {
14
+ status: 'Connected',
15
+ url: await page.evaluate('window.location.href'),
16
+ title: await page.evaluate('document.title'),
17
+ };
18
+ },
19
+ });
@@ -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
+ });
@@ -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
+ });