diff --git a/README.md b/README.md index 4dee590..f1ee252 100644 --- a/README.md +++ b/README.md @@ -143,20 +143,20 @@ npm install -g @jackwener/opencli@latest ## Built-in Commands -**26 sites · 119 commands** — run `opencli list` for the live registry. +**26 sites · 128 commands** — run `opencli list` for the live registry. | Site | Commands | Count | Mode | |------|----------|:-----:|------| | **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 | | **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` | 11 | 🔐 Browser | -| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` | 8 | 🖥️ Desktop | -| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ Desktop | +| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 12 | 🖥️ Desktop | +| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 10 | 🖥️ Desktop | | **v2ex** | `hot` `latest` `topic` `daily` `me` `notifications` | 6 | 🌐 / 🔐 | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 6 | 🔐 Browser | | **antigravity** | `status` `send` `read` `new` `evaluate` | 5 | 🖥️ Desktop | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 5 | 🔐 Browser | -| **chatgpt** | `status` `new` `send` `read` | 4 | 🖥️ Desktop | +| **chatgpt** | `status` `new` `send` `read` `ask` | 5 | 🖥️ Desktop | | **xiaoyuzhou** | `podcast` `podcast-episodes` `episode` | 3 | 🌐 Public | | **youtube** | `search` `video` `transcript` | 3 | 🔐 Browser | | **zhihu** | `hot` `search` `question` | 3 | 🔐 Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a8741f..c63d7de 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,16 +144,16 @@ npm install -g @jackwener/opencli@latest ## 内置命令 -**26 个站点 · 119 命令** — 运行 `opencli list` 查看完整注册表。 +**26 个站点 · 128 命令** — 运行 `opencli list` 查看完整注册表。 | 站点 | 命令 | 数量 | 模式 | |------|------|:----:|------| | **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 | 🖥️ 桌面端 | -| **chatgpt** | `status` `new` `send` `read` | 4 | 🖥️ 桌面端 | -| **codex** | `status` `send` `read` `new` `extract-diff` `model` | 6 | 🖥️ 桌面端 | -| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` | 8 | 🖥️ 桌面端 | +| **chatgpt** | `status` `new` `send` `read` `ask` | 5 | 🖥️ 桌面端 | +| **codex** | `status` `send` `read` `new` `extract-diff` `model` `ask` `screenshot` `history` `export` | 10 | 🖥️ 桌面端 | +| **cursor** | `status` `send` `read` `new` `dump` `composer` `model` `extract-code` `ask` `screenshot` `history` `export` | 12 | 🖥️ 桌面端 | | **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.json b/package.json index a639302..a8ddeae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jackwener/opencli", - "version": "0.9.5", + "version": "0.9.6", "publishConfig": { "access": "public" }, diff --git a/src/clis/antigravity/model.ts b/src/clis/antigravity/model.ts index ea378ac..e74a38e 100644 --- a/src/clis/antigravity/model.ts +++ b/src/clis/antigravity/model.ts @@ -10,7 +10,7 @@ export const modelCommand = cli({ args: [ { name: 'name', help: 'Target model name (e.g. claude, gemini, o1)', required: true, positional: true } ], - columns: ['status'], + columns: ['Status'], func: async (page, kwargs) => { const targetName = kwargs.name.toLowerCase(); @@ -42,6 +42,6 @@ export const modelCommand = cli({ `); await page.wait(0.5); - return [{ status: `Model switched to: ${kwargs.name}` }]; + return [{ Status: `Model switched to: ${kwargs.name}` }]; }, }); diff --git a/src/clis/antigravity/send.ts b/src/clis/antigravity/send.ts index 992c860..3563ffa 100644 --- a/src/clis/antigravity/send.ts +++ b/src/clis/antigravity/send.ts @@ -10,7 +10,7 @@ export const sendCommand = cli({ args: [ { name: 'message', help: 'The message text to send', required: true, positional: true } ], - columns: ['status', 'message'], + columns: ['Status', 'Message'], func: async (page, kwargs) => { const text = kwargs.message; @@ -35,6 +35,6 @@ export const sendCommand = cli({ // Press Enter to submit the message await page.pressKey('Enter'); - return [{ status: 'Sent successfully', message: text }]; + return [{ Status: 'Sent successfully', Message: text }]; }, }); diff --git a/src/clis/chatgpt/ask.ts b/src/clis/chatgpt/ask.ts new file mode 100644 index 0000000..342fa5a --- /dev/null +++ b/src/clis/chatgpt/ask.ts @@ -0,0 +1,77 @@ +import { execSync, spawnSync } from 'node:child_process'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'chatgpt', + name: 'ask', + description: 'Send a prompt and wait for the AI response (send + wait + read)', + domain: 'localhost', + strategy: Strategy.PUBLIC, + browser: false, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage | null, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 30; + + // Backup clipboard + let clipBackup = ''; + try { clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); } catch {} + + // Send the message + spawnSync('pbcopy', { input: text }); + execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); + execSync("osascript -e 'delay 0.5'"); + + const cmd = "osascript " + + "-e 'tell application \"System Events\"' " + + "-e 'keystroke \"v\" using command down' " + + "-e 'delay 0.2' " + + "-e 'keystroke return' " + + "-e 'end tell'"; + execSync(cmd); + + // Clear clipboard marker + spawnSync('pbcopy', { input: '__OPENCLI_WAITING__' }); + + // Wait for response, then read it + const pollInterval = 3; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + + for (let i = 0; i < maxPolls; i++) { + // Wait + execSync(`sleep ${pollInterval}`); + + // Try Cmd+Shift+C to copy the latest response + execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); + execSync("osascript -e 'tell application \"System Events\" to keystroke \"c\" using {command down, shift down}'"); + execSync("osascript -e 'delay 0.3'"); + + const copied = execSync('pbpaste', { encoding: 'utf-8' }).trim(); + if (copied && copied !== '__OPENCLI_WAITING__' && copied !== text) { + response = copied; + break; + } + } + + // Restore clipboard + if (clipBackup) spawnSync('pbcopy', { input: clipBackup }); + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response within ${timeout}s. ChatGPT may still be generating.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); diff --git a/src/clis/chatgpt/send.ts b/src/clis/chatgpt/send.ts index baa3480..a28f463 100644 --- a/src/clis/chatgpt/send.ts +++ b/src/clis/chatgpt/send.ts @@ -14,6 +14,13 @@ export const sendCommand = cli({ func: async (page: IPage | null, kwargs: any) => { const text = kwargs.text as string; try { + // Backup current clipboard content + let clipBackup = ''; + try { + clipBackup = execSync('pbpaste', { encoding: 'utf-8' }); + } catch { /* clipboard may be empty */ } + + // Copy text to clipboard spawnSync('pbcopy', { input: text }); execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); @@ -28,6 +35,11 @@ export const sendCommand = cli({ execSync(cmd); + // Restore original clipboard content + if (clipBackup) { + spawnSync('pbcopy', { input: clipBackup }); + } + return [{ Status: 'Success' }]; } catch (err: any) { return [{ Status: "Error: " + err.message }]; diff --git a/src/clis/codex/ask.ts b/src/clis/codex/ask.ts new file mode 100644 index 0000000..66d3598 --- /dev/null +++ b/src/clis/codex/ask.ts @@ -0,0 +1,77 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'codex', + name: 'ask', + description: 'Send a prompt and wait for the AI response (send + wait + read)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 60)', default: '60' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 60; + + // Snapshot the current content length before sending + const beforeLen = await page.evaluate(` + (function() { + const turns = document.querySelectorAll('[data-content-search-turn-key]'); + return turns.length; + })() + `); + + // Inject and send + await page.evaluate(` + (function(text) { + const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); + const composer = editables.length > 0 ? editables[editables.length - 1] : document.querySelector('textarea'); + if (!composer) throw new Error('Could not find Codex input'); + composer.focus(); + document.execCommand('insertText', false, text); + })(${JSON.stringify(text)}) + `); + await page.wait(0.5); + await page.pressKey('Enter'); + + // Poll for new content + const pollInterval = 3; + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + + const result = await page.evaluate(` + (function(prevLen) { + const turns = document.querySelectorAll('[data-content-search-turn-key]'); + if (turns.length <= prevLen) return null; + const lastTurn = turns[turns.length - 1]; + const text = lastTurn.innerText || lastTurn.textContent; + return text ? text.trim() : null; + })(${beforeLen}) + `); + + if (result) { + response = result; + break; + } + } + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response within ${timeout}s. The agent may still be working.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); diff --git a/src/clis/codex/export.ts b/src/clis/codex/export.ts new file mode 100644 index 0000000..384c700 --- /dev/null +++ b/src/clis/codex/export.ts @@ -0,0 +1,42 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const exportCommand = cli({ + site: 'codex', + name: 'export', + description: 'Export the current Codex conversation to a Markdown file', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: 'Output file (default: /tmp/codex-export.md)' }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || '/tmp/codex-export.md'; + + const md = await page.evaluate(` + (function() { + const turns = document.querySelectorAll('[data-content-search-turn-key]'); + if (turns.length > 0) { + return Array.from(turns).map((t, i) => '## Turn ' + (i + 1) + '\\n\\n' + (t.innerText || t.textContent).trim()).join('\\n\\n---\\n\\n'); + } + + const main = document.querySelector('main, [role="main"], [role="log"]'); + if (main) return main.innerText || main.textContent; + return document.body.innerText; + })() + `); + + fs.writeFileSync(outputPath, '# Codex Conversation Export\\n\\n' + md); + + return [ + { + Status: 'Success', + File: outputPath, + Messages: md.split('## Turn').length - 1, + }, + ]; + }, +}); diff --git a/src/clis/codex/extract-diff.ts b/src/clis/codex/extract-diff.ts index 9247d94..2840ab4 100644 --- a/src/clis/codex/extract-diff.ts +++ b/src/clis/codex/extract-diff.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; export const extractDiffCommand = cli({ site: 'codex', diff --git a/src/clis/codex/history.ts b/src/clis/codex/history.ts new file mode 100644 index 0000000..e6239a9 --- /dev/null +++ b/src/clis/codex/history.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const historyCommand = cli({ + site: 'codex', + name: 'history', + description: 'List recent conversation threads in Codex', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Title'], + func: async (page: IPage) => { + const items = await page.evaluate(` + (function() { + const results = []; + // Codex thread list items + const entries = document.querySelectorAll('[data-testid*="thread"], [class*="thread-list"] a, [role="listbox"] [role="option"]'); + + entries.forEach((item, i) => { + const title = (item.textContent || item.innerText || '').trim().substring(0, 100); + if (title) results.push({ Index: i + 1, Title: title }); + }); + + // Fallback: sidebar/nav links + if (results.length === 0) { + const nav = document.querySelector('nav, [role="navigation"], aside'); + if (nav) { + const links = nav.querySelectorAll('a, button'); + links.forEach((link, i) => { + const text = (link.textContent || '').trim().substring(0, 100); + if (text && text.length > 3) results.push({ Index: i + 1, Title: text }); + }); + } + } + + return results; + })() + `); + + if (items.length === 0) { + return [{ Index: 0, Title: 'No threads found. Try opening the thread list first.' }]; + } + + return items; + }, +}); diff --git a/src/clis/codex/read.ts b/src/clis/codex/read.ts index 75d65b6..9aa75c0 100644 --- a/src/clis/codex/read.ts +++ b/src/clis/codex/read.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; export const readCommand = cli({ site: 'codex', @@ -7,31 +8,29 @@ export const readCommand = cli({ domain: 'localhost', strategy: Strategy.UI, browser: true, - columns: ['Thread_Content'], - func: async (page) => { + args: [], + columns: ['Content'], + func: async (page: IPage) => { const historyText = await page.evaluate(` (function() { - // Precise Codex selector for chat messages const turns = Array.from(document.querySelectorAll('[data-content-search-turn-key]')); if (turns.length > 0) { return turns.map(t => t.innerText || t.textContent).join('\\n\\n---\\n\\n'); } - // Fallback robust scraping heuristic for chat history panes const threadContainer = document.querySelector('[role="log"], [data-testid="conversation"], .thread-container, .messages-list, main'); if (threadContainer) { return threadContainer.innerText || threadContainer.textContent; } - // If specific containers fail, just dump the whole body's readable text minus the navigation return document.body.innerText; })() `); return [ { - Thread_Content: historyText, + Content: historyText, }, ]; }, diff --git a/src/clis/codex/screenshot.ts b/src/clis/codex/screenshot.ts new file mode 100644 index 0000000..41c4d51 --- /dev/null +++ b/src/clis/codex/screenshot.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const screenshotCommand = cli({ + site: 'codex', + name: 'screenshot', + description: 'Capture a snapshot of the current Codex window (DOM + Accessibility tree)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: 'Output file path (default: /tmp/codex-snapshot.txt)' }, + ], + columns: ['Status', 'File'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || '/tmp/codex-snapshot.txt'; + + const snap = await page.snapshot({ compact: true }); + const html = await page.evaluate('document.documentElement.outerHTML'); + + const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html'; + const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt'; + + fs.writeFileSync(htmlPath, html); + fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2)); + + return [ + { Status: 'Success', File: htmlPath }, + { Status: 'Success', File: snapPath }, + ]; + }, +}); diff --git a/src/clis/codex/send.ts b/src/clis/codex/send.ts index eb7ddc6..8463a4c 100644 --- a/src/clis/codex/send.ts +++ b/src/clis/codex/send.ts @@ -1,4 +1,5 @@ import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; export const sendCommand = cli({ site: 'codex', @@ -9,19 +10,16 @@ export const sendCommand = cli({ browser: true, args: [{ name: 'text', required: true, positional: true, help: 'Text, command (e.g. /review), or skill (e.g. $imagegen)' }], columns: ['Status', 'InjectedText'], - func: async (page, kwargs) => { + func: async (page: IPage, kwargs: any) => { const textToInsert = kwargs.text as string; - // We use evaluate to inject text bypassing complex nested shadow roots or contenteditables await page.evaluate(` (function(text) { - // Attempt 1: Look for standard textarea/composer input let composer = document.querySelector('textarea, [contenteditable="true"]'); - // Basic heuristic: prioritize elements that are deeply nested, visible, and have 'composer' or 'input' classes const editables = Array.from(document.querySelectorAll('[contenteditable="true"]')); if (editables.length > 0) { - composer = editables[editables.length - 1]; // Often the active input is appended near the end + composer = editables[editables.length - 1]; } if (!composer) { @@ -29,12 +27,13 @@ export const sendCommand = cli({ } composer.focus(); - - // This handles Lexical/ProseMirror/Monaco rich-text editors effectively by mimicking human paste/type deeply. document.execCommand('insertText', false, text); })(${JSON.stringify(textToInsert)}) `); + // Wait for the UI to register the input + await page.wait(0.5); + // Simulate Enter key to submit await page.pressKey('Enter'); diff --git a/src/clis/codex/status.ts b/src/clis/codex/status.ts index f233322..d38ae4e 100644 --- a/src/clis/codex/status.ts +++ b/src/clis/codex/status.ts @@ -1,14 +1,16 @@ import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; export const statusCommand = cli({ site: 'codex', name: 'status', description: 'Check active CDP connection to OpenAI Codex App', domain: 'localhost', - strategy: Strategy.UI, // Interactive UI manipulation + strategy: Strategy.UI, browser: true, + args: [], columns: ['Status', 'Url', 'Title'], - func: async (page) => { + func: async (page: IPage) => { const url = await page.evaluate('window.location.href'); const title = await page.evaluate('document.title'); diff --git a/src/clis/cursor/ask.ts b/src/clis/cursor/ask.ts new file mode 100644 index 0000000..cebc9ec --- /dev/null +++ b/src/clis/cursor/ask.ts @@ -0,0 +1,81 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const askCommand = cli({ + site: 'cursor', + name: 'ask', + description: 'Send a prompt and wait for the AI response (send + wait + read)', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' }, + ], + columns: ['Role', 'Text'], + func: async (page: IPage, kwargs: any) => { + const text = kwargs.text as string; + const timeout = parseInt(kwargs.timeout as string, 10) || 30; + + // Count existing messages before sending + const beforeCount = await page.evaluate(` + document.querySelectorAll('[data-message-role]').length + `); + + // Inject text into the active editor and submit + const injected = await page.evaluate( + `(function(text) { + let editor = document.querySelector('.aislash-editor-input, [data-lexical-editor="true"], [contenteditable="true"]'); + if (!editor) return false; + editor.focus(); + document.execCommand('insertText', false, text); + return true; + })(${JSON.stringify(text)})` + ); + + if (!injected) throw new Error('Could not find input element.'); + await page.wait(0.5); + await page.pressKey('Enter'); + + // Poll until a new assistant message appears or timeout + const pollInterval = 2; // seconds + const maxPolls = Math.ceil(timeout / pollInterval); + let response = ''; + + for (let i = 0; i < maxPolls; i++) { + await page.wait(pollInterval); + + const result = await page.evaluate(` + (function(prevCount) { + const msgs = document.querySelectorAll('[data-message-role]'); + if (msgs.length <= prevCount) return null; + + const lastMsg = msgs[msgs.length - 1]; + const role = lastMsg.getAttribute('data-message-role'); + if (role === 'human') return null; // Still waiting for assistant + + const root = lastMsg.querySelector('.markdown-root'); + const text = root ? root.innerText : lastMsg.innerText; + return text ? text.trim() : null; + })(${beforeCount}) + `); + + if (result) { + response = result; + break; + } + } + + if (!response) { + return [ + { Role: 'User', Text: text }, + { Role: 'System', Text: `No response received within ${timeout}s. The AI may still be generating.` }, + ]; + } + + return [ + { Role: 'User', Text: text }, + { Role: 'Assistant', Text: response }, + ]; + }, +}); diff --git a/src/clis/cursor/composer.ts b/src/clis/cursor/composer.ts index ab903df..eda08d6 100644 --- a/src/clis/cursor/composer.ts +++ b/src/clis/cursor/composer.ts @@ -13,37 +13,18 @@ export const composerCommand = cli({ 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); - } - } + // Open/Focus Composer via shortcut — always works regardless of current state + await page.pressKey('Meta+I'); + await page.wait(1); 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; - } + let composer = document.activeElement; + if (!composer || !composer.isContentEditable) { + composer = document.querySelector('.composer-bar [data-lexical-editor="true"], [id*="composer"] [contenteditable="true"], .aislash-editor-input'); } + + if (!composer) return false; composer.focus(); document.execCommand('insertText', false, text); @@ -55,15 +36,13 @@ export const composerCommand = cli({ 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)', + Status: 'Success', InjectedText: textToInsert, }, ]; diff --git a/src/clis/cursor/export.ts b/src/clis/cursor/export.ts new file mode 100644 index 0000000..36ab578 --- /dev/null +++ b/src/clis/cursor/export.ts @@ -0,0 +1,57 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +function makeExportCommand(site: string, readSelector: string) { + return cli({ + site, + name: 'export', + description: `Export the current ${site} conversation to a Markdown file`, + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: `Output file (default: /tmp/${site}-export.md)` }, + ], + columns: ['Status', 'File', 'Messages'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || `/tmp/${site}-export.md`; + + const md = await page.evaluate(` + (function() { + const selectors = ${JSON.stringify(readSelector)}.split(','); + let messages = []; + + for (const sel of selectors) { + const nodes = document.querySelectorAll(sel.trim()); + if (nodes.length > 0) { + messages = Array.from(nodes).map(n => n.innerText || n.textContent); + break; + } + } + + if (messages.length === 0) { + const main = document.querySelector('main, [role="main"], .messages-list, [role="log"]'); + if (main) messages = [main.innerText || main.textContent]; + } + + if (messages.length === 0) messages = [document.body.innerText]; + + return messages.map((m, i) => '## Message ' + (i + 1) + '\\n\\n' + m.trim()).join('\\n\\n---\\n\\n'); + })() + `); + + fs.writeFileSync(outputPath, `# ${site} Conversation Export\\n\\n` + md); + + return [ + { + Status: 'Success', + File: outputPath, + Messages: md.split('## Message').length - 1, + }, + ]; + }, + }); +} + +export const cursorExport = makeExportCommand('cursor', '[data-message-role]'); diff --git a/src/clis/cursor/history.ts b/src/clis/cursor/history.ts new file mode 100644 index 0000000..ba669f4 --- /dev/null +++ b/src/clis/cursor/history.ts @@ -0,0 +1,47 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +export const historyCommand = cli({ + site: 'cursor', + name: 'history', + description: 'List recent chat sessions from the Cursor sidebar', + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [], + columns: ['Index', 'Title'], + func: async (page: IPage) => { + const items = await page.evaluate(` + (function() { + const results = []; + // Cursor chat history lives in sidebar items + const entries = document.querySelectorAll('.agent-sidebar-list-item, [data-testid="chat-history-item"], .chat-history-item, .tree-item'); + + entries.forEach((item, i) => { + const title = (item.textContent || item.innerText || '').trim().substring(0, 100); + if (title) results.push({ Index: i + 1, Title: title }); + }); + + // Fallback: try to find sidebar text items + if (results.length === 0) { + const sidebar = document.querySelector('.sidebar, [class*="sidebar"], .agent-sidebar, .side-bar-container'); + if (sidebar) { + const links = sidebar.querySelectorAll('a, [role="treeitem"], [role="option"]'); + links.forEach((link, i) => { + const text = (link.textContent || '').trim().substring(0, 100); + if (text) results.push({ Index: i + 1, Title: text }); + }); + } + } + + return results; + })() + `); + + if (items.length === 0) { + return [{ Index: 0, Title: 'No chat history found. Open the AI sidebar first.' }]; + } + + return items; + }, +}); diff --git a/src/clis/cursor/new.ts b/src/clis/cursor/new.ts index ea89e42..7deba86 100644 --- a/src/clis/cursor/new.ts +++ b/src/clis/cursor/new.ts @@ -8,23 +8,12 @@ export const newCommand = cli({ domain: 'localhost', strategy: Strategy.UI, browser: true, + args: [], 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.'); - } - + // Use keyboard shortcut — most robust approach, avoids brittle DOM selectors + const isMac = process.platform === 'darwin'; + await page.pressKey(isMac ? 'Meta+N' : 'Control+N'); await page.wait(1); return [{ Status: 'Success' }]; diff --git a/src/clis/cursor/screenshot.ts b/src/clis/cursor/screenshot.ts new file mode 100644 index 0000000..bfdccdf --- /dev/null +++ b/src/clis/cursor/screenshot.ts @@ -0,0 +1,38 @@ +import * as fs from 'node:fs'; +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +function makeScreenshotCommand(site: string) { + return cli({ + site, + name: 'screenshot', + description: `Capture a snapshot of the current ${site} window (DOM + Accessibility tree)`, + domain: 'localhost', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'output', required: false, positional: true, help: `Output file path (default: /tmp/${site}-snapshot.txt)` }, + ], + columns: ['Status', 'File'], + func: async (page: IPage, kwargs: any) => { + const outputPath = (kwargs.output as string) || `/tmp/${site}-snapshot.txt`; + + // Get both the accessibility snapshot and the raw DOM HTML + const snap = await page.snapshot({ compact: true }); + const html = await page.evaluate('document.documentElement.outerHTML'); + + const htmlPath = outputPath.replace(/\.\w+$/, '') + '-dom.html'; + const snapPath = outputPath.replace(/\.\w+$/, '') + '-a11y.txt'; + + fs.writeFileSync(htmlPath, html); + fs.writeFileSync(snapPath, typeof snap === 'string' ? snap : JSON.stringify(snap, null, 2)); + + return [ + { Status: 'Success', File: htmlPath }, + { Status: 'Success', File: snapPath }, + ]; + }, + }); +} + +export const screenshotCursor = makeScreenshotCommand('cursor');