diff --git a/CONTEXT.md b/CONTEXT.md index 4485e09..5b335a0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -75,7 +75,7 @@ Job: take the agent's native hook payload, normalise it into the shape the [core ## Core handler -The agent-neutral pipeline that, given a normalised proposal, decides whether to show a preview, computes the original and proposed file content, and makes the [RPC](#rpc) call into the running Neovim. Today: `bin/core-pre-tool.sh` and `bin/core-post-tool.sh`. Issue #47 phases 3 and 4 fold the core handler into in-process Lua (`lua/code-preview/pre_tool.lua` / `post_tool.lua`), invoked through a single RPC call from the per-agent [hook entry](#hook-entry); the orchestration role stays the same but no longer runs in a separate process. See [ADR-0005](docs/adr/0005-core-handler-runs-in-process.md). +The agent-neutral pipeline that, given a normalised proposal, decides whether to show a preview, computes the original and proposed file content, and makes the [RPC](#rpc) call into the running Neovim. Lives in-process at `lua/code-preview/pre_tool/init.lua` and `lua/code-preview/post_tool.lua`, invoked through a single RPC call from the per-agent [hook entry](#hook-entry). The historical out-of-process bash implementation (`bin/core-pre-tool.sh`, `bin/core-post-tool.sh`) was removed when issue #47 phase 3 finished for all backends; see [ADR-0005](docs/adr/0005-core-handler-runs-in-process.md) for the canonical history. The core handler is where shell-write detection, `visible_only` gating, and `permissionDecision` emission live — everything that doesn't depend on which agent fired the hook. @@ -120,7 +120,7 @@ Two levels of fidelity for handling `Bash` proposals. Three distinct path concepts used together in the [preview](#preview) pipeline. They are *not* interchangeable; the current code muddles them (see [issue #55](https://github.com/Cannon07/code-preview.nvim/issues/55)). -- **Source path** — a temp file holding pre-rendered content (`/tmp/claude-diff-{original,proposed}-`). One pair per preview: `original_source_path` and `proposed_source_path`. Scratch files, not the real file. +- **Source path** — a temp file holding pre-rendered content (`/tmp/code-preview-diff-{original,proposed}-`). One pair per preview: `original_source_path` and `proposed_source_path`. Scratch files, not the real file. - **File path** — the absolute canonical path of the real file being edited. The *identity*: used as the key in `active_diffs`, passed to the [changes](#change) registry, used by neo-tree reveal. - **Display path** — what's rendered in the winbar. Usually cwd-relative for readability; never used as an identity. diff --git a/backends/claudecode/code-preview-diff.sh b/backends/claudecode/code-preview-diff.sh index 562a604..43cda42 100755 --- a/backends/claudecode/code-preview-diff.sh +++ b/backends/claudecode/code-preview-diff.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # code-preview-diff.sh — PreToolUse hook entry for Claude Code. # -# 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 600 lines of bash that used to live in -# core-pre-tool.sh are gone. +# 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 600 lines of bash that used to handle this out-of-process are +# gone (see ADR-0005). # # When Neovim is unreachable, the shim abstains: exit 0 with no stdout. # Claude Code falls back to its native permission flow as if the plugin diff --git a/bin/core-post-tool.sh b/bin/core-post-tool.sh deleted file mode 100755 index 46aef81..0000000 --- a/bin/core-post-tool.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# core-post-tool.sh — Unified PostToolUse logic for all backends -# -# Closes the diff preview tab in Neovim after the user accepts or rejects. -# -# Expected JSON format: -# { "tool_name": "Edit|Write|MultiEdit|Bash|ApplyPatch", -# "cwd": "/path/to/project", -# "tool_input": { "file_path": "...", ... } } -# -# Environment: -# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Not read -# by this script; kept set by adapters for symmetry -# with core-pre-tool.sh, which does gate on it. - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Read stdin and extract cwd for socket discovery -INPUT="$(cat)" -CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" -TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || true)" - -# Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers -source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null -source "$SCRIPT_DIR/nvim-call.sh" - -# Set up logging — query debug config from nvim -log_post() { :; } -if [[ -n "${NVIM_SOCKET:-}" ]]; then - _POST_CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" - _POST_DEBUG=$(echo "$_POST_CTX" | jq -r '.debug // false') - _POST_LOG_FILE=$(echo "$_POST_CTX" | jq -r '.log_file // ""') - if [[ "$_POST_DEBUG" == "true" && -n "$_POST_LOG_FILE" ]]; then - log_post() { printf '[%s] [INFO] core-post-tool.sh: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_POST_LOG_FILE"; } - fi -fi - -log_post "tool=$TOOL_NAME" - -# 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_call code-preview.changes clear_by_statuses \ - '[["deleted","bash_modified","bash_created"]]' >/dev/null || true - nvim_call code-preview.neo_tree refresh_deferred '[200]' >/dev/null || true - exit 0 -fi - -# ApplyPatch: extract file paths from patch_text and close each diff -if [[ "$TOOL_NAME" == "ApplyPatch" ]]; then - PATCH_TEXT="$(echo "$INPUT" | jq -r '.tool_input.patch_text // empty' 2>/dev/null || true)" - CWD_POST="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - if [[ -n "$PATCH_TEXT" ]]; then - # Extract paths from both standard unified diff (+++ lines) and - # custom patch format (*** Update File: / *** Add File: lines) - extract_patch_paths() { - echo "$1" | grep -E '^\+\+\+ ' | while IFS= read -r line; do - fpath="${line#+++ }" - fpath="${fpath#b/}" - [[ "$fpath" == "/dev/null" ]] && continue - echo "$fpath" - done - echo "$1" | grep -E '^\*\*\* (Update|Add|Delete) File:' | while IFS= read -r line; do - echo "$line" | sed -E 's/^\*\*\* (Update|Add|Delete) File:[[:space:]]*//' | sed 's/[[:space:]]*$//' - done - } - - while IFS= read -r fpath; do - [[ -z "$fpath" ]] && continue - if [[ "$fpath" != /* && -n "$CWD_POST" ]]; then - fpath="$CWD_POST/$fpath" - fi - log_post "closing diff for patch file=$fpath" - nvim_call code-preview.diff close_for_file \ - "$(jq -nc --arg f "$fpath" '[$f]')" >/dev/null || true - done < <(extract_patch_paths "$PATCH_TEXT") - fi - rm -f "${TMPDIR:-/tmp}"/claude-diff-original* "${TMPDIR:-/tmp}"/claude-diff-proposed* "${TMPDIR:-/tmp}"/claude-patch-* - exit 0 -fi - -# Extract file path early — needed for tagged is_open() check -FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)" - -# Tell Lua to handle this file's close — tolerates out-of-order post-hooks -# (OpenCode may fire them in a different order than pre-hooks). -if [[ -n "$FILE_PATH" ]]; then - log_post "closing diff for file=$FILE_PATH" - nvim_call code-preview.diff close_for_file \ - "$(jq -nc --arg f "$FILE_PATH" '[$f]')" >/dev/null || true - # neo_tree.refresh() is handled inside close_for_file() via vim.schedule() -fi - -# Clean up temp files (both legacy shared paths and per-PID paths) -rm -f "${TMPDIR:-/tmp}"/claude-diff-original* "${TMPDIR:-/tmp}"/claude-diff-proposed* - -exit 0 diff --git a/bin/core-pre-tool.sh b/bin/core-pre-tool.sh deleted file mode 100755 index 44cf7a8..0000000 --- a/bin/core-pre-tool.sh +++ /dev/null @@ -1,412 +0,0 @@ -#!/usr/bin/env bash -# core-pre-tool.sh — Unified PreToolUse logic for all backends -# -# Reads a normalized JSON payload from stdin, computes proposed file content, -# and sends a diff preview to Neovim via RPC. -# -# Expected JSON format: -# { "tool_name": "Edit|Write|MultiEdit|Bash|ApplyPatch", -# "cwd": "/path/to/project", -# "tool_input": { "file_path": "...", ... } } -# -# Environment: -# CODE_PREVIEW_BACKEND — "claudecode" | "opencode" | "copilot". Only -# "claudecode" emits the permissionDecision JSON -# on stdout; other values suppress it. - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -# Read the full hook JSON from stdin -INPUT="$(cat)" - -TOOL_NAME="$(echo "$INPUT" | jq -r '.tool_name')" -CWD="$(echo "$INPUT" | jq -r '.cwd')" - -# Discover Neovim socket (prefer instance whose cwd matches project) and load RPC helpers -source "$SCRIPT_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$SCRIPT_DIR/nvim-call.sh" - -HAS_NVIM=true -if [[ -z "${NVIM_SOCKET:-}" ]]; then - HAS_NVIM=false -fi - -# Set up logging early so all code paths can use it -log_pre() { :; } -if [[ "$HAS_NVIM" == "true" ]]; then - _PRE_CTX="$(nvim_call code-preview.log state '[]' || echo '{}')" - _PRE_DEBUG=$(echo "$_PRE_CTX" | jq -r '.debug // false') - _PRE_LOG_FILE=$(echo "$_PRE_CTX" | jq -r '.log_file // ""') - if [[ "$_PRE_DEBUG" == "true" && -n "$_PRE_LOG_FILE" ]]; then - log_pre() { printf '[%s] [INFO] core-pre-tool.sh: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$_PRE_LOG_FILE"; } - fi -fi - -log_pre "tool=$TOOL_NAME has_nvim=$HAS_NVIM" - -TMPDIR="${TMPDIR:-/tmp}" -# Use unique temp files per hook invocation so rapid-fire pre-hooks -# (OpenCode fires all before-hooks before any after-hooks) don't clobber -# each other's diff content. -HOOK_ID="$$" -ORIG_FILE="$TMPDIR/claude-diff-original-$HOOK_ID" -PROP_FILE="$TMPDIR/claude-diff-proposed-$HOOK_ID" - -# --- Compute original and proposed file content --- - -case "$TOOL_NAME" in - Edit) - FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path')" - OLD_STRING="$(echo "$INPUT" | jq -r '.tool_input.old_string')" - NEW_STRING="$(echo "$INPUT" | jq -r '.tool_input.new_string')" - REPLACE_ALL="$(echo "$INPUT" | jq -r '.tool_input.replace_all // false')" - - if [[ -f "$FILE_PATH" ]]; then - cp "$FILE_PATH" "$ORIG_FILE" - else - > "$ORIG_FILE" - fi - - NVIM_LISTEN_ADDRESS= nvim --headless -l "$SCRIPT_DIR/apply-edit.lua" "$FILE_PATH" "$OLD_STRING" "$NEW_STRING" "$REPLACE_ALL" "$PROP_FILE" || true - ;; - - Write) - FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path')" - CONTENT="$(echo "$INPUT" | jq -r '.tool_input.content')" - - if [[ -f "$FILE_PATH" ]]; then - cp "$FILE_PATH" "$ORIG_FILE" - else - > "$ORIG_FILE" - fi - - printf '%s' "$CONTENT" > "$PROP_FILE" - ;; - - MultiEdit) - FILE_PATH="$(echo "$INPUT" | jq -r '.tool_input.file_path')" - - if [[ -f "$FILE_PATH" ]]; then - cp "$FILE_PATH" "$ORIG_FILE" - else - > "$ORIG_FILE" - fi - - NVIM_LISTEN_ADDRESS= nvim --headless -l "$SCRIPT_DIR/apply-multi-edit.lua" "$INPUT" "$PROP_FILE" - ;; - - Bash) - COMMAND="$(echo "$INPUT" | jq -r '.tool_input.command')" - - # Detect rm commands: split on command separators and check each sub-command - detect_rm_paths() { - local cmd="$1" - # Trim leading whitespace - cmd="$(echo "$cmd" | sed 's/^[[:space:]]*//')" - # Match: optional sudo, then rm as standalone command, then flags/paths - if echo "$cmd" | grep -qE '^(sudo[[:space:]]+)?rm[[:space:]]'; then - # Strip rm command and known flags, leaving paths - echo "$cmd" | sed -E 's/^(sudo[[:space:]]+)?rm[[:space:]]+//' \ - | tr ' ' '\n' \ - | grep -vE '^-' \ - | while read -r p; do - if [[ -z "$p" ]]; then continue; fi - # Strip outer single/double quotes — agents wrap - # paths with shell-special chars (apostrophes, - # spaces) in quotes, and that quoting survives - # into tool_input.command literally. - p="${p#\"}"; p="${p%\"}" - p="${p#\'}"; p="${p%\'}" - # Strip trailing CR (Windows-style payloads). - p="${p%$'\r'}" - if [[ -z "$p" ]]; then continue; fi - # Resolve relative paths against CWD; absolute - # paths and `~/`-prefixed paths pass through. - # `'~/'*` is quoted so bash doesn't tilde-expand - # the pattern at match time. - case "$p" in - /*) echo "$p" ;; - '~/'*) echo "${HOME}/${p#'~/'}" ;; - *) echo "$CWD/$p" ;; - esac - done - fi - } - - # Split command on && || ; and check each part - RM_PATHS="" - while IFS= read -r subcmd; do - while IFS= read -r path; do - [[ -n "$path" ]] && RM_PATHS="$RM_PATHS $path" - done < <(detect_rm_paths "$subcmd") - done < <(echo "$COMMAND" | sed 's/[;&|]\{1,2\}/\n/g') - - # Trim leading/trailing whitespace without invoking xargs — xargs does - # shell-like quote processing on its input and would discard everything - # if any path contained an unbalanced quote (e.g. an apostrophe in - # `it's-mine.txt`). - RM_PATHS="${RM_PATHS#"${RM_PATHS%%[![:space:]]*}"}" - RM_PATHS="${RM_PATHS%"${RM_PATHS##*[![:space:]]}"}" - - # Mark each rm-detected path as deleted in neo-tree - if [[ -n "$RM_PATHS" && "$HAS_NVIM" == "true" ]]; then - for path in $RM_PATHS; do - nvim_call code-preview.changes set \ - "$(jq -nc --arg p "$path" '[$p, "deleted"]')" >/dev/null || true - done - nvim_call code-preview.neo_tree refresh '[]' >/dev/null || true - # Reveal the first deleted file in the tree - FIRST_PATH="$(echo "$RM_PATHS" | awk '{print $1}')" - nvim_call code-preview.neo_tree reveal_deferred \ - "$(jq -nc --arg p "$FIRST_PATH" --argjson d 300 '[$p, $d]')" >/dev/null || 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. 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/*) 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") - # Trim without xargs (see RM_PATHS comment above re: apostrophes). - WRITE_PATHS="${WRITE_PATHS#"${WRITE_PATHS%%[![:space:]]*}"}" - WRITE_PATHS="${WRITE_PATHS%"${WRITE_PATHS##*[![:space:]]}"}" - - # 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 - nvim_call code-preview.changes set \ - "$(jq -nc --arg p "$path" --arg s "$STATUS" '[$p, $s]')" >/dev/null || true - done - nvim_call code-preview.neo_tree refresh '[]' >/dev/null || 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}')" - nvim_call code-preview.neo_tree reveal_deferred \ - "$(jq -nc --arg p "$FIRST_PATH" --argjson d 300 '[$p, $d]')" >/dev/null || true - fi - fi - - exit 0 - ;; - - ApplyPatch) - PATCH_TEXT="$(echo "$INPUT" | jq -r '.tool_input.patch_text // empty')" - if [[ -z "$PATCH_TEXT" ]]; then - log_pre "ApplyPatch: empty patch_text, exiting" - exit 0 - fi - log_pre "ApplyPatch: received patch (${#PATCH_TEXT} chars)" - - # Write patch JSON to a temp file for the Lua parser - PATCH_JSON="$TMPDIR/claude-patch-input-$HOOK_ID.json" - echo "$INPUT" | jq '{patch_text: .tool_input.patch_text}' > "$PATCH_JSON" - - PATCH_OUTDIR="$TMPDIR/claude-patch-out-$HOOK_ID" - mkdir -p "$PATCH_OUTDIR" - - # Parse the custom patch format and compute per-file original/proposed - log_pre "ApplyPatch: running apply-patch.lua" - NVIM_LISTEN_ADDRESS= nvim --headless -l "$SCRIPT_DIR/apply-patch.lua" "$PATCH_JSON" "$CWD" "$PATCH_OUTDIR" 2>/dev/null || true - - RESULTS_FILE="$PATCH_OUTDIR/files.json" - if [[ ! -f "$RESULTS_FILE" ]]; then - log_pre "ApplyPatch: apply-patch.lua produced no results" - rm -f "$PATCH_JSON" - rm -rf "$PATCH_OUTDIR" - exit 0 - fi - - # Read results and send each file's diff to nvim - FILE_COUNT=$(jq 'length' "$RESULTS_FILE") - log_pre "ApplyPatch: parsed $FILE_COUNT file(s)" - - for i in $(seq 0 $((FILE_COUNT - 1))); do - PATCH_FILE_PATH=$(jq -r ".[$i].path" "$RESULTS_FILE") - REL_PATH=$(jq -r ".[$i].rel_path" "$RESULTS_FILE") - ACTION=$(jq -r ".[$i].action" "$RESULTS_FILE") - PATCH_ORIG=$(jq -r ".[$i].orig" "$RESULTS_FILE") - PATCH_PROP=$(jq -r ".[$i].prop" "$RESULTS_FILE") - - log_pre "ApplyPatch: file=$REL_PATH action=$ACTION" - - if [[ "$HAS_NVIM" == "true" ]]; then - HOOK_CTX="$(nvim_call code-preview hook_context \ - "$(jq -nc --arg fp "$PATCH_FILE_PATH" '[$fp]')" || echo '{}')" - VISIBLE_ONLY=$(echo "$HOOK_CTX" | jq -r '.visible_only // false') - FILE_VISIBLE=$(echo "$HOOK_CTX" | jq -r '.file_visible // false') - - SHOULD_SHOW="1" - if [[ "$VISIBLE_ONLY" == "true" && "$FILE_VISIBLE" != "true" ]]; then - SHOULD_SHOW="0" - log_pre "ApplyPatch: skipping diff for $REL_PATH (visible_only)" - fi - - if [[ "$SHOULD_SHOW" == "1" ]]; then - log_pre "ApplyPatch: sending diff for $REL_PATH to nvim (action=$ACTION)" - nvim_call code-preview.diff show_diff \ - "$(jq -nc --arg o "$PATCH_ORIG" --arg p "$PATCH_PROP" --arg d "$REL_PATH" --arg f "$PATCH_FILE_PATH" --arg a "$ACTION" \ - '[$o, $p, $d, $f, $a]')" >/dev/null || true - fi - else - log_pre "ApplyPatch: no nvim connection, skipping diff for $REL_PATH" - fi - done - - rm -f "$PATCH_JSON" - exit 0 - ;; - - *) - exit 0 - ;; -esac - -# --- Send diff to Neovim --- - -DISPLAY_NAME="${FILE_PATH#"$CWD/"}" - -if [[ "$HAS_NVIM" == "true" ]]; then - # Query config + file visibility from nvim in a single RPC call. - # Neo-tree indicator/reveal is now driven from lua/code-preview/diff.lua - # (inside show_diff), so we only need visibility + permission fields here. - HOOK_CTX="$(nvim_call code-preview hook_context \ - "$(jq -nc --arg fp "$FILE_PATH" '[$fp]')" || echo '{}')" - VISIBLE_ONLY=$(echo "$HOOK_CTX" | jq -r '.visible_only // false') - FILE_VISIBLE=$(echo "$HOOK_CTX" | jq -r '.file_visible // false') - DEFER_PERMISSIONS=$(echo "$HOOK_CTX" | jq -r 'if .defer_claude_permissions == true then "true" else "false" end') - - log_pre "file=$FILE_PATH visible_only=$VISIBLE_ONLY file_visible=$FILE_VISIBLE" - - # Decide whether to show the diff — skip nvim UI entirely when visible_only - # is on and the file isn't in any visible window. - SHOULD_SHOW="1" - if [[ "$VISIBLE_ONLY" == "true" && "$FILE_VISIBLE" != "true" ]]; then - SHOULD_SHOW="0" - log_pre "skipping diff: visible_only=true, file not visible" - fi - - if [[ "$SHOULD_SHOW" == "1" ]]; then - log_pre "sending diff to nvim (layout via config)" - nvim_call code-preview.diff show_diff \ - "$(jq -nc --arg o "$ORIG_FILE" --arg p "$PROP_FILE" --arg d "$DISPLAY_NAME" --arg f "$FILE_PATH" \ - '[$o, $p, $d, $f]')" >/dev/null || true - fi -fi - -# --- Backend-specific output --- - -# Permission decision: when defer_claude_permissions is true (or nvim is -# unreachable), produce no output and let Claude Code's own permission -# settings (bypass, ask, allowlist) decide. Otherwise return "ask" to -# prompt the user for every edit, preserving the default review workflow. -if [[ "${CODE_PREVIEW_BACKEND:-}" == "claudecode" && "$HAS_NVIM" == "true" && "$DEFER_PERMISSIONS" != "true" ]]; then - REASON="Diff preview sent to Neovim. Review before accepting." - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"%s"}}\n' "$REASON" -fi diff --git a/lua/code-preview/init.lua b/lua/code-preview/init.lua index ac934fe..867c05d 100644 --- a/lua/code-preview/init.lua +++ b/lua/code-preview/init.lua @@ -94,9 +94,9 @@ function M.setup(user_config) -- Self-register socket + cwd for hook-script discovery require("code-preview.pidfile").setup() - -- Clear any leftover /tmp/claude-diff-* tempfiles from prior sessions - -- (the in-process pre-tool flow has no global wildcard equivalent of the - -- old bash post-tool sweep — see pre_tool.sweep_leftover_tempfiles). + -- Clear any leftover /tmp/code-preview-diff-* tempfiles from prior sessions + -- (also matches the legacy /tmp/claude-diff-* prefix transitionally — see + -- pre_tool.sweep_leftover_tempfiles). require("code-preview.pre_tool").sweep_leftover_tempfiles() -- ── New commands ────────────────────────────────────────────── diff --git a/lua/code-preview/log.lua b/lua/code-preview/log.lua index 2d588d9..13ce09a 100644 --- a/lua/code-preview/log.lua +++ b/lua/code-preview/log.lua @@ -72,10 +72,10 @@ end --- nvim's servername, and its cwd. Backends use the latter two when --- they want to log which nvim instance the diff is being routed to. --- ---- Consumers (all in bin/ and backends/): core-pre-tool.sh, core-post-tool.sh ---- use debug + log_file. copilot/code-preview-diff.sh additionally reads ---- servername + cwd. Renaming any field is a breaking change for those ---- scripts — grep for "log state" before touching this shape. +--- Consumers (all in backends/): the per-backend code-preview-diff.sh / +--- code-close-diff.sh shims read debug + log_file. copilot/code-preview-diff.sh +--- additionally reads servername + cwd. Renaming any field is a breaking change +--- for those scripts — grep for "log state" before touching this shape. --- @return { debug: boolean, log_file: string, servername: string, cwd: string } function M.state() return { diff --git a/lua/code-preview/neo_tree.lua b/lua/code-preview/neo_tree.lua index 3a331cf..a52dd2f 100644 --- a/lua/code-preview/neo_tree.lua +++ b/lua/code-preview/neo_tree.lua @@ -357,8 +357,8 @@ function M.refresh() end) end ---- Schedule a M.refresh() call after `ms` milliseconds. Used by ---- core-post-tool.sh to give the file system a moment to settle after +--- Schedule a M.refresh() call after `ms` milliseconds. Used by the +--- post-tool handler to give the file system a moment to settle after --- Bash tool changes before redrawing the tree. function M.refresh_deferred(ms) vim.defer_fn(function() pcall(M.refresh) end, ms or 200) diff --git a/lua/code-preview/post_tool.lua b/lua/code-preview/post_tool.lua index b1265ab..d97c252 100644 --- a/lua/code-preview/post_tool.lua +++ b/lua/code-preview/post_tool.lua @@ -1,14 +1,13 @@ -- post_tool.lua — In-process orchestration for PostToolUse hooks. -- --- Replaces bin/core-post-tool.sh (which remains alive for not-yet-flipped --- backends). The hook-entry shim passes the raw decoded hook JSON plus the +-- The per-backend hook-entry shim passes the raw decoded hook JSON plus the -- backend name; handle() clears the relevant changes, closes the matching --- preview(s), and refreshes neo-tree. +-- preview(s), and refreshes neo-tree. See docs/adr/0005-core-handler-runs-in-process.md. -- -- Tempfile cleanup is delegated to the OS (/tmp eviction); we do not rm the --- per-proposal tempfiles here. The bash version did, but it was best-effort --- and survived restarts via wildcard sweep — relying on /tmp hygiene is --- equally fine and removes a class of "did I race the next hook?" bugs. +-- per-proposal tempfiles here. The historical bash post-tool did, via a global +-- wildcard sweep — relying on /tmp hygiene is equally fine and removes a class +-- of "did I race the next hook?" bugs. local M = {} diff --git a/lua/code-preview/pre_tool/bash_detect.lua b/lua/code-preview/pre_tool/bash_detect.lua index d096edd..3eafcd2 100644 --- a/lua/code-preview/pre_tool/bash_detect.lua +++ b/lua/code-preview/pre_tool/bash_detect.lua @@ -3,9 +3,9 @@ -- Inputs: a Bash command string and the project cwd. -- Output: a structured table { rm_paths = {...}, write_paths = {...} }. -- --- This is a port of the regex-based detection that lived inline in --- bin/core-pre-tool.sh. The edge cases here all come from real bugs; resist --- "obvious simplifications" without first reading bash_detect_spec.lua. +-- The edge cases here all come from real bugs in the historical bash +-- pre-tool detection logic; resist "obvious simplifications" without first +-- reading bash_detect_spec.lua. local M = {} diff --git a/lua/code-preview/pre_tool/init.lua b/lua/code-preview/pre_tool/init.lua index f5ff7b5..08b2b60 100644 --- a/lua/code-preview/pre_tool/init.lua +++ b/lua/code-preview/pre_tool/init.lua @@ -1,10 +1,9 @@ -- pre_tool/init.lua — In-process orchestration for PreToolUse hooks. -- --- Replaces bin/core-pre-tool.sh (which remains alive for not-yet-flipped --- backends until issue #47 phase 3 finishes for all of them). The per-OS --- hook-entry shim passes the raw decoded hook JSON plus the backend name; --- handle() does normalisation, tool dispatch, side effects (changes registry, --- neo-tree refresh, diff.show_diff), and returns the per-backend stdout bytes. +-- The per-backend hook-entry shim passes the raw decoded hook JSON plus the +-- backend name; handle() does normalisation, tool dispatch, side effects +-- (changes registry, neo-tree refresh, diff.show_diff), and returns the +-- per-backend stdout bytes. -- -- See docs/adr/0005-core-handler-runs-in-process.md. @@ -24,8 +23,9 @@ local diff = require("code-preview.diff") local log = require("code-preview.log") -- ── Tempfile helpers ───────────────────────────────────────────── --- Bash used $$ to namespace /tmp/claude-diff-*. In-process Lua uses hrtime + --- a monotonic counter so multiple proposals in the same Neovim never collide. +-- The historical bash flow used $$ to namespace /tmp/claude-diff-* tempfiles. +-- In-process Lua uses hrtime + a monotonic counter so multiple proposals in +-- the same Neovim never collide. Tempfile prefix is now code-preview-* (see #60). local _counter = 0 local function next_id() @@ -39,13 +39,15 @@ end -- One-time startup sweep of leftover proposal tempfiles. -- --- The bash post-tool ran `rm -f /tmp/claude-diff-original* /tmp/claude-diff-proposed* --- /tmp/claude-patch-*` after every PostToolUse hook — a global wildcard sweep. --- The new in-process flow doesn't have a natural hook to run that on every --- post-tool (and per-proposal tracking is a follow-up — see the parity-port --- hygiene issue). To prevent unbounded accumulation across long sessions --- where Neovim doesn't restart, run the sweep once at setup(). macOS doesn't --- auto-evict /tmp under a few days, so this matters in practice. +-- Per-proposal tempfile tracking is a follow-up (see issue #64). To prevent +-- unbounded accumulation across long sessions where Neovim doesn't restart, +-- run the sweep once at setup(). macOS doesn't auto-evict /tmp under a few +-- days, so this matters in practice. +-- +-- The old claude-* patterns are matched transitionally so leftover tempfiles +-- from prior nvim sessions (pre-#60) still get cleaned up. Drop the old +-- patterns one release after this bridge ships, once users on the prior +-- version have had a chance to upgrade. function M.sweep_leftover_tempfiles() local dir = tmpdir() local fd = vim.loop.fs_scandir(dir) @@ -53,7 +55,11 @@ function M.sweep_leftover_tempfiles() while true do local name = vim.loop.fs_scandir_next(fd) if not name then break end - if name:match("^claude%-diff%-original%-") or + if name:match("^code%-preview%-diff%-original%-") or + name:match("^code%-preview%-diff%-proposed%-") or + name:match("^code%-preview%-patch%-") or + -- transitional; drop in v1.2 + name:match("^claude%-diff%-original%-") or name:match("^claude%-diff%-proposed%-") or name:match("^claude%-patch%-") then pcall(vim.loop.fs_unlink, dir .. "/" .. name) @@ -107,8 +113,8 @@ end -- below is a one-liner around this helper. local function present_single_file(file_path, proposed_content, input, cfg) local id = next_id() - local orig = tmpdir() .. "/claude-diff-original-" .. id - local prop = tmpdir() .. "/claude-diff-proposed-" .. id + local orig = tmpdir() .. "/code-preview-diff-original-" .. id + local prop = tmpdir() .. "/code-preview-diff-proposed-" .. id copy_or_empty(file_path, orig) write_file(prop, proposed_content) @@ -185,7 +191,7 @@ local function handle_apply_patch(input, cfg) end local id = next_id() - local outdir = tmpdir() .. "/claude-patch-out-" .. id + local outdir = tmpdir() .. "/code-preview-patch-out-" .. id vim.fn.mkdir(outdir, "p") local files = apply_patch.parse(patch_text, input.cwd or "") diff --git a/tests/backends/claudecode/test_edit.sh b/tests/backends/claudecode/test_edit.sh index 517b250..ce30154 100644 --- a/tests/backends/claudecode/test_edit.sh +++ b/tests/backends/claudecode/test_edit.sh @@ -54,7 +54,7 @@ EOF # Proposed temp file should contain the edit result (PID-suffixed) local proposed - proposed="$(ls -1t "${TMPDIR:-/tmp}"/claude-diff-proposed-* 2>/dev/null | head -1)" + proposed="$(ls -1t "${TMPDIR:-/tmp}"/code-preview-diff-proposed-* 2>/dev/null | head -1)" assert_file_exists "$proposed" "proposed temp file should exist" || return 1 local proposed_content proposed_content="$(cat "$proposed")" @@ -263,7 +263,7 @@ EOF sleep 0.5 local proposed - proposed="$(ls -1t "${TMPDIR:-/tmp}"/claude-diff-proposed-* 2>/dev/null | head -1)" + proposed="$(ls -1t "${TMPDIR:-/tmp}"/code-preview-diff-proposed-* 2>/dev/null | head -1)" assert_file_exists "$proposed" "proposed temp file should exist" || return 1 local proposed_content proposed_content="$(cat "$proposed")" diff --git a/tests/core/test_post_tool_patch_paths.sh b/tests/core/test_post_tool_patch_paths.sh deleted file mode 100644 index 624b8b3..0000000 --- a/tests/core/test_post_tool_patch_paths.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash -# test_post_tool_patch_paths.sh — Regression test for bin/core-post-tool.sh -# -# Verifies that the patch-path extractor for ApplyPatch calls close_for_file -# for every file referenced in the patch — Update, Add, AND Delete. -# -# Regression: Delete File: directives were previously skipped by the extractor -# regex, leaving delete-diff tabs lingering after accept. - -# ── Setup ──────────────────────────────────────────────────────── - -setup_test_project -start_nvim - -# Install a stub close_for_file that records every path it's called with into -# a global table. We don't care about actual diff lifecycle here — just that -# the hook script extracted the right paths from the patch. -install_stub() { - nvim_exec " - _G.__closed_paths = {} - package.loaded['code-preview.diff'] = package.loaded['code-preview.diff'] or {} - package.loaded['code-preview.diff'].close_for_file = function(p) - table.insert(_G.__closed_paths, p) - end - " -} - -reset_stub() { - nvim_exec "_G.__closed_paths = {}" -} - -# Pipe-joined string of paths. Avoids vim.json.encode, whose forward-slash -# escaping behavior varies across Neovim builds and breaks substring matching -# on paths like "src/new.lua". -closed_paths() { - nvim_eval "table.concat(_G.__closed_paths or {}, '|')" -} - -# Feed a normalized ApplyPatch JSON payload to core-post-tool.sh -run_post_apply_patch() { - local patch_text="$1" - local payload - payload=$(jq -n \ - --arg cwd "$TEST_PROJECT_DIR" \ - --arg patch "$patch_text" \ - '{tool_name:"ApplyPatch", cwd:$cwd, tool_input:{patch_text:$patch}}') - echo "$payload" | \ - NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$REPO_ROOT/bin/core-post-tool.sh" 2>/dev/null || true - # Give nvim time to process async RPC - sleep 0.3 -} - -# ── Test: Delete File directive triggers close_for_file ────────── - -test_delete_file_closes_diff() { - install_stub - reset_stub - - local patch - patch=$(printf '%s\n' \ - "*** Begin Patch" \ - "*** Delete File: to_remove.txt" \ - "*** End Patch") - - run_post_apply_patch "$patch" - - local closed - closed="$(closed_paths)" - assert_contains "$closed" "to_remove.txt" "Delete File path should be passed to close_for_file" || return 1 -} - -# ── Test: Mixed Update + Add + Delete all close ────────────────── - -test_mixed_patch_closes_all_diffs() { - install_stub - reset_stub - - local patch - patch=$(printf '%s\n' \ - "*** Begin Patch" \ - "*** Update File: README.md" \ - "@@" \ - " existing line" \ - "-old text" \ - "+new text" \ - "*** Add File: src/new.lua" \ - "@@" \ - "+local M = {}" \ - "+return M" \ - "*** Delete File: old.txt" \ - "*** End Patch") - - run_post_apply_patch "$patch" - - local closed - closed="$(closed_paths)" - assert_contains "$closed" "README.md" "Update File path should be closed" || return 1 - assert_contains "$closed" "src/new.lua" "Add File path should be closed" || return 1 - assert_contains "$closed" "old.txt" "Delete File path should be closed" || return 1 - - # Confirm exactly three paths were closed — no duplicates, no drops. - local count - count="$(nvim_eval "#(_G.__closed_paths or {})")" - assert_eq "3" "$count" "should close exactly 3 paths for 3-file patch" || return 1 -} - -# ── Test: Update-only patch (sanity — pre-existing behavior) ───── - -test_update_only_closes_diff() { - install_stub - reset_stub - - local patch - patch=$(printf '%s\n' \ - "*** Begin Patch" \ - "*** Update File: a.txt" \ - "@@" \ - " ctx" \ - "-x" \ - "+y" \ - "*** End Patch") - - run_post_apply_patch "$patch" - - local closed - closed="$(closed_paths)" - assert_contains "$closed" "a.txt" "Update File path should be closed" || return 1 -} - -# ── Run all tests ──────────────────────────────────────────────── - -run_test "core-post-tool.sh closes diff for Delete File directive" test_delete_file_closes_diff -run_test "core-post-tool.sh closes diffs for mixed Update+Add+Delete patch" test_mixed_patch_closes_all_diffs -run_test "core-post-tool.sh closes diff for Update File directive" test_update_only_closes_diff - -# ── Teardown ───────────────────────────────────────────────────── - -stop_nvim -cleanup_test_project diff --git a/tests/helpers.sh b/tests/helpers.sh index 98275bc..9802431 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -62,7 +62,8 @@ reset_test_state() { nvim_exec "require('code-preview.diff').close_diff_and_clear()" 2>/dev/null || true # Remove temp files that persist across runs (shared by both backends) local _tmpdir="${TMPDIR:-/tmp}" - rm -f "$_tmpdir"/claude-diff-original* "$_tmpdir"/claude-diff-proposed* + rm -f "$_tmpdir"/code-preview-diff-original* "$_tmpdir"/code-preview-diff-proposed* \ + "$_tmpdir"/claude-diff-original* "$_tmpdir"/claude-diff-proposed* sleep 0.3 } diff --git a/tests/plugin/pre_tool_bash_detect_spec.lua b/tests/plugin/pre_tool_bash_detect_spec.lua index 21b2d30..eb9560f 100644 --- a/tests/plugin/pre_tool_bash_detect_spec.lua +++ b/tests/plugin/pre_tool_bash_detect_spec.lua @@ -1,8 +1,8 @@ -- pre_tool_bash_detect_spec.lua — Tier 1 shell-write + rm detection. -- -- Table-driven. Each row pins a documented edge case from the historical --- bin/core-pre-tool.sh detection logic. New rows go at the bottom with a --- short comment explaining the case. Resist "obvious simplifications" to +-- bash pre-tool detection logic. New rows go at the bottom with a short +-- comment explaining the case. Resist "obvious simplifications" to -- bash_detect.lua without first reading these rows. local bash_detect = require("code-preview.pre_tool.bash_detect") diff --git a/tests/run.sh b/tests/run.sh index 35f104c..9297d4a 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -7,7 +7,6 @@ # ./tests/run.sh backends # run all backend tests # ./tests/run.sh backends/claude # run Claude Code backend tests only # ./tests/run.sh backends/opencode # run OpenCode backend tests only -# ./tests/run.sh core # run core shell tests (bin/ scripts) # ./tests/run.sh edit # run any backend test file matching "edit" set -euo pipefail @@ -52,11 +51,6 @@ discover_backend_tests() { done < <(find "$backend_dir" -name 'test_*.sh' -type f 2>/dev/null | sort) fi ;; - core) - while IFS= read -r f; do - test_files+=("$f") - done < <(find "$SCRIPT_DIR/core" -name 'test_*.sh' -type f 2>/dev/null | sort) - ;; *) # Fuzzy match: find any test file whose name contains the filter while IFS= read -r f; do @@ -65,11 +59,13 @@ discover_backend_tests() { if [[ "$base" == *"$filter"* ]]; then test_files+=("$f") fi - done < <(find "$SCRIPT_DIR/backends" "$SCRIPT_DIR/core" -name 'test_*.sh' -type f 2>/dev/null | sort) + done < <(find "$SCRIPT_DIR/backends" -name 'test_*.sh' -type f 2>/dev/null | sort) ;; esac - printf '%s\n' "${test_files[@]}" + if (( ${#test_files[@]} > 0 )); then + printf '%s\n' "${test_files[@]}" + fi } # Format a test file path as a readable label @@ -132,14 +128,12 @@ main() { all) run_plugin_tests echo "" - run_backend_tests "core" - echo "" run_backend_tests "backends" ;; plugin) run_plugin_tests ;; - backends|backends/*|core) + backends|backends/*) run_backend_tests "$filter" ;; *)