From 9e09d5b2c231b4f050198a5c562767daa97b8407 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 1 May 2026 21:29:42 +0530 Subject: [PATCH 1/4] feat: add OpenAI Codex CLI backend Adds Codex CLI as the fourth supported backend, plus shell-write detection in the Bash hook (motivated by Codex GPT models' atomic- replace idiom) and an ApplyPatch delete-icon fix that surfaced while testing Codex's `*** Delete File:` directives. - Codex backend: `.codex/hooks.json` install + `codex_hooks` feature- flag detection (project + global), surfaced in :CodePreviewStatus and :checkhealth. - Shell-write detection: `>` / `>>` / `&>` / `&>>`, `mv X.tmp X`, `cp`, `tee`, `sed -i` targets are flagged in the changes registry as bash_modified / bash_created so neo-tree shows feedback for shell- driven edits during the approval window. - ApplyPatch delete: show_diff accepts an action hint; deletes now render the red trash icon, with no false positives for legitimate truncate-to-empty edits. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 + README.md | 59 ++++- backends/codex/code-close-diff.sh | 90 +++++++ backends/codex/code-preview-diff.sh | 114 ++++++++ bin/core-post-tool.sh | 7 +- bin/core-pre-tool.sh | 137 +++++++++- lua/code-preview/backends/codex.lua | 240 +++++++++++++++++ lua/code-preview/changes.lua | 12 + lua/code-preview/diff.lua | 19 +- lua/code-preview/health.lua | 37 +++ lua/code-preview/init.lua | 25 ++ lua/code-preview/neo_tree.lua | 19 +- tests/backends/codex/test_apply_patch.sh | 169 ++++++++++++ tests/backends/codex/test_edit.sh | 320 +++++++++++++++++++++++ tests/backends/codex/test_install.sh | 296 +++++++++++++++++++++ tests/plugin/diff_lifecycle_spec.lua | 33 +++ 16 files changed, 1552 insertions(+), 28 deletions(-) create mode 100755 backends/codex/code-close-diff.sh create mode 100755 backends/codex/code-preview-diff.sh create mode 100644 lua/code-preview/backends/codex.lua create mode 100755 tests/backends/codex/test_apply_patch.sh create mode 100755 tests/backends/codex/test_edit.sh create mode 100755 tests/backends/codex/test_install.sh diff --git a/.gitignore b/.gitignore index cba10a8..02a5cc0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ # Test dependencies (plenary.nvim, installed by tests/run_lua.sh) deps/ + +# Test output captured during local test runs +test_output.log diff --git a/README.md b/README.md index 534b8ad..1ae89a8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. -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. +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. --- @@ -28,6 +28,7 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod - [Claude Code](#claude-code) - [OpenCode](#opencode) - [GitHub Copilot CLI](#github-copilot-cli) + - [OpenAI Codex CLI](#openai-codex-cli) - [How it works](#how-it-works) - [Configuration](#configuration) - [Commands](#commands) @@ -44,14 +45,14 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod - **Diff preview** — side-by-side or inline diff opens in Neovim before any file is written - **Multiple layouts** — tab, vsplit, or GitHub-style inline diff with syntax highlighting - **Neo-tree integration** — file tree indicators show which files are being modified, created, or deleted -- **Multi-backend** — works with Claude Code CLI and OpenCode +- **Multi-backend** — works with Claude Code, OpenCode, GitHub Copilot CLI, and OpenAI Codex CLI - **No Python dependency** — file transformations use `nvim --headless -l` --- ## Requirements -- Neovim >= 0.9 +- Neovim >= 0.10 **For Claude Code backend:** - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) with hooks support @@ -62,6 +63,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod **For GitHub Copilot CLI backend:** - [GitHub Copilot CLI](https://github.com/github/copilot-cli) (generally available since Feb 2026) + +**For OpenAI Codex CLI backend:** +- [OpenAI Codex CLI](https://github.com/openai/codex) (recent enough to support `apply_patch` PreToolUse hooks; older builds only fired hooks for `Bash`) - [jq](https://jqlang.github.io/jq/) — for hook payload translation --- @@ -131,6 +135,26 @@ require("code-preview").setup() > **Note:** Copilot CLI does not fire post-tool hooks on rejection, so rejected diffs remain open until you dismiss them (same as Claude Code). +### OpenAI Codex CLI + +1. Install the plugin and call `setup()` +2. Open a project in Neovim +3. Run `:CodePreviewInstallCodexCliHooks` — writes `.codex/hooks.json` +4. Codex requires a feature flag to enable hooks. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add: + + ```toml + [features] + codex_hooks = true + ``` + + The installer warns you if this flag is missing. You can also re-check at any time with `:CodePreviewStatus` or `:checkhealth code-preview`, which both report whether the feature flag is detected. +5. Start Codex CLI in the project directory +6. Ask Codex to edit a file — a diff opens automatically in Neovim +7. Accept/reject in the CLI; the diff closes automatically on accept +8. If rejected, press `dq` to close the diff manually + +> **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. + --- ## How it works @@ -155,6 +179,8 @@ AI Agent (terminal) Neovim **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. +**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`. + All backends communicate with Neovim via RPC (`nvim --server --remote-send`). --- @@ -209,10 +235,12 @@ require("code-preview").setup({ | `:CodePreviewUninstallOpenCodeHooks` | Remove OpenCode plugin | | `:CodePreviewInstallCopilotCliHooks` | Install Copilot CLI hooks to `.github/hooks/code-preview.json` | | `:CodePreviewUninstallCopilotCliHooks` | Remove Copilot CLI hooks | +| `:CodePreviewInstallCodexCliHooks` | Install Codex CLI hooks to `.codex/hooks.json` | +| `:CodePreviewUninstallCodexCliHooks` | Remove Codex CLI hooks | | `:CodePreviewCloseDiff` | Manually close the diff (use after rejecting a change) | | `:CodePreviewStatus` | Show socket path, hook status, and dependency check | | `:CodePreviewToggleVisibleOnly` | Toggle visible_only — show diffs only for open buffers | -| `:checkhealth code-preview` | Full health check (both backends) | +| `:checkhealth code-preview` | Full health check (all backends) | > **Migrating?** The old `:ClaudePreview*` commands still work but show a deprecation warning. They will be removed in a future release. @@ -330,10 +358,12 @@ code-preview.nvim/ │ ├── log.lua opt-in debug logging │ ├── changes.lua change status registry (modified/created/deleted) │ ├── neo_tree.lua neo-tree integration (icons, virtual nodes, reveal) -│ ├── health.lua :checkhealth (both backends) +│ ├── health.lua :checkhealth (all backends) │ └── backends/ │ ├── claudecode.lua Claude Code hook install/uninstall -│ └── opencode.lua OpenCode plugin install/uninstall +│ ├── opencode.lua OpenCode plugin install/uninstall +│ ├── copilot.lua GitHub Copilot CLI hook install/uninstall +│ └── codex.lua OpenAI Codex CLI hook install/uninstall ├── bin/ Shared core scripts │ ├── core-pre-tool.sh Unified PreToolUse logic │ ├── core-post-tool.sh Unified PostToolUse logic @@ -350,9 +380,12 @@ code-preview.nvim/ │ │ ├── index.ts tool.execute.before/after hooks │ │ ├── package.json │ │ └── tsconfig.json -│ └── copilot/ GitHub Copilot CLI adapter -│ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core -│ └── code-close-diff.sh postToolUse hook — same for close +│ ├── copilot/ GitHub Copilot CLI adapter +│ │ ├── code-preview-diff.sh preToolUse hook — translates Copilot JSON → core +│ │ └── code-close-diff.sh postToolUse hook — same for close +│ └── codex/ OpenAI Codex CLI adapter +│ ├── code-preview-diff.sh PreToolUse hook — translates Codex JSON → core +│ └── code-close-diff.sh PostToolUse hook — same for close ``` --- @@ -367,6 +400,8 @@ The test suite uses [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) for ./tests/run.sh backends # all backend integration tests ./tests/run.sh backends/claudecode # Claude Code backend only ./tests/run.sh backends/opencode # OpenCode backend only +./tests/run.sh backends/copilot # GitHub Copilot CLI backend only +./tests/run.sh backends/codex # OpenAI Codex CLI backend only ``` **Dependencies:** Neovim >= 0.10, jq, bun (for OpenCode tests). Plenary is auto-installed to `deps/` on first run. @@ -405,6 +440,12 @@ vim.api.nvim_create_autocmd({ "FocusGained", "BufEnter", "CursorHold" }, { - Ensure `"permission": { "edit": "ask" }` is set in `~/.config/opencode/opencode.json` - Restart OpenCode +**Codex CLI hooks not firing** +- Run `:CodePreviewInstallCodexCliHooks` in the project root +- Confirm `.codex/config.toml` contains `[features]` with `codex_hooks = true` (without it, Codex ignores `hooks.json` silently) +- Update Codex if needed — older versions only fired hooks for `Bash`, not `apply_patch` +- Run `:CodePreviewStatus` and `:checkhealth code-preview` to verify install state and the feature flag + **Copilot CLI hooks not firing** - Run `:CodePreviewInstallCopilotCliHooks` in the project root - Verify `.github/hooks/code-preview.json` exists diff --git a/backends/codex/code-close-diff.sh b/backends/codex/code-close-diff.sh new file mode 100755 index 0000000..41e2f6f --- /dev/null +++ b/backends/codex/code-close-diff.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# code-close-diff.sh — PostToolUse hook adapter for OpenAI Codex CLI. +# +# Mirrors the translation in code-preview-diff.sh and delegates to +# bin/core-post-tool.sh. Only the fields core-post-tool.sh reads are +# populated (tool_name, cwd, file_path or patch_text). + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" +export CODE_PREVIEW_BACKEND="codex" + +INPUT="$(cat)" + +TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" + +case "$TOOL" in + ""|read|view|glob|grep|ls|list_files) exit 0 ;; +esac +case "$TOOL" in + mcp__*) exit 0 ;; +esac + +log() { :; } +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +if [[ -n "${NVIM_SOCKET:-}" ]]; then + _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 '{}') + _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) + _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) + if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then + log() { printf '[%s] [INFO] codex/post: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } + fi +fi + +log "tool=$TOOL" + +case "$TOOL" in + apply_patch) + PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')" + if [[ -z "$PATCH" ]]; then + log "apply_patch with empty/missing patch text — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: "ApplyPatch", + cwd: .cwd, + tool_input: { patch_text: (.tool_input.command // "") } + }')" + ;; + + ApplyPatch|Edit|Write) + FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')" + if [[ -z "$FP" ]]; then + log "$TOOL with empty/missing file_path — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: .tool_name, + cwd: .cwd, + tool_input: .tool_input + }')" + ;; + + Bash) + CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')" + if [[ -z "$CMD" ]]; then + log "Bash with empty/missing command — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: .tool_name, + cwd: .cwd, + tool_input: .tool_input + }')" + ;; + + *) + log "unhandled tool=$TOOL — exiting" + exit 0 + ;; +esac + +log "translated tool=$TOOL → closing" + +printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh" diff --git a/backends/codex/code-preview-diff.sh b/backends/codex/code-preview-diff.sh new file mode 100755 index 0000000..4691ff3 --- /dev/null +++ b/backends/codex/code-preview-diff.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# code-preview-diff.sh — PreToolUse hook adapter for OpenAI Codex CLI. +# +# Translates Codex's hook payload (stdin JSON with tool_name/tool_input) into +# the normalized {tool_name, cwd, tool_input} format consumed by +# bin/core-pre-tool.sh, then delegates to it. +# +# Field mapping: +# apply_patch → ApplyPatch (tool_input.command holds the patch text; +# we move it under .patch_text) +# ApplyPatch → ApplyPatch (passthrough; canonical name) +# Edit → Edit (passthrough; assumes Claude-Code-style +# {file_path, old_string, new_string}) +# Write → Write (passthrough; assumes {file_path, content}) +# Bash → Bash (passthrough) +# read/glob/MCP/... → ignored +# +# Note: today's Codex models route all file edits through `apply_patch`. The +# Edit/Write branches exist defensively in case a future Codex version (or +# an MCP server) emits those names with Claude-Code-style field shapes. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" +export CODE_PREVIEW_BACKEND="codex" + +INPUT="$(cat)" + +TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // ""')" +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // ""')" + +# Skip noisy/no-op tools before the expensive socket/log-setup RPC. +case "$TOOL" in + ""|read|view|glob|grep|ls|list_files) exit 0 ;; +esac +# MCP tools follow `mcp__server__name`; we don't preview them. +case "$TOOL" in + mcp__*) exit 0 ;; +esac + +# Logging — mirrors copilot/code-preview-diff.sh. Gated on `debug = true`. +log() { :; } +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true +# shellcheck source=/dev/null +source "$BIN_DIR/nvim-send.sh" 2>/dev/null || true +if [[ -n "${NVIM_SOCKET:-}" ]]; then + _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 '{}') + _DBG=$(echo "$_CTX" | jq -r '.debug // false' 2>/dev/null) + _LOG=$(echo "$_CTX" | jq -r '.log_file // ""' 2>/dev/null) + if [[ "$_DBG" == "true" && -n "$_LOG" ]]; then + log() { printf '[%s] [INFO] codex/pre: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_LOG"; } + fi +fi + +log "tool=$TOOL cwd=$CWD" + +case "$TOOL" in + apply_patch) + # Codex stores the raw `*** Begin Patch ... *** End Patch` text in + # tool_input.command. Our ApplyPatch handler in core-pre-tool.sh reads + # tool_input.patch_text, so move the field. + PATCH="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')" + if [[ -z "$PATCH" ]]; then + log "apply_patch with empty/missing patch text — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: "ApplyPatch", + cwd: .cwd, + tool_input: { patch_text: (.tool_input.command // "") } + }')" + ;; + + ApplyPatch|Edit|Write) + # Edit/Write-family tools require a non-empty file_path. Without it, + # core-pre-tool.sh would push a broken diff downstream. + FP="$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""')" + if [[ -z "$FP" ]]; then + log "$TOOL with empty/missing file_path — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: .tool_name, + cwd: .cwd, + tool_input: .tool_input + }')" + ;; + + Bash) + # Bash needs a non-empty command to be useful (rm detection, shell-write + # detection both run on the command string). + CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""')" + if [[ -z "$CMD" ]]; then + log "Bash with empty/missing command — skipping" + exit 0 + fi + NORMALIZED="$(printf '%s' "$INPUT" | jq '{ + tool_name: .tool_name, + cwd: .cwd, + tool_input: .tool_input + }')" + ;; + + *) + log "unhandled tool=$TOOL — exiting" + exit 0 + ;; +esac + +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')" + +printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh" diff --git a/bin/core-post-tool.sh b/bin/core-post-tool.sh index 2aa7a18..8e4f16e 100755 --- a/bin/core-post-tool.sh +++ b/bin/core-post-tool.sh @@ -37,9 +37,12 @@ fi log_post "tool=$TOOL_NAME" -# For Bash tool (rm detection), only clear deletion markers — don't touch edit markers or diff tab +# For Bash tool, clear markers set by pre-hook detection (rm + shell writes). +# We use a distinct `bash_modified` status for shell writes so this clear +# doesn't clobber `modified` markers from concurrent Edit/Write/ApplyPatch +# operations whose post-hook hasn't fired yet. if [[ "$TOOL_NAME" == "Bash" ]]; then - nvim_send "require('code-preview.changes').clear_by_status('deleted')" || true + nvim_send "require('code-preview.changes').clear_by_statuses({'deleted','bash_modified','bash_created'})" || true nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').refresh() end) end, 200)" || true exit 0 fi diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh index 4b3480f..2620c93 100755 --- a/bin/core-pre-tool.sh +++ b/bin/core-pre-tool.sh @@ -132,12 +132,9 @@ case "$TOOL_NAME" in done < <(echo "$COMMAND" | sed 's/[;&|]\{1,2\}/\n/g') RM_PATHS="$(echo "$RM_PATHS" | xargs)" - if [[ -z "$RM_PATHS" ]]; then - exit 0 # Not an rm command, pass through - fi - # Mark each path as deleted in neo-tree - if [[ "$HAS_NVIM" == "true" ]]; then + # Mark each rm-detected path as deleted in neo-tree + if [[ -n "$RM_PATHS" && "$HAS_NVIM" == "true" ]]; then for path in $RM_PATHS; do PATH_ESC="$(escape_lua "$path")" nvim_send "require('code-preview.changes').set('$PATH_ESC', 'deleted')" || true @@ -148,6 +145,131 @@ case "$TOOL_NAME" in FIRST_ESC="$(escape_lua "$FIRST_PATH")" nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').reveal('$FIRST_ESC') end) end, 300)" || true fi + + # ── Tier 1 shell-write detection ──────────────────────────────── + # Extract file paths the command will write to via output redirection + # (`>`, `>>`), atomic-replace idiom (`mv X.tmp X`), or in-place tools + # (`tee`, `sed -i`, `awk -i inplace`). We only mark the targets in the + # changes registry — we do NOT compute or display a content diff for + # bash writes (that's Tier 2). Indicators are cleared on PostToolUse so + # they don't linger past the approval window. + detect_write_paths() { + local cmd="$1" + # Output redirection: capture the filename after `>`/`>>` (stdout) or + # `&>`/`&>>` (bash stdout+stderr). Excludes FD redirections like `2>&1` + # (handled by the digit-prefix guard) and `/dev/{null,stdout,stderr}`. + echo "$cmd" \ + | grep -oE '(([^0-9&]|^)>>?|&>>?)[[:space:]]*[^[:space:]&;|<>()`{}]+' \ + | sed -E 's/^[^>]*>+[[:space:]]*//' \ + | grep -vE '^/dev/(null|stdout|stderr|tty)$' || true + # `mv SRC DST` and `cp SRC DST`: emit DST. We greedily grab the last + # whitespace-separated token; misses cases with quoted paths + # containing spaces, which is acceptable for Tier 1. Also note: the + # GNU `-t DST SRC...` flag inverts argument order — we'd emit a source + # file as the target. Not handled in Tier 1. + echo "$cmd" \ + | tr ';&|' '\n' \ + | grep -E '^[[:space:]]*(mv|cp)[[:space:]]' \ + | sed -E 's/^[[:space:]]*(mv|cp)[[:space:]]+//' \ + | awk '{print $NF}' || true + # `tee FILE` (with optional -a): emit FILE. Captures only the first + # target — `tee FILE OTHER_FILE` would miss OTHER_FILE. Acceptable + # for Tier 1. + echo "$cmd" \ + | grep -oE 'tee[[:space:]]+(-a[[:space:]]+)?[^[:space:]&;|<>()`]+' \ + | sed -E 's/^tee[[:space:]]+(-a[[:space:]]+)?//' || true + # `sed -i ... FILE` (BSD/GNU both supported; we don't try to skip the + # backup-suffix arg, so on BSD you'd see the suffix flagged too — + # acceptable for Tier 1). + echo "$cmd" \ + | grep -oE 'sed[[:space:]]+(-[a-zA-Z]*i[a-zA-Z]*)[[:space:]]+([^|&;]+)' \ + | awk '{print $NF}' || true + } + + # Filters: skip transient file extensions and pseudo-paths. + is_transient_path() { + case "$1" in + *.tmp|*.bak|*.swp|*~|/dev/*|/tmp/*) return 0 ;; + esac + return 1 + } + + # Drop strings that don't look like real filesystem paths. Catches false + # positives from the redirection regex matching inside quoted strings — + # e.g. `printf '\n\n'` produces a spurious `\n\n'` capture + # because of the `-->` HTML comment marker. + looks_like_path() { + local p="$1" + # Must not contain a backslash (would imply a literal escape from + # inside a quoted string) or a stray single/double quote. + case "$p" in + *\\*|*\'*|*\"*) return 1 ;; + esac + # Must start with a path-safe character. + case "$p" in + /*|./*|../*|~/*|[A-Za-z0-9_]*) return 0 ;; + esac + return 1 + } + + WRITE_PATHS="" + while IFS= read -r raw; do + [[ -z "$raw" ]] && continue + # Strip surrounding quotes, if any. + raw="${raw#\"}"; raw="${raw%\"}" + raw="${raw#\'}"; raw="${raw%\'}" + # Reject obvious non-paths (escape sequences leaked from quoted strings). + if ! looks_like_path "$raw"; then continue; fi + # Expand a leading `~` to $HOME before the relative-path check — + # otherwise `~/foo` would get prefixed with $CWD and yield $CWD/~/foo. + # Quote `~` in the pattern (`'~/'`) so bash doesn't tilde-expand it + # before doing the prefix strip. + if [[ "$raw" == "~" ]]; then + raw="$HOME" + elif [[ "$raw" == "~/"* ]]; then + raw="$HOME/${raw#'~/'}" + fi + # Resolve relative paths against CWD. + if [[ "$raw" != /* ]]; then + raw="$CWD/$raw" + fi + if is_transient_path "$raw"; then continue; fi + # De-dup + case " $WRITE_PATHS " in + *" $raw "*) ;; + *) WRITE_PATHS="$WRITE_PATHS $raw" ;; + esac + done < <(detect_write_paths "$COMMAND") + WRITE_PATHS="$(echo "$WRITE_PATHS" | xargs)" + + # Note: this branch always runs for Bash (no early-exit on read-only + # commands). The detector forks several subshells per invocation; if + # backends start chaining many small Bash calls we may want to short- + # circuit on commands that obviously can't write (e.g. leading `cat`, + # `ls`, `git status`) before running the regex pipeline. + if [[ -n "$WRITE_PATHS" && "$HAS_NVIM" == "true" ]]; then + log_pre "shell write candidates: $WRITE_PATHS" + for path in $WRITE_PATHS; do + # Distinguish created vs modified by checking current existence. + if [[ -e "$path" ]]; then + STATUS="bash_modified" + else + STATUS="bash_created" + fi + PATH_ESC="$(escape_lua "$path")" + nvim_send "require('code-preview.changes').set('$PATH_ESC', '$STATUS')" || true + done + nvim_send "pcall(function() require('code-preview.neo_tree').refresh() end)" || true + # Reveal precedence: rm wins. If the rm branch already queued a + # reveal, skip ours so we don't double-fire two defer_fn reveals on + # a command that both rm's and writes (e.g. `rm a && echo x > b`). + if [[ -z "$RM_PATHS" ]]; then + FIRST_PATH="$(echo "$WRITE_PATHS" | awk '{print $1}')" + FIRST_ESC="$(escape_lua "$FIRST_PATH")" + nvim_send "vim.defer_fn(function() pcall(function() require('code-preview.neo_tree').reveal('$FIRST_ESC') end) end, 300)" || true + fi + fi + exit 0 ;; @@ -208,8 +330,9 @@ case "$TOOL_NAME" in fi if [[ "$SHOULD_SHOW" == "1" ]]; then - log_pre "ApplyPatch: sending diff for $REL_PATH to nvim" - nvim_send "require('code-preview.diff').show_diff('$orig_esc', '$prop_esc', '$display_esc', '$fpath_esc')" || true + log_pre "ApplyPatch: sending diff for $REL_PATH to nvim (action=$ACTION)" + action_esc="$(escape_lua "$ACTION")" + nvim_send "require('code-preview.diff').show_diff('$orig_esc', '$prop_esc', '$display_esc', '$fpath_esc', '$action_esc')" || true fi else log_pre "ApplyPatch: no nvim connection, skipping diff for $REL_PATH" diff --git a/lua/code-preview/backends/codex.lua b/lua/code-preview/backends/codex.lua new file mode 100644 index 0000000..a89278c --- /dev/null +++ b/lua/code-preview/backends/codex.lua @@ -0,0 +1,240 @@ +local M = {} + +-- Resolve plugin root from this file's location +local function plugin_root() + local src = debug.getinfo(1, "S").source + local lua_file = src:sub(2) + local lua_dir = vim.fn.fnamemodify(lua_file, ":h") + -- Go up three levels: backends/ → code-preview/ → lua/ → plugin root + return vim.fn.fnamemodify(lua_dir, ":h:h:h") +end + +local function scripts_dir() return plugin_root() .. "/backends/codex" end +local function pre_script() return scripts_dir() .. "/code-preview-diff.sh" end +local function post_script() return scripts_dir() .. "/code-close-diff.sh" end + +local function codex_dir() return vim.fn.getcwd() .. "/.codex" end +local function hooks_path() return codex_dir() .. "/hooks.json" end +local function config_path() return codex_dir() .. "/config.toml" end + +-- Markers we use to identify our hook entries when merging with user-authored +-- hooks. The Codex docs allow multiple hooks per event, so we cooperate +-- rather than overwrite. We match by adapter script *path fragment* so the +-- check works for both the pre-hook (code-preview-diff.sh) and the post-hook +-- (code-close-diff.sh) — the latter doesn't share the "code-preview" prefix. +local HOOK_MARKERS = { + "backends/codex/code-preview-diff.sh", + "backends/codex/code-close-diff.sh", +} + +local function is_our_command(cmd) + cmd = tostring(cmd or "") + for _, m in ipairs(HOOK_MARKERS) do + if cmd:find(m, 1, true) then return true end + end + return false +end + +-- Parse JSON file. Returns: +-- ok=true, data= — file present and parsed +-- ok=true, data={} — file missing or empty (treat as fresh) +-- ok=false, err= — file present but invalid JSON +-- Distinguishing "missing" from "invalid" matters for install: a corrupted +-- hooks.json should NOT be silently overwritten (data loss). +local function read_json(path) + if vim.fn.filereadable(path) == 0 then + return true, {} + end + local f = io.open(path, "r") + if not f then + return true, {} + end + local raw = f:read("*a") or "" + f:close() + if raw == "" then return true, {} end + local ok, data = pcall(vim.json.decode, raw) + if not ok then + return false, tostring(data) + end + return true, data or {} +end + +local function write_json(path, data) + vim.fn.mkdir(vim.fn.fnamemodify(path, ":h"), "p") + local f = assert(io.open(path, "w"), "Cannot write to " .. path) + f:write(vim.json.encode(data)) + f:close() +end + +-- Filter out hook entries whose command contains our marker, so install is +-- idempotent and uninstall doesn't touch user-authored entries. +local function remove_ours(list) + local filtered = {} + for _, entry in ipairs(list or {}) do + local keep = true + for _, h in ipairs(entry.hooks or {}) do + if is_our_command(h.command) then + keep = false + break + end + end + if keep then table.insert(filtered, entry) end + end + return filtered +end + +-- Check both the project-local and global config.toml for the codex_hooks +-- feature flag. Returns "enabled" | "disabled" | "missing". +-- enabled — at least one location has `codex_hooks = true` +-- disabled — at least one location exists, but none enable the flag +-- missing — neither location exists +-- The global path mirrors what Codex itself reads, so a user who set the +-- flag in ~/.codex/config.toml shouldn't see a false warning here. +local function file_flag_state(path) + if vim.fn.filereadable(path) == 0 then return "missing" end + local f = io.open(path, "r") + if not f then return "missing" end + local content = f:read("*a") or "" + f:close() + -- Look for `codex_hooks = true` (loose match — handles whitespace & quotes + -- but not deeply parsed; users with exotic TOML are responsible for it). + if content:match("codex_hooks%s*=%s*true") then + return "enabled" + end + return "disabled" +end + +local function global_config_path() + -- Test-only override: lets tests redirect the global path away from the + -- user's real ~/.codex/config.toml. Production callers don't set this. + local override = vim.env.CODE_PREVIEW_CODEX_GLOBAL_CONFIG + if override and override ~= "" then return override end + return vim.fn.expand("~/.codex/config.toml") +end + +local function feature_flag_state() + local local_state = file_flag_state(config_path()) + local global_state = file_flag_state(global_config_path()) + -- Enabled wins if either location turns it on. + if local_state == "enabled" or global_state == "enabled" then + return "enabled" + end + -- If at least one file exists but neither enables the flag, surface as + -- disabled (so we tell the user what to fix). Only report missing when + -- both files are absent. + if local_state == "missing" and global_state == "missing" then + return "missing" + end + return "disabled" +end + +local function ensure_executable(path) + if vim.fn.filereadable(path) == 0 then + vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) + return false + end + vim.fn.system({ "chmod", "+x", path }) + return true +end + +function M.install() + local pre, post = pre_script(), post_script() + if not (ensure_executable(pre) and ensure_executable(post)) then return end + + vim.fn.mkdir(codex_dir(), "p") + + -- Merge with existing hooks rather than overwrite, since the user may have + -- their own entries (logging, prompt scrubbing, etc.) and Codex supports + -- stacking multiple hooks per event. Bail if the existing file is invalid + -- JSON — overwriting would silently destroy whatever the user had. + local ok, data_or_err = read_json(hooks_path()) + if not ok then + vim.notify( + "[code-preview] Refusing to install: " .. hooks_path() + .. " is not valid JSON (" .. data_or_err .. "). Fix or delete it, then retry.", + vim.log.levels.ERROR + ) + return + end + local data = data_or_err + data.hooks = data.hooks or {} + data.hooks.PreToolUse = remove_ours(data.hooks.PreToolUse) + data.hooks.PostToolUse = remove_ours(data.hooks.PostToolUse) + + table.insert(data.hooks.PreToolUse, { + matcher = "", + hooks = { { type = "command", command = pre } }, + }) + table.insert(data.hooks.PostToolUse, { + matcher = "", + hooks = { { type = "command", command = post } }, + }) + + write_json(hooks_path(), data) + vim.notify("[code-preview] Codex hooks installed → " .. hooks_path(), vim.log.levels.INFO) + + -- Codex ignores hooks.json unless `codex_hooks = true` lives under + -- `[features]` in config.toml. We don't edit config.toml automatically + -- (TOML editing without a parser is risky); surface a clear nudge instead. + local state = feature_flag_state() + if state ~= "enabled" then + local msg + if state == "missing" then + msg = "[code-preview] Codex requires a feature flag to enable hooks. Create " + .. config_path() .. " with:\n\n [features]\n codex_hooks = true\n" + else + msg = "[code-preview] Codex requires `codex_hooks = true` under `[features]` in " + .. config_path() .. ". Add it manually before running Codex." + end + vim.notify(msg, vim.log.levels.WARN) + end +end + +function M.uninstall() + local path = hooks_path() + local ok, data_or_err = read_json(path) + if not ok then + vim.notify( + "[code-preview] Cannot uninstall: " .. path + .. " is not valid JSON (" .. data_or_err .. "). Fix or delete it manually.", + vim.log.levels.ERROR + ) + return + end + local data = data_or_err + if not data.hooks then + vim.notify("[code-preview] No Codex hooks found at " .. path, vim.log.levels.WARN) + return + end + + data.hooks.PreToolUse = remove_ours(data.hooks.PreToolUse) + data.hooks.PostToolUse = remove_ours(data.hooks.PostToolUse) + + -- If the file ends up with empty arrays (or just our entries removed and + -- nothing else of substance), keep it on disk — the user might be + -- mid-edit. Don't try to be clever about deleting it. + write_json(path, data) + vim.notify("[code-preview] Codex hooks uninstalled from " .. path, vim.log.levels.INFO) +end + +-- Exposed so :CodePreviewStatus can report whether the feature flag is set +-- without duplicating the parser. +function M.feature_flag_state() return feature_flag_state() end + +-- True iff `path`'s hooks.json contains an entry referencing our adapter +-- script. Used by status display to detect installation without relying on +-- file existence alone. +function M.is_installed() + local ok, data = read_json(hooks_path()) + if not ok or not data.hooks then return false end + for _, ev in ipairs({ "PreToolUse", "PostToolUse" }) do + for _, entry in ipairs(data.hooks[ev] or {}) do + for _, h in ipairs(entry.hooks or {}) do + if is_our_command(h.command) then return true end + end + end + end + return false +end + +return M diff --git a/lua/code-preview/changes.lua b/lua/code-preview/changes.lua index 6cf608d..15151fa 100644 --- a/lua/code-preview/changes.lua +++ b/lua/code-preview/changes.lua @@ -38,4 +38,16 @@ function M.clear_by_status(status) end end +function M.clear_by_statuses(statuses) + local set = {} + for _, s in ipairs(statuses) do + set[s] = true + end + for path, s in pairs(pending) do + if set[s] then + pending[path] = nil + end + end +end + return M diff --git a/lua/code-preview/diff.lua b/lua/code-preview/diff.lua index 83285e9..d12043d 100644 --- a/lua/code-preview/diff.lua +++ b/lua/code-preview/diff.lua @@ -28,12 +28,23 @@ local function apply_highlights(config) end -- Update neo-tree indicator + reveal for a file that's about to be previewed. -local function mark_change_and_reveal(abs_file_path) +-- `action` is an optional hint from callers that know the operation type +-- (e.g. ApplyPatch passes "delete" for `*** Delete File:` directives). We +-- only emit "deleted" when explicitly told — inferring it from an empty +-- proposed file would misclassify legitimate truncations to zero bytes. +local function mark_change_and_reveal(abs_file_path, action) if not abs_file_path or abs_file_path == "" then return end - local status = vim.loop.fs_stat(abs_file_path) and "modified" or "created" + local status + if action == "delete" then + status = "deleted" + elseif vim.uv.fs_stat(abs_file_path) then + status = "modified" + else + status = "created" + end log.debug(log.fmt("mark_change_and_reveal: %s → %s", abs_file_path, status)) pcall(function() require("code-preview.changes").set(abs_file_path, status) end) pcall(function() require("code-preview.neo_tree").refresh() end) @@ -379,7 +390,7 @@ local function show_inline_diff(original_path, proposed_path, real_file_path, cf return { tab = tab, bufs = { buf }, inline_win = win } end -function M.show_diff(original_path, proposed_path, real_file_path, abs_file_path) +function M.show_diff(original_path, proposed_path, real_file_path, abs_file_path, action) local file_key = abs_file_path or real_file_path local cfg = require("code-preview").config log.info(log.fmt("show_diff: file=%s layout=%s active=%d", @@ -394,7 +405,7 @@ function M.show_diff(original_path, proposed_path, real_file_path, abs_file_path end -- Set the neo-tree indicator + reveal - mark_change_and_reveal(abs_file_path) + mark_change_and_reveal(abs_file_path, action) -- Inline layout if cfg.diff.layout == "inline" then diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index c23322a..20d97d5 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -164,6 +164,43 @@ function M.check() else warn("Copilot CLI hooks not installed — run :CodePreviewInstallCopilotCliHooks") end + + -- ── Codex CLI backend ───────────────────────────────────────── + + start("OpenAI Codex CLI backend") + + if vim.fn.executable("codex") == 1 then + ok("codex CLI is available in PATH") + else + warn("codex not found in PATH (install from https://github.com/openai/codex)") + end + + local codex_dir = plugin_root .. "/backends/codex" + for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do + local path = codex_dir .. "/" .. script + if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then + ok(script .. " is executable") + elseif vim.fn.filereadable(path) == 1 then + warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") + else + error(script .. " not found at " .. path) + end + end + + local codex_backend = require("code-preview.backends.codex") + if codex_backend.is_installed() then + ok("Codex CLI hooks are installed (.codex/hooks.json)") + local flag = codex_backend.feature_flag_state() + if flag == "enabled" then + ok(".codex/config.toml has codex_hooks = true") + elseif flag == "disabled" then + warn(".codex/config.toml is missing `codex_hooks = true` under [features] — hooks will not fire") + else + warn(".codex/config.toml not found — create it with `[features]\\ncodex_hooks = true`") + end + else + warn("Codex CLI hooks not installed — run :CodePreviewInstallCodexCliHooks") + end end return M diff --git a/lua/code-preview/init.lua b/lua/code-preview/init.lua index ab29de5..5d47309 100644 --- a/lua/code-preview/init.lua +++ b/lua/code-preview/init.lua @@ -117,6 +117,14 @@ function M.setup(user_config) require("code-preview.backends.copilot").uninstall() end, { desc = "Uninstall code-preview hooks for GitHub Copilot CLI" }) + vim.api.nvim_create_user_command("CodePreviewInstallCodexCliHooks", function() + require("code-preview.backends.codex").install() + end, { desc = "Install code-preview hooks for OpenAI Codex CLI" }) + + vim.api.nvim_create_user_command("CodePreviewUninstallCodexCliHooks", function() + require("code-preview.backends.codex").uninstall() + end, { desc = "Uninstall code-preview hooks for OpenAI Codex CLI" }) + vim.api.nvim_create_user_command("CodePreviewCloseDiff", function() require("code-preview.diff").close_diff_and_clear() end, { desc = "Manually close code-preview diff (use after rejecting a change)" }) @@ -265,6 +273,23 @@ function M.status() table.insert(lines, " Copilot CLI : not installed -> :CodePreviewInstallCopilotCliHooks") end + -- Codex CLI — installation requires both our hooks.json entries AND the + -- `codex_hooks = true` feature flag in config.toml; report both so users + -- can debug a "hooks aren't firing" state without guessing. + local codex = require("code-preview.backends.codex") + if codex.is_installed() then + local flag = codex.feature_flag_state() + if flag == "enabled" then + table.insert(lines, " Codex CLI : installed (codex_hooks=true)") + elseif flag == "disabled" then + table.insert(lines, " Codex CLI : installed BUT codex_hooks flag missing in .codex/config.toml") + else + table.insert(lines, " Codex CLI : installed BUT .codex/config.toml not found (need codex_hooks=true)") + end + else + table.insert(lines, " Codex CLI : not installed -> :CodePreviewInstallCodexCliHooks") + end + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO, { title = "code-preview" }) end diff --git a/lua/code-preview/neo_tree.lua b/lua/code-preview/neo_tree.lua index 774dc5e..693d355 100644 --- a/lua/code-preview/neo_tree.lua +++ b/lua/code-preview/neo_tree.lua @@ -72,9 +72,9 @@ local function wrap_name_component(state) if type(result) == "table" then local lookup = s.claude_status_lookup or {} local status = resolve_status(lookup, node.path) - if node._claude_virtual or status == "created" then + if node._claude_virtual or status == "created" or status == "bash_created" then result.highlight = "CodePreviewTreeVirtual" - elseif status == "modified" then + elseif status == "modified" or status == "bash_modified" then result.highlight = "CodePreviewTreeModified" elseif status == "deleted" then result.highlight = "CodePreviewTreeDeleted" @@ -116,6 +116,12 @@ local function inject_renderer(state, node_type) end -- Inject the claude_status icon component +-- +-- v1 simplification: bash_created shares styling with created, and +-- bash_modified shares styling with modified. Users can't visually +-- distinguish a shell-write from an editor write. If backends start +-- routing most edits through Bash (Codex), we may want a distinct icon +-- and highlight for shell-driven changes. Tracked as future work. local function inject_status_component(state, symbols) if state.components.claude_status then return @@ -123,12 +129,12 @@ local function inject_status_component(state, symbols) state.components.claude_status = function(config, node, s) local lookup = s.claude_status_lookup or {} local status = resolve_status(lookup, node.path) - if node._claude_virtual or status == "created" then + if node._claude_virtual or status == "created" or status == "bash_created" then return { text = (symbols.created or "") .. " ", highlight = "CodePreviewTreeCreated", } - elseif status == "modified" then + elseif status == "modified" or status == "bash_modified" then return { text = (symbols.modified or "󰏫") .. " ", highlight = "CodePreviewTreeModified", @@ -147,7 +153,8 @@ end local function cleanup_stale_virtual_nodes(state, pending) local stale = {} for path, _ in pairs(virtual_nodes) do - if pending[path] ~= "created" then + local s = pending[path] + if s ~= "created" and s ~= "bash_created" then table.insert(stale, path) end end @@ -167,7 +174,7 @@ local function inject_virtual_nodes(state, pending) local changed = false for filepath, status in pairs(pending) do - if status ~= "created" then + if status ~= "created" and status ~= "bash_created" then goto continue end diff --git a/tests/backends/codex/test_apply_patch.sh b/tests/backends/codex/test_apply_patch.sh new file mode 100755 index 0000000..44a9d48 --- /dev/null +++ b/tests/backends/codex/test_apply_patch.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# test_apply_patch.sh — E2E tests for Codex CLI apply_patch workflow +# +# Codex carries the `*** Begin Patch … *** End Patch` payload in +# tool_input.command (not tool_input.patch_text). The adapter rewrites the +# field name and forwards to bin/core-pre-tool.sh, which uses the same +# apply-patch.lua parser the other backends share. + +CODEX_PRE="$REPO_ROOT/backends/codex/code-preview-diff.sh" +CODEX_POST="$REPO_ROOT/backends/codex/code-close-diff.sh" + +# Build a Codex apply_patch payload — patch text lives in tool_input.command. +run_codex_pre_patch() { + local patch_text="$1" + local payload + payload=$(jq -n \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg pt "$patch_text" \ + '{tool_name:"apply_patch", cwd:$cwd, tool_input:{command:$pt}}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$CODEX_PRE" 2>/dev/null || true +} + +run_codex_post_patch() { + local patch_text="$1" + local payload + payload=$(jq -n \ + --arg cwd "$TEST_PROJECT_DIR" \ + --arg pt "$patch_text" \ + '{tool_name:"apply_patch", cwd:$cwd, tool_input:{command:$pt}}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$CODEX_POST" 2>/dev/null || true +} + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# ── Test: single-file Update via apply_patch ──────────────────── + +test_codex_apply_patch_update() { + reset_test_state + local test_file + test_file="$(create_test_file "hello.txt" "line one +line two +line three")" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: hello.txt" \ + "@@" \ + " line one" \ + "-line two" \ + "+line two modified" \ + " line three" \ + "*** End Patch") + + run_codex_pre_patch "$patch" + sleep 0.5 + + assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should open after apply_patch update" || return 1 + assert_eq "modified" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "Update File should mark target as modified" || return 1 + + run_codex_post_patch "$patch" + sleep 0.5 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should close after Update File post" || return 1 +} + +# ── Test: Add File marks new file as created ──────────────────── + +test_codex_apply_patch_add() { + reset_test_state + local new_file="$TEST_PROJECT_DIR/src/cx_added.lua" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Add File: src/cx_added.lua" \ + "+local M = {}" \ + "+return M" \ + "*** End Patch") + + run_codex_pre_patch "$patch" + sleep 0.5 + + assert_eq "created" "$(nvim_eval "require('code-preview.changes').get('$new_file')")" \ + "Add File should mark target as created" || return 1 + + run_codex_post_patch "$patch" + sleep 0.5 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should close after Add File post" || return 1 + assert_eq "0" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "registry should be empty after Add File cycle" || return 1 +} + +# ── Test: mixed Update+Add+Delete — all open, all close ───────── + +# Mirrors tests/backends/copilot/test_apply_patch.sh — locks the contract +# that the post-hook closes diffs for every directive in the patch, not +# just the first one. +test_codex_apply_patch_mixed() { + reset_test_state + + local f_update f_delete1 f_delete2 f_add + f_update="$(create_test_file "README.md" "existing line +old text +tail")" + f_delete1="$(create_test_file "old1.txt" "bye1")" + f_delete2="$(create_test_file "old2.txt" "bye2")" + f_add="$TEST_PROJECT_DIR/brand_new.txt" + + local patch + patch=$(printf '%s\n' \ + "*** Begin Patch" \ + "*** Update File: README.md" \ + "@@" \ + " existing line" \ + "-old text" \ + "+new text" \ + " tail" \ + "*** Add File: brand_new.txt" \ + "+hello from new file" \ + "*** Delete File: old1.txt" \ + "*** Delete File: old2.txt" \ + "*** End Patch") + + run_codex_pre_patch "$patch" + sleep 0.6 + + assert_eq "4" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "4 files should be tracked after pre-hook" || return 1 + + assert_eq "modified" "$(nvim_eval "require('code-preview.changes').get('$f_update')")" \ + "Update File should be modified" || return 1 + assert_eq "created" "$(nvim_eval "require('code-preview.changes').get('$f_add')")" \ + "Add File should be created" || return 1 + + assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should be open during mixed patch" || return 1 + + run_codex_post_patch "$patch" + sleep 0.6 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "all diffs should close after post-hook" || return 1 + assert_eq "0" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "registry should be empty after mixed-patch post" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Codex apply_patch Update File opens and closes diff" test_codex_apply_patch_update +run_test "Codex apply_patch Add File marks as created" test_codex_apply_patch_add +run_test "Codex apply_patch mixed Update+Add+Delete closes all" test_codex_apply_patch_mixed + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/backends/codex/test_edit.sh b/tests/backends/codex/test_edit.sh new file mode 100755 index 0000000..55e232d --- /dev/null +++ b/tests/backends/codex/test_edit.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# test_edit.sh — E2E tests for Codex CLI Bash + edit workflows +# +# Drives Codex's hook payload shape ({tool_name, tool_input, cwd}) through +# backends/codex/code-preview-diff.sh (pre) and code-close-diff.sh (post), +# then verifies Neovim state via RPC. +# +# Codex specifics: +# - apply_patch carries the patch text in tool_input.command (not patch_text). +# The adapter rewrites that field; covered in test_apply_patch.sh. +# - Today's models route ALL file edits through apply_patch. Edit/Write/ +# MultiEdit are passed through defensively for forward compat. +# - Bash detection: rm marks deleted; output redirection (Tier 1 shell +# writes) marks bash_modified / bash_created. Both clear on PostToolUse. + +CODEX_PRE="$REPO_ROOT/backends/codex/code-preview-diff.sh" +CODEX_POST="$REPO_ROOT/backends/codex/code-close-diff.sh" + +# Feed a Codex-shaped payload to the pre-tool adapter. +# $1 = tool_name, $2 = tool_input (JSON object) +run_codex_pre() { + local tool_name="$1" + local tool_input="$2" + local payload + payload=$(jq -n \ + --arg tn "$tool_name" \ + --arg cwd "$TEST_PROJECT_DIR" \ + --argjson ti "$tool_input" \ + '{tool_name:$tn, cwd:$cwd, tool_input:$ti}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$CODEX_PRE" 2>/dev/null || true +} + +run_codex_post() { + local tool_name="$1" + local tool_input="$2" + local payload + payload=$(jq -n \ + --arg tn "$tool_name" \ + --arg cwd "$TEST_PROJECT_DIR" \ + --argjson ti "$tool_input" \ + '{tool_name:$tn, cwd:$cwd, tool_input:$ti}') + echo "$payload" | \ + NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ + bash "$CODEX_POST" 2>/dev/null || true +} + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +# ── Test: defensive Edit passthrough ──────────────────────────── + +# Codex doesn't currently emit `Edit` (it routes via apply_patch), but the +# adapter passes it through anyway in case a future Codex version or MCP +# tool uses Claude-Code-style {file_path, old_string, new_string} payloads. +test_codex_edit_passthrough() { + reset_test_state + local test_file + test_file="$(create_test_file "src/cx_edit.lua" 'local x = 1')" + + local tool_input + tool_input=$(jq -nc \ + --arg p "$test_file" \ + --arg o "local x = 1" \ + --arg n "local x = 99" \ + '{file_path:$p, old_string:$o, new_string:$n}') + + run_codex_pre "Edit" "$tool_input" + sleep 0.5 + + assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should open on Edit passthrough" || return 1 + assert_eq "modified" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "Edit should mark file as modified" || return 1 + + run_codex_post "Edit" "$tool_input" + sleep 0.5 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should close after Edit post" || return 1 +} + +# ── Test: defensive Write passthrough ─────────────────────────── + +test_codex_write_passthrough() { + reset_test_state + local new_file="$TEST_PROJECT_DIR/src/cx_new.lua" + + local tool_input + tool_input=$(jq -nc \ + --arg p "$new_file" \ + --arg c "local M = {} +return M" \ + '{file_path:$p, content:$c}') + + run_codex_pre "Write" "$tool_input" + sleep 0.5 + + assert_eq "true" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should open on Write passthrough" || return 1 + assert_eq "created" "$(nvim_eval "require('code-preview.changes').get('$new_file')")" \ + "Write should mark new file as created" || return 1 + + run_codex_post "Write" "$tool_input" + sleep 0.5 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "diff should close after Write post" || return 1 +} + +# ── Test: Bash rm marks target as deleted ─────────────────────── + +test_codex_bash_rm() { + reset_test_state + local test_file + test_file="$(create_test_file "cx_delete_me.txt" 'goodbye')" + + local tool_input + tool_input=$(jq -nc --arg cmd "rm $test_file" '{command:$cmd}') + + run_codex_pre "Bash" "$tool_input" + sleep 0.4 + + assert_eq "deleted" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "rm target should be marked as deleted" || return 1 + + run_codex_post "Bash" "$tool_input" + sleep 0.4 + + assert_eq "nil" "$(nvim_eval "require('code-preview.changes').get('$test_file') or 'nil'")" \ + "deletion marker should be cleared on Bash post" || return 1 +} + +# ── Test: Tier 1 shell-write detection (bash_modified) ────────── + +# Codex sometimes performs file edits via shell redirection instead of +# apply_patch (e.g. `printf … >> file`). Tier 1 detection extracts the +# target path and marks it bash_modified so the user gets a neo-tree icon +# during the approval window. +test_codex_bash_shell_write_modified() { + reset_test_state + local test_file + test_file="$(create_test_file "cx_shell_target.txt" "original line")" + + # Append to existing file via redirection. + local tool_input + tool_input=$(jq -nc --arg cmd "printf 'extra\n' >> $test_file" '{command:$cmd}') + + run_codex_pre "Bash" "$tool_input" + sleep 0.4 + + assert_eq "bash_modified" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "shell-write target on existing file should be bash_modified" || return 1 + + run_codex_post "Bash" "$tool_input" + sleep 0.4 + + assert_eq "nil" "$(nvim_eval "require('code-preview.changes').get('$test_file') or 'nil'")" \ + "bash_modified marker should clear on Bash post" || return 1 +} + +# ── Test: Tier 1 — atomic-replace idiom (mv X.tmp X) ──────────── + +# This is the specific pattern Codex's GPT models use for prepend/rewrite: +# `{ printf …; cat F; } > F.tmp && mv F.tmp F` +# Detection should flag F as bash_modified (existing) and filter F.tmp +# (transient). The .tmp side falls under is_transient_path. +test_codex_bash_atomic_replace() { + reset_test_state + local test_file + test_file="$(create_test_file "cx_atomic.txt" "original\n")" + + local cmd="{ printf 'note\\n'; cat $test_file; } > $test_file.tmp && mv $test_file.tmp $test_file" + local tool_input + tool_input=$(jq -nc --arg cmd "$cmd" '{command:$cmd}') + + run_codex_pre "Bash" "$tool_input" + sleep 0.4 + + assert_eq "bash_modified" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "atomic-replace target should be marked bash_modified" || return 1 + + # The .tmp file should NOT be in the changes registry (filtered as transient). + assert_eq "nil" "$(nvim_eval "require('code-preview.changes').get('$test_file.tmp') or 'nil'")" \ + "atomic-replace .tmp file should be filtered out" || return 1 + + run_codex_post "Bash" "$tool_input" + sleep 0.4 +} + +# ── Test: Tier 1 — write to non-existent file marks bash_created ─ + +test_codex_bash_shell_write_created() { + reset_test_state + local new_file="$TEST_PROJECT_DIR/cx_brand_new.txt" + [[ -f "$new_file" ]] && rm -f "$new_file" + + local tool_input + tool_input=$(jq -nc --arg cmd "printf 'hello\n' > $new_file" '{command:$cmd}') + + run_codex_pre "Bash" "$tool_input" + sleep 0.4 + + assert_eq "bash_created" "$(nvim_eval "require('code-preview.changes').get('$new_file')")" \ + "shell-write to non-existent file should be bash_created" || return 1 + + run_codex_post "Bash" "$tool_input" + sleep 0.4 + + assert_eq "nil" "$(nvim_eval "require('code-preview.changes').get('$new_file') or 'nil'")" \ + "bash_created marker should clear on Bash post" || return 1 +} + +# ── Test: Tier 1 — read-only Bash commands don't pollute registry ─ + +# Pure-read commands (ls, cat, grep) must NOT mark anything. The detector +# only fires on write indicators, so this should be a true no-op. +test_codex_bash_readonly_no_marks() { + reset_test_state + + local tool_input + tool_input=$(jq -nc --arg cmd "ls -la $TEST_PROJECT_DIR" '{command:$cmd}') + run_codex_pre "Bash" "$tool_input" + sleep 0.3 + + assert_eq "0" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "read-only Bash command should not mark any files" || return 1 +} + +# ── Test: Tier 1 — false-positive guard for HTML comments in printf ─ + +# `` inside a printf string contains `>` characters that the +# redirection regex would otherwise capture. looks_like_path() must filter +# the resulting `\n…'` capture so it doesn't reach the registry. +test_codex_bash_html_comment_false_positive() { + reset_test_state + local test_file + test_file="$(create_test_file "cx_html.md" "# heading")" + + # The printf payload contains '-->' which the redirection regex sees as a + # `>` boundary. Without the looks_like_path filter, this would mark a + # bogus `\n\n'`-style entry. + local cmd="{ printf '\\n\\n'; cat $test_file; } > $test_file.tmp && mv $test_file.tmp $test_file" + local tool_input + tool_input=$(jq -nc --arg cmd "$cmd" '{command:$cmd}') + + run_codex_pre "Bash" "$tool_input" + sleep 0.4 + + # Exactly one entry — only the real target, no junk. + assert_eq "1" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "HTML-comment-in-printf must not produce phantom entries" || return 1 + assert_eq "bash_modified" "$(nvim_eval "require('code-preview.changes').get('$test_file')")" \ + "real target should still be detected" || return 1 + + run_codex_post "Bash" "$tool_input" + sleep 0.4 +} + +# ── Test: noise tools exit without side effects ───────────────── + +# read/glob/grep and MCP tools (mcp__*) should be no-ops in the adapter. +test_codex_noise_tools_ignored() { + reset_test_state + + run_codex_pre "read" '{"path":"/tmp/whatever"}' + run_codex_pre "glob" '{"pattern":"**/*.lua"}' + run_codex_pre "mcp__fs__read" '{"path":"/tmp/x"}' + sleep 0.3 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "noise tools should not open a diff" || return 1 + assert_eq "0" "$(nvim_eval "vim.tbl_count(require('code-preview.changes').get_all())")" \ + "noise tools should not mark changes" || return 1 +} + +# ── Test: malformed payloads skip cleanly ─────────────────────── + +# Defensive: the adapter must exit 0 on missing/empty tool_input rather +# than push a broken diff downstream. +test_codex_malformed_payloads_skip() { + reset_test_state + + # Edit with empty file_path + run_codex_pre "Edit" '{"old_string":"a","new_string":"b"}' + # Write with missing file_path + run_codex_pre "Write" '{"content":"hello"}' + # Bash with empty command + run_codex_pre "Bash" '{}' + # tool_input entirely absent + local payload + payload=$(jq -n --arg cwd "$TEST_PROJECT_DIR" '{tool_name:"Edit", cwd:$cwd}') + echo "$payload" | NVIM_LISTEN_ADDRESS="$TEST_SOCKET" bash "$CODEX_PRE" 2>/dev/null || true + + sleep 0.3 + + assert_eq "false" "$(nvim_eval "require('code-preview.diff').is_open()")" \ + "malformed payloads should not open a diff" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Codex Edit passthrough opens and closes diff" test_codex_edit_passthrough +run_test "Codex Write passthrough marks file as created" test_codex_write_passthrough +run_test "Codex Bash rm marks target as deleted" test_codex_bash_rm +run_test "Codex Bash shell write marks existing file modified" test_codex_bash_shell_write_modified +run_test "Codex Bash atomic-replace idiom marks real target" test_codex_bash_atomic_replace +run_test "Codex Bash shell write marks new file created" test_codex_bash_shell_write_created +run_test "Codex Bash read-only commands leave registry empty" test_codex_bash_readonly_no_marks +run_test "Codex Bash filters HTML-comment false positives" test_codex_bash_html_comment_false_positive +run_test "Codex noise tools (read/glob/mcp__) ignored" test_codex_noise_tools_ignored +run_test "Codex malformed payloads skip cleanly" test_codex_malformed_payloads_skip + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/backends/codex/test_install.sh b/tests/backends/codex/test_install.sh new file mode 100755 index 0000000..3acd56f --- /dev/null +++ b/tests/backends/codex/test_install.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# test_install.sh — OpenAI Codex CLI hook install/uninstall tests +# +# Codex reads hooks from .codex/hooks.json and requires `codex_hooks = true` +# under [features] in .codex/config.toml. Our installer writes hooks.json +# (merging with any existing entries) and warns if the feature flag is +# missing — it does NOT edit config.toml. These tests pin that contract. + +# ── Setup ──────────────────────────────────────────────────────── + +setup_test_project +start_nvim + +nvim_exec "vim.cmd('cd $TEST_PROJECT_DIR')" + +HOOKS_FILE="$TEST_PROJECT_DIR/.codex/hooks.json" +CONFIG_FILE="$TEST_PROJECT_DIR/.codex/config.toml" + +# Redirect the "global" config path used by feature_flag_state away from +# the user's real ~/.codex/config.toml so this test never touches it. +GLOBAL_CONFIG_FILE="$TEST_PROJECT_DIR/.fake-home-codex-config.toml" +nvim_exec "vim.env.CODE_PREVIEW_CODEX_GLOBAL_CONFIG = '$GLOBAL_CONFIG_FILE'" +rm -f "$GLOBAL_CONFIG_FILE" + +# ── Test: Install writes the correct hook file ────────────────── + +test_install_codex_hooks() { + rm -rf "$TEST_PROJECT_DIR/.codex" + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.3 + + assert_file_exists "$HOOKS_FILE" "hooks.json should be created" || return 1 + + # Both hook events present and pointing at our adapter scripts + local content + content="$(cat "$HOOKS_FILE")" + assert_contains "$content" "PreToolUse" "should have PreToolUse hook" || return 1 + assert_contains "$content" "PostToolUse" "should have PostToolUse hook" || return 1 + assert_contains "$content" "code-preview-diff.sh" "should reference pre-tool script" || return 1 + assert_contains "$content" "code-close-diff.sh" "should reference post-tool script" || return 1 + + # Exactly one entry per event after a fresh install. + local pre_count post_count + pre_count="$(jq '.hooks.PreToolUse | length' "$HOOKS_FILE")" + post_count="$(jq '.hooks.PostToolUse | length' "$HOOKS_FILE")" + assert_eq "1" "$pre_count" "PreToolUse should have 1 entry" || return 1 + assert_eq "1" "$post_count" "PostToolUse should have 1 entry" || return 1 +} + +# ── Test: Install is idempotent ───────────────────────────────── + +# Re-running install must not append duplicate entries — `is_installed()` +# uses our adapter path as the marker, and we filter them out before +# inserting on every install. +test_install_idempotent() { + rm -rf "$TEST_PROJECT_DIR/.codex" + nvim_exec "require('code-preview.backends.codex').install()" + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.3 + + local pre_count post_count + pre_count="$(jq '.hooks.PreToolUse | length' "$HOOKS_FILE")" + post_count="$(jq '.hooks.PostToolUse | length' "$HOOKS_FILE")" + assert_eq "1" "$pre_count" "PreToolUse should still have 1 entry after re-install" || return 1 + assert_eq "1" "$post_count" "PostToolUse should still have 1 entry after re-install" || return 1 +} + +# ── Test: Install preserves user-authored hook entries ────────── + +# Codex supports stacking multiple hooks per event. A user might have their +# own logging or policy hook alongside ours. Install must merge, not stomp. +test_install_preserves_user_hooks() { + rm -rf "$TEST_PROJECT_DIR/.codex" + mkdir -p "$TEST_PROJECT_DIR/.codex" + + # User-authored hooks.json with unrelated commands in BOTH PreToolUse and + # PostToolUse — install must preserve user entries on both events. + cat > "$HOOKS_FILE" <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { "matcher": "", "hooks": [ { "type": "command", "command": "/usr/bin/true # user-pre-policy" } ] } + ], + "PostToolUse": [ + { "matcher": "", "hooks": [ { "type": "command", "command": "/usr/bin/true # user-post-policy" } ] } + ] + } +} +EOF + + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.3 + + # Both user entries must survive. + local content + content="$(cat "$HOOKS_FILE")" + assert_contains "$content" "user-pre-policy" "user PreToolUse entry should survive install" || return 1 + assert_contains "$content" "user-post-policy" "user PostToolUse entry should survive install" || return 1 + + # Both ours and theirs should be present in PreToolUse and PostToolUse. + local pre_count post_count + pre_count="$(jq '.hooks.PreToolUse | length' "$HOOKS_FILE")" + post_count="$(jq '.hooks.PostToolUse | length' "$HOOKS_FILE")" + assert_eq "2" "$pre_count" "PreToolUse should now have 2 entries (user + ours)" || return 1 + assert_eq "2" "$post_count" "PostToolUse should now have 2 entries (user + ours)" || return 1 +} + +# ── Test: Uninstall removes only our entries ──────────────────── + +test_uninstall_preserves_user_hooks() { + rm -rf "$TEST_PROJECT_DIR/.codex" + mkdir -p "$TEST_PROJECT_DIR/.codex" + + cat > "$HOOKS_FILE" <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { "matcher": "", "hooks": [ { "type": "command", "command": "/usr/bin/true # user-policy" } ] } + ] + } +} +EOF + + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.2 + nvim_exec "require('code-preview.backends.codex').uninstall()" + sleep 0.2 + + # File should still exist (we don't delete it — user may have other entries). + assert_file_exists "$HOOKS_FILE" "hooks.json should not be deleted on uninstall" || return 1 + + local content + content="$(cat "$HOOKS_FILE")" + assert_contains "$content" "user-policy" "user entry must survive uninstall" || return 1 + assert_not_contains "$content" "code-preview-diff.sh" "our pre-hook must be removed" || return 1 + assert_not_contains "$content" "code-close-diff.sh" "our post-hook must be removed" || return 1 +} + +# ── Test: feature_flag_state reports the three modes ──────────── + +# Drives the helper that :CodePreviewStatus and :checkhealth use to surface +# the codex_hooks feature flag. The flag is the silent failure mode for +# Codex hooks, so the detector must not produce false positives or negatives. +test_feature_flag_state() { + rm -rf "$TEST_PROJECT_DIR/.codex" + rm -f "$GLOBAL_CONFIG_FILE" + + # Both project-local and global absent. + local missing + missing="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "missing" "$missing" "no config files should report 'missing'" || return 1 + + # Project-local exists without the flag, global still absent → disabled. + mkdir -p "$TEST_PROJECT_DIR/.codex" + cat > "$CONFIG_FILE" <<'EOF' +approval_policy = "on-request" +EOF + local disabled + disabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$disabled" "config.toml without flag should report 'disabled'" || return 1 + + # Project-local has the flag → enabled. + cat > "$CONFIG_FILE" <<'EOF' +approval_policy = "on-request" + +[features] +codex_hooks = true +EOF + local enabled + enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$enabled" "config.toml with flag should report 'enabled'" || return 1 +} + +# ── Test: feature_flag_state honors the global config.toml ────── + +# Codex reads ~/.codex/config.toml (global) in addition to .codex/config.toml +# (project-local). A user with the flag set globally should NOT see a +# misleading "disabled/missing" warning. Mirrors the docs we link in README. +test_feature_flag_state_global() { + rm -rf "$TEST_PROJECT_DIR/.codex" + rm -f "$GLOBAL_CONFIG_FILE" + + # Only the global file has the flag — project-local is absent. + cat > "$GLOBAL_CONFIG_FILE" <<'EOF' +[features] +codex_hooks = true +EOF + local enabled + enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$enabled" "global config with flag should report 'enabled'" || return 1 + + # Project-local without the flag must NOT downgrade an enabled global. + mkdir -p "$TEST_PROJECT_DIR/.codex" + cat > "$CONFIG_FILE" <<'EOF' +approval_policy = "on-request" +EOF + local still_enabled + still_enabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "enabled" "$still_enabled" "global flag should win over local-without-flag" || return 1 + + # Both files present, neither enables → disabled (not missing). + rm -f "$GLOBAL_CONFIG_FILE" + cat > "$GLOBAL_CONFIG_FILE" <<'EOF' +# nothing useful here +EOF + local disabled + disabled="$(nvim_eval "require('code-preview.backends.codex').feature_flag_state()")" + assert_eq "disabled" "$disabled" "two configs, neither enabling, should be 'disabled'" || return 1 +} + +# ── Test: install refuses to overwrite a corrupted hooks.json ─── + +# Hand-edits or interrupted writes can leave hooks.json in an unparseable +# state. Silent overwrite would destroy whatever the user had. Install must +# bail with a clear error so the user can recover. +test_install_refuses_corrupted_hooks_json() { + rm -rf "$TEST_PROJECT_DIR/.codex" + mkdir -p "$TEST_PROJECT_DIR/.codex" + # Garbage that can never decode as JSON. + printf '%s\n' '{ this is not valid json at all' > "$HOOKS_FILE" + + local original_content + original_content="$(cat "$HOOKS_FILE")" + + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.3 + + # File contents must be unchanged. + local after_content + after_content="$(cat "$HOOKS_FILE")" + assert_eq "$original_content" "$after_content" \ + "corrupted hooks.json must not be overwritten on install" || return 1 + + # is_installed should still be false because we bailed. + local installed + installed="$(nvim_eval "require('code-preview.backends.codex').is_installed()")" + assert_eq "false" "$installed" "install should not register after bailing on corrupt JSON" || return 1 +} + +# ── Test: uninstall surfaces corrupted JSON instead of stomping ─ + +test_uninstall_handles_corrupted_hooks_json() { + rm -rf "$TEST_PROJECT_DIR/.codex" + mkdir -p "$TEST_PROJECT_DIR/.codex" + printf '%s\n' '{ broken' > "$HOOKS_FILE" + + local original_content + original_content="$(cat "$HOOKS_FILE")" + + nvim_exec "require('code-preview.backends.codex').uninstall()" + sleep 0.3 + + local after_content + after_content="$(cat "$HOOKS_FILE")" + assert_eq "$original_content" "$after_content" \ + "corrupted hooks.json must not be modified on uninstall" || return 1 +} + +# ── Test: is_installed reflects current hooks.json state ──────── + +test_is_installed_detection() { + rm -rf "$TEST_PROJECT_DIR/.codex" + + local before + before="$(nvim_eval "require('code-preview.backends.codex').is_installed()")" + assert_eq "false" "$before" "is_installed should be false when nothing is set up" || return 1 + + nvim_exec "require('code-preview.backends.codex').install()" + sleep 0.2 + local after + after="$(nvim_eval "require('code-preview.backends.codex').is_installed()")" + assert_eq "true" "$after" "is_installed should be true after install" || return 1 + + nvim_exec "require('code-preview.backends.codex').uninstall()" + sleep 0.2 + local removed + removed="$(nvim_eval "require('code-preview.backends.codex').is_installed()")" + assert_eq "false" "$removed" "is_installed should be false after uninstall" || return 1 +} + +# ── Run all tests ──────────────────────────────────────────────── + +run_test "Install Codex CLI hooks writes correct config" test_install_codex_hooks +run_test "Install is idempotent (no duplicate entries)" test_install_idempotent +run_test "Install preserves user-authored hook entries" test_install_preserves_user_hooks +run_test "Uninstall preserves user-authored hook entries" test_uninstall_preserves_user_hooks +run_test "feature_flag_state reports missing/disabled/enabled" test_feature_flag_state +run_test "feature_flag_state honors global ~/.codex/config.toml" test_feature_flag_state_global +run_test "Install refuses to overwrite corrupted hooks.json" test_install_refuses_corrupted_hooks_json +run_test "Uninstall doesn't stomp corrupted hooks.json" test_uninstall_handles_corrupted_hooks_json +run_test "is_installed reflects hooks.json state" test_is_installed_detection + +# ── Teardown ───────────────────────────────────────────────────── + +stop_nvim +cleanup_test_project diff --git a/tests/plugin/diff_lifecycle_spec.lua b/tests/plugin/diff_lifecycle_spec.lua index 9150181..e50af6c 100644 --- a/tests/plugin/diff_lifecycle_spec.lua +++ b/tests/plugin/diff_lifecycle_spec.lua @@ -156,6 +156,39 @@ describe("diff lifecycle", function() os.remove(prop) end) + it("show_diff with action=delete marks the file as deleted in the changes registry", function() + local orig = tmp_file("del_orig.txt", "to be removed\n") + local prop = tmp_file("del_prop.txt", "") + + -- abs_file_path must point to a real on-disk file — `mark_change_and_reveal` + -- only honors the delete hint for files that currently exist. + local abs = tmp_file("del_abs.txt", "to be removed\n") + + diff.show_diff(orig, prop, "deleted.txt", abs, "delete") + assert.equals("deleted", changes.get(abs)) + + diff.close_for_file(abs) + os.remove(orig) + os.remove(prop) + os.remove(abs) + end) + + it("show_diff without action does NOT mark a truncation-to-empty as deleted", function() + -- Regression guard: a legitimate "edit file down to zero bytes" must + -- show as modified, not deleted. + local orig = tmp_file("trunc_orig.txt", "stub content\n") + local prop = tmp_file("trunc_prop.txt", "") + local abs = tmp_file("trunc_abs.txt", "stub content\n") + + diff.show_diff(orig, prop, "trunc.txt", abs) + assert.equals("modified", changes.get(abs)) + + diff.close_for_file(abs) + os.remove(orig) + os.remove(prop) + os.remove(abs) + end) + it("close_diff_and_clear closes all active diffs", function() local orig1 = tmp_file("drain_orig1.txt", "aaa") local prop1 = tmp_file("drain_prop1.txt", "bbb") From c5ebb83ed154bdf4b811e3bead108d6c65d309a9 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 1 May 2026 21:34:32 +0530 Subject: [PATCH 2/4] fix(bash-detect): don't filter /tmp/* in is_transient_path The blanket /tmp/* rule masked real shell-write targets on Linux, where mktemp paths stay under /tmp (macOS resolves /tmp to /private/tmp via pwd -P, so the filter happened to miss). Transience is signaled by the extension or /dev/*, not by being under /tmp. Fixes Ubuntu CI failures in tests/backends/codex/test_edit.sh: - Codex Bash shell write marks existing file modified - Codex Bash atomic-replace idiom marks real target - Codex Bash shell write marks new file created - Codex Bash filters HTML-comment false positives Co-Authored-By: Claude Opus 4.7 --- bin/core-pre-tool.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh index 2620c93..6e1d2bc 100755 --- a/bin/core-pre-tool.sh +++ b/bin/core-pre-tool.sh @@ -186,10 +186,14 @@ case "$TOOL_NAME" in | awk '{print $NF}' || true } - # Filters: skip transient file extensions and pseudo-paths. + # Filters: skip transient file extensions and pseudo-paths. We + # deliberately do NOT blanket-filter `/tmp/*` — on Linux `pwd -P` + # resolves to a real `/tmp/...` path, and we still want shell-write + # detection to mark targets there. Transience is signaled by the + # extension or by `/dev/*`, not by being under /tmp. is_transient_path() { case "$1" in - *.tmp|*.bak|*.swp|*~|/dev/*|/tmp/*) return 0 ;; + *.tmp|*.bak|*.swp|*~|/dev/*) return 0 ;; esac return 1 } From 200b1a5cbf3e0bfdf87e8ade8387e538a807979d Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 1 May 2026 21:56:53 +0530 Subject: [PATCH 3/4] docs: document Codex approval_policy + sandbox_mode in README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `approval_policy = "on-request"` and `sandbox_mode = "read-only"` in config.toml, Codex applies edits without prompting and the diff preview never blocks on the user's decision — defeating the point of the workflow. Bundle both alongside the existing `codex_hooks` flag in the Quick Start. Co-Authored-By: Claude Opus 4.7 --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ae89a8..2151f1c 100644 --- a/README.md +++ b/README.md @@ -140,14 +140,19 @@ require("code-preview").setup() 1. Install the plugin and call `setup()` 2. Open a project in Neovim 3. Run `:CodePreviewInstallCodexCliHooks` — writes `.codex/hooks.json` -4. Codex requires a feature flag to enable hooks. Create or edit `.codex/config.toml` (project-local) or `~/.codex/config.toml` (global) and add: +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: ```toml + approval_policy = "on-request" + sandbox_mode = "read-only" + [features] codex_hooks = true ``` - The installer warns you if this flag is missing. You can also re-check at any time with `:CodePreviewStatus` or `:checkhealth code-preview`, which both report whether the feature flag is detected. + `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. + + 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. 5. Start Codex CLI in the project directory 6. Ask Codex to edit a file — a diff opens automatically in Neovim 7. Accept/reject in the CLI; the diff closes automatically on accept From 4d82621f3f44456145d50cf75fe6f3f98ae72803 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 2 May 2026 01:59:14 +0530 Subject: [PATCH 4/4] docs: add Codex CLI demo gif to README Co-Authored-By: Claude Opus 4.7 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 2151f1c..41c3f1e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCod ### GitHub Copilot CLI ![GitHub Copilot CLI demo](docs/code-preview-copilot.gif) +### OpenAI Codex CLI +![OpenAI Codex CLI demo](docs/code-preview-codex.gif) + --- ## Table of Contents