@jackwener/opencli 0.9.2 → 0.9.4

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.
@@ -0,0 +1,72 @@
1
+ ---
2
+ description: How to CLI-ify and automate any Electron Desktop Application via CDP
3
+ ---
4
+
5
+ # CLI-ifying Electron Applications (Skill Guide)
6
+
7
+ Based on the successful extraction and automation of **Antigravity** and **OpenAI Codex** desktop apps, this guide serves as the standard operating procedure (SOP) for adapting ANY Electron-based application into an OpenCLI adapter.
8
+
9
+ ## 核心原理 (Core Concept)
10
+ Electron 应用本质上是运行在本地的 Chromium 浏览器实例。只要在启动应用时暴露了调试端口(CDP,Chrome DevTools Protocol),我们就可以利用 Playwright MCP 直接穿透其 UI 层,获取并操控包括 React/Vue 组件、Shadow DOM 等在内的所有底层状态,实现“从应用外挂入自动化脚本”。
11
+
12
+ ### 启动 Target App
13
+ 要在本地操作任何 Electron 应用,必须先要求用户使用以下参数注入调试端点:
14
+ ```bash
15
+ /Applications/AppName.app/Contents/MacOS/AppName --remote-debugging-port=9222
16
+ ```
17
+
18
+ ## 标准适配模式:The 5-Command Pattern
19
+
20
+ 适配一个新的 App,必须在 `src/clis/<app_name>/` 下实现这 5 个标准化指令:
21
+
22
+ ### 1. `status.ts` (连接测试)
23
+ 负责确认应用监听正确。
24
+ - **机制**: 直接 `export const statusCommand = cli({...})`
25
+ - **核心代码**: 获取 `window.location.href` 与 `document.title`。
26
+ - **注意**: 必须指明 `domain: 'localhost'` 和 `browser: true`。
27
+
28
+ ### 2. `dump.ts` (逆向工程核心)
29
+ 很多现代 App DOM 极其庞大且混淆。**千万不要直接猜选择器**。
30
+ 首先编写 dump 脚本,将当前页面的 DOM 与 Accessibility Tree 导出到 `/tmp/`,方便用 AI (或者 `grep`) 提取精确的容器名称和 Class。
31
+ ```typescript
32
+ const dom = await page.evaluate('document.body.innerHTML');
33
+ fs.writeFileSync('/tmp/app-dom.html', dom);
34
+ const snap = await page.snapshot({ interactive: false });
35
+ fs.writeFileSync('/tmp/app-snapshot.json', JSON.stringify(snap, null, 2));
36
+ ```
37
+
38
+ ### 3. `send.ts` (高级注入技巧)
39
+ Electron 应用常常使用极端复杂的富文本编辑器(如 Monaco, Lexical, ProseMirror)。直接修改元素的 `value` 常常会被 React 状态机忽略。
40
+ - **最佳实践**: 使用 `document.execCommand('insertText')` 完美模拟真实的人类复制粘贴输入流,完全穿透 React state。
41
+ ```javascript
42
+ // 寻路机制:优先尝试寻找 contenteditable
43
+ let composer = document.querySelector('[contenteditable="true"]');
44
+ composer.focus();
45
+ document.execCommand('insertText', false, "你好");
46
+ ```
47
+ - **提交快捷键**: `await page.pressKey('Enter')`。
48
+
49
+ ### 4. `read.ts` (上下文解析)
50
+ 不要提取整个页面的文本。应该利用 `dump.ts` 抓取出来的特征寻找真正的“对话容器”。
51
+ - **技巧**: 检查带有语义化结构的数据,例如 `[role="log"]`、`[data-testid="conversation"]` 或是 `[data-content-search-turn-key]`。
52
+ - **格式化**: 拼接抓取出的文本转粗暴渲染成 Markdown 返回,这样不仅你和人类能读懂,LLM 后续作为 Agent 也能精准切分。
53
+
54
+ ### 5. `new.ts` / Action Macros (底层事件模拟)
55
+ 许多图形界面操作难以找到按钮实例,但它们通常响应原生快捷键。
56
+ - **最佳实践**: 模拟系统级快捷键直接驱动 `(Meta+N / Control+N)`。
57
+ ```typescript
58
+ const isMac = process.platform === 'darwin';
59
+ await page.pressKey(isMac ? 'Meta+N' : 'Control+N');
60
+ await page.wait(1); // 等待重渲染
61
+ ```
62
+
63
+ ## 全局环境变量
64
+ 为了让 Core Framework 挂载到我们指定的端口,必须在执行指令前(或在 README 中指导用户)注入目标环境端口:
65
+ ```bash
66
+ export OPENCLI_CDP_ENDPOINT="http://127.0.0.1:9222"
67
+ ```
68
+
69
+ ## 踩坑避雷 (Pitfalls & Gotchas)
70
+ 1. **端口占用 (EADDRINUSE)**: 确保同一时间只能有一个 App 占据一个端口。如果同时测试 Antigravity (9224) 且你要测试别的 App (9222),要将 CDP Endpoint 分配开来。
71
+ 2. **TypeScript 抽象**: OpenCLI 内部封装了 `IPage` 类型(`src/types.ts`),不是原生的 Playwright Page。要用 `page.pressKey()` 和 `page.evaluate()`,而非 `page.keyboard.press()`。
72
+ 3. **延时等待**: DOM 发生剧烈变化后,一定要加上 `await page.wait(0.5)` 到 `1.0` 给框架反应的时间。不要立刻 return 导致连接 prematurely 阻断。
package/README.md CHANGED
@@ -149,7 +149,16 @@ npm install -g @jackwener/opencli@latest
149
149
  |------|----------|:-----:|------|
150
150
  | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser |
151
151
  | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser |
152
+ | **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ Desktop |
153
+ | **bbc** | `news` | 1 | 🌐 Public |
152
154
  | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser |
155
+ | **boss** | `search` `detail` | 2 | 🔐 Browser |
156
+ | **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ Desktop |
157
+ | **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser |
158
+ | **ctrip** | `search` | 1 | 🔐 Browser |
159
+ | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ Desktop |
160
+ | **github** | `search` | 1 | 🌐 Public |
161
+ | **hackernews** | `top` | 1 | 🌐 Public |
153
162
  | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
154
163
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser |
155
164
  | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser |
package/README.zh-CN.md CHANGED
@@ -150,6 +150,9 @@ npm install -g @jackwener/opencli@latest
150
150
  |------|------|:----:|------|
151
151
  | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 |
152
152
  | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 |
153
+ | **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ 桌面端 |
154
+ | **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ 桌面端 |
155
+ | **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ 桌面端 |
153
156
  | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 |
154
157
  | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 |
155
158
  | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 |
@@ -840,6 +840,29 @@
840
840
  "Diff"
841
841
  ]
842
842
  },
843
+ {
844
+ "site": "codex",
845
+ "name": "model",
846
+ "description": "Get or switch the currently active AI model in Codex Desktop",
847
+ "strategy": "ui",
848
+ "browser": true,
849
+ "args": [
850
+ {
851
+ "name": "model_name",
852
+ "type": "str",
853
+ "required": false,
854
+ "positional": true,
855
+ "help": "The ID of the model to switch to (e.g. gpt-4)"
856
+ }
857
+ ],
858
+ "type": "ts",
859
+ "modulePath": "codex/model.js",
860
+ "domain": "localhost",
861
+ "columns": [
862
+ "Status",
863
+ "Model"
864
+ ]
865
+ },
843
866
  {
844
867
  "site": "codex",
845
868
  "name": "new",
@@ -1021,6 +1044,149 @@
1021
1044
  "url"
1022
1045
  ]
1023
1046
  },
1047
+ {
1048
+ "site": "cursor",
1049
+ "name": "composer",
1050
+ "description": "Send a prompt directly into Cursor Composer (Cmd+I shortcut)",
1051
+ "strategy": "ui",
1052
+ "browser": true,
1053
+ "args": [
1054
+ {
1055
+ "name": "text",
1056
+ "type": "str",
1057
+ "required": true,
1058
+ "positional": true,
1059
+ "help": "Text to send into Composer"
1060
+ }
1061
+ ],
1062
+ "type": "ts",
1063
+ "modulePath": "cursor/composer.js",
1064
+ "domain": "localhost",
1065
+ "columns": [
1066
+ "Status",
1067
+ "InjectedText"
1068
+ ]
1069
+ },
1070
+ {
1071
+ "site": "cursor",
1072
+ "name": "dump",
1073
+ "description": "Dump the DOM and Accessibility tree of Cursor for reverse-engineering",
1074
+ "strategy": "ui",
1075
+ "browser": true,
1076
+ "args": [],
1077
+ "type": "ts",
1078
+ "modulePath": "cursor/dump.js",
1079
+ "domain": "localhost",
1080
+ "columns": [
1081
+ "action",
1082
+ "files"
1083
+ ]
1084
+ },
1085
+ {
1086
+ "site": "cursor",
1087
+ "name": "extract-code",
1088
+ "description": "Extract multi-line code blocks from the current Cursor conversation",
1089
+ "strategy": "ui",
1090
+ "browser": true,
1091
+ "args": [],
1092
+ "type": "ts",
1093
+ "modulePath": "cursor/extract-code.js",
1094
+ "domain": "localhost",
1095
+ "columns": [
1096
+ "Code"
1097
+ ]
1098
+ },
1099
+ {
1100
+ "site": "cursor",
1101
+ "name": "model",
1102
+ "description": "Get or switch the currently active AI model in Cursor",
1103
+ "strategy": "ui",
1104
+ "browser": true,
1105
+ "args": [
1106
+ {
1107
+ "name": "model_name",
1108
+ "type": "str",
1109
+ "required": false,
1110
+ "positional": true,
1111
+ "help": "The ID of the model to switch to (e.g. claude-3.5-sonnet)"
1112
+ }
1113
+ ],
1114
+ "type": "ts",
1115
+ "modulePath": "cursor/model.js",
1116
+ "domain": "localhost",
1117
+ "columns": [
1118
+ "Status",
1119
+ "Model"
1120
+ ]
1121
+ },
1122
+ {
1123
+ "site": "cursor",
1124
+ "name": "new",
1125
+ "description": "Start a new Cursor chat or Composer session",
1126
+ "strategy": "ui",
1127
+ "browser": true,
1128
+ "args": [],
1129
+ "type": "ts",
1130
+ "modulePath": "cursor/new.js",
1131
+ "domain": "localhost",
1132
+ "columns": [
1133
+ "Status"
1134
+ ]
1135
+ },
1136
+ {
1137
+ "site": "cursor",
1138
+ "name": "read",
1139
+ "description": "Read the current Cursor chat/composer conversation history",
1140
+ "strategy": "ui",
1141
+ "browser": true,
1142
+ "args": [],
1143
+ "type": "ts",
1144
+ "modulePath": "cursor/read.js",
1145
+ "domain": "localhost",
1146
+ "columns": [
1147
+ "Role",
1148
+ "Text"
1149
+ ]
1150
+ },
1151
+ {
1152
+ "site": "cursor",
1153
+ "name": "send",
1154
+ "description": "Send a prompt directly into Cursor Composer/Chat",
1155
+ "strategy": "ui",
1156
+ "browser": true,
1157
+ "args": [
1158
+ {
1159
+ "name": "text",
1160
+ "type": "str",
1161
+ "required": true,
1162
+ "positional": true,
1163
+ "help": "Text to send into Cursor"
1164
+ }
1165
+ ],
1166
+ "type": "ts",
1167
+ "modulePath": "cursor/send.js",
1168
+ "domain": "localhost",
1169
+ "columns": [
1170
+ "Status",
1171
+ "InjectedText"
1172
+ ]
1173
+ },
1174
+ {
1175
+ "site": "cursor",
1176
+ "name": "status",
1177
+ "description": "Check active CDP connection to Cursor AI Editor",
1178
+ "strategy": "ui",
1179
+ "browser": true,
1180
+ "args": [],
1181
+ "type": "ts",
1182
+ "modulePath": "cursor/status.js",
1183
+ "domain": "localhost",
1184
+ "columns": [
1185
+ "Status",
1186
+ "Url",
1187
+ "Title"
1188
+ ]
1189
+ },
1024
1190
  {
1025
1191
  "site": "hackernews",
1026
1192
  "name": "top",
@@ -0,0 +1 @@
1
+ export declare const modelCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,55 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const modelCommand = cli({
3
+ site: 'codex',
4
+ name: 'model',
5
+ description: 'Get or switch the currently active AI model in Codex Desktop',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. gpt-4)' }
11
+ ],
12
+ columns: ['Status', 'Model'],
13
+ func: async (page, kwargs) => {
14
+ const desiredModel = kwargs.model_name;
15
+ if (!desiredModel) {
16
+ // Just read the current model. We traverse iframes/webviews if needed.
17
+ const currentModel = await page.evaluate(`
18
+ (function() {
19
+ // Look for any typical model switcher selectors in the DOM
20
+ let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
21
+
22
+ if (!m && document.querySelector('webview, iframe')) {
23
+ // Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[].
24
+ return 'Unknown (Likely inside a WebView, please focus the Chat tab)';
25
+ }
26
+ return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found';
27
+ })()
28
+ `);
29
+ return [
30
+ {
31
+ Status: 'Active',
32
+ Model: currentModel,
33
+ },
34
+ ];
35
+ }
36
+ else {
37
+ // Try to switch model (click dropdown, type/select model)
38
+ const success = await page.evaluate(`
39
+ (function(targetModel) {
40
+ const dropdown = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
41
+ if (!dropdown) return 'Dropdown not found';
42
+
43
+ dropdown.click();
44
+ return 'Dropdown clicked. Generic interaction initiated.';
45
+ })(${JSON.stringify(desiredModel)})
46
+ `);
47
+ return [
48
+ {
49
+ Status: success,
50
+ Model: desiredModel,
51
+ },
52
+ ];
53
+ }
54
+ },
55
+ });
@@ -0,0 +1 @@
1
+ export declare const composerCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,60 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const composerCommand = cli({
3
+ site: 'cursor',
4
+ name: 'composer',
5
+ description: 'Send a prompt directly into Cursor Composer (Cmd+I shortcut)',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Composer' }],
10
+ columns: ['Status', 'InjectedText'],
11
+ func: async (page, kwargs) => {
12
+ const textToInsert = kwargs.text;
13
+ const injected = await page.evaluate(`(async function() {
14
+ let isComposerVisible = document.querySelector('.composer-bar') !== null || document.querySelector('#composer-toolbar-section') !== null;
15
+ return isComposerVisible;
16
+ })()`);
17
+ if (!injected) {
18
+ await page.pressKey('Meta+I');
19
+ await page.wait(1.0);
20
+ }
21
+ else {
22
+ // Just focus it if it's open but unfocused (we can't easily know if it's focused without triggering something)
23
+ await page.pressKey('Meta+I');
24
+ await page.wait(0.2);
25
+ const isStillVisible = await page.evaluate('document.querySelector(".composer-bar") !== null');
26
+ if (!isStillVisible) {
27
+ await page.pressKey('Meta+I'); // Re-open
28
+ await page.wait(0.5);
29
+ }
30
+ }
31
+ const typed = await page.evaluate(`(function(text) {
32
+ let composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input');
33
+
34
+ if (!composer) {
35
+ composer = document.activeElement;
36
+ if (!composer || !composer.isContentEditable) {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ composer.focus();
42
+ document.execCommand('insertText', false, text);
43
+ return true;
44
+ })(${JSON.stringify(textToInsert)})`);
45
+ if (!typed) {
46
+ throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
47
+ }
48
+ // Submit the command. In Cursor Composer, Enter usually submits if it's not a multi-line edit.
49
+ // Sometimes Cmd+Enter is needed? We'll just submit standard Enter.
50
+ await page.wait(0.5);
51
+ await page.pressKey('Enter');
52
+ await page.wait(1);
53
+ return [
54
+ {
55
+ Status: 'Success (Composer)',
56
+ InjectedText: textToInsert,
57
+ },
58
+ ];
59
+ },
60
+ });
@@ -0,0 +1 @@
1
+ export declare const dumpCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,25 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+ export const dumpCommand = cli({
4
+ site: 'cursor',
5
+ name: 'dump',
6
+ description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI,
9
+ browser: true,
10
+ columns: ['action', 'files'],
11
+ func: async (page) => {
12
+ // Extract full HTML
13
+ const dom = await page.evaluate('document.body.innerHTML');
14
+ fs.writeFileSync('/tmp/cursor-dom.html', dom);
15
+ // Get accessibility snapshot
16
+ const snap = await page.snapshot({ interactive: false });
17
+ fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2));
18
+ return [
19
+ {
20
+ action: 'Dom extraction finished',
21
+ files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json',
22
+ },
23
+ ];
24
+ },
25
+ });
@@ -0,0 +1 @@
1
+ export declare const extractCodeCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,35 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const extractCodeCommand = cli({
3
+ site: 'cursor',
4
+ name: 'extract-code',
5
+ description: 'Extract multi-line code blocks from the current Cursor conversation',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [],
10
+ columns: ['Code'],
11
+ func: async (page) => {
12
+ const blocks = await page.evaluate(`
13
+ (function() {
14
+ // Find standard pre/code blocks
15
+ let elements = Array.from(document.querySelectorAll('pre code, .markdown-root pre'));
16
+
17
+ // Fallback to Monaco editor content inside the UI
18
+ if (elements.length === 0) {
19
+ elements = Array.from(document.querySelectorAll('.monaco-editor'));
20
+ }
21
+
22
+ // Generic fallback to any code tag that spans multiple lines
23
+ if (elements.length === 0) {
24
+ elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
25
+ }
26
+
27
+ return elements.map(el => el.innerText || el.textContent || '').filter(text => text.trim().length > 0);
28
+ })()
29
+ `);
30
+ if (!blocks || blocks.length === 0) {
31
+ return [{ Code: 'No code blocks found in Cursor.' }];
32
+ }
33
+ return blocks.map((code) => ({ Code: code }));
34
+ },
35
+ });
@@ -0,0 +1 @@
1
+ export declare const modelCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,53 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const modelCommand = cli({
3
+ site: 'cursor',
4
+ name: 'model',
5
+ description: 'Get or switch the currently active AI model in Cursor',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. claude-3.5-sonnet)' }
11
+ ],
12
+ columns: ['Status', 'Model'],
13
+ func: async (page, kwargs) => {
14
+ const desiredModel = kwargs.model_name;
15
+ if (!desiredModel) {
16
+ // Just read the current model
17
+ const currentModel = await page.evaluate(`
18
+ (function() {
19
+ const m = document.querySelector('.composer-unified-dropdown-model span, [class*="unifiedmodeldropdown"] span');
20
+ return m ? m.textContent.trim() : 'Unknown or Not Found';
21
+ })()
22
+ `);
23
+ return [
24
+ {
25
+ Status: 'Active',
26
+ Model: currentModel,
27
+ },
28
+ ];
29
+ }
30
+ else {
31
+ // Try to switch model (click dropdown, type/select model)
32
+ const success = await page.evaluate(`
33
+ (function(targetModel) {
34
+ const dropdown = document.querySelector('.composer-unified-dropdown-model, [class*="unifiedmodeldropdown"]');
35
+ if (!dropdown) return 'Dropdown not found';
36
+
37
+ dropdown.click();
38
+ // After clicking, the DOM usually spawns a popup list.
39
+ // Because it's hard to predict exactly how the list renders,
40
+ // a simple scriptable approach is just to click it, and hope we can select it via UI.
41
+ // In many React apps, clicking it opens a menu, and clicking the item works.
42
+ return 'Dropdown opened. Automated switching is not fully generic. Please implement precise list navigation depending on DOM.';
43
+ })(${JSON.stringify(desiredModel)})
44
+ `);
45
+ return [
46
+ {
47
+ Status: success,
48
+ Model: desiredModel,
49
+ },
50
+ ];
51
+ }
52
+ },
53
+ });
@@ -0,0 +1 @@
1
+ export declare const newCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const newCommand = cli({
3
+ site: 'cursor',
4
+ name: 'new',
5
+ description: 'Start a new Cursor chat or Composer session',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ columns: ['Status'],
10
+ func: async (page) => {
11
+ const success = await page.evaluate(`
12
+ (function() {
13
+ const newChatButton = document.querySelector('[aria-label="New Chat"], [aria-label="New Chat (⌘N)"], .agent-sidebar-new-agent-button');
14
+ if (newChatButton) {
15
+ newChatButton.click();
16
+ return true;
17
+ }
18
+ return false;
19
+ })()
20
+ `);
21
+ if (!success) {
22
+ throw new Error('Could not find New Chat button in Cursor DOM.');
23
+ }
24
+ await page.wait(1);
25
+ return [{ Status: 'Success' }];
26
+ },
27
+ });
@@ -0,0 +1 @@
1
+ export declare const readCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,43 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const readCommand = cli({
3
+ site: 'cursor',
4
+ name: 'read',
5
+ description: 'Read the current Cursor chat/composer conversation history',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ columns: ['Role', 'Text'],
10
+ func: async (page) => {
11
+ const history = await page.evaluate(`
12
+ (function() {
13
+ const messages = Array.from(document.querySelectorAll('[data-message-role]'));
14
+
15
+ if (messages.length === 0) {
16
+ return [];
17
+ }
18
+
19
+ return messages.map(msg => {
20
+ const role = msg.getAttribute('data-message-role');
21
+ let text = '';
22
+
23
+ // Try to get structured markdown root for AI, or lexical text for human
24
+ const markdownRoot = msg.querySelector('.markdown-root');
25
+ if (markdownRoot) {
26
+ text = markdownRoot.innerText || markdownRoot.textContent;
27
+ } else {
28
+ text = msg.innerText || msg.textContent;
29
+ }
30
+
31
+ return {
32
+ Role: role === 'human' ? 'User' : 'Assistant',
33
+ Text: text.trim()
34
+ };
35
+ });
36
+ })()
37
+ `);
38
+ if (!history || history.length === 0) {
39
+ throw new Error('No conversation history found in Cursor.');
40
+ }
41
+ return history;
42
+ },
43
+ });
@@ -0,0 +1 @@
1
+ export declare const sendCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,39 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const sendCommand = cli({
3
+ site: 'cursor',
4
+ name: 'send',
5
+ description: 'Send a prompt directly into Cursor Composer/Chat',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Cursor' }],
10
+ columns: ['Status', 'InjectedText'],
11
+ func: async (page, kwargs) => {
12
+ const textToInsert = kwargs.text;
13
+ const injected = await page.evaluate(`(function(text) {
14
+ // Find the Lexical editor input for Composer or Chat
15
+ let composer = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]');
16
+
17
+ if (!composer) {
18
+ return false;
19
+ }
20
+
21
+ composer.focus();
22
+ document.execCommand('insertText', false, text);
23
+ return true;
24
+ })(${JSON.stringify(textToInsert)})`);
25
+ if (!injected) {
26
+ throw new Error('Could not find Cursor Composer input element.');
27
+ }
28
+ // Submit the command. In Cursor, Enter usually submits the chat.
29
+ await page.wait(0.5);
30
+ await page.pressKey('Enter');
31
+ await page.wait(1);
32
+ return [
33
+ {
34
+ Status: 'Success',
35
+ InjectedText: textToInsert,
36
+ },
37
+ ];
38
+ },
39
+ });
@@ -0,0 +1 @@
1
+ export declare const statusCommand: import("../../registry.js").CliCommand;
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ export const statusCommand = cli({
3
+ site: 'cursor',
4
+ name: 'status',
5
+ description: 'Check active CDP connection to Cursor AI Editor',
6
+ domain: 'localhost',
7
+ strategy: Strategy.UI, // Interactive UI manipulation
8
+ browser: true,
9
+ columns: ['Status', 'Url', 'Title'],
10
+ func: async (page) => {
11
+ const url = await page.evaluate('window.location.href');
12
+ const title = await page.evaluate('document.title');
13
+ return [
14
+ {
15
+ Status: 'Connected',
16
+ Url: url,
17
+ Title: title,
18
+ },
19
+ ];
20
+ },
21
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,59 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const modelCommand = cli({
5
+ site: 'codex',
6
+ name: 'model',
7
+ description: 'Get or switch the currently active AI model in Codex Desktop',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. gpt-4)' }
13
+ ],
14
+ columns: ['Status', 'Model'],
15
+ func: async (page: IPage, kwargs: any) => {
16
+ const desiredModel = kwargs.model_name as string | undefined;
17
+
18
+ if (!desiredModel) {
19
+ // Just read the current model. We traverse iframes/webviews if needed.
20
+ const currentModel = await page.evaluate(`
21
+ (function() {
22
+ // Look for any typical model switcher selectors in the DOM
23
+ let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
24
+
25
+ if (!m && document.querySelector('webview, iframe')) {
26
+ // Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[].
27
+ return 'Unknown (Likely inside a WebView, please focus the Chat tab)';
28
+ }
29
+ return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found';
30
+ })()
31
+ `);
32
+
33
+ return [
34
+ {
35
+ Status: 'Active',
36
+ Model: currentModel,
37
+ },
38
+ ];
39
+ } else {
40
+ // Try to switch model (click dropdown, type/select model)
41
+ const success = await page.evaluate(`
42
+ (function(targetModel) {
43
+ const dropdown = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]');
44
+ if (!dropdown) return 'Dropdown not found';
45
+
46
+ dropdown.click();
47
+ return 'Dropdown clicked. Generic interaction initiated.';
48
+ })(${JSON.stringify(desiredModel)})
49
+ `);
50
+
51
+ return [
52
+ {
53
+ Status: success,
54
+ Model: desiredModel,
55
+ },
56
+ ];
57
+ }
58
+ },
59
+ });
@@ -0,0 +1,71 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const composerCommand = cli({
5
+ site: 'cursor',
6
+ name: 'composer',
7
+ description: 'Send a prompt directly into Cursor Composer (Cmd+I shortcut)',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Composer' }],
12
+ columns: ['Status', 'InjectedText'],
13
+ func: async (page: IPage, kwargs: any) => {
14
+ const textToInsert = kwargs.text as string;
15
+
16
+ const injected = await page.evaluate(
17
+ `(async function() {
18
+ let isComposerVisible = document.querySelector('.composer-bar') !== null || document.querySelector('#composer-toolbar-section') !== null;
19
+ return isComposerVisible;
20
+ })()`
21
+ );
22
+
23
+ if (!injected) {
24
+ await page.pressKey('Meta+I');
25
+ await page.wait(1.0);
26
+ } else {
27
+ // Just focus it if it's open but unfocused (we can't easily know if it's focused without triggering something)
28
+ await page.pressKey('Meta+I');
29
+ await page.wait(0.2);
30
+ const isStillVisible = await page.evaluate('document.querySelector(".composer-bar") !== null');
31
+ if (!isStillVisible) {
32
+ await page.pressKey('Meta+I'); // Re-open
33
+ await page.wait(0.5);
34
+ }
35
+ }
36
+
37
+ const typed = await page.evaluate(
38
+ `(function(text) {
39
+ let composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input');
40
+
41
+ if (!composer) {
42
+ composer = document.activeElement;
43
+ if (!composer || !composer.isContentEditable) {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ composer.focus();
49
+ document.execCommand('insertText', false, text);
50
+ return true;
51
+ })(${JSON.stringify(textToInsert)})`
52
+ );
53
+
54
+ if (!typed) {
55
+ throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.');
56
+ }
57
+
58
+ // Submit the command. In Cursor Composer, Enter usually submits if it's not a multi-line edit.
59
+ // Sometimes Cmd+Enter is needed? We'll just submit standard Enter.
60
+ await page.wait(0.5);
61
+ await page.pressKey('Enter');
62
+ await page.wait(1);
63
+
64
+ return [
65
+ {
66
+ Status: 'Success (Composer)',
67
+ InjectedText: textToInsert,
68
+ },
69
+ ];
70
+ },
71
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import * as fs from 'fs';
3
+
4
+ export const dumpCommand = cli({
5
+ site: 'cursor',
6
+ name: 'dump',
7
+ description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ columns: ['action', 'files'],
12
+ func: async (page) => {
13
+ // Extract full HTML
14
+ const dom = await page.evaluate('document.body.innerHTML');
15
+ fs.writeFileSync('/tmp/cursor-dom.html', dom);
16
+
17
+ // Get accessibility snapshot
18
+ const snap = await page.snapshot({ interactive: false });
19
+ fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2));
20
+
21
+ return [
22
+ {
23
+ action: 'Dom extraction finished',
24
+ files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json',
25
+ },
26
+ ];
27
+ },
28
+ });
@@ -0,0 +1,39 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const extractCodeCommand = cli({
5
+ site: 'cursor',
6
+ name: 'extract-code',
7
+ description: 'Extract multi-line code blocks from the current Cursor conversation',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [],
12
+ columns: ['Code'],
13
+ func: async (page: IPage) => {
14
+ const blocks = await page.evaluate(`
15
+ (function() {
16
+ // Find standard pre/code blocks
17
+ let elements = Array.from(document.querySelectorAll('pre code, .markdown-root pre'));
18
+
19
+ // Fallback to Monaco editor content inside the UI
20
+ if (elements.length === 0) {
21
+ elements = Array.from(document.querySelectorAll('.monaco-editor'));
22
+ }
23
+
24
+ // Generic fallback to any code tag that spans multiple lines
25
+ if (elements.length === 0) {
26
+ elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n'));
27
+ }
28
+
29
+ return elements.map(el => el.innerText || el.textContent || '').filter(text => text.trim().length > 0);
30
+ })()
31
+ `);
32
+
33
+ if (!blocks || blocks.length === 0) {
34
+ return [{ Code: 'No code blocks found in Cursor.' }];
35
+ }
36
+
37
+ return blocks.map((code: string) => ({ Code: code }));
38
+ },
39
+ });
@@ -0,0 +1,57 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const modelCommand = cli({
5
+ site: 'cursor',
6
+ name: 'model',
7
+ description: 'Get or switch the currently active AI model in Cursor',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [
12
+ { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. claude-3.5-sonnet)' }
13
+ ],
14
+ columns: ['Status', 'Model'],
15
+ func: async (page: IPage, kwargs: any) => {
16
+ const desiredModel = kwargs.model_name as string | undefined;
17
+
18
+ if (!desiredModel) {
19
+ // Just read the current model
20
+ const currentModel = await page.evaluate(`
21
+ (function() {
22
+ const m = document.querySelector('.composer-unified-dropdown-model span, [class*="unifiedmodeldropdown"] span');
23
+ return m ? m.textContent.trim() : 'Unknown or Not Found';
24
+ })()
25
+ `);
26
+
27
+ return [
28
+ {
29
+ Status: 'Active',
30
+ Model: currentModel,
31
+ },
32
+ ];
33
+ } else {
34
+ // Try to switch model (click dropdown, type/select model)
35
+ const success = await page.evaluate(`
36
+ (function(targetModel) {
37
+ const dropdown = document.querySelector('.composer-unified-dropdown-model, [class*="unifiedmodeldropdown"]');
38
+ if (!dropdown) return 'Dropdown not found';
39
+
40
+ dropdown.click();
41
+ // After clicking, the DOM usually spawns a popup list.
42
+ // Because it's hard to predict exactly how the list renders,
43
+ // a simple scriptable approach is just to click it, and hope we can select it via UI.
44
+ // In many React apps, clicking it opens a menu, and clicking the item works.
45
+ return 'Dropdown opened. Automated switching is not fully generic. Please implement precise list navigation depending on DOM.';
46
+ })(${JSON.stringify(desiredModel)})
47
+ `);
48
+
49
+ return [
50
+ {
51
+ Status: success,
52
+ Model: desiredModel,
53
+ },
54
+ ];
55
+ }
56
+ },
57
+ });
@@ -0,0 +1,32 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const newCommand = cli({
5
+ site: 'cursor',
6
+ name: 'new',
7
+ description: 'Start a new Cursor chat or Composer session',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ columns: ['Status'],
12
+ func: async (page: IPage) => {
13
+ const success = await page.evaluate(`
14
+ (function() {
15
+ const newChatButton = document.querySelector('[aria-label="New Chat"], [aria-label="New Chat (⌘N)"], .agent-sidebar-new-agent-button');
16
+ if (newChatButton) {
17
+ newChatButton.click();
18
+ return true;
19
+ }
20
+ return false;
21
+ })()
22
+ `);
23
+
24
+ if (!success) {
25
+ throw new Error('Could not find New Chat button in Cursor DOM.');
26
+ }
27
+
28
+ await page.wait(1);
29
+
30
+ return [{ Status: 'Success' }];
31
+ },
32
+ });
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const readCommand = cli({
5
+ site: 'cursor',
6
+ name: 'read',
7
+ description: 'Read the current Cursor chat/composer conversation history',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ columns: ['Role', 'Text'],
12
+ func: async (page: IPage) => {
13
+ const history = await page.evaluate(`
14
+ (function() {
15
+ const messages = Array.from(document.querySelectorAll('[data-message-role]'));
16
+
17
+ if (messages.length === 0) {
18
+ return [];
19
+ }
20
+
21
+ return messages.map(msg => {
22
+ const role = msg.getAttribute('data-message-role');
23
+ let text = '';
24
+
25
+ // Try to get structured markdown root for AI, or lexical text for human
26
+ const markdownRoot = msg.querySelector('.markdown-root');
27
+ if (markdownRoot) {
28
+ text = markdownRoot.innerText || markdownRoot.textContent;
29
+ } else {
30
+ text = msg.innerText || msg.textContent;
31
+ }
32
+
33
+ return {
34
+ Role: role === 'human' ? 'User' : 'Assistant',
35
+ Text: text.trim()
36
+ };
37
+ });
38
+ })()
39
+ `);
40
+
41
+ if (!history || history.length === 0) {
42
+ throw new Error('No conversation history found in Cursor.');
43
+ }
44
+
45
+ return history;
46
+ },
47
+ });
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ export const sendCommand = cli({
5
+ site: 'cursor',
6
+ name: 'send',
7
+ description: 'Send a prompt directly into Cursor Composer/Chat',
8
+ domain: 'localhost',
9
+ strategy: Strategy.UI,
10
+ browser: true,
11
+ args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Cursor' }],
12
+ columns: ['Status', 'InjectedText'],
13
+ func: async (page: IPage, kwargs: any) => {
14
+ const textToInsert = kwargs.text as string;
15
+
16
+ const injected = await page.evaluate(
17
+ `(function(text) {
18
+ // Find the Lexical editor input for Composer or Chat
19
+ let composer = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]');
20
+
21
+ if (!composer) {
22
+ return false;
23
+ }
24
+
25
+ composer.focus();
26
+ document.execCommand('insertText', false, text);
27
+ return true;
28
+ })(${JSON.stringify(textToInsert)})`
29
+ );
30
+
31
+ if (!injected) {
32
+ throw new Error('Could not find Cursor Composer input element.');
33
+ }
34
+
35
+ // Submit the command. In Cursor, Enter usually submits the chat.
36
+ await page.wait(0.5);
37
+ await page.pressKey('Enter');
38
+ await page.wait(1);
39
+
40
+ return [
41
+ {
42
+ Status: 'Success',
43
+ InjectedText: textToInsert,
44
+ },
45
+ ];
46
+ },
47
+ });
@@ -0,0 +1,23 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+
3
+ export const statusCommand = cli({
4
+ site: 'cursor',
5
+ name: 'status',
6
+ description: 'Check active CDP connection to Cursor AI Editor',
7
+ domain: 'localhost',
8
+ strategy: Strategy.UI, // Interactive UI manipulation
9
+ browser: true,
10
+ columns: ['Status', 'Url', 'Title'],
11
+ func: async (page) => {
12
+ const url = await page.evaluate('window.location.href');
13
+ const title = await page.evaluate('document.title');
14
+
15
+ return [
16
+ {
17
+ Status: 'Connected',
18
+ Url: url,
19
+ Title: title,
20
+ },
21
+ ];
22
+ },
23
+ });