Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 19 additions & 72 deletions backends/codex/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -1,90 +1,37 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook adapter for OpenAI Codex CLI.
# code-close-diff.sh — PostToolUse hook entry 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).
# Single RPC into the in-process orchestrator (lua/code-preview/post_tool.lua).
# The orchestrator clears the changes registry, closes any open preview for
# the affected file, and refreshes neo-tree.
#
# When Neovim is unreachable, the shim abstains silently (exit 0).

# No `set -e`: abstain on jq/nvim_call failure rather than surfacing a
# hook failure to the agent. See the matching note in code-preview-diff.sh.
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 // ""')"

# Fast-path filter — see the matching note in code-preview-diff.sh.
TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)"
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-call.sh" 2>/dev/null || true
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX="$(nvim_call code-preview.log state '[]' || 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"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

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
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

log "translated tool=$TOOL → closing"
if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-post-tool.sh"
ARGS="$(jq -nc --argjson r "$INPUT" --arg b codex '[$r, $b]' 2>/dev/null || true)"
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.post_tool handle "$ARGS" >/dev/null
131 changes: 35 additions & 96 deletions backends/codex/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -1,114 +1,53 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook adapter for OpenAI Codex CLI.
# code-preview-diff.sh — PreToolUse hook entry 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.
# After issue #47 phase 3, this shim does almost nothing: it discovers the
# running Neovim's socket and makes a single RPC call into the in-process
# orchestrator (lua/code-preview/pre_tool/init.lua), then prints whatever the
# orchestrator returns. The bash that used to translate Codex's
# {tool_name, cwd, tool_input} payload (and the apply_patch → ApplyPatch
# field move) now lives in lua/code-preview/pre_tool/normalisers.lua
# (codex entry).
#
# 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.

# When Neovim is unreachable, the shim abstains: exit 0 with no stdout.
# Codex then falls back to its native ask-before-write loop as if the plugin
# weren't installed. See docs/adr/0005-core-handler-runs-in-process.md.

# No `set -e`: the shim is the boundary between the agent and the plugin.
# When jq fails on a malformed payload or nvim_call returns rc=2, we want
# to exit 0 (abstain) so the agent falls back to its native flow rather
# than seeing a hook failure.
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.
# Fast-path filter for tools that never produce a preview. Codex hits hooks
# directly (no TS-side allowlist like opencode), so every tool firing —
# including the very chatty read/view/glob/grep/ls/list_files and MCP
# tools — would otherwise pay for socket discovery + an RPC round-trip just
# for the Lua normaliser to return tool_name=nil. The Lua map in
# pre_tool.normalisers remains the source of truth; this case is purely a
# perf filter.
TOOL="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)"
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-call.sh" 2>/dev/null || true
if [[ -n "${NVIM_SOCKET:-}" ]]; then
_CTX="$(nvim_call code-preview.log state '[]' || 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"
CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"

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
# Socket discovery — silent failure is fine, we abstain below.
source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true
source "$BIN_DIR/nvim-call.sh"

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')"
if [[ -z "${NVIM_SOCKET:-}" ]]; then
exit 0
fi

printf '%s' "$NORMALIZED" | "$BIN_DIR/core-pre-tool.sh"
ARGS="$(jq -nc --argjson r "$INPUT" --arg b codex '[$r, $b]' 2>/dev/null || true)"
# Malformed payload (jq couldn't parse) — abstain silently.
[[ -z "$ARGS" ]] && exit 0
nvim_call code-preview.pre_tool handle "$ARGS"
3 changes: 2 additions & 1 deletion lua/code-preview/pre_tool/emitters.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ M.emitters = {
claudecode = claudecode,
opencode = none,
copilot = none,
-- codex / gemini default to `none` via the fallback below.
codex = none,
-- gemini defaults to `none` via the fallback below.
}

--- @param backend string
Expand Down
63 changes: 62 additions & 1 deletion lua/code-preview/pre_tool/normalisers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,72 @@ local function copilot(raw)
}
end

-- Codex's hook payload is almost canonical: top-level {tool_name, cwd,
-- tool_input}. The only real translation is apply_patch → ApplyPatch with
-- tool_input.command (the raw `*** Begin Patch ... *** End Patch` text)
-- moved to tool_input.patch_text. Edit/Write/Bash/ApplyPatch are otherwise
-- passthrough — codex models route all edits through apply_patch today,
-- but the Edit/Write/Bash branches exist defensively in case a future
-- codex version (or an MCP server) emits those names with Claude-Code-
-- style field shapes.
--
-- Note: file paths are run through the shared `resolve_path`, which collapses
-- ../ and ./ segments via vim.fs.normalize. The old bash codex shim did not —
-- paths were preserved verbatim. The change is deliberate and matches the
-- opencode/copilot contract: internal keys (active_diffs, changes registry)
-- must be canonical so logically-same files compare equal across backends.
--
-- The canonical-ApplyPatch branch (uppercase) below also fixes a dormant
-- bug in the old shim: its `ApplyPatch|Edit|Write` case blank-checked
-- tool_input.file_path, which canonical ApplyPatch doesn't carry, so any
-- such payload would have been silently dropped. Nothing emits canonical
-- ApplyPatch today, but the new branch checks patch_text correctly.
local function codex(raw)
local tool = (raw and raw.tool_name) or ""
local cwd = (raw and raw.cwd) or ""
local args = (raw and raw.tool_input) or {}

local function blank(s) return s == nil or s == "" end

if tool == "apply_patch" then
if blank(args.command) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
return {
tool_name = "ApplyPatch",
cwd = cwd,
tool_input = { patch_text = args.command },
}
elseif tool == "ApplyPatch" then
if blank(args.patch_text) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
return { tool_name = "ApplyPatch", cwd = cwd, tool_input = args }
elseif tool == "Edit" or tool == "Write" then
local fp = resolve_path(args.file_path, cwd)
if blank(fp) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
local out = {}
for k, v in pairs(args) do out[k] = v end
out.file_path = fp
return { tool_name = tool, cwd = cwd, tool_input = out }
elseif tool == "Bash" then
if blank(args.command) then
return { tool_name = nil, cwd = cwd, tool_input = {} }
end
return { tool_name = "Bash", cwd = cwd, tool_input = args }
end

return { tool_name = nil, cwd = cwd, tool_input = {} }
end

M.normalisers = {
claudecode = identity,
opencode = opencode,
copilot = copilot,
-- codex / gemini will land their own normalisers as they flip.
codex = codex,
-- gemini will land its own normaliser when it flips.
}

--- @param raw table decoded hook payload
Expand Down
5 changes: 3 additions & 2 deletions tests/backends/codex/test_apply_patch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# 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
# tool_input.command (not tool_input.patch_text). After #47 phase 3 the
# rename happens in lua/code-preview/pre_tool/normalisers.lua (codex entry);
# the shim now just RPCs into pre_tool.handle, which uses the same
# apply-patch.lua parser the other backends share.

CODEX_PRE="$REPO_ROOT/backends/codex/code-preview-diff.sh"
Expand Down
3 changes: 2 additions & 1 deletion tests/backends/codex/test_edit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
#
# 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.
# The codex normaliser in lua/code-preview/pre_tool/normalisers.lua
# 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
Expand Down
Loading
Loading