@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
|
@@ -250,6 +250,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
250
250
|
return await handleScreenshot(cmd, workspace);
|
|
251
251
|
case 'close-window':
|
|
252
252
|
return await handleCloseWindow(cmd, workspace);
|
|
253
|
+
case 'cdp':
|
|
254
|
+
return await handleCdp(cmd, workspace);
|
|
253
255
|
case 'sessions':
|
|
254
256
|
return await handleSessions(cmd);
|
|
255
257
|
case 'set-file-input':
|
|
@@ -269,12 +271,12 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
269
271
|
// ─── Action handlers ─────────────────────────────────────────────────
|
|
270
272
|
|
|
271
273
|
/** Internal blank page used when no user URL is provided. */
|
|
272
|
-
const BLANK_PAGE = '
|
|
274
|
+
const BLANK_PAGE = 'about:blank';
|
|
273
275
|
|
|
274
|
-
/** Check if a URL can be attached via CDP — only allow http(s) and
|
|
276
|
+
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
|
275
277
|
function isDebuggableUrl(url?: string): boolean {
|
|
276
278
|
if (!url) return true; // empty/undefined = tab still loading, allow it
|
|
277
|
-
return url.startsWith('http://') || url.startsWith('https://') || url ===
|
|
279
|
+
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/** Check if a URL is safe for user-facing navigation (http/https only). */
|
|
@@ -387,7 +389,8 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
|
|
387
389
|
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
|
388
390
|
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
389
391
|
try {
|
|
390
|
-
const
|
|
392
|
+
const aggressive = workspace.startsWith('operate:');
|
|
393
|
+
const data = await executor.evaluateAsync(tabId, cmd.code, aggressive);
|
|
391
394
|
return { id: cmd.id, ok: true, data };
|
|
392
395
|
} catch (err) {
|
|
393
396
|
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
@@ -578,6 +581,50 @@ async function handleScreenshot(cmd: Command, workspace: string): Promise<Result
|
|
|
578
581
|
}
|
|
579
582
|
}
|
|
580
583
|
|
|
584
|
+
/** CDP methods permitted via the 'cdp' passthrough action. */
|
|
585
|
+
const CDP_ALLOWLIST = new Set([
|
|
586
|
+
// Agent DOM context
|
|
587
|
+
'Accessibility.getFullAXTree',
|
|
588
|
+
'DOM.getDocument',
|
|
589
|
+
'DOM.getBoxModel',
|
|
590
|
+
'DOM.getContentQuads',
|
|
591
|
+
'DOM.querySelectorAll',
|
|
592
|
+
'DOM.scrollIntoViewIfNeeded',
|
|
593
|
+
'DOMSnapshot.captureSnapshot',
|
|
594
|
+
// Native input events
|
|
595
|
+
'Input.dispatchMouseEvent',
|
|
596
|
+
'Input.dispatchKeyEvent',
|
|
597
|
+
'Input.insertText',
|
|
598
|
+
// Page metrics & screenshots
|
|
599
|
+
'Page.getLayoutMetrics',
|
|
600
|
+
'Page.captureScreenshot',
|
|
601
|
+
// Runtime.enable needed for CDP attach setup (Runtime.evaluate goes through 'exec' action)
|
|
602
|
+
'Runtime.enable',
|
|
603
|
+
// Emulation (used by screenshot full-page)
|
|
604
|
+
'Emulation.setDeviceMetricsOverride',
|
|
605
|
+
'Emulation.clearDeviceMetricsOverride',
|
|
606
|
+
]);
|
|
607
|
+
|
|
608
|
+
async function handleCdp(cmd: Command, workspace: string): Promise<Result> {
|
|
609
|
+
if (!cmd.cdpMethod) return { id: cmd.id, ok: false, error: 'Missing cdpMethod' };
|
|
610
|
+
if (!CDP_ALLOWLIST.has(cmd.cdpMethod)) {
|
|
611
|
+
return { id: cmd.id, ok: false, error: `CDP method not permitted: ${cmd.cdpMethod}` };
|
|
612
|
+
}
|
|
613
|
+
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
614
|
+
try {
|
|
615
|
+
const aggressive = workspace.startsWith('operate:');
|
|
616
|
+
await executor.ensureAttached(tabId, aggressive);
|
|
617
|
+
const data = await chrome.debugger.sendCommand(
|
|
618
|
+
{ tabId },
|
|
619
|
+
cmd.cdpMethod,
|
|
620
|
+
cmd.cdpParams ?? {},
|
|
621
|
+
);
|
|
622
|
+
return { id: cmd.id, ok: true, data };
|
|
623
|
+
} catch (err) {
|
|
624
|
+
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
581
628
|
async function handleCloseWindow(cmd: Command, workspace: string): Promise<Result> {
|
|
582
629
|
const session = automationSessions.get(workspace);
|
|
583
630
|
if (session) {
|
package/extension/src/cdp.ts
CHANGED
|
@@ -8,79 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
const attached = new Set<number>();
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
const BLANK_PAGE = 'data:text/html,<html></html>';
|
|
13
|
-
const FOREIGN_EXTENSION_URL_PREFIX = 'chrome-extension://';
|
|
14
|
-
const ATTACH_RECOVERY_DELAY_MS = 120;
|
|
15
|
-
|
|
16
|
-
/** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
|
|
11
|
+
/** Check if a URL can be attached via CDP — only allow http(s) and blank pages. */
|
|
17
12
|
function isDebuggableUrl(url?: string): boolean {
|
|
18
13
|
if (!url) return true; // empty/undefined = tab still loading, allow it
|
|
19
|
-
return url.startsWith('http://') || url.startsWith('https://') || url ===
|
|
14
|
+
return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
|
|
20
15
|
}
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async function removeForeignExtensionEmbeds(tabId: number): Promise<CleanupResult> {
|
|
25
|
-
const tab = await chrome.tabs.get(tabId);
|
|
26
|
-
if (!tab.url || (!tab.url.startsWith('http://') && !tab.url.startsWith('https://'))) {
|
|
27
|
-
return { removed: 0 };
|
|
28
|
-
}
|
|
29
|
-
if (!chrome.scripting?.executeScript) return { removed: 0 };
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const [result] = await chrome.scripting.executeScript({
|
|
33
|
-
target: { tabId },
|
|
34
|
-
args: [`${FOREIGN_EXTENSION_URL_PREFIX}${chrome.runtime.id}/`],
|
|
35
|
-
func: (ownExtensionPrefix: string) => {
|
|
36
|
-
const extensionPrefix = 'chrome-extension://';
|
|
37
|
-
const selectors = ['iframe', 'frame', 'embed', 'object'];
|
|
38
|
-
const visitedRoots = new Set<Document | ShadowRoot>();
|
|
39
|
-
const roots: Array<Document | ShadowRoot> = [document];
|
|
40
|
-
let removed = 0;
|
|
41
|
-
|
|
42
|
-
while (roots.length > 0) {
|
|
43
|
-
const root = roots.pop();
|
|
44
|
-
if (!root || visitedRoots.has(root)) continue;
|
|
45
|
-
visitedRoots.add(root);
|
|
46
|
-
|
|
47
|
-
for (const selector of selectors) {
|
|
48
|
-
const nodes = root.querySelectorAll(selector);
|
|
49
|
-
for (const node of nodes) {
|
|
50
|
-
const src = node.getAttribute('src') || node.getAttribute('data') || '';
|
|
51
|
-
if (!src.startsWith(extensionPrefix) || src.startsWith(ownExtensionPrefix)) continue;
|
|
52
|
-
node.remove();
|
|
53
|
-
removed++;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
58
|
-
let current = walker.nextNode();
|
|
59
|
-
while (current) {
|
|
60
|
-
const element = current as Element & { shadowRoot?: ShadowRoot | null };
|
|
61
|
-
if (element.shadowRoot) roots.push(element.shadowRoot);
|
|
62
|
-
current = walker.nextNode();
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { removed };
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
return result?.result ?? { removed: 0 };
|
|
70
|
-
} catch {
|
|
71
|
-
return { removed: 0 };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function delay(ms: number): Promise<void> {
|
|
76
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function tryAttach(tabId: number): Promise<void> {
|
|
80
|
-
await chrome.debugger.attach({ tabId }, '1.3');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function ensureAttached(tabId: number): Promise<void> {
|
|
17
|
+
export async function ensureAttached(tabId: number, aggressiveRetry: boolean = false): Promise<void> {
|
|
84
18
|
// Verify the tab URL is debuggable before attempting attach
|
|
85
19
|
try {
|
|
86
20
|
const tab = await chrome.tabs.get(tabId);
|
|
@@ -109,35 +43,47 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
109
43
|
}
|
|
110
44
|
}
|
|
111
45
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
await delay(ATTACH_RECOVERY_DELAY_MS);
|
|
125
|
-
try {
|
|
126
|
-
await tryAttach(tabId);
|
|
127
|
-
} catch {
|
|
128
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
129
|
-
}
|
|
130
|
-
} else if (msg.includes('Another debugger is already attached')) {
|
|
46
|
+
// Retry attach up to 3 times — other extensions (1Password, Playwright MCP Bridge)
|
|
47
|
+
// can temporarily interfere with chrome.debugger. A short delay usually resolves it.
|
|
48
|
+
// Normal commands: 2 retries, 500ms delay (fast fail for non-operate use)
|
|
49
|
+
// Operate commands: 5 retries, 1500ms delay (aggressive, tolerates extension interference)
|
|
50
|
+
const MAX_ATTACH_RETRIES = aggressiveRetry ? 5 : 2;
|
|
51
|
+
const RETRY_DELAY_MS = aggressiveRetry ? 1500 : 500;
|
|
52
|
+
let lastError = '';
|
|
53
|
+
|
|
54
|
+
for (let attempt = 1; attempt <= MAX_ATTACH_RETRIES; attempt++) {
|
|
55
|
+
try {
|
|
56
|
+
// Force detach first to clear any stale state from other extensions
|
|
131
57
|
try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
58
|
+
await chrome.debugger.attach({ tabId }, '1.3');
|
|
59
|
+
lastError = '';
|
|
60
|
+
break; // Success
|
|
61
|
+
} catch (e: unknown) {
|
|
62
|
+
lastError = e instanceof Error ? e.message : String(e);
|
|
63
|
+
if (attempt < MAX_ATTACH_RETRIES) {
|
|
64
|
+
console.warn(`[opencli] attach attempt ${attempt}/${MAX_ATTACH_RETRIES} failed: ${lastError}, retrying in ${RETRY_DELAY_MS}ms...`);
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
66
|
+
// Re-verify tab URL before retrying (it may have changed)
|
|
67
|
+
try {
|
|
68
|
+
const tab = await chrome.tabs.get(tabId);
|
|
69
|
+
if (!isDebuggableUrl(tab.url)) {
|
|
70
|
+
lastError = `Tab URL changed to ${tab.url} during retry`;
|
|
71
|
+
break; // Don't retry if URL became un-debuggable
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
lastError = `Tab ${tabId} no longer exists`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
136
77
|
}
|
|
137
|
-
} else {
|
|
138
|
-
throw new Error(`attach failed: ${msg}${hint}`);
|
|
139
78
|
}
|
|
140
79
|
}
|
|
80
|
+
|
|
81
|
+
if (lastError) {
|
|
82
|
+
const hint = lastError.includes('chrome-extension://')
|
|
83
|
+
? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
|
|
84
|
+
: '';
|
|
85
|
+
throw new Error(`attach failed: ${lastError}${hint}`);
|
|
86
|
+
}
|
|
141
87
|
attached.add(tabId);
|
|
142
88
|
|
|
143
89
|
try {
|
|
@@ -145,40 +91,47 @@ async function ensureAttached(tabId: number): Promise<void> {
|
|
|
145
91
|
} catch {
|
|
146
92
|
// Some pages may not need explicit enable
|
|
147
93
|
}
|
|
148
|
-
|
|
149
|
-
// Disable breakpoints so that `debugger;` statements in page code don't
|
|
150
|
-
// pause execution. Anti-bot scripts use `debugger;` traps to detect CDP —
|
|
151
|
-
// they measure the time gap caused by the pause. Deactivating breakpoints
|
|
152
|
-
// makes the engine skip `debugger;` entirely, neutralising the timing
|
|
153
|
-
// side-channel without patching page JS.
|
|
154
|
-
try {
|
|
155
|
-
await chrome.debugger.sendCommand({ tabId }, 'Debugger.enable');
|
|
156
|
-
await chrome.debugger.sendCommand({ tabId }, 'Debugger.setBreakpointsActive', { active: false });
|
|
157
|
-
} catch {
|
|
158
|
-
// Non-fatal: best-effort hardening
|
|
159
|
-
}
|
|
160
94
|
}
|
|
161
95
|
|
|
162
|
-
export async function evaluate(tabId: number, expression: string): Promise<unknown> {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
96
|
+
export async function evaluate(tabId: number, expression: string, aggressiveRetry: boolean = false): Promise<unknown> {
|
|
97
|
+
// Retry the entire evaluate (attach + command).
|
|
98
|
+
// Normal: 2 retries. Operate: 3 retries (tolerates extension interference).
|
|
99
|
+
const MAX_EVAL_RETRIES = aggressiveRetry ? 3 : 2;
|
|
100
|
+
for (let attempt = 1; attempt <= MAX_EVAL_RETRIES; attempt++) {
|
|
101
|
+
try {
|
|
102
|
+
await ensureAttached(tabId, aggressiveRetry);
|
|
103
|
+
|
|
104
|
+
const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
105
|
+
expression,
|
|
106
|
+
returnByValue: true,
|
|
107
|
+
awaitPromise: true,
|
|
108
|
+
}) as {
|
|
109
|
+
result?: { type: string; value?: unknown; description?: string; subtype?: string };
|
|
110
|
+
exceptionDetails?: { exception?: { description?: string }; text?: string };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (result.exceptionDetails) {
|
|
114
|
+
const errMsg = result.exceptionDetails.exception?.description
|
|
115
|
+
|| result.exceptionDetails.text
|
|
116
|
+
|| 'Eval error';
|
|
117
|
+
throw new Error(errMsg);
|
|
118
|
+
}
|
|
173
119
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
120
|
+
return result.result?.value;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
123
|
+
// Only retry on attach/debugger errors, not on JS eval errors
|
|
124
|
+
const isAttachError = msg.includes('attach failed') || msg.includes('Debugger is not attached')
|
|
125
|
+
|| msg.includes('chrome-extension://') || msg.includes('Target closed');
|
|
126
|
+
if (isAttachError && attempt < MAX_EVAL_RETRIES) {
|
|
127
|
+
attached.delete(tabId); // Force re-attach on next attempt
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
179
133
|
}
|
|
180
|
-
|
|
181
|
-
return result.result?.value;
|
|
134
|
+
throw new Error('evaluate: max retries exhausted');
|
|
182
135
|
}
|
|
183
136
|
|
|
184
137
|
export const evaluateAsync = evaluate;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Everything else is just JS code sent via 'exec'.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input';
|
|
8
|
+
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'cdp';
|
|
9
9
|
|
|
10
10
|
export interface Command {
|
|
11
11
|
/** Unique request ID */
|
|
@@ -36,6 +36,10 @@ export interface Command {
|
|
|
36
36
|
files?: string[];
|
|
37
37
|
/** CSS selector for file input element (set-file-input action) */
|
|
38
38
|
selector?: string;
|
|
39
|
+
/** CDP method name for 'cdp' action (e.g. 'Accessibility.getFullAXTree') */
|
|
40
|
+
cdpMethod?: string;
|
|
41
|
+
/** CDP method params for 'cdp' action */
|
|
42
|
+
cdpParams?: Record<string, unknown>;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface Result {
|
package/package.json
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opencli-explorer
|
|
3
|
+
description: Use when creating a new OpenCLI adapter from scratch, adding support for a new website or platform, or exploring a site's API endpoints via browser DevTools. Covers API discovery workflow, authentication strategy selection, YAML/TS adapter writing, and testing.
|
|
4
|
+
tags: [opencli, adapter, browser, api-discovery, cli, web-scraping, automation]
|
|
5
|
+
---
|
|
6
|
+
|
|
1
7
|
# CLI-EXPLORER — 适配器探索式开发完全指南
|
|
2
8
|
|
|
3
9
|
> 本文档教你(或 AI Agent)如何为 OpenCLI 添加一个新网站的命令。
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opencli-oneshot
|
|
3
|
+
description: Use when quickly generating a single OpenCLI command from a specific URL and goal description. 4-step process — open page, capture API, write YAML adapter, test. For full site exploration, use opencli-explorer instead.
|
|
4
|
+
tags: [opencli, adapter, quick-start, yaml, cli, one-shot, automation]
|
|
5
|
+
---
|
|
6
|
+
|
|
1
7
|
# CLI-ONESHOT — 单点快速 CLI 生成
|
|
2
8
|
|
|
3
9
|
> 给一个 URL + 一句话描述,4 步生成一个 CLI 命令。
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opencli-operate
|
|
3
|
+
description: Make websites accessible for AI agents. Navigate, click, type, extract, wait — using Chrome with existing login sessions. No LLM API key needed.
|
|
4
|
+
allowed-tools: Bash(opencli:*), Read, Edit, Write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# OpenCLI — Make Websites Accessible for AI Agents
|
|
8
|
+
|
|
9
|
+
Control Chrome step-by-step via CLI. Reuses existing login sessions — no passwords needed.
|
|
10
|
+
|
|
11
|
+
## Prerequisites
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
opencli doctor # Verify extension + daemon connectivity
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires: Chrome running + OpenCLI Browser Bridge extension installed.
|
|
18
|
+
|
|
19
|
+
## Quickstart for AI Agents (1 step)
|
|
20
|
+
|
|
21
|
+
Point your AI agent to this file. It contains everything needed to operate browsers.
|
|
22
|
+
|
|
23
|
+
## Quickstart for Humans (3 steps)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g @jackwener/opencli # 1. Install
|
|
27
|
+
# Install extension from chrome://extensions # 2. Load extension
|
|
28
|
+
opencli operate open https://example.com # 3. Go!
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Core Workflow
|
|
32
|
+
|
|
33
|
+
1. **Navigate**: `opencli operate open <url>`
|
|
34
|
+
2. **Inspect**: `opencli operate state` → see elements with `[N]` indices
|
|
35
|
+
3. **Interact**: use indices — `click`, `type`, `select`, `keys`
|
|
36
|
+
4. **Wait**: `opencli operate wait selector ".loaded"` or `wait text "Success"`
|
|
37
|
+
5. **Verify**: `opencli operate get title` or `opencli operate screenshot`
|
|
38
|
+
6. **Repeat**: browser stays open between commands
|
|
39
|
+
7. **Save**: write a TS adapter to `~/.opencli/clis/<site>/<command>.ts`
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
### Navigation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
opencli operate open <url> # Open URL
|
|
47
|
+
opencli operate back # Go back
|
|
48
|
+
opencli operate scroll down # Scroll (up/down, --amount N)
|
|
49
|
+
opencli operate scroll up --amount 1000
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Inspect
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
opencli operate state # Elements with [N] indices
|
|
56
|
+
opencli operate screenshot [path.png] # Screenshot
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Get (structured data)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
opencli operate get title # Page title
|
|
63
|
+
opencli operate get url # Current URL
|
|
64
|
+
opencli operate get text <index> # Element text content
|
|
65
|
+
opencli operate get value <index> # Input/textarea value
|
|
66
|
+
opencli operate get html # Full page HTML
|
|
67
|
+
opencli operate get html --selector "h1" # Scoped HTML
|
|
68
|
+
opencli operate get attributes <index> # Element attributes
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Interact
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
opencli operate click <index> # Click element [N]
|
|
75
|
+
opencli operate type <index> "text" # Type into element [N]
|
|
76
|
+
opencli operate select <index> "option" # Select dropdown
|
|
77
|
+
opencli operate keys "Enter" # Press key (Enter, Escape, Tab, Control+a)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Wait
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
opencli operate wait selector ".loaded" # Wait for element
|
|
84
|
+
opencli operate wait selector ".spinner" --timeout 5000 # With timeout
|
|
85
|
+
opencli operate wait text "Success" # Wait for text
|
|
86
|
+
opencli operate wait time 3 # Wait N seconds
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Extract
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
opencli operate eval "document.title"
|
|
93
|
+
opencli operate eval "JSON.stringify([...document.querySelectorAll('h2')].map(e => e.textContent))"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Network (API Discovery)
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
opencli operate network # Show captured API requests (auto-captured since open)
|
|
100
|
+
opencli operate network --detail 3 # Show full response body of request #3
|
|
101
|
+
opencli operate network --all # Include static resources
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Sedimentation (Save as CLI)
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
opencli operate init hn/top # Generate adapter scaffold
|
|
108
|
+
opencli operate verify hn/top # Test the adapter
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Session
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
opencli operate close # Close automation window
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Example: Extract HN Stories
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
opencli operate open https://news.ycombinator.com
|
|
121
|
+
opencli operate state # See [1] a "Story 1", [2] a "Story 2"...
|
|
122
|
+
opencli operate eval "JSON.stringify([...document.querySelectorAll('.titleline a')].slice(0,5).map(a => ({title: a.textContent, url: a.href})))"
|
|
123
|
+
opencli operate close
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Example: Fill a Form
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
opencli operate open https://httpbin.org/forms/post
|
|
130
|
+
opencli operate state # See [3] input "Customer Name", [4] input "Telephone"
|
|
131
|
+
opencli operate type 3 "OpenCLI"
|
|
132
|
+
opencli operate type 4 "555-0100"
|
|
133
|
+
opencli operate get value 3 # Verify: "OpenCLI"
|
|
134
|
+
opencli operate close
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Saving as Reusable CLI — Complete Workflow
|
|
138
|
+
|
|
139
|
+
### Step-by-step sedimentation flow:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# 1. Explore the website
|
|
143
|
+
opencli operate open https://news.ycombinator.com
|
|
144
|
+
opencli operate state # Understand DOM structure
|
|
145
|
+
|
|
146
|
+
# 2. Discover APIs (crucial for high-quality adapters)
|
|
147
|
+
opencli operate eval "fetch('/api/...').then(r=>r.json())" # Trigger API calls
|
|
148
|
+
opencli operate network # See captured API requests
|
|
149
|
+
opencli operate network --detail 0 # Inspect response body
|
|
150
|
+
|
|
151
|
+
# 3. Generate scaffold
|
|
152
|
+
opencli operate init hn/top # Creates ~/.opencli/clis/hn/top.ts
|
|
153
|
+
|
|
154
|
+
# 4. Edit the adapter (fill in func logic)
|
|
155
|
+
# - If API found: use fetch() directly (Strategy.PUBLIC or COOKIE)
|
|
156
|
+
# - If no API: use page.evaluate() for DOM extraction (Strategy.UI)
|
|
157
|
+
|
|
158
|
+
# 5. Verify
|
|
159
|
+
opencli operate verify hn/top # Runs the adapter and shows output
|
|
160
|
+
|
|
161
|
+
# 6. If verify fails, edit and retry
|
|
162
|
+
# 7. Close when done
|
|
163
|
+
opencli operate close
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Example adapter:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// ~/.opencli/clis/hn/top.ts
|
|
170
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
171
|
+
|
|
172
|
+
cli({
|
|
173
|
+
site: 'hn',
|
|
174
|
+
name: 'top',
|
|
175
|
+
description: 'Top Hacker News stories',
|
|
176
|
+
domain: 'news.ycombinator.com',
|
|
177
|
+
strategy: Strategy.PUBLIC,
|
|
178
|
+
browser: false,
|
|
179
|
+
args: [{ name: 'limit', type: 'int', default: 5 }],
|
|
180
|
+
columns: ['rank', 'title', 'score', 'url'],
|
|
181
|
+
func: async (_page, kwargs) => {
|
|
182
|
+
const limit = Math.min(Math.max(1, kwargs.limit ?? 5), 50);
|
|
183
|
+
const resp = await fetch('https://hacker-news.firebaseio.com/v0/topstories.json');
|
|
184
|
+
const ids = await resp.json();
|
|
185
|
+
return Promise.all(
|
|
186
|
+
ids.slice(0, limit).map(async (id: number, i: number) => {
|
|
187
|
+
const item = await (await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)).json();
|
|
188
|
+
return { rank: i + 1, title: item.title, score: item.score, url: item.url ?? '' };
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Save to `~/.opencli/clis/<site>/<command>.ts` → immediately available as `opencli <site> <command>`.
|
|
196
|
+
|
|
197
|
+
### Strategy Guide
|
|
198
|
+
|
|
199
|
+
| Strategy | When | browser: |
|
|
200
|
+
|----------|------|----------|
|
|
201
|
+
| `Strategy.PUBLIC` | Public API, no auth | `false` |
|
|
202
|
+
| `Strategy.COOKIE` | Needs login cookies | `true` |
|
|
203
|
+
| `Strategy.UI` | Direct DOM interaction | `true` |
|
|
204
|
+
|
|
205
|
+
**Always prefer API over UI** — if you discovered an API during browsing, use `fetch()` directly.
|
|
206
|
+
|
|
207
|
+
## Troubleshooting
|
|
208
|
+
|
|
209
|
+
| Error | Fix |
|
|
210
|
+
|-------|-----|
|
|
211
|
+
| "Browser not connected" | Run `opencli doctor` |
|
|
212
|
+
| "attach failed: chrome-extension://" | Disable 1Password temporarily |
|
|
213
|
+
| Element not found | `opencli operate scroll down` then `opencli operate state` |
|