@jackwener/opencli 1.5.9 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +18 -0
  3. package/SKILL.md +59 -0
  4. package/autoresearch/baseline-browse.txt +1 -0
  5. package/autoresearch/baseline-skill.txt +1 -0
  6. package/autoresearch/browse-tasks.json +688 -0
  7. package/autoresearch/eval-browse.ts +185 -0
  8. package/autoresearch/eval-skill.ts +248 -0
  9. package/autoresearch/run-browse.sh +9 -0
  10. package/autoresearch/run-skill.sh +9 -0
  11. package/bun.lock +615 -0
  12. package/dist/browser/daemon-client.d.ts +20 -1
  13. package/dist/browser/daemon-client.js +37 -30
  14. package/dist/browser/daemon-client.test.d.ts +1 -0
  15. package/dist/browser/daemon-client.test.js +77 -0
  16. package/dist/browser/discover.js +8 -19
  17. package/dist/browser/page.d.ts +4 -0
  18. package/dist/browser/page.js +48 -1
  19. package/dist/cli-manifest.json +2 -2
  20. package/dist/cli.js +392 -0
  21. package/dist/clis/twitter/article.js +28 -1
  22. package/dist/clis/twitter/search.js +67 -5
  23. package/dist/clis/twitter/search.test.js +83 -5
  24. package/dist/clis/xiaohongshu/note.js +11 -0
  25. package/dist/clis/xiaohongshu/note.test.js +49 -0
  26. package/dist/commanderAdapter.js +1 -1
  27. package/dist/commanderAdapter.test.js +43 -0
  28. package/dist/commands/daemon.js +7 -46
  29. package/dist/commands/daemon.test.js +44 -69
  30. package/dist/discovery.js +27 -0
  31. package/dist/types.d.ts +8 -0
  32. package/docs/guide/getting-started.md +21 -0
  33. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  34. package/docs/zh/guide/getting-started.md +21 -0
  35. package/extension/package-lock.json +2 -2
  36. package/extension/src/background.ts +51 -4
  37. package/extension/src/cdp.ts +77 -124
  38. package/extension/src/protocol.ts +5 -1
  39. package/package.json +1 -1
  40. package/skills/opencli-explorer/SKILL.md +6 -0
  41. package/skills/opencli-oneshot/SKILL.md +6 -0
  42. package/skills/opencli-operate/SKILL.md +213 -0
  43. package/skills/opencli-usage/SKILL.md +113 -32
  44. package/src/browser/daemon-client.test.ts +103 -0
  45. package/src/browser/daemon-client.ts +53 -30
  46. package/src/browser/discover.ts +8 -17
  47. package/src/browser/page.ts +48 -1
  48. package/src/cli.ts +392 -0
  49. package/src/clis/twitter/article.ts +31 -1
  50. package/src/clis/twitter/search.test.ts +88 -5
  51. package/src/clis/twitter/search.ts +68 -5
  52. package/src/clis/xiaohongshu/note.test.ts +51 -0
  53. package/src/clis/xiaohongshu/note.ts +18 -0
  54. package/src/commanderAdapter.test.ts +62 -0
  55. package/src/commanderAdapter.ts +1 -1
  56. package/src/commands/daemon.test.ts +49 -83
  57. package/src/commands/daemon.ts +7 -55
  58. package/src/discovery.ts +22 -0
  59. package/src/doctor.ts +1 -1
  60. package/src/types.ts +8 -0
  61. package/extension/dist/background.js +0 -681
@@ -32,6 +32,27 @@ opencli bilibili hot -f md # Markdown
32
32
  opencli bilibili hot -f csv # CSV
33
33
  ```
34
34
 
35
+ ## 终端自动补全
36
+
37
+ OpenCLI 支持智能的 Tab 自动补全,加快命令输入:
38
+
39
+ ```bash
40
+ # 把自动补全加入 shell 启动配置
41
+ echo 'eval "$(opencli completion zsh)"' >> ~/.zshrc # Zsh
42
+ echo 'eval "$(opencli completion bash)"' >> ~/.bashrc # Bash
43
+ echo 'opencli completion fish | source' >> ~/.config/fish/config.fish # Fish
44
+
45
+ # 重启 shell 后,按 Tab 键补全:
46
+ opencli [Tab] # 补全站点名称(bilibili、zhihu、twitter...)
47
+ opencli bilibili [Tab] # 补全命令(hot、search、me、download...)
48
+ ```
49
+
50
+ 补全功能包含:
51
+ - 所有可用的站点和适配器
52
+ - 内置命令(list、explore、validate...)
53
+ - 命令别名
54
+ - 新增适配器时的实时更新
55
+
35
56
  ## 下一步
36
57
 
37
58
  - [安装详情](/zh/guide/installation)
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "opencli-extension",
9
- "version": "1.5.4",
9
+ "version": "1.5.5",
10
10
  "devDependencies": {
11
11
  "@types/chrome": "^0.0.287",
12
12
  "typescript": "^5.7.0",
@@ -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 = 'data:text/html,<html></html>';
274
+ const BLANK_PAGE = 'about:blank';
273
275
 
274
- /** Check if a URL can be attached via CDP — only allow http(s) and our internal blank page. */
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 === BLANK_PAGE;
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 data = await executor.evaluateAsync(tabId, cmd.code);
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) {
@@ -8,79 +8,13 @@
8
8
 
9
9
  const attached = new Set<number>();
10
10
 
11
- /** Internal blank page used when no user URL is provided. */
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 === BLANK_PAGE;
14
+ return url.startsWith('http://') || url.startsWith('https://') || url === 'about:blank' || url.startsWith('data:');
20
15
  }
21
16
 
22
- type CleanupResult = { removed: number };
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
- try {
113
- await tryAttach(tabId);
114
- } catch (e: unknown) {
115
- const msg = e instanceof Error ? e.message : String(e);
116
- const hint = msg.includes('chrome-extension://')
117
- ? '. Tip: another Chrome extension may be interfering — try disabling other extensions'
118
- : '';
119
- if (msg.includes('chrome-extension://')) {
120
- const recoveryCleanup = await removeForeignExtensionEmbeds(tabId);
121
- if (recoveryCleanup.removed > 0) {
122
- console.warn(`[opencli] Removed ${recoveryCleanup.removed} foreign extension frame(s) after attach failure on tab ${tabId}`);
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
- try {
133
- await tryAttach(tabId);
134
- } catch {
135
- throw new Error(`attach failed: ${msg}${hint}`);
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
- await ensureAttached(tabId);
164
-
165
- const result = await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
166
- expression,
167
- returnByValue: true,
168
- awaitPromise: true,
169
- }) as {
170
- result?: { type: string; value?: unknown; description?: string; subtype?: string };
171
- exceptionDetails?: { exception?: { description?: string }; text?: string };
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
- if (result.exceptionDetails) {
175
- const errMsg = result.exceptionDetails.exception?.description
176
- || result.exceptionDetails.text
177
- || 'Eval error';
178
- throw new Error(errMsg);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.9",
3
+ "version": "1.6.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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` |