Skip to content

Commit 89a6937

Browse files
authored
feat: add OpenAI Codex CLI backend (#48)
1 parent 9b963a7 commit 89a6937

16 files changed

Lines changed: 1564 additions & 28 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@
1212

1313
# Test dependencies (plenary.nvim, installed by tests/run_lua.sh)
1414
deps/
15+
16+
# Test output captured during local test runs
17+
test_output.log

README.md

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A Neovim plugin that shows a **diff preview before your AI coding agent applies any file change** — letting you review exactly what's changing before accepting.
44

5-
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), and [GitHub Copilot CLI](https://github.com/github/copilot-cli) as backends.
5+
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [GitHub Copilot CLI](https://github.com/github/copilot-cli), and [OpenAI Codex CLI](https://github.com/openai/codex) as backends.
66

77
---
88

@@ -17,6 +17,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
1717
### GitHub Copilot CLI
1818
![GitHub Copilot CLI demo](docs/code-preview-copilot.gif)
1919

20+
### OpenAI Codex CLI
21+
![OpenAI Codex CLI demo](docs/code-preview-codex.gif)
22+
2023
---
2124

2225
## Table of Contents
@@ -28,6 +31,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
2831
- [Claude Code](#claude-code)
2932
- [OpenCode](#opencode)
3033
- [GitHub Copilot CLI](#github-copilot-cli)
34+
- [OpenAI Codex CLI](#openai-codex-cli)
3135
- [How it works](#how-it-works)
3236
- [Configuration](#configuration)
3337
- [Commands](#commands)
@@ -44,14 +48,14 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
4448
- **Diff preview** — side-by-side or inline diff opens in Neovim before any file is written
4549
- **Multiple layouts** — tab, vsplit, or GitHub-style inline diff with syntax highlighting
4650
- **Neo-tree integration** — file tree indicators show which files are being modified, created, or deleted
47-
- **Multi-backend** — works with Claude Code CLI and OpenCode
51+
- **Multi-backend** — works with Claude Code, OpenCode, GitHub Copilot CLI, and OpenAI Codex CLI
4852
- **No Python dependency** — file transformations use `nvim --headless -l`
4953

5054
---
5155

5256
## Requirements
5357

54-
- Neovim >= 0.9
58+
- Neovim >= 0.10
5559

5660
**For Claude Code backend:**
5761
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) with hooks support
@@ -62,6 +66,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod
6266

6367
**For GitHub Copilot CLI backend:**
6468
- [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026)
69+
70+
**For OpenAI Codex CLI backend:**
71+
- [OpenAI Codex CLI](https://github.com/openai/codex) (recent enough to support `apply_patch` PreToolUse hooks; older builds only fired hooks for `Bash`)
6572
- [jq](https://jqlang.github.io/jq/) — for hook payload translation
6673

6774
---
@@ -131,6 +138,31 @@ require("code-preview").setup()
131138

132139
> **Note:** Copilot CLI does not fire post-tool hooks on rejection, so rejected diffs remain open until you dismiss them (same as Claude Code).
133140
141+
### OpenAI Codex CLI
142+
143+
1. Install the plugin and call `setup()`
144+
2. Open a project in Neovim
145+
3. Run `:CodePreviewInstallCodexCliHooks` — writes `.codex/hooks.json`
146+
4. Codex requires a feature flag to enable hooks, and the diff-preview workflow only makes sense when Codex asks before applying edits. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add:
147+
148+
```toml
149+
approval_policy = "on-request"
150+
sandbox_mode = "read-only"
151+
152+
[features]
153+
codex_hooks = true
154+
```
155+
156+
`approval_policy = "on-request"` and `sandbox_mode = "read-only"` ensure Codex prompts you before every edit, so the diff preview has time to open and you have time to review. Without them, Codex may apply changes without prompting and the preview window will never block on your decision.
157+
158+
The installer warns you if `codex_hooks` is missing. You can re-check at any time with `:CodePreviewStatus` or `:checkhealth code-preview`, which both report whether the feature flag is detected.
159+
5. Start Codex CLI in the project directory
160+
6. Ask Codex to edit a file — a diff opens automatically in Neovim
161+
7. Accept/reject in the CLI; the diff closes automatically on accept
162+
8. If rejected, press `<leader>dq` to close the diff manually
163+
164+
> **Note:** Today's Codex models route all file edits through the `apply_patch` tool. New file creation that Codex performs via shell redirection (e.g. `printf … > foo.txt`) is not previewed — only `apply_patch` and edits via the dedicated `Edit`/`Write` tools (when emitted) are.
165+
134166
---
135167

136168
## How it works
@@ -155,6 +187,8 @@ AI Agent (terminal) Neovim
155187

156188
**GitHub Copilot CLI** uses shell-based hooks (`preToolUse`/`postToolUse`) configured in `.github/hooks/code-preview.json`. The adapter translates Copilot's tool vocabulary (`apply_patch`, `edit`, `create`, `bash`) into the same normalized format used by the other backends.
157189

190+
**OpenAI Codex CLI** uses shell-based hooks (`PreToolUse`/`PostToolUse`) configured in `.codex/hooks.json`, gated by `codex_hooks = true` under `[features]` in `.codex/config.toml`. The adapter passes `Bash` through and rewrites `apply_patch` (whose patch text lives in `tool_input.command`) into the canonical `ApplyPatch` shape with `tool_input.patch_text`.
191+
158192
All backends communicate with Neovim via RPC (`nvim --server <socket> --remote-send`).
159193

160194
---
@@ -209,10 +243,12 @@ require("code-preview").setup({
209243
| `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin |
210244
| `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` |
211245
| `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot CLI hooks |
246+
| `:CodePreviewInstallCodexCliHooks` | Install Codex CLI hooks to `.codex/hooks.json` |
247+
| `:CodePreviewUninstallCodexCliHooks` | Remove Codex CLI hooks |
212248
| `:CodePreviewCloseDiff` | Manually close the diff (use after rejecting a change) |
213249
| `:CodePreviewStatus` | Show socket path, hook status, and dependency check |
214250
| `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers |
215-
| `:checkhealth code-preview` | Full health check (both backends) |
251+
| `:checkhealth code-preview` | Full health check (all backends) |
216252

217253
> **Migrating?** The old `:ClaudePreview*` commands still work but show a deprecation warning. They will be removed in a future release.
218254
@@ -330,10 +366,12 @@ code-preview.nvim/
330366
│ ├── log.lua opt-in debug logging
331367
│ ├── changes.lua change status registry (modified/created/deleted)
332368
│ ├── neo_tree.lua neo-tree integration (icons, virtual nodes, reveal)
333-
│ ├── health.lua :checkhealth (both backends)
369+
│ ├── health.lua :checkhealth (all backends)
334370
│ └── backends/
335371
│ ├── claudecode.lua Claude Code hook install/uninstall
336-
│ └── opencode.lua OpenCode plugin install/uninstall
372+
│ ├── opencode.lua OpenCode plugin install/uninstall
373+
│ ├── copilot.lua GitHub Copilot CLI hook install/uninstall
374+
│ └── codex.lua OpenAI Codex CLI hook install/uninstall
337375
├── bin/ Shared core scripts
338376
│ ├── core-pre-tool.sh Unified PreToolUse logic
339377
│ ├── core-post-tool.sh Unified PostToolUse logic
@@ -350,9 +388,12 @@ code-preview.nvim/
350388
│ │ ├── index.ts tool.execute.before/after hooks
351389
│ │ ├── package.json
352390
│ │ └── tsconfig.json
353-
│ └── copilot/ GitHub Copilot CLI adapter
354-
│ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core
355-
│ └── code-close-diff.sh postToolUse hook — same for close
391+
│ ├── copilot/ GitHub Copilot CLI adapter
392+
│ │ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core
393+
│ │ └── code-close-diff.sh postToolUse hook — same for close
394+
│ └── codex/ OpenAI Codex CLI adapter
395+
│ ├── code-preview-diff.sh PreToolUse hook — translates Codex JSON → core
396+
│ └── code-close-diff.sh PostToolUse hook — same for close
356397
```
357398

358399
---
@@ -367,6 +408,8 @@ The test suite uses [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) for
367408
./tests/run.sh backends # all backend integration tests
368409
./tests/run.sh backends/claudecode # Claude Code backend only
369410
./tests/run.sh backends/opencode # OpenCode backend only
411+
./tests/run.sh backends/copilot # GitHub Copilot CLI backend only
412+
./tests/run.sh backends/codex # OpenAI Codex CLI backend only
370413
```
371414

372415
**Dependencies:** Neovim >= 0.10, jq, bun (for OpenCode tests). Plenary is auto-installed to `deps/` on first run.
@@ -405,6 +448,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, {
405448
- Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json`
406449
- Restart OpenCode
407450

451+
**Codex CLI hooks not firing**
452+
- Run `:CodePreviewInstallCodexCliHooks` in the project root
453+
- Confirm `.codex/config.toml` contains `[features]` with `codex_hooks = true` (without it, Codex ignores `hooks.json` silently)
454+
- Update Codex if needed — older versions only fired hooks for `Bash`, not `apply_patch`
455+
- Run `:CodePreviewStatus` and `:checkhealth code-preview` to verify install state and the feature flag
456+
408457
**Copilot CLI hooks not firing**
409458
- Run `:CodePreviewInstallCopilotCliHooks` in the project root
410459
- Verify `.github/hooks/code-preview.json` exists

backends/codex/code-close-diff.sh

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env bash
2+
# code-close-diff.sh — PostToolUse hook adapter for OpenAI Codex CLI.
3+
#
4+
# Mirrors the translation in code-preview-diff.sh and delegates to
5+
# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are
6+
# populated (tool_name, cwd, file_path or patch_text).
7+
8+
set -uo pipefail
9+
10+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
11+
BIN_DIR="$SCRIPT_DIR/../../bin"
12+
export CODE_PREVIEW_BACKEND="codex"
13+
14+
INPUT="$(cat)"
15+
16+
TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')"
17+
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"
18+
19+
case "$TOOL" in
20+
""|read|view|glob|grep|ls|list_files) exit 0 ;;
21+
esac
22+
case "$TOOL" in
23+
mcp__*) exit 0 ;;
24+
esac
25+
26+
log() { :; }
27+
# shellcheck source=/dev/null
28+
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
29+
# shellcheck source=/dev/null
30+
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
31+
if [[ -n "${NVIM_SOCKET:-}" ]]; then
32+
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}')
33+
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
34+
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
35+
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
36+
log() { printf '[%s] [INFO] codex/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
37+
fi
38+
fi
39+
40+
log "tool=$TOOL"
41+
42+
case "$TOOL" in
43+
apply_patch)
44+
PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
45+
if [[ -z "$PATCH" ]]; then
46+
log "apply_patch with empty/missing patch text — skipping"
47+
exit 0
48+
fi
49+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
50+
tool_name: "ApplyPatch",
51+
cwd: .cwd,
52+
tool_input: { patch_text: (.tool_input.command // "") }
53+
}')"
54+
;;
55+
56+
ApplyPatch|Edit|Write)
57+
FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')"
58+
if [[ -z "$FP" ]]; then
59+
log "$TOOL with empty/missing file_path — skipping"
60+
exit 0
61+
fi
62+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
63+
tool_name: .tool_name,
64+
cwd: .cwd,
65+
tool_input: .tool_input
66+
}')"
67+
;;
68+
69+
Bash)
70+
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
71+
if [[ -z "$CMD" ]]; then
72+
log "Bash with empty/missing command — skipping"
73+
exit 0
74+
fi
75+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
76+
tool_name: .tool_name,
77+
cwd: .cwd,
78+
tool_input: .tool_input
79+
}')"
80+
;;
81+
82+
*)
83+
log "unhandled tool=$TOOL — exiting"
84+
exit 0
85+
;;
86+
esac
87+
88+
log "translated tool=$TOOL → closing"
89+
90+
printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env bash
2+
# code-preview-diff.sh — PreToolUse hook adapter for OpenAI Codex CLI.
3+
#
4+
# Translates Codex's hook payload (stdin JSON with tool_name/tool_input) into
5+
# the normalized {tool_name, cwd, tool_input} format consumed by
6+
# bin/core-pre-tool.sh, then delegates to it.
7+
#
8+
# Field mapping:
9+
# apply_patch → ApplyPatch (tool_input.command holds the patch text;
10+
# we move it under .patch_text)
11+
# ApplyPatch → ApplyPatch (passthrough; canonical name)
12+
# Edit → Edit (passthrough; assumes Claude-Code-style
13+
# {file_path, old_string, new_string})
14+
# Write → Write (passthrough; assumes {file_path, content})
15+
# Bash → Bash (passthrough)
16+
# read/glob/MCP/... → ignored
17+
#
18+
# Note: today's Codex models route all file edits through `apply_patch`. The
19+
# Edit/Write branches exist defensively in case a future Codex version (or
20+
# an MCP server) emits those names with Claude-Code-style field shapes.
21+
22+
set -uo pipefail
23+
24+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25+
BIN_DIR="$SCRIPT_DIR/../../bin"
26+
export CODE_PREVIEW_BACKEND="codex"
27+
28+
INPUT="$(cat)"
29+
30+
TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')"
31+
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')"
32+
33+
# Skip noisy/no-op tools before the expensive socket/log-setup RPC.
34+
case "$TOOL" in
35+
""|read|view|glob|grep|ls|list_files) exit 0 ;;
36+
esac
37+
# MCP tools follow `mcp__server__name`; we don't preview them.
38+
case "$TOOL" in
39+
mcp__*) exit 0 ;;
40+
esac
41+
42+
# Logging — mirrors copilot/code-preview-diff.sh. Gated on `debug = true`.
43+
log() { :; }
44+
# shellcheck source=/dev/null
45+
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
46+
# shellcheck source=/dev/null
47+
source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true
48+
if [[ -n "${NVIM_SOCKET:-}" ]]; then
49+
_CTX=$(nvim --server "$NVIM_SOCKET" --remote-expr "luaeval(\"vim.json.encode({debug=require('code-preview.log').is_enabled(),log_file=require('code-preview.log').get_log_path() or ''})\")" 2>/dev/null || echo '{}')
50+
_DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null)
51+
_LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null)
52+
if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then
53+
log() { printf '[%s] [INFO] codex/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; }
54+
fi
55+
fi
56+
57+
log "tool=$TOOL cwd=$CWD"
58+
59+
case "$TOOL" in
60+
apply_patch)
61+
# Codex stores the raw `*** Begin Patch ... *** End Patch` text in
62+
# tool_input.command. Our ApplyPatch handler in core-pre-tool.sh reads
63+
# tool_input.patch_text, so move the field.
64+
PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
65+
if [[ -z "$PATCH" ]]; then
66+
log "apply_patch with empty/missing patch text — skipping"
67+
exit 0
68+
fi
69+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
70+
tool_name: "ApplyPatch",
71+
cwd: .cwd,
72+
tool_input: { patch_text: (.tool_input.command // "") }
73+
}')"
74+
;;
75+
76+
ApplyPatch|Edit|Write)
77+
# Edit/Write-family tools require a non-empty file_path. Without it,
78+
# core-pre-tool.sh would push a broken diff downstream.
79+
FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')"
80+
if [[ -z "$FP" ]]; then
81+
log "$TOOL with empty/missing file_path — skipping"
82+
exit 0
83+
fi
84+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
85+
tool_name: .tool_name,
86+
cwd: .cwd,
87+
tool_input: .tool_input
88+
}')"
89+
;;
90+
91+
Bash)
92+
# Bash needs a non-empty command to be useful (rm detection, shell-write
93+
# detection both run on the command string).
94+
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')"
95+
if [[ -z "$CMD" ]]; then
96+
log "Bash with empty/missing command — skipping"
97+
exit 0
98+
fi
99+
NORMALIZED="$(printf '%s' "$INPUT" | jq '{
100+
tool_name: .tool_name,
101+
cwd: .cwd,
102+
tool_input: .tool_input
103+
}')"
104+
;;
105+
106+
*)
107+
log "unhandled tool=$TOOL — exiting"
108+
exit 0
109+
;;
110+
esac
111+
112+
log "translated tool=$TOOL$(printf '%s' "$NORMALIZED" | jq -c '{tool_name, file: .tool_input.file_path // "", has_patch: (.tool_input.patch_text != null)}' 2>/dev/null || echo 'parse-error')"
113+
114+
printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh"

bin/core-post-tool.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ fi
3737

3838
log_post "tool=$TOOL_NAME"
3939

40-
# For Bash tool (rm detection), only clear deletion markers — don't touch edit markers or diff tab
40+
# For Bash tool, clear markers set by pre-hook detection (rm + shell writes).
41+
# We use a distinct `bash_modified` status for shell writes so this clear
42+
# doesn't clobber `modified` markers from concurrent Edit/Write/ApplyPatch
43+
# operations whose post-hook hasn't fired yet.
4144
if [[ "$TOOL_NAME" == "Bash" ]]; then
42-
nvim_send "require('code-preview.changes').clear_by_status('deleted')" || true
45+
nvim_send "require('code-preview.changes').clear_by_statuses({'deleted','bash_modified','bash_created'})" || true
4346
nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').refresh() end) end, 200)" || true
4447
exit 0
4548
fi

0 commit comments

Comments
 (0)