feat(codex): implement generic CDP adapters for OpenAI Codex desktop app
This commit is contained in:
1 parent
abac070ce4
commit
3cc2cb5504
8 files changed
+274
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# OpenAI Codex Adapter for OpenCLI
|
||||
|
||||
Control the **OpenAI Codex Desktop App** headless or headfully via Chrome DevTools Protocol (CDP).
|
||||
Because Codex is built on Electron, OpenCLI can directly drive its internal UI, automate slash commands, and manipulate its AI agent threads.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. You must have the official OpenAI Codex app installed.
|
||||
2. Launch it via the terminal and expose the remote debugging port:
|
||||
```bash
|
||||
# macOS
|
||||
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Export the CDP endpoint in your shell:
|
||||
```bash
|
||||
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Diagnostics
|
||||
- `opencli codex status`: Checks connection and reads the current active window URL/title.
|
||||
- `opencli codex dump`: Dumps the full UI DOM and Accessibility tree into `/tmp` (ideal for building AI automation tools on top of it).
|
||||
|
||||
### Agent Manipulation
|
||||
- `opencli codex new`: Simulates `Cmd+N` to start a completely fresh and isolated Git Worktree thread context.
|
||||
- `opencli codex send "message"`: Robustly finds the active Thread Composer and injects your text.
|
||||
- *Pro-tip*: You can trigger internal shortcuts by sending them, e.g., `opencli codex send "/review"` or `opencli codex send "$imagegen draw a cat"`.
|
||||
- `opencli codex read`: Extracts the entire current thread history and AI reasoning logs into readable text.
|
||||
- `opencli codex extract-diff`: Automatically scrapes any visual Patch chunks and Code Diffs the AI generated inside the review UI.
|
||||
@@ -0,0 +1,33 @@
|
||||
# OpenAI Codex 桌面端适配器 (OpenCLI)
|
||||
|
||||
利用 CDP 协议,直接从命令行/外部脚本接管和操控 **OpenAI Codex 官方桌面版**。
|
||||
因为官方 Codex 是基于 Electron 构建的“多 Agent 协作中心”,通过本适配器,你可以让 AI 自动控制另一个 AI 完成工作,甚至自动截取代码审查的 Diff!
|
||||
|
||||
## 前置环境准备
|
||||
|
||||
1. 你必须下载并安装了官方原版的 OpenAI Codex 客户端。
|
||||
2. 必须通过命令行挂载 CDP 调试端口启动它:
|
||||
```bash
|
||||
# macOS 启动示例
|
||||
/Applications/Codex.app/Contents/MacOS/Codex --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
## 配置指南
|
||||
|
||||
在你要运行命令的终端里导出环境变量:
|
||||
```bash
|
||||
export OPENCLI_CODEX_CDP_ENDPOINT="http://127.0.0.1:9222"
|
||||
```
|
||||
|
||||
## 核心指令
|
||||
|
||||
### 探查与调试
|
||||
- `opencli codex status`: 检查是否成功连上内部 Chromium,获取上下文 Title。
|
||||
- `opencli codex dump`: 强制剥离整个 App 的内部 DOM 树和无障碍视图并保存到 `/tmp`,是编写复杂自动化 RPA 脚本的终极利刃。
|
||||
|
||||
### 自动化执行
|
||||
- `opencli codex new`: 模拟按下 `Cmd+N`。建立一个彻底干净、隔离了 Git Worktree 的全线并行 Thread。
|
||||
- `opencli codex send "要发送的话"`: 强行跨越 Shadow Root 找到对应的富文本编辑器并注入提词。
|
||||
- *高阶技巧*: 你可以直接发送内置宏!例如 `opencli codex send "/review"` 就能触发本工作流的代码审查,或者 `opencli codex send "$imagegen"` 触发技能。
|
||||
- `opencli codex read`: 完整抓取并提取整个当前 Thread 里的思考过程和对话日志。
|
||||
- `opencli codex extract-diff`: 专门用于拦截并提取由 AI 建议的 `+` / `-` 代码 Patch 修改块,直接输出结构化数据!
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export const dumpCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'dump',
|
||||
description: 'Dump the DOM and Accessibility tree of Codex 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/codex-dom.html', dom);
|
||||
|
||||
// Get accessibility snapshot
|
||||
const snap = await page.snapshot({ interactive: false });
|
||||
fs.writeFileSync('/tmp/codex-snapshot.json', JSON.stringify(snap, null, 2));
|
||||
|
||||
return [
|
||||
{
|
||||
action: 'Dom extraction finished',
|
||||
files: '/tmp/codex-dom.html, /tmp/codex-snapshot.json',
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const extractDiffCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'extract-diff',
|
||||
description: 'Extract visual code review diff patches from Codex',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['File', 'Diff'],
|
||||
func: async (page) => {
|
||||
const diffs = await page.evaluate(`
|
||||
(function() {
|
||||
const results = [];
|
||||
// Assuming diffs are rendered with standard diff classes or monaco difference editors
|
||||
const diffBlocks = document.querySelectorAll('.diff-editor, .monaco-diff-editor, [data-testid="diff-view"]');
|
||||
|
||||
diffBlocks.forEach((block, index) => {
|
||||
// Very roughly scrape text representing additions/deletions mapped from the inner wrapper
|
||||
results.push({
|
||||
File: block.getAttribute('data-filename') || \`DiffBlock_\${index+1}\`,
|
||||
Diff: block.innerText || block.textContent
|
||||
});
|
||||
});
|
||||
|
||||
// If no structured diffs found, try to find any code blocks labeled as patches
|
||||
if (results.length === 0) {
|
||||
const codeBlocks = document.querySelectorAll('pre code.language-diff, pre code.language-patch');
|
||||
codeBlocks.forEach((code, index) => {
|
||||
results.push({
|
||||
File: \`Patch_\${index+1}\`,
|
||||
Diff: code.innerText || code.textContent
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
|
||||
if (diffs.length === 0) {
|
||||
return [{ File: 'No diffs found', Diff: 'Try running opencli codex send "/review" first' }];
|
||||
}
|
||||
|
||||
return diffs;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const newCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'new',
|
||||
description: 'Start a new Codex conversation thread / isolated workspace',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Status', 'Action'],
|
||||
func: async (page) => {
|
||||
// According to research, Cmd+N / Ctrl+N spins up a new thread
|
||||
const isMac = process.platform === 'darwin';
|
||||
const newThreadKey = isMac ? 'Meta+N' : 'Control+N';
|
||||
|
||||
// Simulate keyboard shortcut
|
||||
await page.pressKey(newThreadKey);
|
||||
|
||||
// Wait a brief moment for UI animation
|
||||
await page.wait(1);
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
Action: `Pressed ${newThreadKey} to trigger New Thread`,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const readCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'read',
|
||||
description: 'Read the contents of the current Codex conversation thread',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
columns: ['Thread_Content'],
|
||||
func: async (page) => {
|
||||
const historyText = await page.evaluate(`
|
||||
(function() {
|
||||
// Fallback robust scraping heuristic for chat history panes
|
||||
// We look for large scrolling areas or generic message lists
|
||||
const threadContainer = document.querySelector('[role="log"], [data-testid="conversation"], main, .thread-container, .messages-list');
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
export const sendCommand = cli({
|
||||
site: 'codex',
|
||||
name: 'send',
|
||||
description: 'Send text/commands to the Codex AI composer',
|
||||
domain: 'localhost',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [{ name: 'text', required: true, help: 'Text, command (e.g. /review), or skill (e.g. $imagegen)' }],
|
||||
columns: ['Status', 'InjectedText'],
|
||||
func: async (page, kwargs) => {
|
||||
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
|
||||
}
|
||||
|
||||
if (!composer) {
|
||||
throw new Error('Could not find Composer input element in Codex UI');
|
||||
}
|
||||
|
||||
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)})
|
||||
`);
|
||||
|
||||
// Simulate Enter key to submit
|
||||
await page.pressKey('Enter');
|
||||
|
||||
return [
|
||||
{
|
||||
Status: 'Success',
|
||||
InjectedText: textToInsert,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cli, Strategy } from '../../registry.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
|
||||
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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user