@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.
- package/README.md +7 -2
- package/README.zh-CN.md +9 -2
- package/SKILL.md +11 -2
- package/dist/cli-manifest.json +343 -0
- package/dist/clis/antigravity/dump.d.ts +1 -0
- package/dist/clis/antigravity/dump.js +28 -0
- package/dist/clis/antigravity/extract-code.d.ts +1 -0
- package/dist/clis/antigravity/extract-code.js +32 -0
- package/dist/clis/antigravity/model.d.ts +1 -0
- package/dist/clis/antigravity/model.js +44 -0
- package/dist/clis/antigravity/new.d.ts +1 -0
- package/dist/clis/antigravity/new.js +25 -0
- package/dist/clis/antigravity/read.d.ts +1 -0
- package/dist/clis/antigravity/read.js +34 -0
- package/dist/clis/antigravity/send.d.ts +1 -0
- package/dist/clis/antigravity/send.js +35 -0
- package/dist/clis/antigravity/status.d.ts +1 -0
- package/dist/clis/antigravity/status.js +18 -0
- package/dist/clis/antigravity/watch.d.ts +1 -0
- package/dist/clis/antigravity/watch.js +41 -0
- package/dist/clis/codex/dump.d.ts +1 -0
- package/dist/clis/codex/dump.js +25 -0
- package/dist/clis/codex/extract-diff.d.ts +1 -0
- package/dist/clis/codex/extract-diff.js +44 -0
- package/dist/clis/codex/new.d.ts +1 -0
- package/dist/clis/codex/new.js +25 -0
- package/dist/clis/codex/read.d.ts +1 -0
- package/dist/clis/codex/read.js +31 -0
- package/dist/clis/codex/send.d.ts +1 -0
- package/dist/clis/codex/send.js +44 -0
- package/dist/clis/codex/status.d.ts +1 -0
- package/dist/clis/codex/status.js +21 -0
- package/dist/clis/xiaoyuzhou/episode.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/episode.js +28 -0
- package/dist/clis/xiaoyuzhou/podcast-episodes.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/podcast-episodes.js +36 -0
- package/dist/clis/xiaoyuzhou/podcast.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/podcast.js +27 -0
- package/dist/clis/xiaoyuzhou/utils.d.ts +16 -0
- package/dist/clis/xiaoyuzhou/utils.js +55 -0
- package/dist/clis/xiaoyuzhou/utils.test.d.ts +1 -0
- package/dist/clis/xiaoyuzhou/utils.test.js +99 -0
- package/package.json +1 -1
- package/src/clis/antigravity/README.md +49 -0
- package/src/clis/antigravity/README.zh-CN.md +52 -0
- package/src/clis/antigravity/SKILL.md +42 -0
- package/src/clis/antigravity/dump.ts +30 -0
- package/src/clis/antigravity/extract-code.ts +34 -0
- package/src/clis/antigravity/model.ts +47 -0
- package/src/clis/antigravity/new.ts +28 -0
- package/src/clis/antigravity/read.ts +36 -0
- package/src/clis/antigravity/send.ts +40 -0
- package/src/clis/antigravity/status.ts +19 -0
- package/src/clis/antigravity/watch.ts +45 -0
- package/src/clis/codex/README.md +33 -0
- package/src/clis/codex/README.zh-CN.md +33 -0
- package/src/clis/codex/dump.ts +28 -0
- package/src/clis/codex/extract-diff.ts +47 -0
- package/src/clis/codex/new.ts +29 -0
- package/src/clis/codex/read.ts +33 -0
- package/src/clis/codex/send.ts +48 -0
- package/src/clis/codex/status.ts +23 -0
- package/src/clis/xiaoyuzhou/episode.ts +28 -0
- package/src/clis/xiaoyuzhou/podcast-episodes.ts +36 -0
- package/src/clis/xiaoyuzhou/podcast.ts +27 -0
- package/src/clis/xiaoyuzhou/utils.test.ts +122 -0
- package/src/clis/xiaoyuzhou/utils.ts +65 -0
- 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
|
@@ -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.
|