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:
1 parent
813631e468
commit
e76de39f42
21 files changed
+553
-73
No files matched your search
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jackwener/opencli",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.6",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -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}` }];
|
||||
},
|
||||
});
|
||||
@@ -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 }];
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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 }];
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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,4 +1,5 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
export const extractDiffCommand = cli({
|
||||
site: 'codex',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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]');
|
||||
@@ -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
@@ -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' }];
|
||||
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user