From b32fe1cbc3b63f0be46ae8b88f45fbc4437aa06b Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 18 Mar 2026 17:41:41 +0800 Subject: [PATCH] feat: add advanced cursor and codex capabilities --- README.md | 9 +++++ README.zh-CN.md | 3 ++ package-lock.json | 4 +- package.json | 2 +- src/clis/codex/model.ts | 59 +++++++++++++++++++++++++++ src/clis/cursor/composer.ts | 71 +++++++++++++++++++++++++++++++++ src/clis/cursor/dump.ts | 28 +++++++++++++ src/clis/cursor/extract-code.ts | 39 ++++++++++++++++++ src/clis/cursor/model.ts | 57 ++++++++++++++++++++++++++ src/clis/cursor/new.ts | 32 +++++++++++++++ src/clis/cursor/read.ts | 47 ++++++++++++++++++++++ src/clis/cursor/send.ts | 47 ++++++++++++++++++++++ src/clis/cursor/status.ts | 23 +++++++++++ 13 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 src/clis/codex/model.ts create mode 100644 src/clis/cursor/composer.ts create mode 100644 src/clis/cursor/dump.ts create mode 100644 src/clis/cursor/extract-code.ts create mode 100644 src/clis/cursor/model.ts create mode 100644 src/clis/cursor/new.ts create mode 100644 src/clis/cursor/read.ts create mode 100644 src/clis/cursor/send.ts create mode 100644 src/clis/cursor/status.ts diff --git a/README.md b/README.md index 3ed6fff..5dbb625 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,16 @@ npm install -g @jackwener/opencli@latest |------|----------|:-----:|------| | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 Browser | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 Browser | +| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ Desktop | +| **bbc** | `news` | 1 | 🌐 Public | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser | +| **boss** | `search` `detail` | 2 | 🔐 Browser | +| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ Desktop | +| **coupang** | `search` `add-to-cart` | 2 | 🔐 Browser | +| **ctrip** | `search` | 1 | 🔐 Browser | +| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ Desktop | +| **github** | `search` | 1 | 🌐 Public | +| **hackernews** | `top` | 1 | 🌐 Public | | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2514c2c..2efd805 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -150,6 +150,9 @@ npm install -g @jackwener/opencli@latest |------|------|:----:|------| | **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `thread` `following` `followers` `notifications` `post` `reply` `delete` `like` `article` `follow` `unfollow` `bookmark` `unbookmark` | 18 | 🔐 浏览器 | | **reddit** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 15 | 🔐 浏览器 | +| **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ 桌面端 | +| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ 桌面端 | +| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` | 7 | 🖥️ 桌面端 | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 浏览器 | | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 浏览器 | diff --git a/package-lock.json b/package-lock.json index f260f0b..7a7a170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jackwener/opencli", - "version": "0.9.2", + "version": "0.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jackwener/opencli", - "version": "0.9.2", + "version": "0.9.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e644ded..a6871e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jackwener/opencli", - "version": "0.9.2", + "version": "0.9.3", "publishConfig": { "access": "public" }, diff --git a/src/clis/codex/model.ts b/src/clis/codex/model.ts new file mode 100644 index 0000000..029b317 --- /dev/null +++ b/src/clis/codex/model.ts @@ -0,0 +1,59 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const modelCommand = cli({ + site: 'codex', + name: 'model', + description: 'Get or switch the currently active AI model in Codex Desktop', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. gpt-4)' } + ], + columns: ['Status', 'Model'], + func: async (page: IPage, kwargs: any) => { + const desiredModel = kwargs.model_name as string | undefined; + + if (!desiredModel) { + // Just read the current model. We traverse iframes/webviews if needed. + const currentModel = await page.evaluate(` + (function() { + // Look for any typical model switcher selectors in the DOM + let m = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]'); + + if (!m && document.querySelector('webview, iframe')) { + // Not directly in main DOM, might be in a webview, but Playwright evaluate doesn't cross origin boundaries easily without frames[]. + return 'Unknown (Likely inside a WebView, please focus the Chat tab)'; + } + return m ? (m.textContent || m.getAttribute('title') || m.getAttribute('aria-label')).trim() : 'Unknown or Not Found'; + })() + `); + + return [ + { + Status: 'Active', + Model: currentModel, + }, + ]; + } else { + // Try to switch model (click dropdown, type/select model) + const success = await page.evaluate(` + (function(targetModel) { + const dropdown = document.querySelector('[title*="Model"], [aria-label*="Model"], .model-selector, [class*="ModelPicker"]'); + if (!dropdown) return 'Dropdown not found'; + + dropdown.click(); + return 'Dropdown clicked. Generic interaction initiated.'; + })(${JSON.stringify(desiredModel)}) + `); + + return [ + { + Status: success, + Model: desiredModel, + }, + ]; + } + }, +}); diff --git a/src/clis/cursor/composer.ts b/src/clis/cursor/composer.ts new file mode 100644 index 0000000..ab903df --- /dev/null +++ b/src/clis/cursor/composer.ts @@ -0,0 +1,71 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const composerCommand = cli({ + site: 'cursor', + name: 'composer', + description: 'Send a prompt directly into Cursor Composer (Cmd+I shortcut)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Composer' }], + columns: ['Status', 'InjectedText'], + func: async (page: IPage, kwargs: any) => { + const textToInsert = kwargs.text as string; + + const injected = await page.evaluate( + `(async function() { + let isComposerVisible = document.querySelector('.composer-bar') !== null || document.querySelector('#composer-toolbar-section') !== null; + return isComposerVisible; + })()` + ); + + if (!injected) { + await page.pressKey('Meta+I'); + await page.wait(1.0); + } else { + // Just focus it if it's open but unfocused (we can't easily know if it's focused without triggering something) + await page.pressKey('Meta+I'); + await page.wait(0.2); + const isStillVisible = await page.evaluate('document.querySelector(".composer-bar") !== null'); + if (!isStillVisible) { + await page.pressKey('Meta+I'); // Re-open + await page.wait(0.5); + } + } + + const typed = await page.evaluate( + `(function(text) { + let composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input'); + + if (!composer) { + composer = document.activeElement; + if (!composer || !composer.isContentEditable) { + return false; + } + } + + composer.focus(); + document.execCommand('insertText', false, text); + return true; + })(${JSON.stringify(textToInsert)})` + ); + + if (!typed) { + throw new Error('Could not find Cursor Composer input element after pressing Cmd+I.'); + } + + // Submit the command. In Cursor Composer, Enter usually submits if it's not a multi-line edit. + // Sometimes Cmd+Enter is needed? We'll just submit standard Enter. + await page.wait(0.5); + await page.pressKey('Enter'); + await page.wait(1); + + return [ + { + Status: 'Success (Composer)', + InjectedText: textToInsert, + }, + ]; + }, +}); diff --git a/src/clis/cursor/dump.ts b/src/clis/cursor/dump.ts new file mode 100644 index 0000000..104f6ac --- /dev/null +++ b/src/clis/cursor/dump.ts @@ -0,0 +1,28 @@ +import { cli, Strategy } from '../../registry.js'; +import * as fs from 'fs'; + +export const dumpCommand = cli({ + site: 'cursor', + name: 'dump', + description: 'Dump the DOM and Accessibility tree of Cursor for reverse-engineering', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + columns: ['action', 'files'], + func: async (page) => { + // Extract full HTML + const dom = await page.evaluate('document.body.innerHTML'); + fs.writeFileSync('/tmp/cursor-dom.html', dom); + + // Get accessibility snapshot + const snap = await page.snapshot({ interactive: false }); + fs.writeFileSync('/tmp/cursor-snapshot.json', JSON.stringify(snap, null, 2)); + + return [ + { + action: 'Dom extraction finished', + files: '/tmp/cursor-dom.html, /tmp/cursor-snapshot.json', + }, + ]; + }, +}); diff --git a/src/clis/cursor/extract-code.ts b/src/clis/cursor/extract-code.ts new file mode 100644 index 0000000..cc8a424 --- /dev/null +++ b/src/clis/cursor/extract-code.ts @@ -0,0 +1,39 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const extractCodeCommand = cli({ + site: 'cursor', + name: 'extract-code', + description: 'Extract multi-line code blocks from the current Cursor conversation', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Code'], + func: async (page: IPage) => { + const blocks = await page.evaluate(` + (function() { + // Find standard pre/code blocks + let elements = Array.from(document.querySelectorAll('pre code, .markdown-root pre')); + + // Fallback to Monaco editor content inside the UI + if (elements.length === 0) { + elements = Array.from(document.querySelectorAll('.monaco-editor')); + } + + // Generic fallback to any code tag that spans multiple lines + if (elements.length === 0) { + elements = Array.from(document.querySelectorAll('code')).filter(c => c.innerText.includes('\\n')); + } + + return elements.map(el => el.innerText || el.textContent || '').filter(text => text.trim().length > 0); + })() + `); + + if (!blocks || blocks.length === 0) { + return [{ Code: 'No code blocks found in Cursor.' }]; + } + + return blocks.map((code: string) => ({ Code: code })); + }, +}); diff --git a/src/clis/cursor/model.ts b/src/clis/cursor/model.ts new file mode 100644 index 0000000..4bbc77d --- /dev/null +++ b/src/clis/cursor/model.ts @@ -0,0 +1,57 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const modelCommand = cli({ + site: 'cursor', + name: 'model', + description: 'Get or switch the currently active AI model in Cursor', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'model_name', required: false, positional: true, help: 'The ID of the model to switch to (e.g. claude-3.5-sonnet)' } + ], + columns: ['Status', 'Model'], + func: async (page: IPage, kwargs: any) => { + const desiredModel = kwargs.model_name as string | undefined; + + if (!desiredModel) { + // Just read the current model + const currentModel = await page.evaluate(` + (function() { + const m = document.querySelector('.composer-unified-dropdown-model span, [class*="unifiedmodeldropdown"] span'); + return m ? m.textContent.trim() : 'Unknown or Not Found'; + })() + `); + + return [ + { + Status: 'Active', + Model: currentModel, + }, + ]; + } else { + // Try to switch model (click dropdown, type/select model) + const success = await page.evaluate(` + (function(targetModel) { + const dropdown = document.querySelector('.composer-unified-dropdown-model, [class*="unifiedmodeldropdown"]'); + if (!dropdown) return 'Dropdown not found'; + + dropdown.click(); + // After clicking, the DOM usually spawns a popup list. + // Because it's hard to predict exactly how the list renders, + // a simple scriptable approach is just to click it, and hope we can select it via UI. + // In many React apps, clicking it opens a menu, and clicking the item works. + return 'Dropdown opened. Automated switching is not fully generic. Please implement precise list navigation depending on DOM.'; + })(${JSON.stringify(desiredModel)}) + `); + + return [ + { + Status: success, + Model: desiredModel, + }, + ]; + } + }, +}); diff --git a/src/clis/cursor/new.ts b/src/clis/cursor/new.ts new file mode 100644 index 0000000..ea89e42 --- /dev/null +++ b/src/clis/cursor/new.ts @@ -0,0 +1,32 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const newCommand = cli({ + site: 'cursor', + name: 'new', + description: 'Start a new Cursor chat or Composer session', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + columns: ['Status'], + func: async (page: IPage) => { + const success = await page.evaluate(` + (function() { + const newChatButton = document.querySelector('[aria-label="New Chat"], [aria-label="New Chat (⌘N)"], .agent-sidebar-new-agent-button'); + if (newChatButton) { + newChatButton.click(); + return true; + } + return false; + })() + `); + + if (!success) { + throw new Error('Could not find New Chat button in Cursor DOM.'); + } + + await page.wait(1); + + return [{ Status: 'Success' }]; + }, +}); diff --git a/src/clis/cursor/read.ts b/src/clis/cursor/read.ts new file mode 100644 index 0000000..b514eec --- /dev/null +++ b/src/clis/cursor/read.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const readCommand = cli({ + site: 'cursor', + name: 'read', + description: 'Read the current Cursor chat/composer conversation history', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + columns: ['Role', 'Text'], + func: async (page: IPage) => { + const history = await page.evaluate(` + (function() { + const messages = Array.from(document.querySelectorAll('[data-message-role]')); + + if (messages.length === 0) { + return []; + } + + return messages.map(msg => { + const role = msg.getAttribute('data-message-role'); + let text = ''; + + // Try to get structured markdown root for AI, or lexical text for human + const markdownRoot = msg.querySelector('.markdown-root'); + if (markdownRoot) { + text = markdownRoot.innerText || markdownRoot.textContent; + } else { + text = msg.innerText || msg.textContent; + } + + return { + Role: role === 'human' ? 'User' : 'Assistant', + Text: text.trim() + }; + }); + })() + `); + + if (!history || history.length === 0) { + throw new Error('No conversation history found in Cursor.'); + } + + return history; + }, +}); diff --git a/src/clis/cursor/send.ts b/src/clis/cursor/send.ts new file mode 100644 index 0000000..5a5a5ea --- /dev/null +++ b/src/clis/cursor/send.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const sendCommand = cli({ + site: 'cursor', + name: 'send', + description: 'Send a prompt directly into Cursor Composer/Chat', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [{ name: 'text', required: true, positional: true, help: 'Text to send into Cursor' }], + columns: ['Status', 'InjectedText'], + func: async (page: IPage, kwargs: any) => { + const textToInsert = kwargs.text as string; + + const injected = await page.evaluate( + `(function(text) { + // Find the Lexical editor input for Composer or Chat + let composer = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]'); + + if (!composer) { + return false; + } + + composer.focus(); + document.execCommand('insertText', false, text); + return true; + })(${JSON.stringify(textToInsert)})` + ); + + if (!injected) { + throw new Error('Could not find Cursor Composer input element.'); + } + + // Submit the command. In Cursor, Enter usually submits the chat. + await page.wait(0.5); + await page.pressKey('Enter'); + await page.wait(1); + + return [ + { + Status: 'Success', + InjectedText: textToInsert, + }, + ]; + }, +}); diff --git a/src/clis/cursor/status.ts b/src/clis/cursor/status.ts new file mode 100644 index 0000000..cb39fdf --- /dev/null +++ b/src/clis/cursor/status.ts @@ -0,0 +1,23 @@ +import { cli, Strategy } from '../../registry.js'; + +export const statusCommand = cli({ + site: 'cursor', + name: 'status', + description: 'Check active CDP connection to Cursor AI Editor', + domain: 'localhost', + strategy: Strategy.UI, // Interactive UI manipulation + browser: true, + columns: ['Status', 'Url', 'Title'], + func: async (page) => { + const url = await page.evaluate('window.location.href'); + const title = await page.evaluate('document.title'); + + return [ + { + Status: 'Connected', + Url: url, + Title: title, + }, + ]; + }, +});