feat: desktop adapter improvements — bug fixes + 9 new commands

P0 Bug Fixes:
- codex: add missing args/IPage imports, add wait(0.5) before Enter in send
- cursor: new.ts uses Meta+N shortcut (more robust), composer.ts simplified
- chatgpt: send.ts now backs up and restores clipboard
- antigravity: send.ts/model.ts columns unified to PascalCase
- codex: read.ts column renamed Thread_Content → Content

P1 New Features:
- ask: one-shot send+wait+read for cursor, codex, chatgpt (send → poll DOM → return response)
- screenshot: DOM + accessibility snapshot export for cursor, codex

P2 New Features:
- history: list sidebar chat sessions for cursor, codex
- export: save full conversation as Markdown for cursor, codex

Total: 26 sites · 128 commands
This commit is contained in:
jackwener committed 2026-03-18 19:52:39 +08:00
1 parent 813631e468
commit e76de39f42
21 files changed
+553 -73

No files matched your search

+4 -4
View File
@@ -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 |
+4 -4
View File
@@ -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 | 🔐 浏览器 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@jackwener/opencli",
"version": "0.9.5",
"version": "0.9.6",
"publishConfig": {
"access": "public"
},
+2 -2
View File
@@ -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}` }];
},
});
+2 -2
View File
@@ -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 }];
},
});
+77
View File
@@ -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 },
];
},
});
+12
View File
@@ -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 }];
+77
View File
@@ -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 },
];
},
});
+42
View File
@@ -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,
},
];
},
});
+1
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '../../registry.js';
import type { IPage } from '../../types.js';
export const extractDiffCommand = cli({
site: 'codex',
+47
View File
@@ -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;
},
});
+5 -6
View File
@@ -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,
},
];
},
+33
View File
@@ -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 },
];
},
});
+6 -7
View File
@@ -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');
+4 -2
View File
@@ -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');
+81
View File
@@ -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 },
];
},
});
+9 -30
View File
@@ -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,
},
];
+57
View File
@@ -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]');
+47
View File
@@ -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;
},
});
+4 -15
View File
@@ -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' }];
+38
View File
@@ -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');