@jackwener/opencli 1.5.9 → 1.6.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/CHANGELOG.md +21 -0
- package/README.md +18 -0
- package/SKILL.md +59 -0
- package/autoresearch/baseline-browse.txt +1 -0
- package/autoresearch/baseline-skill.txt +1 -0
- package/autoresearch/browse-tasks.json +688 -0
- package/autoresearch/eval-browse.ts +185 -0
- package/autoresearch/eval-skill.ts +248 -0
- package/autoresearch/run-browse.sh +9 -0
- package/autoresearch/run-skill.sh +9 -0
- package/dist/browser/daemon-client.d.ts +20 -1
- package/dist/browser/daemon-client.js +37 -30
- package/dist/browser/daemon-client.test.d.ts +1 -0
- package/dist/browser/daemon-client.test.js +77 -0
- package/dist/browser/discover.js +8 -19
- package/dist/browser/page.d.ts +4 -0
- package/dist/browser/page.js +48 -1
- package/dist/cli.js +392 -0
- package/dist/clis/twitter/article.js +28 -1
- package/dist/clis/xiaohongshu/note.js +11 -0
- package/dist/clis/xiaohongshu/note.test.js +49 -0
- package/dist/commanderAdapter.js +1 -1
- package/dist/commanderAdapter.test.js +43 -0
- package/dist/commands/daemon.js +7 -46
- package/dist/commands/daemon.test.js +44 -69
- package/dist/discovery.js +27 -0
- package/dist/types.d.ts +8 -0
- package/docs/guide/getting-started.md +21 -0
- package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
- package/docs/zh/guide/getting-started.md +21 -0
- package/extension/package-lock.json +2 -2
- package/extension/src/background.ts +51 -4
- package/extension/src/cdp.ts +77 -124
- package/extension/src/protocol.ts +5 -1
- package/package.json +1 -1
- package/skills/opencli-explorer/SKILL.md +6 -0
- package/skills/opencli-oneshot/SKILL.md +6 -0
- package/skills/opencli-operate/SKILL.md +213 -0
- package/skills/opencli-usage/SKILL.md +113 -32
- package/src/browser/daemon-client.test.ts +103 -0
- package/src/browser/daemon-client.ts +53 -30
- package/src/browser/discover.ts +8 -17
- package/src/browser/page.ts +48 -1
- package/src/cli.ts +392 -0
- package/src/clis/twitter/article.ts +31 -1
- package/src/clis/xiaohongshu/note.test.ts +51 -0
- package/src/clis/xiaohongshu/note.ts +18 -0
- package/src/commanderAdapter.test.ts +62 -0
- package/src/commanderAdapter.ts +1 -1
- package/src/commands/daemon.test.ts +49 -83
- package/src/commands/daemon.ts +7 -55
- package/src/discovery.ts +22 -0
- package/src/doctor.ts +1 -1
- package/src/types.ts +8 -0
- package/extension/dist/background.js +0 -681
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fetchDaemonStatus, isDaemonRunning, isExtensionConnected, requestDaemonShutdown, } from './daemon-client.js';
|
|
3
|
+
describe('daemon-client', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.stubGlobal('fetch', vi.fn());
|
|
6
|
+
});
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
it('fetchDaemonStatus sends the shared status request and returns parsed data', async () => {
|
|
11
|
+
const status = {
|
|
12
|
+
ok: true,
|
|
13
|
+
pid: 123,
|
|
14
|
+
uptime: 10,
|
|
15
|
+
extensionConnected: true,
|
|
16
|
+
extensionVersion: '1.2.3',
|
|
17
|
+
pending: 0,
|
|
18
|
+
lastCliRequestTime: Date.now(),
|
|
19
|
+
memoryMB: 32,
|
|
20
|
+
port: 19825,
|
|
21
|
+
};
|
|
22
|
+
const fetchMock = vi.mocked(fetch);
|
|
23
|
+
fetchMock.mockResolvedValue({
|
|
24
|
+
ok: true,
|
|
25
|
+
json: () => Promise.resolve(status),
|
|
26
|
+
});
|
|
27
|
+
await expect(fetchDaemonStatus()).resolves.toEqual(status);
|
|
28
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/status$/), expect.objectContaining({
|
|
29
|
+
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
|
|
30
|
+
}));
|
|
31
|
+
});
|
|
32
|
+
it('fetchDaemonStatus returns null on network failure', async () => {
|
|
33
|
+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
|
|
34
|
+
await expect(fetchDaemonStatus()).resolves.toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('requestDaemonShutdown POSTs to the shared shutdown endpoint', async () => {
|
|
37
|
+
const fetchMock = vi.mocked(fetch);
|
|
38
|
+
fetchMock.mockResolvedValue({ ok: true });
|
|
39
|
+
await expect(requestDaemonShutdown()).resolves.toBe(true);
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/shutdown$/), expect.objectContaining({
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: expect.objectContaining({ 'X-OpenCLI': '1' }),
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
it('isDaemonRunning reflects shared status availability', async () => {
|
|
46
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: () => Promise.resolve({
|
|
49
|
+
ok: true,
|
|
50
|
+
pid: 123,
|
|
51
|
+
uptime: 10,
|
|
52
|
+
extensionConnected: false,
|
|
53
|
+
pending: 0,
|
|
54
|
+
lastCliRequestTime: Date.now(),
|
|
55
|
+
memoryMB: 16,
|
|
56
|
+
port: 19825,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
await expect(isDaemonRunning()).resolves.toBe(true);
|
|
60
|
+
});
|
|
61
|
+
it('isExtensionConnected reflects shared status payload', async () => {
|
|
62
|
+
vi.mocked(fetch).mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: () => Promise.resolve({
|
|
65
|
+
ok: true,
|
|
66
|
+
pid: 123,
|
|
67
|
+
uptime: 10,
|
|
68
|
+
extensionConnected: false,
|
|
69
|
+
pending: 0,
|
|
70
|
+
lastCliRequestTime: Date.now(),
|
|
71
|
+
memoryMB: 16,
|
|
72
|
+
port: 19825,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
await expect(isExtensionConnected()).resolves.toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/dist/browser/discover.js
CHANGED
|
@@ -1,30 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Daemon discovery — checks if the daemon is running.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import { isDaemonRunning } from './daemon-client.js';
|
|
4
|
+
import { fetchDaemonStatus, isDaemonRunning } from './daemon-client.js';
|
|
6
5
|
export { isDaemonRunning };
|
|
7
6
|
/**
|
|
8
7
|
* Check daemon status and return connection info.
|
|
9
8
|
*/
|
|
10
9
|
export async function checkDaemonStatus(opts) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const controller = new AbortController();
|
|
14
|
-
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
|
|
15
|
-
try {
|
|
16
|
-
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
17
|
-
headers: { 'X-OpenCLI': '1' },
|
|
18
|
-
signal: controller.signal,
|
|
19
|
-
});
|
|
20
|
-
const data = await res.json();
|
|
21
|
-
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
|
|
22
|
-
}
|
|
23
|
-
finally {
|
|
24
|
-
clearTimeout(timer);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
10
|
+
const status = await fetchDaemonStatus({ timeout: opts?.timeout ?? 2000 });
|
|
11
|
+
if (!status) {
|
|
28
12
|
return { running: false, extensionConnected: false };
|
|
29
13
|
}
|
|
14
|
+
return {
|
|
15
|
+
running: true,
|
|
16
|
+
extensionConnected: status.extensionConnected,
|
|
17
|
+
extensionVersion: status.extensionVersion,
|
|
18
|
+
};
|
|
30
19
|
}
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -50,4 +50,8 @@ export declare class Page extends BasePage {
|
|
|
50
50
|
* payload size limits of base64-in-evaluate.
|
|
51
51
|
*/
|
|
52
52
|
setFileInput(files: string[], selector?: string): Promise<void>;
|
|
53
|
+
cdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
54
|
+
nativeClick(x: number, y: number): Promise<void>;
|
|
55
|
+
nativeType(text: string): Promise<void>;
|
|
56
|
+
nativeKeyPress(key: string, modifiers?: string[]): Promise<void>;
|
|
53
57
|
}
|
package/dist/browser/page.js
CHANGED
|
@@ -171,5 +171,52 @@ export class Page extends BasePage {
|
|
|
171
171
|
throw new Error('setFileInput returned no count — command may not be supported by the extension');
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
async cdp(method, params = {}) {
|
|
175
|
+
return sendCommand('cdp', {
|
|
176
|
+
cdpMethod: method,
|
|
177
|
+
cdpParams: params,
|
|
178
|
+
...this._cmdOpts(),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async nativeClick(x, y) {
|
|
182
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
183
|
+
type: 'mousePressed',
|
|
184
|
+
x, y,
|
|
185
|
+
button: 'left',
|
|
186
|
+
clickCount: 1,
|
|
187
|
+
});
|
|
188
|
+
await this.cdp('Input.dispatchMouseEvent', {
|
|
189
|
+
type: 'mouseReleased',
|
|
190
|
+
x, y,
|
|
191
|
+
button: 'left',
|
|
192
|
+
clickCount: 1,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async nativeType(text) {
|
|
196
|
+
// Use Input.insertText for reliable Unicode/CJK text insertion
|
|
197
|
+
await this.cdp('Input.insertText', { text });
|
|
198
|
+
}
|
|
199
|
+
async nativeKeyPress(key, modifiers = []) {
|
|
200
|
+
let modifierFlags = 0;
|
|
201
|
+
for (const mod of modifiers) {
|
|
202
|
+
if (mod === 'Alt')
|
|
203
|
+
modifierFlags |= 1;
|
|
204
|
+
if (mod === 'Ctrl')
|
|
205
|
+
modifierFlags |= 2;
|
|
206
|
+
if (mod === 'Meta')
|
|
207
|
+
modifierFlags |= 4;
|
|
208
|
+
if (mod === 'Shift')
|
|
209
|
+
modifierFlags |= 8;
|
|
210
|
+
}
|
|
211
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
212
|
+
type: 'keyDown',
|
|
213
|
+
key,
|
|
214
|
+
modifiers: modifierFlags,
|
|
215
|
+
});
|
|
216
|
+
await this.cdp('Input.dispatchKeyEvent', {
|
|
217
|
+
type: 'keyUp',
|
|
218
|
+
key,
|
|
219
|
+
modifiers: modifierFlags,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
174
222
|
}
|
|
175
|
-
// (End of file)
|
package/dist/cli.js
CHANGED
|
@@ -16,6 +16,12 @@ import { loadExternalClis, executeExternalCli, installExternalCli, registerExter
|
|
|
16
16
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
17
17
|
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
|
18
18
|
import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
|
|
19
|
+
/** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */
|
|
20
|
+
async function getOperatePage() {
|
|
21
|
+
const { BrowserBridge } = await import('./browser/index.js');
|
|
22
|
+
const bridge = new BrowserBridge();
|
|
23
|
+
return bridge.connect({ timeout: 30, workspace: 'operate:default' });
|
|
24
|
+
}
|
|
19
25
|
export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
20
26
|
const program = new Command();
|
|
21
27
|
// enablePositionalOptions: prevents parent from consuming flags meant for subcommands;
|
|
@@ -210,6 +216,392 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
|
|
|
210
216
|
}, { workspace });
|
|
211
217
|
console.log(renderCascadeResult(result));
|
|
212
218
|
});
|
|
219
|
+
// ── Built-in: operate (browser control for Claude Code skill) ───────────────
|
|
220
|
+
//
|
|
221
|
+
// Make websites accessible for AI agents.
|
|
222
|
+
// All commands wrapped in operateAction() for consistent error handling.
|
|
223
|
+
const operate = program
|
|
224
|
+
.command('operate')
|
|
225
|
+
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
226
|
+
/** Wrap operate actions with error handling and optional --json output */
|
|
227
|
+
function operateAction(fn) {
|
|
228
|
+
return async (...args) => {
|
|
229
|
+
try {
|
|
230
|
+
const page = await getOperatePage();
|
|
231
|
+
await fn(page, ...args);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
|
|
236
|
+
console.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
|
|
237
|
+
}
|
|
238
|
+
else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
|
|
239
|
+
console.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
console.error(`Error: ${msg}`);
|
|
243
|
+
}
|
|
244
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// ── Navigation ──
|
|
249
|
+
/** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */
|
|
250
|
+
const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length<M){var b=null;if(t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),method:(arguments[1]&&arguments[1].method)||'GET',status:r.status,size:t.length,ct:ct,body:b})}}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if((ct.includes('json')||ct.includes('text'))&&window.__opencli_net.length<M){var t=x.responseText,b=null;if(t&&t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:x._ou,method:x._om||'GET',status:x.status,size:t?t.length:0,ct:ct,body:b})}}catch(e){}});return S.apply(this,arguments)}})()`;
|
|
251
|
+
operate.command('open').argument('<url>').description('Open URL in automation window')
|
|
252
|
+
.action(operateAction(async (page, url) => {
|
|
253
|
+
await page.goto(url);
|
|
254
|
+
await page.wait(2);
|
|
255
|
+
// Auto-inject network interceptor for API discovery
|
|
256
|
+
try {
|
|
257
|
+
await page.evaluate(NETWORK_INTERCEPTOR_JS);
|
|
258
|
+
}
|
|
259
|
+
catch { /* non-fatal */ }
|
|
260
|
+
console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`);
|
|
261
|
+
}));
|
|
262
|
+
operate.command('back').description('Go back in browser history')
|
|
263
|
+
.action(operateAction(async (page) => {
|
|
264
|
+
await page.evaluate('history.back()');
|
|
265
|
+
await page.wait(2);
|
|
266
|
+
console.log('Navigated back');
|
|
267
|
+
}));
|
|
268
|
+
operate.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')
|
|
269
|
+
.description('Scroll page')
|
|
270
|
+
.action(operateAction(async (page, direction, opts) => {
|
|
271
|
+
if (direction !== 'up' && direction !== 'down') {
|
|
272
|
+
console.error(`Invalid direction "${direction}". Use "up" or "down".`);
|
|
273
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await page.scroll(direction, parseInt(opts.amount, 10));
|
|
277
|
+
console.log(`Scrolled ${direction}`);
|
|
278
|
+
}));
|
|
279
|
+
// ── Inspect ──
|
|
280
|
+
operate.command('state').description('Page state: URL, title, interactive elements with [N] indices')
|
|
281
|
+
.action(operateAction(async (page) => {
|
|
282
|
+
const snapshot = await page.snapshot({ viewportExpand: 800 });
|
|
283
|
+
const url = await page.getCurrentUrl?.() ?? '';
|
|
284
|
+
console.log(`URL: ${url}\n`);
|
|
285
|
+
console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
|
|
286
|
+
}));
|
|
287
|
+
operate.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')
|
|
288
|
+
.description('Take screenshot')
|
|
289
|
+
.action(operateAction(async (page, path) => {
|
|
290
|
+
if (path) {
|
|
291
|
+
await page.screenshot({ path });
|
|
292
|
+
console.log(`Screenshot saved to: ${path}`);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
console.log(await page.screenshot({ format: 'png' }));
|
|
296
|
+
}
|
|
297
|
+
}));
|
|
298
|
+
// ── Get commands (structured data extraction) ──
|
|
299
|
+
const get = operate.command('get').description('Get page properties');
|
|
300
|
+
get.command('title').description('Page title')
|
|
301
|
+
.action(operateAction(async (page) => {
|
|
302
|
+
console.log(await page.evaluate('document.title'));
|
|
303
|
+
}));
|
|
304
|
+
get.command('url').description('Current page URL')
|
|
305
|
+
.action(operateAction(async (page) => {
|
|
306
|
+
console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
|
|
307
|
+
}));
|
|
308
|
+
get.command('text').argument('<index>', 'Element index').description('Element text content')
|
|
309
|
+
.action(operateAction(async (page, index) => {
|
|
310
|
+
const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`);
|
|
311
|
+
console.log(text ?? '(empty)');
|
|
312
|
+
}));
|
|
313
|
+
get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
|
|
314
|
+
.action(operateAction(async (page, index) => {
|
|
315
|
+
const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`);
|
|
316
|
+
console.log(val ?? '(empty)');
|
|
317
|
+
}));
|
|
318
|
+
get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
|
|
319
|
+
.action(operateAction(async (page, opts) => {
|
|
320
|
+
const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
|
|
321
|
+
const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`);
|
|
322
|
+
console.log(html ?? '(empty)');
|
|
323
|
+
}));
|
|
324
|
+
get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
|
|
325
|
+
.action(operateAction(async (page, index) => {
|
|
326
|
+
const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`);
|
|
327
|
+
console.log(attrs ?? '{}');
|
|
328
|
+
}));
|
|
329
|
+
// ── Interact ──
|
|
330
|
+
operate.command('click').argument('<index>', 'Element index from state').description('Click element by index')
|
|
331
|
+
.action(operateAction(async (page, index) => {
|
|
332
|
+
await page.click(index);
|
|
333
|
+
console.log(`Clicked element [${index}]`);
|
|
334
|
+
}));
|
|
335
|
+
operate.command('type').argument('<index>', 'Element index').argument('<text>', 'Text to type')
|
|
336
|
+
.description('Click element, then type text')
|
|
337
|
+
.action(operateAction(async (page, index, text) => {
|
|
338
|
+
await page.click(index);
|
|
339
|
+
await page.wait(0.3);
|
|
340
|
+
await page.typeText(index, text);
|
|
341
|
+
console.log(`Typed "${text}" into element [${index}]`);
|
|
342
|
+
}));
|
|
343
|
+
operate.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
|
|
344
|
+
.description('Select dropdown option')
|
|
345
|
+
.action(operateAction(async (page, index, option) => {
|
|
346
|
+
const result = await page.evaluate(`
|
|
347
|
+
(function() {
|
|
348
|
+
var sel = document.querySelector('[data-opencli-ref="${index}"]');
|
|
349
|
+
if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
|
|
350
|
+
var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
|
|
351
|
+
if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
|
|
352
|
+
var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
|
|
353
|
+
if (setter) setter.call(sel, match.value); else sel.value = match.value;
|
|
354
|
+
sel.dispatchEvent(new Event('input', {bubbles:true}));
|
|
355
|
+
sel.dispatchEvent(new Event('change', {bubbles:true}));
|
|
356
|
+
return { selected: match.text };
|
|
357
|
+
})()
|
|
358
|
+
`);
|
|
359
|
+
if (result?.error) {
|
|
360
|
+
console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
|
|
361
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
console.log(`Selected "${result?.selected}" in element [${index}]`);
|
|
365
|
+
}
|
|
366
|
+
}));
|
|
367
|
+
operate.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')
|
|
368
|
+
.description('Press keyboard key')
|
|
369
|
+
.action(operateAction(async (page, key) => {
|
|
370
|
+
await page.pressKey(key);
|
|
371
|
+
console.log(`Pressed: ${key}`);
|
|
372
|
+
}));
|
|
373
|
+
// ── Wait commands ──
|
|
374
|
+
operate.command('wait')
|
|
375
|
+
.argument('<type>', 'selector, text, or time')
|
|
376
|
+
.argument('[value]', 'CSS selector, text string, or seconds')
|
|
377
|
+
.option('--timeout <ms>', 'Timeout in milliseconds', '10000')
|
|
378
|
+
.description('Wait for selector, text, or time (e.g. wait selector ".loaded", wait text "Success", wait time 3)')
|
|
379
|
+
.action(operateAction(async (page, type, value, opts) => {
|
|
380
|
+
const timeout = parseInt(opts.timeout, 10);
|
|
381
|
+
if (type === 'time') {
|
|
382
|
+
const seconds = parseFloat(value ?? '2');
|
|
383
|
+
await page.wait(seconds);
|
|
384
|
+
console.log(`Waited ${seconds}s`);
|
|
385
|
+
}
|
|
386
|
+
else if (type === 'selector') {
|
|
387
|
+
if (!value) {
|
|
388
|
+
console.error('Missing CSS selector');
|
|
389
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
await page.wait({ selector: value, timeout: timeout / 1000 });
|
|
393
|
+
console.log(`Element "${value}" appeared`);
|
|
394
|
+
}
|
|
395
|
+
else if (type === 'text') {
|
|
396
|
+
if (!value) {
|
|
397
|
+
console.error('Missing text');
|
|
398
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
await page.wait({ text: value, timeout: timeout / 1000 });
|
|
402
|
+
console.log(`Text "${value}" appeared`);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
console.error(`Unknown wait type "${type}". Use: selector, text, or time`);
|
|
406
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
407
|
+
}
|
|
408
|
+
}));
|
|
409
|
+
// ── Extract ──
|
|
410
|
+
operate.command('eval').argument('<js>', 'JavaScript code').description('Execute JS in page context, return result')
|
|
411
|
+
.action(operateAction(async (page, js) => {
|
|
412
|
+
const result = await page.evaluate(js);
|
|
413
|
+
if (typeof result === 'string')
|
|
414
|
+
console.log(result);
|
|
415
|
+
else
|
|
416
|
+
console.log(JSON.stringify(result, null, 2));
|
|
417
|
+
}));
|
|
418
|
+
// ── Network (API discovery) ──
|
|
419
|
+
operate.command('network')
|
|
420
|
+
.option('--detail <index>', 'Show full response body of request at index')
|
|
421
|
+
.option('--all', 'Show all requests including static resources')
|
|
422
|
+
.description('Show captured network requests (auto-captured since last open)')
|
|
423
|
+
.action(operateAction(async (page, opts) => {
|
|
424
|
+
const requests = await page.evaluate(`(function(){
|
|
425
|
+
var reqs = window.__opencli_net || [];
|
|
426
|
+
return JSON.stringify(reqs);
|
|
427
|
+
})()`);
|
|
428
|
+
let items = [];
|
|
429
|
+
try {
|
|
430
|
+
items = JSON.parse(requests);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
console.log('No network data captured. Run "operate open <url>" first.');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (items.length === 0) {
|
|
437
|
+
console.log('No requests captured.');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// Filter out static resources unless --all
|
|
441
|
+
if (!opts.all) {
|
|
442
|
+
items = items.filter(r => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
|
|
443
|
+
!/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
|
|
444
|
+
!/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
|
|
445
|
+
}
|
|
446
|
+
if (opts.detail !== undefined) {
|
|
447
|
+
const idx = parseInt(opts.detail, 10);
|
|
448
|
+
const req = items[idx];
|
|
449
|
+
if (!req) {
|
|
450
|
+
console.error(`Request #${idx} not found. ${items.length} requests available.`);
|
|
451
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
console.log(`${req.method} ${req.url}`);
|
|
455
|
+
console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
|
|
456
|
+
console.log('---');
|
|
457
|
+
console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
console.log(`Captured ${items.length} API requests:\n`);
|
|
461
|
+
items.forEach((r, i) => {
|
|
462
|
+
const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
|
|
463
|
+
console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
|
|
464
|
+
if (bodyPreview)
|
|
465
|
+
console.log(` ${bodyPreview}...`);
|
|
466
|
+
});
|
|
467
|
+
console.log(`\nUse --detail <index> to see full response body.`);
|
|
468
|
+
}
|
|
469
|
+
}));
|
|
470
|
+
// ── Init (adapter scaffolding) ──
|
|
471
|
+
operate.command('init')
|
|
472
|
+
.argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
|
|
473
|
+
.description('Generate adapter scaffold in ~/.opencli/clis/')
|
|
474
|
+
.action(async (name) => {
|
|
475
|
+
try {
|
|
476
|
+
const parts = name.split('/');
|
|
477
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
478
|
+
console.error('Name must be site/command format (e.g. hn/top)');
|
|
479
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const [site, command] = parts;
|
|
483
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
|
|
484
|
+
console.error('Name parts must be alphanumeric/dash/underscore only');
|
|
485
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const os = await import('node:os');
|
|
489
|
+
const fs = await import('node:fs');
|
|
490
|
+
const path = await import('node:path');
|
|
491
|
+
const dir = path.join(os.homedir(), '.opencli', 'clis', site);
|
|
492
|
+
const filePath = path.join(dir, `${command}.ts`);
|
|
493
|
+
if (fs.existsSync(filePath)) {
|
|
494
|
+
console.log(`Adapter already exists: ${filePath}`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// Try to detect domain from last operate session
|
|
498
|
+
let domain = site;
|
|
499
|
+
try {
|
|
500
|
+
const page = await getOperatePage();
|
|
501
|
+
const url = await page.getCurrentUrl?.();
|
|
502
|
+
if (url) {
|
|
503
|
+
try {
|
|
504
|
+
domain = new URL(url).hostname;
|
|
505
|
+
}
|
|
506
|
+
catch { }
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch { /* no active session */ }
|
|
510
|
+
const template = `import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
511
|
+
|
|
512
|
+
cli({
|
|
513
|
+
site: '${site}',
|
|
514
|
+
name: '${command}',
|
|
515
|
+
description: '', // TODO: describe what this command does
|
|
516
|
+
domain: '${domain}',
|
|
517
|
+
strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction)
|
|
518
|
+
browser: false, // TODO: set true if needs browser
|
|
519
|
+
args: [
|
|
520
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of items' },
|
|
521
|
+
],
|
|
522
|
+
columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
|
|
523
|
+
func: async (page, kwargs) => {
|
|
524
|
+
// TODO: implement data fetching
|
|
525
|
+
// Prefer API calls (fetch) over browser automation
|
|
526
|
+
// page is available if browser: true
|
|
527
|
+
return [];
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
`;
|
|
531
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
532
|
+
fs.writeFileSync(filePath, template, 'utf-8');
|
|
533
|
+
console.log(`Created: ${filePath}`);
|
|
534
|
+
console.log(`Edit the file to implement your adapter, then run: opencli operate verify ${name}`);
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
538
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
// ── Verify (test adapter) ──
|
|
542
|
+
operate.command('verify')
|
|
543
|
+
.argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
|
|
544
|
+
.description('Execute an adapter and show results')
|
|
545
|
+
.action(async (name) => {
|
|
546
|
+
try {
|
|
547
|
+
const parts = name.split('/');
|
|
548
|
+
if (parts.length !== 2) {
|
|
549
|
+
console.error('Name must be site/command format');
|
|
550
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const [site, command] = parts;
|
|
554
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
|
|
555
|
+
console.error('Name parts must be alphanumeric/dash/underscore only');
|
|
556
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const { execSync } = await import('node:child_process');
|
|
560
|
+
const os = await import('node:os');
|
|
561
|
+
const path = await import('node:path');
|
|
562
|
+
const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`);
|
|
563
|
+
const fs = await import('node:fs');
|
|
564
|
+
if (!fs.existsSync(filePath)) {
|
|
565
|
+
console.error(`Adapter not found: ${filePath}`);
|
|
566
|
+
console.error(`Run "opencli operate init ${name}" to create it.`);
|
|
567
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
console.log(`🔍 Verifying ${name}...\n`);
|
|
571
|
+
console.log(` Loading: ${filePath}`);
|
|
572
|
+
try {
|
|
573
|
+
const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, {
|
|
574
|
+
cwd: path.join(path.dirname(import.meta.url.replace('file://', '')), '..'),
|
|
575
|
+
timeout: 30000,
|
|
576
|
+
encoding: 'utf-8',
|
|
577
|
+
env: process.env,
|
|
578
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
579
|
+
});
|
|
580
|
+
console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
|
|
581
|
+
console.log(output);
|
|
582
|
+
console.log(`\n ✓ Adapter works!`);
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
|
|
586
|
+
if (err.stdout)
|
|
587
|
+
console.log(err.stdout);
|
|
588
|
+
if (err.stderr)
|
|
589
|
+
console.error(err.stderr.slice(0, 500));
|
|
590
|
+
console.log(`\n ✗ Adapter failed. Fix the code and try again.`);
|
|
591
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
596
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
// ── Session ──
|
|
600
|
+
operate.command('close').description('Close the automation window')
|
|
601
|
+
.action(operateAction(async (page) => {
|
|
602
|
+
await page.closeWindow?.();
|
|
603
|
+
console.log('Automation window closed');
|
|
604
|
+
}));
|
|
213
605
|
// ── Built-in: doctor / completion ──────────────────────────────────────────
|
|
214
606
|
program
|
|
215
607
|
.command('doctor')
|
|
@@ -14,11 +14,38 @@ cli({
|
|
|
14
14
|
],
|
|
15
15
|
columns: ['title', 'author', 'content', 'url'],
|
|
16
16
|
func: async (page, kwargs) => {
|
|
17
|
-
// Extract tweet ID from URL if needed
|
|
17
|
+
// Extract tweet ID from URL if needed.
|
|
18
|
+
// Article URLs (x.com/i/article/{articleId}) use a different ID than
|
|
19
|
+
// tweet status URLs — the GraphQL endpoint needs the parent tweet ID.
|
|
18
20
|
let tweetId = kwargs['tweet-id'];
|
|
21
|
+
const isArticleUrl = /\/article\/\d+/.test(tweetId);
|
|
19
22
|
const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
|
|
20
23
|
if (urlMatch)
|
|
21
24
|
tweetId = urlMatch[1];
|
|
25
|
+
if (isArticleUrl) {
|
|
26
|
+
// Navigate to the article page and resolve the parent tweet ID from DOM
|
|
27
|
+
await page.goto(`https://x.com/i/article/${tweetId}`);
|
|
28
|
+
await page.wait(3);
|
|
29
|
+
const resolvedId = await page.evaluate(`
|
|
30
|
+
(function() {
|
|
31
|
+
var links = document.querySelectorAll('a[href*="/status/"]');
|
|
32
|
+
for (var i = 0; i < links.length; i++) {
|
|
33
|
+
var m = links[i].href.match(/\\/status\\/(\\d+)/);
|
|
34
|
+
if (m) return m[1];
|
|
35
|
+
}
|
|
36
|
+
var og = document.querySelector('meta[property="og:url"]');
|
|
37
|
+
if (og && og.content) {
|
|
38
|
+
var m2 = og.content.match(/\\/status\\/(\\d+)/);
|
|
39
|
+
if (m2) return m2[1];
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
})()
|
|
43
|
+
`);
|
|
44
|
+
if (!resolvedId || typeof resolvedId !== 'string') {
|
|
45
|
+
throw new CommandExecutionError(`Could not resolve article ${tweetId} to a tweet ID. The article page may not contain a linked tweet.`);
|
|
46
|
+
}
|
|
47
|
+
tweetId = resolvedId;
|
|
48
|
+
}
|
|
22
49
|
// Navigate to the tweet page for cookie context
|
|
23
50
|
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
24
51
|
await page.wait(3);
|
|
@@ -19,6 +19,7 @@ cli({
|
|
|
19
19
|
columns: ['field', 'value'],
|
|
20
20
|
func: async (page, kwargs) => {
|
|
21
21
|
const raw = String(kwargs['note-id']);
|
|
22
|
+
const isBareNoteId = !/^https?:\/\//.test(raw.trim());
|
|
22
23
|
const noteId = parseNoteId(raw);
|
|
23
24
|
const url = buildNoteUrl(raw);
|
|
24
25
|
await page.goto(url);
|
|
@@ -60,6 +61,16 @@ cli({
|
|
|
60
61
|
// XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
|
|
61
62
|
// normalize to '0' unless the value looks numeric.
|
|
62
63
|
const numOrZero = (v) => /^\d+/.test(v) ? v : '0';
|
|
64
|
+
// XHS sometimes renders an empty shell page for bare /explore/<id> visits
|
|
65
|
+
// when the request lacks a valid xsec_token. Title + author are always
|
|
66
|
+
// present on a real note, so their absence is the simplest reliable signal.
|
|
67
|
+
const emptyShell = !d.title && !d.author;
|
|
68
|
+
if (emptyShell) {
|
|
69
|
+
if (isBareNoteId) {
|
|
70
|
+
throw new EmptyResultError('xiaohongshu/note', 'Pass the full search_result URL with xsec_token, for example from `opencli xiaohongshu search`, instead of a bare note ID.');
|
|
71
|
+
}
|
|
72
|
+
throw new EmptyResultError('xiaohongshu/note', 'The note page loaded without visible content. Retry with a fresh URL or run with --verbose; if it persists, the page structure may have changed.');
|
|
73
|
+
}
|
|
63
74
|
const rows = [
|
|
64
75
|
{ field: 'title', value: d.title || '' },
|
|
65
76
|
{ field: 'author', value: d.author || '' },
|