@jackwener/opencli 1.0.6 → 1.1.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.
- package/README.md +26 -0
- package/README.zh-CN.md +3 -0
- package/SKILL.md +7 -2
- package/dist/cli-manifest.json +506 -6
- package/dist/cli.js +51 -1
- package/dist/clis/antigravity/serve.js +296 -47
- package/dist/clis/arxiv/paper.d.ts +1 -0
- package/dist/clis/arxiv/paper.js +21 -0
- package/dist/clis/arxiv/search.d.ts +1 -0
- package/dist/clis/arxiv/search.js +24 -0
- package/dist/clis/arxiv/utils.d.ts +18 -0
- package/dist/clis/arxiv/utils.js +49 -0
- package/dist/clis/boss/batchgreet.d.ts +1 -0
- package/dist/clis/boss/batchgreet.js +147 -0
- package/dist/clis/boss/exchange.d.ts +1 -0
- package/dist/clis/boss/exchange.js +111 -0
- package/dist/clis/boss/greet.d.ts +1 -0
- package/dist/clis/boss/greet.js +175 -0
- package/dist/clis/boss/invite.d.ts +1 -0
- package/dist/clis/boss/invite.js +158 -0
- package/dist/clis/boss/joblist.d.ts +1 -0
- package/dist/clis/boss/joblist.js +55 -0
- package/dist/clis/boss/mark.d.ts +1 -0
- package/dist/clis/boss/mark.js +141 -0
- package/dist/clis/boss/recommend.d.ts +1 -0
- package/dist/clis/boss/recommend.js +83 -0
- package/dist/clis/boss/stats.d.ts +1 -0
- package/dist/clis/boss/stats.js +116 -0
- package/dist/clis/sinafinance/news.d.ts +7 -0
- package/dist/clis/sinafinance/news.js +61 -0
- package/dist/clis/wikipedia/search.d.ts +1 -0
- package/dist/clis/wikipedia/search.js +30 -0
- package/dist/clis/wikipedia/summary.d.ts +1 -0
- package/dist/clis/wikipedia/summary.js +28 -0
- package/dist/clis/wikipedia/utils.d.ts +8 -0
- package/dist/clis/wikipedia/utils.js +18 -0
- package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
- package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
- package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
- package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
- package/dist/clis/xiaohongshu/creator-notes.js +159 -71
- package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
- package/dist/external.d.ts +20 -0
- package/dist/external.js +159 -0
- package/docs/.vitepress/config.mts +1 -1
- package/docs/public/CNAME +1 -0
- package/package.json +1 -1
- package/src/browser/cdp.ts +3 -3
- package/src/cli.ts +56 -1
- package/src/clis/antigravity/serve.ts +323 -50
- package/src/clis/arxiv/paper.ts +21 -0
- package/src/clis/arxiv/search.ts +24 -0
- package/src/clis/arxiv/utils.ts +63 -0
- package/src/clis/boss/batchgreet.ts +167 -0
- package/src/clis/boss/exchange.ts +126 -0
- package/src/clis/boss/greet.ts +198 -0
- package/src/clis/boss/invite.ts +177 -0
- package/src/clis/boss/joblist.ts +63 -0
- package/src/clis/boss/mark.ts +155 -0
- package/src/clis/boss/recommend.ts +94 -0
- package/src/clis/boss/stats.ts +130 -0
- package/src/clis/sinafinance/news.ts +76 -0
- package/src/clis/wikipedia/search.ts +32 -0
- package/src/clis/wikipedia/summary.ts +28 -0
- package/src/clis/wikipedia/utils.ts +20 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
- package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
- package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
- package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
- package/src/clis/xiaohongshu/creator-notes.ts +215 -75
- package/src/daemon.ts +3 -3
- package/src/external-clis.yaml +39 -0
- package/src/external.ts +182 -0
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { PKG_VERSION } from './version.js';
|
|
|
9
9
|
import { printCompletionScript } from './completion.js';
|
|
10
10
|
import { CliError } from './errors.js';
|
|
11
11
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
12
|
+
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
12
13
|
export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
13
14
|
const program = new Command();
|
|
14
15
|
program.name('opencli').description('Make any website your CLI. Zero setup. AI-powered.').version(PKG_VERSION);
|
|
@@ -53,7 +54,17 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
|
53
54
|
}
|
|
54
55
|
console.log();
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
const externalClis = loadExternalClis();
|
|
58
|
+
if (externalClis.length > 0) {
|
|
59
|
+
console.log(chalk.bold.cyan(` external CLIs`));
|
|
60
|
+
for (const ext of externalClis) {
|
|
61
|
+
const isInstalled = isBinaryInstalled(ext.binary);
|
|
62
|
+
const tag = isInstalled ? chalk.green('[installed]') : chalk.yellow('[auto-install]');
|
|
63
|
+
console.log(` ${ext.name} ${tag}${ext.description ? chalk.dim(` — ${ext.description}`) : ''}`);
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
}
|
|
67
|
+
console.log(chalk.dim(` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
|
|
57
68
|
console.log();
|
|
58
69
|
});
|
|
59
70
|
program.command('validate').description('Validate CLI definitions').argument('[target]', 'site or site/name')
|
|
@@ -126,6 +137,45 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
|
126
137
|
.action((shell) => {
|
|
127
138
|
printCompletionScript(shell);
|
|
128
139
|
});
|
|
140
|
+
const externalClis = loadExternalClis();
|
|
141
|
+
program.command('install')
|
|
142
|
+
.description('Install an external CLI')
|
|
143
|
+
.argument('<name>', 'Name of the external CLI')
|
|
144
|
+
.action((name) => {
|
|
145
|
+
const ext = externalClis.find(e => e.name === name);
|
|
146
|
+
if (!ext) {
|
|
147
|
+
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
installExternalCli(ext);
|
|
152
|
+
});
|
|
153
|
+
program.command('register')
|
|
154
|
+
.description('Register an external CLI')
|
|
155
|
+
.argument('<name>', 'Name of the CLI')
|
|
156
|
+
.option('--binary <bin>', 'Binary name if different from name')
|
|
157
|
+
.option('--install <cmd>', 'Auto-install command')
|
|
158
|
+
.option('--desc <text>', 'Description')
|
|
159
|
+
.action((name, opts) => {
|
|
160
|
+
registerExternalCli(name, opts.binary, opts.install, opts.desc);
|
|
161
|
+
});
|
|
162
|
+
for (const ext of externalClis) {
|
|
163
|
+
if (program.commands.some(c => c.name() === ext.name))
|
|
164
|
+
continue;
|
|
165
|
+
program.command(ext.name)
|
|
166
|
+
.description(`(External) ${ext.description || ext.name}`)
|
|
167
|
+
.allowUnknownOption()
|
|
168
|
+
.action(() => {
|
|
169
|
+
// Retrieve args passed to the external CLI
|
|
170
|
+
// Commander consumes standard args before the action, so we must slice process.argv directly.
|
|
171
|
+
const extIndex = process.argv.indexOf(ext.name);
|
|
172
|
+
const args = process.argv.slice(extIndex + 1);
|
|
173
|
+
executeExternalCli(ext.name, args).catch(err => {
|
|
174
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
175
|
+
process.exitCode = 1;
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
129
179
|
// ── Antigravity serve (built-in, long-running) ──────────────────────────────
|
|
130
180
|
const antigravityCmd = program.command('antigravity').description('antigravity commands');
|
|
131
181
|
antigravityCmd.command('serve')
|
|
@@ -52,68 +52,254 @@ function jsonResponse(res, status, data) {
|
|
|
52
52
|
function sleep(ms) {
|
|
53
53
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
54
54
|
}
|
|
55
|
+
// ─── DOM helpers ─────────────────────────────────────────────────────
|
|
56
|
+
/**
|
|
57
|
+
* Click the 'New Conversation' button to reset context.
|
|
58
|
+
*/
|
|
59
|
+
async function startNewConversation(page) {
|
|
60
|
+
await page.evaluate(`
|
|
61
|
+
(() => {
|
|
62
|
+
const btn = document.querySelector('[data-tooltip-id="new-conversation-tooltip"]');
|
|
63
|
+
if (btn) btn.click();
|
|
64
|
+
})()
|
|
65
|
+
`);
|
|
66
|
+
await sleep(1000); // Give UI time to clear
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Switch the active model in Antigravity UI.
|
|
70
|
+
*/
|
|
71
|
+
async function switchModel(page, anthropicModelId) {
|
|
72
|
+
// Map standard model IDs to Antigravity UI names based on actual UI
|
|
73
|
+
let targetName = 'claude sonnet 4.6'; // Default fallback
|
|
74
|
+
const id = anthropicModelId.toLowerCase();
|
|
75
|
+
if (id.includes('sonnet')) {
|
|
76
|
+
targetName = 'claude sonnet 4.6';
|
|
77
|
+
}
|
|
78
|
+
else if (id.includes('opus')) {
|
|
79
|
+
targetName = 'claude opus 4.6';
|
|
80
|
+
}
|
|
81
|
+
else if (id.includes('gemini') && id.includes('pro')) {
|
|
82
|
+
targetName = 'gemini 3.1 pro (high)';
|
|
83
|
+
}
|
|
84
|
+
else if (id.includes('gemini') && id.includes('flash')) {
|
|
85
|
+
targetName = 'gemini 3 flash';
|
|
86
|
+
}
|
|
87
|
+
else if (id.includes('gpt')) {
|
|
88
|
+
targetName = 'gpt-oss 120b';
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await page.evaluate(`
|
|
92
|
+
async () => {
|
|
93
|
+
const targetModelName = ${JSON.stringify(targetName)};
|
|
94
|
+
const trigger = document.querySelector('div[aria-haspopup="dialog"] > div[tabindex="0"]');
|
|
95
|
+
if (!trigger) return; // Silent fail if UI changed
|
|
96
|
+
|
|
97
|
+
// Open dropdown only if not already selected
|
|
98
|
+
if (trigger.innerText.toLowerCase().includes(targetModelName)) return;
|
|
99
|
+
|
|
100
|
+
trigger.click();
|
|
101
|
+
await new Promise(r => setTimeout(r, 200));
|
|
102
|
+
|
|
103
|
+
const spans = Array.from(document.querySelectorAll('[role="dialog"] span'));
|
|
104
|
+
const target = spans.find(s => s.innerText.toLowerCase().includes(targetModelName));
|
|
105
|
+
if (target) {
|
|
106
|
+
const optionNode = target.closest('.cursor-pointer') || target;
|
|
107
|
+
optionNode.click();
|
|
108
|
+
} else {
|
|
109
|
+
// Close if not found
|
|
110
|
+
trigger.click();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
`);
|
|
114
|
+
await sleep(500); // Wait for switch
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.error(`[serve] Warning: Could not switch to model ${targetName}:`, err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check if the Antigravity UI is currently generating a response
|
|
122
|
+
* by looking for Stop/Cancel buttons or loading indicators.
|
|
123
|
+
*/
|
|
124
|
+
async function isGenerating(page) {
|
|
125
|
+
const result = await page.evaluate(`
|
|
126
|
+
(() => {
|
|
127
|
+
// Look for a cancel/stop button in the UI
|
|
128
|
+
const cancelBtn = document.querySelector('button[aria-label*="cancel" i], button[aria-label*="stop" i], button[title*="cancel" i], button[title*="stop" i]');
|
|
129
|
+
return !!cancelBtn;
|
|
130
|
+
})()
|
|
131
|
+
`);
|
|
132
|
+
return Boolean(result);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Walk from the scroll container and find the deepest element that
|
|
136
|
+
* has multiple non-empty children (our message container).
|
|
137
|
+
*/
|
|
138
|
+
function findMessageContainer(root, depth = 0) {
|
|
139
|
+
if (!root || depth > 12)
|
|
140
|
+
return null;
|
|
141
|
+
const nonEmpty = Array.from(root.children).filter(c => c.innerText?.trim().length > 5);
|
|
142
|
+
if (nonEmpty.length >= 2)
|
|
143
|
+
return root;
|
|
144
|
+
if (nonEmpty.length === 1)
|
|
145
|
+
return findMessageContainer(nonEmpty[0], depth + 1);
|
|
146
|
+
return root;
|
|
147
|
+
}
|
|
55
148
|
// ─── Antigravity CDP Operations ──────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Get the full chat text for change-detection polling.
|
|
151
|
+
*/
|
|
56
152
|
async function getConversationText(page) {
|
|
57
153
|
const text = await page.evaluate(`
|
|
58
154
|
(() => {
|
|
59
155
|
const container = document.getElementById('conversation');
|
|
60
|
-
|
|
156
|
+
if (!container) return '';
|
|
157
|
+
// Read only the first child div (actual chat content),
|
|
158
|
+
// skipping UI chrome like file change panels, model selectors, etc.
|
|
159
|
+
const chatContent = container.children[0];
|
|
160
|
+
return chatContent ? chatContent.innerText : container.innerText;
|
|
61
161
|
})()
|
|
62
162
|
`);
|
|
63
163
|
return String(text ?? '');
|
|
64
164
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Get the text of the last assistant reply by navigating to the message container
|
|
167
|
+
* and extracting the last non-empty message block.
|
|
168
|
+
*/
|
|
169
|
+
async function getLastAssistantReply(page, userText) {
|
|
170
|
+
const text = await page.evaluate(`
|
|
171
|
+
(() => {
|
|
172
|
+
const conv = document.getElementById('conversation')?.children[0];
|
|
173
|
+
const scroll = conv?.querySelector('.overflow-y-auto');
|
|
174
|
+
|
|
175
|
+
// Walk down until we find a container with multiple message siblings
|
|
176
|
+
function findMsgContainer(el, depth) {
|
|
177
|
+
if (!el || depth > 12) return null;
|
|
178
|
+
const nonEmpty = Array.from(el.children).filter(c => c.innerText && c.innerText.trim().length > 5);
|
|
179
|
+
if (nonEmpty.length >= 2) return el;
|
|
180
|
+
if (nonEmpty.length === 1) return findMsgContainer(nonEmpty[0], depth + 1);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const container = findMsgContainer(scroll || conv, 0);
|
|
185
|
+
if (!container) return '';
|
|
186
|
+
|
|
187
|
+
// Get all non-empty children (skip trailing empty UI divs)
|
|
188
|
+
const msgs = Array.from(container.children).filter(
|
|
189
|
+
c => c.innerText && c.innerText.trim().length > 5
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (msgs.length === 0) return '';
|
|
193
|
+
|
|
194
|
+
// The last element is the last assistant reply
|
|
195
|
+
const last = msgs[msgs.length - 1];
|
|
196
|
+
return last.innerText || '';
|
|
197
|
+
})()
|
|
198
|
+
`);
|
|
199
|
+
let reply = String(text ?? '').trim();
|
|
200
|
+
// Strip echoed user message from the top (Antigravity sometimes includes it)
|
|
201
|
+
if (userText && reply.startsWith(userText)) {
|
|
202
|
+
reply = reply.slice(userText.length).trim();
|
|
203
|
+
}
|
|
204
|
+
// Strip thinking block: "Thought for Xs\n..." at the start
|
|
205
|
+
reply = reply.replace(/^Thought for[^\n]*\n+/i, '').trim();
|
|
206
|
+
// Strip "Copy" button text at the end
|
|
207
|
+
reply = reply.replace(/\s*\bCopy\b\s*$/m, '').trim();
|
|
208
|
+
// De-duplicate trailing repeated content (e.g., "OK\n\nOK" → "OK")
|
|
209
|
+
const half = Math.floor(reply.length / 2);
|
|
210
|
+
const firstHalf = reply.slice(0, half).trim();
|
|
211
|
+
const secondHalf = reply.slice(half).trim();
|
|
212
|
+
if (firstHalf && firstHalf === secondHalf) {
|
|
213
|
+
reply = firstHalf;
|
|
214
|
+
}
|
|
215
|
+
return reply;
|
|
216
|
+
}
|
|
217
|
+
async function sendMessage(page, message, bridge) {
|
|
218
|
+
if (!bridge) {
|
|
219
|
+
// Fallback: use JS-based approach
|
|
220
|
+
await page.evaluate(`
|
|
221
|
+
(() => {
|
|
222
|
+
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
|
223
|
+
const editor = container?.querySelector('[data-lexical-editor="true"]');
|
|
224
|
+
if (!editor) throw new Error('Could not find input box');
|
|
225
|
+
editor.focus();
|
|
226
|
+
document.execCommand('insertText', false, ${JSON.stringify(message)});
|
|
227
|
+
})()
|
|
228
|
+
`);
|
|
229
|
+
await sleep(500);
|
|
230
|
+
await page.pressKey('Enter');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Get the bounding box of the Lexical editor for a physical mouse click
|
|
234
|
+
const rect = await page.evaluate(`
|
|
235
|
+
(() => {
|
|
68
236
|
const container = document.getElementById('antigravity.agentSidePanelInputBox');
|
|
69
237
|
if (!container) throw new Error('Could not find antigravity.agentSidePanelInputBox');
|
|
70
238
|
const editor = container.querySelector('[data-lexical-editor="true"]');
|
|
71
239
|
if (!editor) throw new Error('Could not find Antigravity input box');
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
document.execCommand('insertText', false, ${JSON.stringify(message)});
|
|
240
|
+
const r = editor.getBoundingClientRect();
|
|
241
|
+
return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 });
|
|
75
242
|
})()
|
|
76
243
|
`);
|
|
77
|
-
|
|
78
|
-
|
|
244
|
+
const { x, y } = JSON.parse(String(rect));
|
|
245
|
+
// Physical mouse click to give the element real browser focus
|
|
246
|
+
await bridge.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
247
|
+
await sleep(50);
|
|
248
|
+
await bridge.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
249
|
+
await sleep(200);
|
|
250
|
+
// Inject text at the CDP level (no deprecated execCommand)
|
|
251
|
+
await bridge.send('Input.insertText', { text: message });
|
|
252
|
+
await sleep(300);
|
|
253
|
+
// Send Enter via native CDP key event
|
|
254
|
+
await bridge.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
|
255
|
+
await sleep(50);
|
|
256
|
+
await bridge.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
|
|
79
257
|
}
|
|
80
258
|
async function waitForReply(page, beforeText, opts = {}) {
|
|
81
259
|
const timeout = opts.timeout ?? 120_000; // 2 minutes max
|
|
82
260
|
const pollInterval = opts.pollInterval ?? 500; // 500ms polling
|
|
83
|
-
const stableThreshold = opts.stableThreshold ?? 6; // 6 × 500ms = 3s stable
|
|
84
261
|
const deadline = Date.now() + timeout;
|
|
262
|
+
// Wait a bit to ensure the UI transitions to "generating" state after we hit Enter
|
|
263
|
+
await sleep(1000);
|
|
264
|
+
let hasStartedGenerating = false;
|
|
85
265
|
let lastText = beforeText;
|
|
86
266
|
let stableCount = 0;
|
|
87
|
-
//
|
|
88
|
-
await sleep(1000);
|
|
267
|
+
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
|
|
89
268
|
while (Date.now() < deadline) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
269
|
+
const generating = await isGenerating(page);
|
|
270
|
+
const currentText = await getConversationText(page);
|
|
271
|
+
const textChanged = currentText !== beforeText && currentText.length > 0;
|
|
272
|
+
if (generating) {
|
|
273
|
+
hasStartedGenerating = true;
|
|
274
|
+
stableCount = 0; // Reset stability while generating
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
if (hasStartedGenerating) {
|
|
278
|
+
// It actively generated and now it stopped -> DONE
|
|
279
|
+
// Provide a small buffer to let React render the final message fully
|
|
280
|
+
await sleep(500);
|
|
281
|
+
return;
|
|
99
282
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
283
|
+
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
|
|
284
|
+
if (textChanged) {
|
|
285
|
+
if (currentText === lastText) {
|
|
286
|
+
stableCount++;
|
|
287
|
+
if (stableCount >= stableThreshold) {
|
|
288
|
+
return; // Text has been stable for 2 seconds -> DONE
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
stableCount = 0;
|
|
293
|
+
lastText = currentText;
|
|
294
|
+
}
|
|
104
295
|
}
|
|
105
296
|
}
|
|
106
297
|
await sleep(pollInterval);
|
|
107
298
|
}
|
|
108
|
-
// Timeout — return whatever we have
|
|
109
|
-
const finalText = await getConversationText(page);
|
|
110
|
-
if (finalText.length > beforeText.length) {
|
|
111
|
-
return finalText.slice(beforeText.length).trim();
|
|
112
|
-
}
|
|
113
299
|
throw new Error('Timeout waiting for Antigravity reply');
|
|
114
300
|
}
|
|
115
301
|
// ─── Request Handlers ────────────────────────────────────────────────
|
|
116
|
-
async function handleMessages(body, page) {
|
|
302
|
+
async function handleMessages(body, page, bridge) {
|
|
117
303
|
// Extract the last user message
|
|
118
304
|
const userMessages = body.messages.filter(m => m.role === 'user');
|
|
119
305
|
if (userMessages.length === 0) {
|
|
@@ -124,14 +310,25 @@ async function handleMessages(body, page) {
|
|
|
124
310
|
if (!userText.trim()) {
|
|
125
311
|
throw new Error('Empty user message');
|
|
126
312
|
}
|
|
313
|
+
// Optimization 1: New conversation if this is the first message in the session
|
|
314
|
+
if (body.messages.length === 1) {
|
|
315
|
+
console.error(`[serve] New session detected (1 message). Starting new conversation in UI.`);
|
|
316
|
+
await startNewConversation(page);
|
|
317
|
+
}
|
|
318
|
+
// Optimization 3: Switch model if requested
|
|
319
|
+
if (body.model) {
|
|
320
|
+
await switchModel(page, body.model);
|
|
321
|
+
}
|
|
127
322
|
// Get conversation state before sending
|
|
128
323
|
const beforeText = await getConversationText(page);
|
|
129
324
|
// Send the message
|
|
130
325
|
console.error(`[serve] Sending: "${userText.slice(0, 80)}${userText.length > 80 ? '...' : ''}"`);
|
|
131
|
-
await sendMessage(page, userText);
|
|
132
|
-
// Poll for reply
|
|
326
|
+
await sendMessage(page, userText, bridge);
|
|
327
|
+
// Poll for reply (change detection)
|
|
133
328
|
console.error('[serve] Waiting for reply...');
|
|
134
|
-
|
|
329
|
+
await waitForReply(page, beforeText);
|
|
330
|
+
// Extract the actual reply text precisely from the DOM
|
|
331
|
+
const replyText = await getLastAssistantReply(page, userText);
|
|
135
332
|
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
|
|
136
333
|
return {
|
|
137
334
|
id: generateMsgId(),
|
|
@@ -150,15 +347,65 @@ async function handleMessages(body, page) {
|
|
|
150
347
|
// ─── Server ──────────────────────────────────────────────────────────
|
|
151
348
|
export async function startServe(opts = {}) {
|
|
152
349
|
const port = opts.port ?? 8082;
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const page = await cdp.connect({ timeout: 15_000 });
|
|
157
|
-
console.error('[serve] CDP connected successfully.');
|
|
158
|
-
// Verify we can read conversation
|
|
159
|
-
const testText = await getConversationText(page);
|
|
160
|
-
console.error(`[serve] Conversation element found (${testText.length} chars).`);
|
|
350
|
+
// Lazy CDP connection — connect when first request comes in
|
|
351
|
+
let cdp = null;
|
|
352
|
+
let page = null;
|
|
161
353
|
let requestInFlight = false;
|
|
354
|
+
async function ensureConnected() {
|
|
355
|
+
if (page) {
|
|
356
|
+
try {
|
|
357
|
+
await page.evaluate('1+1');
|
|
358
|
+
return page;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
console.error('[serve] CDP connection lost, reconnecting...');
|
|
362
|
+
cdp?.close().catch(() => { });
|
|
363
|
+
cdp = null;
|
|
364
|
+
page = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
368
|
+
if (!endpoint) {
|
|
369
|
+
throw new Error('OPENCLI_CDP_ENDPOINT is not set.\n' +
|
|
370
|
+
'Usage: OPENCLI_CDP_ENDPOINT=http://127.0.0.1:9224 opencli antigravity serve');
|
|
371
|
+
}
|
|
372
|
+
// Note: Antigravity chat panel lives inside editor windows, not in Launchpad.
|
|
373
|
+
// If multiple editor windows are open, set OPENCLI_CDP_TARGET to the window title.
|
|
374
|
+
if (process.env.OPENCLI_CDP_TARGET) {
|
|
375
|
+
console.error(`[serve] Using OPENCLI_CDP_TARGET=${process.env.OPENCLI_CDP_TARGET}`);
|
|
376
|
+
}
|
|
377
|
+
// List available targets for debugging
|
|
378
|
+
try {
|
|
379
|
+
const res = await fetch(`${endpoint.replace(/\/$/, '')}/json`);
|
|
380
|
+
const targets = await res.json();
|
|
381
|
+
const pages = targets.filter(t => t.type === 'page');
|
|
382
|
+
console.error(`[serve] Available targets: ${pages.map(t => `"${t.title}"`).join(', ')}`);
|
|
383
|
+
}
|
|
384
|
+
catch { /* ignore */ }
|
|
385
|
+
console.error(`[serve] Connecting via CDP (target pattern: "${process.env.OPENCLI_CDP_TARGET}")...`);
|
|
386
|
+
cdp = new CDPBridge();
|
|
387
|
+
try {
|
|
388
|
+
page = await cdp.connect({ timeout: 15_000 });
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
cdp = null;
|
|
392
|
+
const isRefused = err?.cause?.code === 'ECONNREFUSED' || err?.message?.includes('ECONNREFUSED');
|
|
393
|
+
throw new Error(isRefused
|
|
394
|
+
? `Cannot connect to Antigravity at ${endpoint}.\n` +
|
|
395
|
+
' 1. Make sure Antigravity is running\n' +
|
|
396
|
+
' 2. Launch with: --remote-debugging-port=9224'
|
|
397
|
+
: `CDP connection failed: ${err.message}`);
|
|
398
|
+
}
|
|
399
|
+
console.error('[serve] ✅ CDP connected.');
|
|
400
|
+
// Quick verification
|
|
401
|
+
const hasUI = await page.evaluate(`
|
|
402
|
+
(() => !!document.getElementById('conversation') || !!document.getElementById('antigravity.agentSidePanelInputBox'))()
|
|
403
|
+
`);
|
|
404
|
+
if (!hasUI) {
|
|
405
|
+
console.error('[serve] ⚠️ Warning: chat UI elements not found in this target. Try setting OPENCLI_CDP_TARGET to the correct window title.');
|
|
406
|
+
}
|
|
407
|
+
return page;
|
|
408
|
+
}
|
|
162
409
|
const server = createServer(async (req, res) => {
|
|
163
410
|
// CORS preflight
|
|
164
411
|
if (req.method === 'OPTIONS') {
|
|
@@ -204,7 +451,6 @@ export async function startServe(opts = {}) {
|
|
|
204
451
|
const rawBody = await readBody(req);
|
|
205
452
|
const body = JSON.parse(rawBody);
|
|
206
453
|
if (body.stream) {
|
|
207
|
-
// We don't support streaming — return error
|
|
208
454
|
jsonResponse(res, 400, {
|
|
209
455
|
type: 'error',
|
|
210
456
|
error: {
|
|
@@ -214,7 +460,9 @@ export async function startServe(opts = {}) {
|
|
|
214
460
|
});
|
|
215
461
|
return;
|
|
216
462
|
}
|
|
217
|
-
|
|
463
|
+
// Lazy connect on first request
|
|
464
|
+
const activePage = await ensureConnected();
|
|
465
|
+
const response = await handleMessages(body, activePage, cdp ?? undefined);
|
|
218
466
|
jsonResponse(res, 200, response);
|
|
219
467
|
}
|
|
220
468
|
finally {
|
|
@@ -224,7 +472,7 @@ export async function startServe(opts = {}) {
|
|
|
224
472
|
}
|
|
225
473
|
// Health check
|
|
226
474
|
if (req.method === 'GET' && (pathname === '/' || pathname === '/health')) {
|
|
227
|
-
jsonResponse(res, 200, { ok: true,
|
|
475
|
+
jsonResponse(res, 200, { ok: true, cdpConnected: page !== null });
|
|
228
476
|
return;
|
|
229
477
|
}
|
|
230
478
|
jsonResponse(res, 404, {
|
|
@@ -233,7 +481,7 @@ export async function startServe(opts = {}) {
|
|
|
233
481
|
});
|
|
234
482
|
}
|
|
235
483
|
catch (err) {
|
|
236
|
-
console.error('[serve] Error:', err);
|
|
484
|
+
console.error('[serve] Error:', err instanceof Error ? err.message : err);
|
|
237
485
|
jsonResponse(res, 500, {
|
|
238
486
|
type: 'error',
|
|
239
487
|
error: {
|
|
@@ -246,13 +494,14 @@ export async function startServe(opts = {}) {
|
|
|
246
494
|
server.listen(port, '127.0.0.1', () => {
|
|
247
495
|
console.error(`\n[serve] ✅ Antigravity API proxy running at http://127.0.0.1:${port}`);
|
|
248
496
|
console.error(`[serve] Compatible with Anthropic /v1/messages API`);
|
|
497
|
+
console.error(`[serve] CDP connection will be established on first request.`);
|
|
249
498
|
console.error(`\n[serve] Usage with Claude Code:`);
|
|
250
499
|
console.error(` ANTHROPIC_BASE_URL=http://localhost:${port} claude\n`);
|
|
251
500
|
});
|
|
252
501
|
// Graceful shutdown
|
|
253
502
|
const shutdown = () => {
|
|
254
503
|
console.error('\n[serve] Shutting down...');
|
|
255
|
-
cdp
|
|
504
|
+
cdp?.close().catch(() => { });
|
|
256
505
|
server.close();
|
|
257
506
|
process.exit(0);
|
|
258
507
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { arxivFetch, parseEntries } from './utils.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'arxiv',
|
|
6
|
+
name: 'paper',
|
|
7
|
+
description: 'Get arXiv paper details by ID',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
|
|
14
|
+
func: async (_page, args) => {
|
|
15
|
+
const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
|
|
16
|
+
const entries = parseEntries(xml);
|
|
17
|
+
if (!entries.length)
|
|
18
|
+
throw new CliError('NOT_FOUND', `Paper ${args.id} not found`, 'Check the arXiv ID format, e.g. 1706.03762');
|
|
19
|
+
return entries;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { arxivFetch, parseEntries } from './utils.js';
|
|
4
|
+
cli({
|
|
5
|
+
site: 'arxiv',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: 'Search arXiv papers',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search keyword (e.g. "attention is all you need")' },
|
|
12
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['id', 'title', 'authors', 'published'],
|
|
15
|
+
func: async (_page, args) => {
|
|
16
|
+
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
|
17
|
+
const query = encodeURIComponent(`all:${args.keyword}`);
|
|
18
|
+
const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
|
|
19
|
+
const entries = parseEntries(xml);
|
|
20
|
+
if (!entries.length)
|
|
21
|
+
throw new CliError('NOT_FOUND', 'No papers found', 'Try a different keyword');
|
|
22
|
+
return entries.map(e => ({ id: e.id, title: e.title, authors: e.authors, published: e.published }));
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* arXiv adapter utilities.
|
|
3
|
+
*
|
|
4
|
+
* arXiv exposes a public Atom/XML API — no key required.
|
|
5
|
+
* https://info.arxiv.org/help/api/index.html
|
|
6
|
+
*/
|
|
7
|
+
export declare const ARXIV_BASE = "https://export.arxiv.org/api/query";
|
|
8
|
+
export declare function arxivFetch(params: string): Promise<string>;
|
|
9
|
+
export interface ArxivEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
authors: string;
|
|
13
|
+
abstract: string;
|
|
14
|
+
published: string;
|
|
15
|
+
url: string;
|
|
16
|
+
}
|
|
17
|
+
/** Parse Atom XML feed into structured entries. */
|
|
18
|
+
export declare function parseEntries(xml: string): ArxivEntry[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* arXiv adapter utilities.
|
|
3
|
+
*
|
|
4
|
+
* arXiv exposes a public Atom/XML API — no key required.
|
|
5
|
+
* https://info.arxiv.org/help/api/index.html
|
|
6
|
+
*/
|
|
7
|
+
import { CliError } from '../../errors.js';
|
|
8
|
+
export const ARXIV_BASE = 'https://export.arxiv.org/api/query';
|
|
9
|
+
export async function arxivFetch(params) {
|
|
10
|
+
const resp = await fetch(`${ARXIV_BASE}?${params}`);
|
|
11
|
+
if (!resp.ok) {
|
|
12
|
+
throw new CliError('FETCH_ERROR', `arXiv API HTTP ${resp.status}`, 'Check your search term or paper ID');
|
|
13
|
+
}
|
|
14
|
+
return resp.text();
|
|
15
|
+
}
|
|
16
|
+
/** Extract the text content of the first matching XML tag. */
|
|
17
|
+
function extract(xml, tag) {
|
|
18
|
+
const m = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
|
|
19
|
+
return m ? m[1].trim() : '';
|
|
20
|
+
}
|
|
21
|
+
/** Extract all text contents of a repeated XML tag. */
|
|
22
|
+
function extractAll(xml, tag) {
|
|
23
|
+
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
|
24
|
+
const results = [];
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = re.exec(xml)) !== null)
|
|
27
|
+
results.push(m[1].trim());
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
/** Parse Atom XML feed into structured entries. */
|
|
31
|
+
export function parseEntries(xml) {
|
|
32
|
+
const entryRe = /<entry>([\s\S]*?)<\/entry>/g;
|
|
33
|
+
const entries = [];
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = entryRe.exec(xml)) !== null) {
|
|
36
|
+
const e = m[1];
|
|
37
|
+
const rawId = extract(e, 'id');
|
|
38
|
+
const arxivId = rawId.replace(/^https?:\/\/arxiv\.org\/abs\//, '').replace(/v\d+$/, '');
|
|
39
|
+
entries.push({
|
|
40
|
+
id: arxivId,
|
|
41
|
+
title: extract(e, 'title').replace(/\s+/g, ' '),
|
|
42
|
+
authors: extractAll(e, 'name').slice(0, 3).join(', '),
|
|
43
|
+
abstract: (() => { const s = extract(e, 'summary').replace(/\s+/g, ' '); return s.length > 200 ? s.slice(0, 200) + '...' : s; })(),
|
|
44
|
+
published: extract(e, 'published').slice(0, 10),
|
|
45
|
+
url: `https://arxiv.org/abs/${arxivId}`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|