diff --git a/CONTEXT.md b/CONTEXT.md index e19ac7a..4f0de1f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,7 +17,7 @@ The directory `backends/` and the env var `CODE_PREVIEW_BACKEND` are historical The per-agent adapter that translates that agent's hook format into the plugin's normalised core. One integration per agent. Each integration has two parts: - **Installer** (`lua/code-preview/backends/.lua`) — wires the agent's config files (e.g. `.claude/settings.local.json`, `.opencode/plugins/index.ts`) to point at the plugin's hook scripts. -- **Hook entry** (`backends//code-{preview,close}-diff.sh`) — see [Hook entry](#hook-entry). +- **Hook entry** (`bin/hook-entry.{sh,ps1}`, one generic shim per OS) — see [Hook entry](#hook-entry). When a doc or issue says "the Codex integration," it means the installer + adapter scripts for Codex — never the running Codex CLI itself. @@ -73,9 +73,11 @@ The pidfile is *one of several* socket discovery paths, not a synonym for socket ## Hook entry -The per-agent script the agent invokes directly when it's about to (or has just) used an editing tool. One pair per [integration](#integration): `code-preview-diff.sh` for pre-tool, `code-close-diff.sh` for post-tool. Lives in `backends//`. +The script the agent invokes directly when it's about to (or has just) used an editing tool. **One generic shim per OS, shared by all agents** ([ADR-0008](docs/adr/0008-one-hook-entry-per-os.md)): `bin/hook-entry.sh` on Unix and `bin/hook-entry.ps1` on Windows, invoked as `hook-entry `. (Before the consolidation each agent had its own `backends//code-{preview,close}-diff.{sh,ps1}` pair.) -Job: take the agent's native hook payload, normalise it into the shape the [core handler](#core-handler) expects (`{tool_name, cwd, tool_input}`), then hand off. The hook entry is **per-OS**: a `.sh` shim on Unix, a PowerShell `.ps1` shim on Windows (issue #46). PowerShell is the single Windows logic language across all agents — it is the only stock-Windows-11 tool that parses JSON natively, enumerates named pipes, and probes the RPC socket. The installer writes the interpreter explicitly into the agent's `command` field (`powershell -NoProfile -ExecutionPolicy Bypass -File .ps1`); a thin `.cmd` trampoline is added only for an agent that raw-execs a bare path and rejects a multi-token command. Windows PowerShell 5.1 (`powershell.exe`) is the floor, not pwsh 7. +Job: take the agent's native hook payload, optionally fast-path-filter noisy tools (a backend-keyed branch inside the shim; the [normalisers](#core-handler) tool map is the source of truth), discover the running Neovim, splice the payload + backend name, and make one [RPC](#rpc) into the [core handler](#core-handler). + +The hook entry is **per-OS** because that is a language boundary: a `.sh` shim on Unix, a PowerShell `.ps1` shim on Windows (issue #46). PowerShell is the single Windows logic language — the only stock-Windows-11 tool that parses JSON natively, enumerates named pipes, and probes the RPC socket; Windows PowerShell 5.1 (`powershell.exe`) is the floor, not pwsh 7. The installer writes the interpreter explicitly into the agent's `command` field (`powershell -NoProfile -ExecutionPolicy Bypass -File \hook-entry.ps1 `) via [`platform.hook_command`](docs/adr/0008-one-hook-entry-per-os.md). Copilot is the exception: its config uses a `bash` field, so it always invokes `hook-entry.sh` (Copilot-on-Windows would need git-bash, deferred). ## Core handler diff --git a/backends/claudecode/code-close-diff.ps1 b/backends/claudecode/code-close-diff.ps1 deleted file mode 100644 index 9ae68ae..0000000 --- a/backends/claudecode/code-close-diff.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -# code-close-diff.ps1 — PostToolUse hook entry for Claude Code on Windows. -# PowerShell counterpart to code-close-diff.sh. Makes a single RPC into the -# in-process orchestrator (lua/code-preview/post_tool.lua) and exits; the -# orchestrator clears the changes registry, closes any open preview for the -# affected file, and refreshes neo-tree. -# -# Abstains silently (exit 0) when Neovim is unreachable or anything fails. -# See ADR-0007. - -try { - $raw = [Console]::In.ReadToEnd() - if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } - - $cwd = ($raw | ConvertFrom-Json).cwd - - $binDir = Join-Path $PSScriptRoot "..\..\bin" - . (Join-Path $binDir "nvim-socket.ps1") - . (Join-Path $binDir "nvim-call.ps1") - - $socket = Find-NvimSocket -ProjectCwd $cwd - if ([string]::IsNullOrEmpty($socket)) { exit 0 } - - $argsJson = "[$raw,""claudecode""]" - - # Output is discarded for the post-tool path. - $null = Invoke-NvimCall -Server $socket -Module "code-preview.post_tool" ` - -Function "handle" -ArgsJson $argsJson -} catch { - exit 0 -} diff --git a/backends/claudecode/code-close-diff.sh b/backends/claudecode/code-close-diff.sh deleted file mode 100755 index 9862db8..0000000 --- a/backends/claudecode/code-close-diff.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# code-close-diff.sh — PostToolUse hook entry for Claude Code. -# -# After issue #47 phase 3, this shim makes a single RPC call into the -# in-process orchestrator (lua/code-preview/post_tool.lua) and exits. 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" - -INPUT="$(cat)" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$BIN_DIR/nvim-call.sh" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b claudecode '[$r, $b]' 2>/dev/null || true)" -[[ -z "$ARGS" ]] && exit 0 -nvim_call code-preview.post_tool handle "$ARGS" >/dev/null diff --git a/backends/claudecode/code-preview-diff.ps1 b/backends/claudecode/code-preview-diff.ps1 deleted file mode 100644 index be3b9b9..0000000 --- a/backends/claudecode/code-preview-diff.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -# code-preview-diff.ps1 — PreToolUse hook entry for Claude Code on Windows. -# PowerShell counterpart to code-preview-diff.sh (see that file for the full -# rationale). Reads the hook payload from stdin, discovers the running Neovim, -# and makes a single RPC into the in-process orchestrator -# (lua/code-preview/pre_tool/init.lua), printing whatever it returns. -# -# When Neovim is unreachable — or anything else fails — the shim abstains: -# exit 0 with no stdout, so Claude Code falls back to its native permission -# flow as if the plugin weren't installed. See ADR-0007. - -try { - # Read all of stdin. - $raw = [Console]::In.ReadToEnd() - if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } - - # Parse only the shallow .cwd we need for socket discovery. ConvertFrom-Json - # reads arbitrarily deep, so this never truncates; a parse failure means a - # malformed payload — abstain. (We never re-serialise: the raw payload is - # spliced verbatim below, per ADR-0007.) - $cwd = ($raw | ConvertFrom-Json).cwd - - $binDir = Join-Path $PSScriptRoot "..\..\bin" - . (Join-Path $binDir "nvim-socket.ps1") - . (Join-Path $binDir "nvim-call.ps1") - - $socket = Find-NvimSocket -ProjectCwd $cwd - if ([string]::IsNullOrEmpty($socket)) { exit 0 } - - # Build the RPC args array [payload, backend] by splicing the raw payload - # JSON verbatim — the PowerShell analogue of jq's `--argjson r "$INPUT"`. - $argsJson = "[$raw,""claudecode""]" - - $result = Invoke-NvimCall -Server $socket -Module "code-preview.pre_tool" ` - -Function "handle" -ArgsJson $argsJson - if ($null -ne $result -and $result -ne "") { - Write-Output $result - } -} catch { - # The shim is the boundary between the agent and the plugin: abstain on any - # failure rather than surfacing a hook error to Claude Code. - exit 0 -} diff --git a/backends/claudecode/code-preview-diff.sh b/backends/claudecode/code-preview-diff.sh deleted file mode 100755 index 43cda42..0000000 --- a/backends/claudecode/code-preview-diff.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook entry for Claude Code. -# -# 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 -# 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" - -INPUT="$(cat)" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -# 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" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b claudecode '[$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" diff --git a/backends/codex/code-close-diff.sh b/backends/codex/code-close-diff.sh deleted file mode 100755 index 638bcf5..0000000 --- a/backends/codex/code-close-diff.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# code-close-diff.sh — PostToolUse hook entry for OpenAI Codex CLI. -# -# 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" - -INPUT="$(cat)" - -# 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 ;; - mcp__*) exit 0 ;; -esac - -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$BIN_DIR/nvim-call.sh" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -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 diff --git a/backends/codex/code-preview-diff.sh b/backends/codex/code-preview-diff.sh deleted file mode 100755 index 95da6c7..0000000 --- a/backends/codex/code-preview-diff.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook entry for OpenAI Codex CLI. -# -# 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). -# -# 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" - -INPUT="$(cat)" - -# 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 ;; - mcp__*) exit 0 ;; -esac - -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -# 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" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -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" diff --git a/backends/copilot/code-close-diff.sh b/backends/copilot/code-close-diff.sh deleted file mode 100755 index 6cce078..0000000 --- a/backends/copilot/code-close-diff.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# code-close-diff.sh — PostToolUse hook entry for GitHub Copilot CLI. -# -# 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" - -INPUT="$(cat)" - -# Fast-path filter — see the matching note in code-preview-diff.sh. -TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)" -case "$TOOL" in - ""|view|glob|grep|ls|report_intent) exit 0 ;; -esac - -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$BIN_DIR/nvim-call.sh" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$r, $b]' 2>/dev/null || true)" -[[ -z "$ARGS" ]] && exit 0 -nvim_call code-preview.post_tool handle "$ARGS" >/dev/null diff --git a/backends/copilot/code-preview-diff.sh b/backends/copilot/code-preview-diff.sh deleted file mode 100755 index 55fb371..0000000 --- a/backends/copilot/code-preview-diff.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook entry for GitHub Copilot CLI. -# -# 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 Copilot's -# {toolName, cwd, toolArgs} payload into the canonical hook shape now lives -# in lua/code-preview/pre_tool/normalisers.lua (copilot entry). -# -# When Neovim is unreachable, the shim abstains: exit 0 with no stdout. -# Copilot then falls back to its native flow 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" - -INPUT="$(cat)" - -# Fast-path filter for tools that never produce a preview. Copilot has no -# per-tool hook matcher and no TS-side allowlist (unlike Claude Code's -# settings.json and opencode's TS plugin), so every tool firing — including -# the very chatty view/glob/grep/ls/report_intent — 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 '.toolName // empty' 2>/dev/null || true)" -case "$TOOL" in - ""|view|glob|grep|ls|report_intent) exit 0 ;; -esac - -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -# 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" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b copilot '[$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" diff --git a/backends/opencode/code-close-diff.sh b/backends/opencode/code-close-diff.sh deleted file mode 100755 index 163c847..0000000 --- a/backends/opencode/code-close-diff.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# code-close-diff.sh — PostToolUse hook entry for OpenCode. -# -# 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" - -INPUT="$(cat)" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -source "$BIN_DIR/nvim-socket.sh" "$CWD" 2>/dev/null || true -source "$BIN_DIR/nvim-call.sh" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$r, $b]' 2>/dev/null || true)" -[[ -z "$ARGS" ]] && exit 0 -nvim_call code-preview.post_tool handle "$ARGS" >/dev/null diff --git a/backends/opencode/code-preview-diff.sh b/backends/opencode/code-preview-diff.sh deleted file mode 100755 index 0c478e9..0000000 --- a/backends/opencode/code-preview-diff.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook entry for OpenCode. -# -# After issue #47 phase 3, this shim is a thin wrapper around a single RPC -# into the in-process orchestrator (lua/code-preview/pre_tool/init.lua). The -# TS plugin (backends/opencode/index.ts) collects OpenCode's {tool, args, -# directory} into a JSON payload, pipes it to this shim, and awaits the -# result. Lua-side normalisation maps the camelCase/lowercase shape into the -# canonical form. See docs/adr/0006-opencode-defers-os-independence-to-46.md. -# -# When Neovim is unreachable, the shim abstains silently (exit 0). - -# 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" - -INPUT="$(cat)" -CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" - -# 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" - -if [[ -z "${NVIM_SOCKET:-}" ]]; then - exit 0 -fi - -ARGS="$(jq -nc --argjson r "$INPUT" --arg b opencode '[$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" diff --git a/backends/opencode/index.ts b/backends/opencode/index.ts index e8bc6d3..d70f65b 100644 --- a/backends/opencode/index.ts +++ b/backends/opencode/index.ts @@ -1,13 +1,13 @@ // index.ts — OpenCode plugin entry point. // -// After issue #47 phase 3, this plugin is a thin transport layer. It collects -// OpenCode's {tool, args, directory} from each hook firing, JSON-encodes it, -// and pipes it into the shell shim under backends/opencode/, which performs +// Thin transport layer: collects OpenCode's {tool, args, directory} per hook +// firing, JSON-encodes it, and pipes it into the shared generic hook entry +// (bin/hook-entry.{sh,ps1}), invoked as `opencode pre|post`, which performs // socket discovery and RPCs the in-process orchestrator. Tool-name and // camelCase→snake_case mapping live Lua-side (pre_tool.normalisers.opencode). // -// See docs/adr/0006-opencode-defers-os-independence-to-46.md for why this -// keeps the bash shim instead of speaking nvim RPC directly from TS. +// See docs/adr/0008-one-hook-entry-per-os.md — OpenCode shares the same +// per-OS shim as the other agents rather than owning its own. import type { Plugin } from "@opencode-ai/plugin" import { execSync } from "child_process" @@ -18,30 +18,26 @@ import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// ── Shim path resolution ───────────────────────────────────────── -// bin-path.txt was historically written by the installer pointing at the -// plugin's bin/ directory. Phase 3 changes its meaning to the plugin root -// (so we can locate backends/opencode/ alongside bin/). For users who -// upgrade without re-running :CodePreviewInstallOpenCodeHooks, fall back to -// the legacy interpretation by stepping up one directory. -// -// Transitional for v2.3; remove the legacy fallback in v3.0. +const IS_WIN = process.platform === "win32" + +// ── Hook-entry resolution ──────────────────────────────────────── +// bin-path.txt (written by the installer) points at the plugin root; the shim +// lives at /bin/hook-entry.{sh,ps1}. Re-run :CodePreviewInstallOpenCodeHooks +// after upgrading so bin-path.txt is refreshed. -function resolveShim(name: string): string | null { +function resolveHookEntry(): string | null { const root = readBinPath() if (!root) return null - const primary = resolve(root, "backends/opencode", name) - if (existsSync(primary)) return primary - const legacy = resolve(root, "..", "backends/opencode", name) - if (existsSync(legacy)) return legacy - return null + const name = IS_WIN ? "hook-entry.ps1" : "hook-entry.sh" + const p = resolve(root, "bin", name) + return existsSync(p) ? p : null } function readBinPath(): string | null { try { return readFileSync(resolve(__dirname, "bin-path.txt"), "utf-8").trim() } catch { - // Development fallback: plugin source lives at /backends/opencode/. + // Development fallback: index.ts lives at /backends/opencode/. return resolve(__dirname, "../..") } } @@ -60,17 +56,21 @@ const PREVIEW_TOOLS = new Set(["edit", "write", "multiedit", "bash", "apply_patc // ── Shim invocation ────────────────────────────────────────────── -function runShim(scriptName: string, payload: object): void { - const shim = resolveShim(scriptName) +function runHook(event: "pre" | "post", payload: object): void { + const shim = resolveHookEntry() if (!shim) { - // Symmetric with the timeout branch below: surface enough breadcrumb - // that a misconfigured bin-path.txt isn't a silently-broken plugin. + // Surface enough breadcrumb that a misconfigured bin-path.txt isn't a + // silently-broken plugin. // eslint-disable-next-line no-console - console.debug(`[code-preview] could not resolve shim ${scriptName}`) + console.debug(`[code-preview] could not resolve hook-entry shim`) return } + // On Windows the .ps1 runs through PowerShell; on Unix the .sh runs directly. + const cmd = IS_WIN + ? `powershell -NoProfile -ExecutionPolicy Bypass -File "${shim}" opencode ${event}` + : `"${shim}" opencode ${event}` try { - execSync(`"${shim}"`, { + execSync(cmd, { input: JSON.stringify(payload), env: { ...process.env, CODE_PREVIEW_BACKEND: "opencode" }, timeout: 15000, @@ -82,7 +82,7 @@ function runShim(scriptName: string, payload: object): void { // is treated as best-effort and swallowed. if (err && (err.code === "ETIMEDOUT" || err.signal === "SIGTERM")) { // eslint-disable-next-line no-console - console.debug(`[code-preview] ${scriptName} timed out after 15s`) + console.debug(`[code-preview] hook-entry ${event} timed out after 15s`) } } } @@ -111,14 +111,14 @@ const plugin: Plugin = async ({ directory }) => { if (!PREVIEW_TOOLS.has(input.tool)) return const args = (output.args as Record) ?? {} const payload = { tool: input.tool, args, cwd: directory } - await enqueueHook(() => runShim("code-preview-diff.sh", payload)) + await enqueueHook(() => runHook("pre", payload)) }, "tool.execute.after": async (input, _output) => { if (!PREVIEW_TOOLS.has(input.tool)) return const args = ((input as any).args as Record) ?? {} const payload = { tool: input.tool, args, cwd: directory } - await enqueueHook(() => runShim("code-close-diff.sh", payload)) + await enqueueHook(() => runHook("post", payload)) }, } } diff --git a/bin/hook-entry.ps1 b/bin/hook-entry.ps1 new file mode 100644 index 0000000..b979396 --- /dev/null +++ b/bin/hook-entry.ps1 @@ -0,0 +1,64 @@ +# hook-entry.ps1 — generic per-OS hook entry (Windows), parameterized by +# backend + event. Windows counterpart to bin/hook-entry.sh; replaces the +# per-backend backends//code-{preview,close}-diff.ps1 shims. See ADR-0008. +# +# Invoked by the installer as: +# powershell -NoProfile -ExecutionPolicy Bypass -File hook-entry.ps1 +# +# Reads the agent's hook payload on stdin, optionally fast-path-filters noisy +# tools, discovers the running Neovim (named pipe), and makes a single RPC into +# the in-process orchestrator. Abstains (exit 0, no stdout) on any failure. + +# $HookEvent, not $Event: $Event is a PowerShell automatic variable (eventing +# subsystem). Harmless here, but renamed to avoid the foot-gun. +param([string]$Backend, [string]$HookEvent) + +try { + $raw = [Console]::In.ReadToEnd() + if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + + # ConvertFrom-Json is read-only and unbounded in depth, so this never + # truncates; we use it only for the shallow fields below. The payload itself + # is spliced verbatim into the RPC args (ADR-0007), never re-serialised. + $payload = $raw | ConvertFrom-Json + + # Per-backend fast-path filter (perf gate; the Lua normaliser is the source of + # truth). Only codex/copilot need it; claudecode filters via its settings + # matcher, opencode via its TS allowlist. + switch ($Backend) { + 'codex' { + $tool = $payload.tool_name + if ([string]::IsNullOrEmpty($tool) -or + $tool -in @('read','view','glob','grep','ls','list_files') -or + $tool -like 'mcp__*') { exit 0 } + } + 'copilot' { + $tool = $payload.toolName + if ([string]::IsNullOrEmpty($tool) -or + $tool -in @('view','glob','grep','ls','report_intent')) { exit 0 } + } + } + + $cwd = $payload.cwd + + . (Join-Path $PSScriptRoot "nvim-socket.ps1") + . (Join-Path $PSScriptRoot "nvim-call.ps1") + + $socket = Find-NvimSocket -ProjectCwd $cwd + if ([string]::IsNullOrEmpty($socket)) { exit 0 } + + # Verbatim splice of the raw payload into [payload, backend]. + $argsJson = "[$raw,""$Backend""]" + + if ($HookEvent -eq 'post') { + $null = Invoke-NvimCall -Server $socket -Module 'code-preview.post_tool' ` + -Function 'handle' -ArgsJson $argsJson + } else { + $result = Invoke-NvimCall -Server $socket -Module 'code-preview.pre_tool' ` + -Function 'handle' -ArgsJson $argsJson + if ($null -ne $result -and $result -ne '') { Write-Output $result } + } +} catch { + # Boundary between agent and plugin: abstain on any failure. + exit 0 +} diff --git a/bin/hook-entry.sh b/bin/hook-entry.sh new file mode 100755 index 0000000..6946ac3 --- /dev/null +++ b/bin/hook-entry.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# hook-entry.sh — generic per-OS hook entry, parameterized by backend + event. +# +# Replaces the per-backend backends//code-{preview,close}-diff.sh shims: +# post-#47 they were near-identical, differing only in data (backend name, the +# pre/post event, and a fast-path tool filter). One shim per OS, parameterized, +# scales 2→2 as agents are added instead of 2-per-agent. See ADR-0008. +# +# Usage (written into the agent's hook config by the installer): +# hook-entry.sh +# +# Reads the agent's hook payload on stdin, optionally fast-path-filters noisy +# tools, discovers the running Neovim, and makes a single RPC into the in-process +# orchestrator. Abstains (exit 0, no stdout) when Neovim is unreachable, so the +# agent falls back to its native flow. See ADR-0005. + +# No `set -e`: the shim is the boundary between the agent and the plugin — on any +# failure (bad payload, unreachable nvim) we exit 0 (abstain) rather than surface +# a hook error. +set -uo pipefail + +BACKEND="${1:-}" +EVENT="${2:-}" + +# hook-entry.sh lives in bin/ alongside nvim-socket.sh / nvim-call.sh. +BIN_DIR="$(cd "$(dirname "$0")" && pwd)" + +INPUT="$(cat)" + +# Per-backend fast-path filter — skip tools that never produce a preview before +# paying for socket discovery + an RPC round-trip. The Lua normaliser remains the +# source of truth; this is purely a perf gate. Only codex/copilot need it: +# claudecode filters via its settings.json matcher, opencode via its TS allowlist. +case "$BACKEND" in + codex) + 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 ;; + mcp__*) exit 0 ;; + esac + ;; + copilot) + TOOL="$(printf '%s' "$INPUT" | jq -r '.toolName // empty' 2>/dev/null || true)" + case "$TOOL" in + ""|view|glob|grep|ls|report_intent) exit 0 ;; + esac + ;; +esac + +CWD="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)" + +# 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" + +if [[ -z "${NVIM_SOCKET:-}" ]]; then + exit 0 +fi + +# Splice the raw payload verbatim into the RPC args array [payload, backend] — +# never re-serialise it. Malformed payload (jq fails) → abstain. +ARGS="$(jq -nc --argjson r "$INPUT" --arg b "$BACKEND" '[$r, $b]' 2>/dev/null || true)" +[[ -z "$ARGS" ]] && exit 0 + +case "$EVENT" in + pre) nvim_call code-preview.pre_tool handle "$ARGS" ;; + post) nvim_call code-preview.post_tool handle "$ARGS" >/dev/null ;; +esac diff --git a/docs/adr/0008-one-hook-entry-per-os.md b/docs/adr/0008-one-hook-entry-per-os.md new file mode 100644 index 0000000..24b5418 --- /dev/null +++ b/docs/adr/0008-one-hook-entry-per-os.md @@ -0,0 +1,25 @@ +# One parameterized hook entry per OS, not one per agent + +Status: accepted + +Each agent integration had its own pair of [hook entry](../../CONTEXT.md#hook-entry) shims (`backends//code-{preview,close}-diff.{sh,ps1}`). Post-[#47](0005-core-handler-runs-in-process.md) these were near-identical — read stdin, optionally fast-path-filter noisy tools, discover the running Neovim, splice the payload, make one [RPC](../../CONTEXT.md#rpc) — differing only in *data*: the backend name, the pre/post event, and which tools the fast-path filter drops. Extending that per-agent shape to Windows (a `.ps1` per agent) would have reached 4 agents × 2 OSes × 2 events ≈ 16 near-identical files, each carrying its own copy of the abstain contract and the verbatim-splice invariant. + +We collapse the hook entry to **one parameterized shim per OS** — `bin/hook-entry.sh` and `bin/hook-entry.ps1` — invoked as `hook-entry `. The fast-path filter becomes a backend-keyed branch inside the shim (only codex/copilot need one; claudecode filters via its settings matcher, opencode via its TS allowlist); the `pre_tool.normalisers` tool map remains the source of truth. The OS branch that selects the shim and builds the command is centralised in `lua/code-preview/platform.lua` (`script_ext` / `hook_command` / `make_executable` / `shim_dependency`), replacing logic that was duplicated across every installer and `health.lua`. + +## Considered Options + +- **Keep per-agent shims** — rejected: 16 copies of the same glue, so every change to the abstain/splice contract (exactly the kind ADR-0007 keeps revising) lands 16 times. The per-agent seam was hypothetical — no agent has ever needed different shim *logic*. +- **One shim per OS *and* event (4 files)** — acceptable fallback; rejected in favour of folding the trivial event axis into an argument. +- **One shim per OS (2 files)** *(chosen)* — the OS axis is the only real axis of variation, because it is a language boundary (bash vs PowerShell). + +## Consequences + +- Adding the remaining Windows agents is "register a name," not "write more `.ps1` files." `backends/` now holds only OpenCode's TS plugin; the per-agent shim directories are gone. +- This extends [ADR-0007](0007-windows-shim-via-shared-powershell-discovery.md)'s "one discovery implementation per OS" to "one hook entry per OS." +- The per-agent customisation seam is *defaulted, not abolished*: a future agent that genuinely needs bespoke pre-processing can still ship its own shim and the installer point at it. We stop paying for the seam until a second adapter proves it real. +- **Copilot** invokes the shim through its `bash` config field, so it always uses `hook-entry.sh` (the PowerShell-wrapped command form doesn't apply); Copilot-on-Windows would need git-bash and stays deferred. +- **Multi-token command field — validated.** The installed command passes ` ` as positional arguments (after `powershell -File hook-entry.ps1` on Windows, or after a bare `hook-entry.sh` on Unix). This only works if the agent *shell-executes* the command rather than raw-execing it as a single `argv[0]`. Confirmed on the two paths that needed proving: + - **claudecode on Windows** (PowerShell 5.1) — the ` ` args reach `hook-entry.ps1` intact, so the PS 5.1 `PSNativeCommandArgumentPassing` gap does not bite for these simple alphanumeric tokens, and no `.cmd` trampoline is needed. + - **codex on macOS** — the bare-path → multi-token change works, so codex shell-execs (settling the ADR-0007 per-agent-invocation spike for codex on its own runtime). + + Copilot (its hook field is literally `bash`) and OpenCode (its `index.ts` builds the command explicitly) are multi-token-safe by construction. **Still pending:** codex / copilot / opencode *on Windows* — wired to the generic shim, but their Windows command-field invocation has not been run (their Windows enablement remains deferred). diff --git a/lua/code-preview/backends/claudecode.lua b/lua/code-preview/backends/claudecode.lua index 5f3756f..f4bb8e5 100644 --- a/lua/code-preview/backends/claudecode.lua +++ b/lua/code-preview/backends/claudecode.lua @@ -17,14 +17,22 @@ local function bin_dir() return plugin_root() .. "/bin" end --- Path to Claude Code adapter scripts (backends/claudecode/) -local function scripts_dir() - return plugin_root() .. "/backends/claudecode" +local platform = require("code-preview.platform") + +-- Markers identifying our hook entries. "hook-entry" is the current stem +-- (bin/hook-entry.{sh,ps1}); "code-preview" / "claude-preview" match older +-- installs (the per-backend code-preview-diff shim, and the legacy name) so +-- uninstall still cleans them up after an upgrade. +local HOOK_MARKERS = { "hook-entry", "code-preview", "claude-preview" } + +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 -local HOOK_MARKER = "code-preview" -local LEGACY_HOOK_MARKER = "claude-preview" -- match old entries during transition - -- Tools whose proposals we intercept. On Windows, Claude Code exposes a -- distinct `PowerShell` tool alongside `Bash` and routes shell file ops -- (Remove-Item / Move-Item / Set-Content …) through it — observed with the @@ -35,21 +43,6 @@ local LEGACY_HOOK_MARKER = "claude-preview" -- match old entries during transit -- the hook fires. Harmless on Unix (no such tool is ever emitted there). local TOOL_MATCHER = "Edit|Write|MultiEdit|Bash|PowerShell" --- The hook entry is per-OS (issue #46 / ADR-0007): a .sh shim on Unix, a .ps1 --- shim on Windows invoked through PowerShell. The installer writes the --- interpreter explicitly into Claude Code's `command` field, since the file is --- not directly executable on Windows. -local function script_ext() - return vim.fn.has("win32") == 1 and ".ps1" or ".sh" -end - -local function hook_command(script_path) - if vim.fn.has("win32") == 1 then - return string.format('powershell -NoProfile -ExecutionPolicy Bypass -File "%s"', script_path) - end - return script_path -end - local function settings_path() return vim.fn.getcwd() .. "/.claude/settings.local.json" end @@ -81,7 +74,7 @@ local function remove_ours(list) if entry.hooks and entry.hooks[1] then cmd = tostring(entry.hooks[1].command or "") end - if not (cmd:find(HOOK_MARKER, 1, true) or cmd:find(LEGACY_HOOK_MARKER, 1, true)) then + if not is_our_command(cmd) then table.insert(filtered, entry) end end @@ -89,14 +82,11 @@ local function remove_ours(list) end function M.install() - local dir = scripts_dir() - local ext = script_ext() - local preview = dir .. "/code-preview-diff" .. ext - local close = dir .. "/code-close-diff" .. ext - - -- Verify scripts exist - if vim.fn.filereadable(preview) == 0 then - vim.notify("[code-preview] hook script not found: " .. preview, vim.log.levels.ERROR) + -- One generic shim per OS, parameterized by backend + event (ADR-0008). + local hook = bin_dir() .. "/hook-entry" .. platform.script_ext() + + if vim.fn.filereadable(hook) == 0 then + vim.notify("[code-preview] hook script not found: " .. hook, vim.log.levels.ERROR) return end @@ -111,15 +101,15 @@ function M.install() data.hooks.PreToolUse = remove_ours(data.hooks.PreToolUse) data.hooks.PostToolUse = remove_ours(data.hooks.PostToolUse) - -- Add our entries. On Windows the command invokes PowerShell explicitly - -- against the .ps1 shim; on Unix it's the bare .sh path. See ADR-0007. + -- The command invokes the shim with the backend + event; on Windows + -- platform.hook_command wraps it in `powershell -File …`. See ADR-0007/0008. table.insert(data.hooks.PreToolUse, { matcher = TOOL_MATCHER, - hooks = { { type = "command", command = hook_command(preview) } }, + hooks = { { type = "command", command = platform.hook_command(hook, "claudecode pre") } }, }) table.insert(data.hooks.PostToolUse, { matcher = TOOL_MATCHER, - hooks = { { type = "command", command = hook_command(close) } }, + hooks = { { type = "command", command = platform.hook_command(hook, "claudecode post") } }, }) write_settings(path, data) @@ -134,9 +124,7 @@ function M.install_state() if not f then return { state = "missing" } end local content = f:read("*a") or "" f:close() - local installed = content:find(HOOK_MARKER, 1, true) ~= nil - or content:find(LEGACY_HOOK_MARKER, 1, true) ~= nil - if installed then return { state = "installed" } end + if is_our_command(content) then return { state = "installed" } end return { state = "missing" } end diff --git a/lua/code-preview/backends/codex.lua b/lua/code-preview/backends/codex.lua index c60fc9b..795ff8e 100644 --- a/lua/code-preview/backends/codex.lua +++ b/lua/code-preview/backends/codex.lua @@ -9,23 +9,23 @@ local function 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 platform = require("code-preview.platform") + +local function bin_dir() return plugin_root() .. "/bin" end +local function hook_script() return bin_dir() .. "/hook-entry" .. platform.script_ext() 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 *stem* (no directory, no --- extension) so the check works across OSes: the installed command references --- code-preview-diff.sh / code-close-diff.sh on Unix and the .ps1 counterparts --- on Windows (issue #46), with forward- or back-slashed paths. Matching the --- bare stem covers all of them; both stems are specific enough that a --- user-authored hook is unlikely to collide. +-- hooks. The Codex docs allow multiple hooks per event, so we cooperate rather +-- than overwrite. "hook-entry" is the current generic shim (ADR-0008); the +-- code-preview-diff / code-close-diff stems match older per-backend installs so +-- uninstall still cleans them up after an upgrade. Matched as substrings, so +-- they work across OSes and slash styles. local HOOK_MARKERS = { + "hook-entry", "code-preview-diff", "code-close-diff", } @@ -143,18 +143,13 @@ local function ensure_executable(path) vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) return false end - -- chmod is a no-op (and the binary is absent) on Windows, where the hook - -- command invokes the interpreter explicitly (powershell -File ...) rather - -- than relying on an executable bit. See issue #46. - if vim.fn.has("unix") == 1 then - vim.fn.system({ "chmod", "+x", path }) - end + platform.make_executable(path) -- chmod +x on Unix; no-op on Windows 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 + local hook = hook_script() + if not ensure_executable(hook) then return end vim.fn.mkdir(codex_dir(), "p") @@ -178,11 +173,11 @@ function M.install() table.insert(data.hooks.PreToolUse, { matcher = "", - hooks = { { type = "command", command = pre } }, + hooks = { { type = "command", command = platform.hook_command(hook, "codex pre") } }, }) table.insert(data.hooks.PostToolUse, { matcher = "", - hooks = { { type = "command", command = post } }, + hooks = { { type = "command", command = platform.hook_command(hook, "codex post") } }, }) write_json(hooks_path(), data) diff --git a/lua/code-preview/backends/copilot.lua b/lua/code-preview/backends/copilot.lua index 94503b9..dafe09b 100644 --- a/lua/code-preview/backends/copilot.lua +++ b/lua/code-preview/backends/copilot.lua @@ -9,9 +9,14 @@ local function plugin_root() return vim.fn.fnamemodify(lua_dir, ":h:h:h") end -local function scripts_dir() return plugin_root() .. "/backends/copilot" 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 platform = require("code-preview.platform") + +local function bin_dir() return plugin_root() .. "/bin" end +-- Copilot's hook field is `bash` (the value runs under a bash shell), so it +-- always invokes the .sh shim — the PowerShell-wrapped command form doesn't +-- apply to this field shape. Copilot-on-Windows (which would need git-bash) +-- is deferred (issue #46). +local function hook_script() return bin_dir() .. "/hook-entry.sh" end local function hooks_dir() return vim.fn.getcwd() .. "/.github/hooks" end local function config_path() return hooks_dir() .. "/code-preview.json" end @@ -22,19 +27,20 @@ local function shquote(s) end -- True iff `path` looks like a code-preview.json our installer produced. We --- match on the pre-tool adapter script *stem* (no extension) — every install() --- writes it verbatim, and it's specific enough that user-authored hook files --- are unlikely to collide. Matching the stem rather than code-preview-diff.sh --- keeps detection working on Windows, where the installed command references --- the .ps1 counterpart (issue #46). Guards status display and uninstall from --- misidentifying a user-owned file with the same name. +-- match on the hook-entry shim stem ("hook-entry"), with "code-preview-diff" +-- kept so older per-backend installs are still recognised for uninstall after +-- an upgrade. Specific enough that user-authored hook files are unlikely to +-- collide. Guards status display and uninstall from misidentifying a +-- user-owned file with the same name. function M.is_our_config(path) if vim.fn.filereadable(path) == 0 then return false end local f = io.open(path, "r") if not f then return false end local content = f:read("*a") f:close() - return content and content:find("code-preview-diff", 1, true) ~= nil + if not content then return false end + return content:find("hook-entry", 1, true) ~= nil + or content:find("code-preview-diff", 1, true) ~= nil end local function ensure_executable(path) @@ -42,26 +48,22 @@ local function ensure_executable(path) vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) return false end - -- chmod is a no-op (and the binary is absent) on Windows, where the hook - -- command invokes the interpreter explicitly (powershell -File ...) rather - -- than relying on an executable bit. See issue #46. - if vim.fn.has("unix") == 1 then - vim.fn.system({ "chmod", "+x", path }) - end + platform.make_executable(path) -- chmod +x on Unix; no-op on Windows 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 + local hook = hook_script() + if not ensure_executable(hook) then return end vim.fn.mkdir(hooks_dir(), "p") + -- The bash field runs the shim under bash with the backend + event args. local data = { version = 1, hooks = { - preToolUse = { { type = "command", bash = shquote(pre), timeoutSec = 30 } }, - postToolUse = { { type = "command", bash = shquote(post), timeoutSec = 30 } }, + preToolUse = { { type = "command", bash = shquote(hook) .. " copilot pre", timeoutSec = 30 } }, + postToolUse = { { type = "command", bash = shquote(hook) .. " copilot post", timeoutSec = 30 } }, }, } diff --git a/lua/code-preview/backends/opencode.lua b/lua/code-preview/backends/opencode.lua index 95a4ae9..e0518f9 100644 --- a/lua/code-preview/backends/opencode.lua +++ b/lua/code-preview/backends/opencode.lua @@ -10,6 +10,8 @@ local function plugin_root() return vim.fn.fnamemodify(lua_dir, ":h:h:h") end +local platform = require("code-preview.platform") + local function plugin_source_dir() return plugin_root() .. "/backends/opencode" end @@ -30,20 +32,20 @@ function M.install() local target = opencode_target_dir() vim.fn.mkdir(target, "p") - -- Copy plugin files + -- Copy plugin files. Portable copy (libuv) rather than `cp`, which is absent + -- on Windows (issue #46). + local uv = vim.uv or vim.loop local files = { "index.ts", "package.json", "tsconfig.json" } for _, file in ipairs(files) do local src_path = source .. "/" .. file local dst_path = target .. "/" .. file if vim.fn.filereadable(src_path) == 1 then - vim.fn.system({ "cp", src_path, dst_path }) + uv.fs_copyfile(src_path, dst_path) end end - -- Write bin-path.txt pointing at the plugin root. The TS plugin derives - -- both backends/opencode/ (shim location) and bin/ (legacy callers) from - -- this single path. Historically this file pointed at bin/ directly; the - -- TS side keeps a transitional fallback for that legacy value. + -- Write bin-path.txt pointing at the plugin root; the TS plugin resolves + -- bin/hook-entry.{sh,ps1} relative to it. local bin_path_file = target .. "/bin-path.txt" local bf = io.open(bin_path_file, "w") if bf then @@ -51,18 +53,9 @@ function M.install() bf:close() end - -- Ensure shim scripts are executable in-tree. The TS plugin execSyncs them - -- directly from /backends/opencode/. No-op on Windows: the - -- permissions model differs and bash isn't the end-state for opencode-on- - -- Windows anyway (see ADR-0006). #46 will resolve the Windows story. - if vim.fn.has("unix") == 1 then - for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do - local script_path = source .. "/" .. script - if vim.fn.filereadable(script_path) == 1 then - vim.fn.system({ "chmod", "+x", script_path }) - end - end - end + -- The TS plugin execSyncs the shared bin/hook-entry shim; ensure it's + -- executable on Unix (no-op on Windows). See ADR-0008. + platform.make_executable(plugin_root() .. "/bin/hook-entry.sh") vim.notify("[code-preview] OpenCode plugin installed → " .. target, vim.log.levels.INFO) end diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index 5d5d344..de4d23a 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -9,8 +9,9 @@ function M.check() local start = h.start or h.report_start -- Hook shims are per-OS: .sh on Unix, .ps1 on Windows (issue #46 / ADR-0007). - local is_win = vim.fn.has("win32") == 1 - local shim_ext = is_win and ".ps1" or ".sh" + local platform = require("code-preview.platform") + local is_win = platform.is_windows() + local shim_ext = platform.script_ext() -- Report a shim/script artifact. On Windows there is no executable bit (the -- hook command invokes the interpreter explicitly), so readability is the @@ -72,78 +73,48 @@ function M.check() start("Claude Code backend") -- Hook-shim dependency, reported per-OS. The Unix shims (.sh) parse JSON with - -- jq; the Windows shims (.ps1) use PowerShell's native ConvertFrom-Json, so jq - -- is irrelevant there. See issue #46. - if vim.fn.has("win32") == 1 then - if vim.fn.executable("powershell") == 1 then + -- jq; the Windows shims (.ps1) use PowerShell's native ConvertFrom-Json. See + -- issue #46. + local dep = platform.shim_dependency() + if vim.fn.executable(dep) == 1 then + if is_win then ok("PowerShell is available (used by the Windows hook shims; built in on Windows 11)") else - warn("powershell not found in PATH (required by the Windows hook scripts)") + ok("jq is available") end - elseif vim.fn.executable("jq") == 1 then - ok("jq is available") else - warn("jq not found in PATH (required by the Unix hook scripts)") + warn(dep .. " not found in PATH (required by the hook scripts)") end - -- Hook scripts executable + -- Shared shims. The hook entry is one generic per-OS shim (bin/hook-entry, + -- ADR-0008); the discovery + RPC shims are per-OS too; the apply-* workers + -- are Lua on every OS. local src = debug.getinfo(1, "S").source local lua_file = src:sub(2) local lua_dir = vim.fn.fnamemodify(lua_file, ":h") local plugin_root = vim.fn.fnamemodify(lua_dir, ":h:h") local bin = plugin_root .. "/bin" - local claudecode_dir = plugin_root .. "/backends/claudecode" - - -- Claude Code adapter scripts (per-OS shim extension) - for _, stem in ipairs({ "code-preview-diff", "code-close-diff" }) do - check_script(stem .. shim_ext, claudecode_dir .. "/" .. stem .. shim_ext) - end - -- Shared scripts: the discovery + RPC shims are per-OS; the apply-* workers - -- are Lua on every OS. - for _, stem in ipairs({ "nvim-socket", "nvim-call" }) do + for _, stem in ipairs({ "hook-entry", "nvim-socket", "nvim-call" }) do check_script(stem .. shim_ext, bin .. "/" .. stem .. shim_ext) end for _, script in ipairs({ "apply-edit.lua", "apply-multi-edit.lua" }) do check_script(script, bin .. "/" .. script) end - -- .claude/settings.local.json + -- .claude/settings.local.json — delegate hook detection to the backend, which + -- matches by command *shape* (the "hook-entry" marker), not by install path. + -- (A previous inline re-parse keyed off "code-preview"/"claude-preview" + -- substrings, which mis-fired after the hook-entry rename — e.g. flagging a + -- fresh install as "legacy" just because the plugin lived under a + -- claude-preview.nvim directory.) local settings = vim.fn.getcwd() .. "/.claude/settings.local.json" - local f = io.open(settings, "r") - if not f then + if vim.fn.filereadable(settings) == 0 then warn(".claude/settings.local.json not found — run :CodePreviewInstallClaudeCodeHooks") + elseif require("code-preview.backends.claudecode").install_state().state == "installed" then + ok("Claude Code hooks are installed") else - local raw = f:read("*a") - f:close() - local parsed_ok, data = pcall(vim.json.decode, raw) - if not parsed_ok then - error(".claude/settings.local.json is invalid JSON") - elseif not (data.hooks and data.hooks.PreToolUse) then - warn(".claude/settings.local.json exists but code-preview hooks are not installed") - else - local found_new = false - local found_legacy = false - for _, entry in ipairs(data.hooks.PreToolUse) do - local cmd = "" - if entry.hooks and entry.hooks[1] then - cmd = tostring(entry.hooks[1].command or "") - end - if cmd:find("code-preview", 1, true) then - found_new = true - break - elseif cmd:find("claude-preview", 1, true) then - found_legacy = true - end - end - if found_new then - ok("Claude Code hooks are installed") - elseif found_legacy then - warn("Legacy claude-preview hooks detected — run :CodePreviewInstallClaudeCodeHooks to update") - else - warn("code-preview hooks not found — run :CodePreviewInstallClaudeCodeHooks") - end - end + warn("code-preview hooks not installed in .claude/settings.local.json — run :CodePreviewInstallClaudeCodeHooks") end -- ── OpenCode backend ────────────────────────────────────────── @@ -176,14 +147,11 @@ function M.check() warn("copilot not found in PATH (install from https://github.com/github/copilot-cli)") end - -- Adapter scripts (Unix only — Copilot's Windows shim is pending, issue #46) - local copilot_dir = plugin_root .. "/backends/copilot" + -- Copilot uses the shared bin/hook-entry.sh (checked above) through its `bash` + -- hook field. On Windows that field needs git-bash, so Copilot-on-Windows is + -- deferred (issue #46). if is_win then warn("Copilot CLI on Windows is not yet supported (issue #46); use Claude Code on Windows") - else - for _, stem in ipairs({ "code-preview-diff", "code-close-diff" }) do - check_script(stem .. ".sh", copilot_dir .. "/" .. stem .. ".sh") - end end -- hooks.json installed @@ -204,13 +172,11 @@ function M.check() warn("codex not found in PATH (install from https://github.com/openai/codex)") end - local codex_dir = plugin_root .. "/backends/codex" + -- Codex now uses the shared bin/hook-entry shim (checked above); no + -- per-backend adapter script remains. Codex-on-Windows is wired but not yet + -- validated end-to-end (issue #46). if is_win then - warn("Codex CLI on Windows is not yet supported (issue #46); use Claude Code on Windows") - else - for _, stem in ipairs({ "code-preview-diff", "code-close-diff" }) do - check_script(stem .. ".sh", codex_dir .. "/" .. stem .. ".sh") - end + warn("Codex CLI on Windows is not yet validated (issue #46); use Claude Code on Windows") end local codex_backend = require("code-preview.backends.codex") diff --git a/lua/code-preview/platform.lua b/lua/code-preview/platform.lua new file mode 100644 index 0000000..7a01bfd --- /dev/null +++ b/lua/code-preview/platform.lua @@ -0,0 +1,54 @@ +-- platform.lua — the single home for the per-OS branch in the integration +-- layer. Before this, script-extension / hook-command / chmod logic was +-- duplicated across every backend installer and health.lua; centralising it +-- keeps the OS fork in one place as more backends go cross-platform. +-- See issue #46 / ADR-0008. + +local M = {} + +function M.is_windows() + return vim.fn.has("win32") == 1 +end + +-- Hook shims are per-OS: a .sh shim on Unix, a .ps1 shim on Windows. +function M.script_ext() + return M.is_windows() and ".ps1" or ".sh" +end + +--- Build the command an agent should invoke for a hook entry. +--- On Windows the .ps1 is run through PowerShell explicitly (the file is not +--- directly executable); on Unix the .sh path runs directly. `args` (a string, +--- e.g. "claudecode pre") is appended so the generic hook-entry shim knows +--- which backend + event it is serving. +--- @param script_path string absolute path to the hook-entry shim +--- @param args string? space-separated args appended to the command +--- @return string +function M.hook_command(script_path, args) + local suffix = (args and args ~= "") and (" " .. args) or "" + if M.is_windows() then + return string.format( + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%s"%s', + script_path, suffix + ) + end + return script_path .. suffix +end + +--- Make a shim executable. chmod +x on Unix; a no-op on Windows, where there is +--- no executable bit and the interpreter is invoked explicitly. +--- @param path string +function M.make_executable(path) + if not M.is_windows() then + vim.fn.system({ "chmod", "+x", path }) + end +end + +--- The external dependency each OS's shim relies on, for health reporting: +--- the Unix shims parse JSON with jq; the Windows shims use PowerShell's native +--- ConvertFrom-Json (so jq is irrelevant there). +--- @return string +function M.shim_dependency() + return M.is_windows() and "powershell" or "jq" +end + +return M diff --git a/tests/backends/claudecode/test_install.sh b/tests/backends/claudecode/test_install.sh index 4a210dd..2058080 100644 --- a/tests/backends/claudecode/test_install.sh +++ b/tests/backends/claudecode/test_install.sh @@ -24,8 +24,10 @@ test_install_claude_hooks() { # Should have PreToolUse and PostToolUse hooks 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 diff script" || return 1 - assert_contains "$content" "code-close-diff.sh" "should reference close script" || return 1 + # One generic shim per OS, parameterized by backend + event (ADR-0008). + assert_contains "$content" "hook-entry.sh" "should reference the generic hook-entry shim" || return 1 + assert_contains "$content" "claudecode pre" "PreToolUse should pass the pre event" || return 1 + assert_contains "$content" "claudecode post" "PostToolUse should pass the post event" || return 1 # PowerShell is matched too: on Windows Claude Code routes shell file ops # (Remove-Item / Set-Content …) through a distinct PowerShell tool (issue #46 # follow-up). The matcher is the same on every OS; the normaliser folds @@ -51,8 +53,7 @@ test_uninstall_claude_hooks() { content="$(cat "$settings_file")" # Hook entries should be removed (empty arrays) - assert_not_contains "$content" "code-preview-diff.sh" "diff script should be removed" || return 1 - assert_not_contains "$content" "code-close-diff.sh" "close script should be removed" || return 1 + assert_not_contains "$content" "hook-entry.sh" "hook-entry shim should be removed" || return 1 } # ── Test: Install is idempotent (no duplicates) ───────────────── @@ -66,9 +67,9 @@ test_install_idempotent() { local content content="$(cat "$settings_file")" - # Count occurrences of the diff script — should be exactly 1 + # Count PreToolUse entries (the pre event) — should be exactly 1. local count - count="$(echo "$content" | grep -o "code-preview-diff.sh" | wc -l | tr -d ' ')" + count="$(echo "$content" | grep -o "claudecode pre" | wc -l | tr -d ' ')" assert_eq "1" "$count" "should have exactly one PreToolUse hook entry" || return 1 } @@ -98,7 +99,7 @@ JSON assert_contains "$content" "permissions" "existing permissions should be preserved" || return 1 assert_contains "$content" "echo read" "existing hook should be preserved" || return 1 # Our hooks should also be present - assert_contains "$content" "code-preview-diff.sh" "our hooks should be added" || return 1 + assert_contains "$content" "hook-entry.sh" "our hooks should be added" || return 1 } # ── Run all tests ──────────────────────────────────────────────── diff --git a/tests/backends/claudecode/test_stale_socket.sh b/tests/backends/claudecode/test_stale_socket.sh index f5c4359..6a1d200 100644 --- a/tests/backends/claudecode/test_stale_socket.sh +++ b/tests/backends/claudecode/test_stale_socket.sh @@ -124,7 +124,7 @@ EOF echo "$payload" | \ NVIM_LISTEN_ADDRESS= \ TMPDIR="$hook_tmpdir" \ - bash "$REPO_ROOT/backends/claudecode/code-preview-diff.sh" >/dev/null 2>&1 || exit_code=$? + bash "$REPO_ROOT/bin/hook-entry.sh" claudecode pre >/dev/null 2>&1 || exit_code=$? # The hook exits 0 on non-crash paths, including when it cannot identify a # project-specific nvim instance. diff --git a/tests/backends/codex/test_apply_patch.sh b/tests/backends/codex/test_apply_patch.sh index 74da92f..9bc5975 100755 --- a/tests/backends/codex/test_apply_patch.sh +++ b/tests/backends/codex/test_apply_patch.sh @@ -7,8 +7,8 @@ # 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" -CODEX_POST="$REPO_ROOT/backends/codex/code-close-diff.sh" +CODEX_PRE="$REPO_ROOT/bin/hook-entry.sh" +CODEX_POST="$REPO_ROOT/bin/hook-entry.sh" # Build a Codex apply_patch payload — patch text lives in tool_input.command. run_codex_pre_patch() { @@ -20,7 +20,7 @@ run_codex_pre_patch() { '{tool_name:"apply_patch", cwd:$cwd, tool_input:{command:$pt}}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$CODEX_PRE" 2>/dev/null || true + bash "$CODEX_PRE" codex pre 2>/dev/null || true } run_codex_post_patch() { @@ -32,7 +32,7 @@ run_codex_post_patch() { '{tool_name:"apply_patch", cwd:$cwd, tool_input:{command:$pt}}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$CODEX_POST" 2>/dev/null || true + bash "$CODEX_POST" codex post 2>/dev/null || true } # ── Setup ──────────────────────────────────────────────────────── diff --git a/tests/backends/codex/test_edit.sh b/tests/backends/codex/test_edit.sh index 6f20305..8962c2d 100755 --- a/tests/backends/codex/test_edit.sh +++ b/tests/backends/codex/test_edit.sh @@ -1,9 +1,9 @@ #!/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. +# Drives Codex's hook payload shape ({tool_name, tool_input, cwd}) through the +# generic bin/hook-entry.sh (invoked as `codex pre` / `codex post`), then +# verifies Neovim state via RPC. # # Codex specifics: # - apply_patch carries the patch text in tool_input.command (not patch_text). @@ -14,8 +14,8 @@ # - 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" +CODEX_PRE="$REPO_ROOT/bin/hook-entry.sh" +CODEX_POST="$REPO_ROOT/bin/hook-entry.sh" # Feed a Codex-shaped payload to the pre-tool adapter. # $1 = tool_name, $2 = tool_input (JSON object) @@ -30,7 +30,7 @@ run_codex_pre() { '{tool_name:$tn, cwd:$cwd, tool_input:$ti}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$CODEX_PRE" 2>/dev/null || true + bash "$CODEX_PRE" codex pre 2>/dev/null || true } run_codex_post() { @@ -44,7 +44,7 @@ run_codex_post() { '{tool_name:$tn, cwd:$cwd, tool_input:$ti}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$CODEX_POST" 2>/dev/null || true + bash "$CODEX_POST" codex post 2>/dev/null || true } # ── Setup ──────────────────────────────────────────────────────── @@ -294,7 +294,7 @@ test_codex_malformed_payloads_skip() { # 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 + echo "$payload" | NVIM_LISTEN_ADDRESS="$TEST_SOCKET" bash "$CODEX_PRE" codex pre 2>/dev/null || true sleep 0.3 diff --git a/tests/backends/codex/test_install.sh b/tests/backends/codex/test_install.sh index 22cf1a6..640c57f 100755 --- a/tests/backends/codex/test_install.sh +++ b/tests/backends/codex/test_install.sh @@ -37,8 +37,9 @@ test_install_codex_hooks() { 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 + assert_contains "$content" "hook-entry.sh" "should reference the generic hook-entry shim" || return 1 + assert_contains "$content" "codex pre" "PreToolUse should pass the pre event" || return 1 + assert_contains "$content" "codex post" "PostToolUse should pass the post event" || return 1 # Exactly one entry per event after a fresh install. local pre_count post_count @@ -132,9 +133,8 @@ EOF 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 + assert_contains "$content" "user-policy" "user entry must survive uninstall" || return 1 + assert_not_contains "$content" "hook-entry.sh" "our hooks must be removed" || return 1 } # ── Test: feature_flag_state reflects default-enabled semantics ── diff --git a/tests/backends/copilot/test_apply_patch.sh b/tests/backends/copilot/test_apply_patch.sh index 140f655..4768852 100644 --- a/tests/backends/copilot/test_apply_patch.sh +++ b/tests/backends/copilot/test_apply_patch.sh @@ -2,20 +2,20 @@ # test_apply_patch.sh — E2E tests for Copilot CLI apply_patch workflow # # Drives the full pipeline for GPT-style apply_patch tool calls: -# raw patch text as toolArgs → backends/copilot/code-preview-diff.sh +# raw patch text as toolArgs → bin/hook-entry.sh copilot pre # → nvim_call → lua/code-preview/pre_tool/init.lua # → lua/code-preview/apply/patch.lua # → Neovim diff previews for all files in the patch # And the mirror post path: -# → backends/copilot/code-close-diff.sh +# → bin/hook-entry.sh copilot post # → nvim_call → lua/code-preview/post_tool.lua # → close_for_file for every Update/Add/Delete directive. # # Distinct from tests/backends/opencode/test_apply_patch.sh, which exercises # the patch parser in isolation. -COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" -COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh" +COPILOT_PRE="$REPO_ROOT/bin/hook-entry.sh" +COPILOT_POST="$REPO_ROOT/bin/hook-entry.sh" # apply_patch's toolArgs is the raw patch text, not a JSON object. jq will # still encode it as a JSON string when we build the outer payload, and the @@ -29,7 +29,7 @@ run_copilot_pre_patch() { '{toolName:"apply_patch", cwd:$cwd, toolArgs:$ta}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$COPILOT_PRE" 2>/dev/null || true + bash "$COPILOT_PRE" copilot pre 2>/dev/null || true } run_copilot_post_patch() { @@ -41,7 +41,7 @@ run_copilot_post_patch() { '{toolName:"apply_patch", cwd:$cwd, toolArgs:$ta}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$COPILOT_POST" 2>/dev/null || true + bash "$COPILOT_POST" copilot post 2>/dev/null || true } # ── Setup ──────────────────────────────────────────────────────── diff --git a/tests/backends/copilot/test_edit.sh b/tests/backends/copilot/test_edit.sh index 36814af..cdd17b1 100644 --- a/tests/backends/copilot/test_edit.sh +++ b/tests/backends/copilot/test_edit.sh @@ -2,7 +2,7 @@ # test_edit.sh — E2E tests for GitHub Copilot CLI edit/create/bash workflows # # Drives Copilot's native hook payload shape ({toolName, cwd, toolArgs}) through -# backends/copilot/code-preview-diff.sh (pre) and code-close-diff.sh (post), +# the generic bin/hook-entry.sh (invoked as `copilot pre` / `copilot post`), # then verifies Neovim state via RPC. # # Copilot quirk: toolArgs is a stringified JSON object for most tools, and the @@ -10,8 +10,8 @@ # lua/code-preview/pre_tool/normalisers.lua maps both into the canonical # {tool_name, cwd, tool_input} shape consumed by pre_tool.handle(). -COPILOT_PRE="$REPO_ROOT/backends/copilot/code-preview-diff.sh" -COPILOT_POST="$REPO_ROOT/backends/copilot/code-close-diff.sh" +COPILOT_PRE="$REPO_ROOT/bin/hook-entry.sh" +COPILOT_POST="$REPO_ROOT/bin/hook-entry.sh" # Feed a Copilot-shaped payload to the pre-tool adapter. # $1 = toolName, $2 = toolArgs (JSON-encoded string OR raw text for apply_patch) @@ -26,7 +26,7 @@ run_copilot_pre() { '{toolName:$tn, cwd:$cwd, toolArgs:$ta}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$COPILOT_PRE" 2>/dev/null || true + bash "$COPILOT_PRE" copilot pre 2>/dev/null || true } run_copilot_post() { @@ -40,7 +40,7 @@ run_copilot_post() { '{toolName:$tn, cwd:$cwd, toolArgs:$ta}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$COPILOT_POST" 2>/dev/null || true + bash "$COPILOT_POST" copilot post 2>/dev/null || true } # ── Setup ──────────────────────────────────────────────────────── @@ -251,7 +251,7 @@ test_copilot_malformed_payloads_skip() { payload=$(jq -n --arg cwd "$TEST_PROJECT_DIR" '{toolName:"edit", cwd:$cwd}') echo "$payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$COPILOT_PRE" 2>/dev/null || true + bash "$COPILOT_PRE" copilot pre 2>/dev/null || true sleep 0.3 diff --git a/tests/backends/copilot/test_install.sh b/tests/backends/copilot/test_install.sh index 3acac4b..754ccc1 100644 --- a/tests/backends/copilot/test_install.sh +++ b/tests/backends/copilot/test_install.sh @@ -36,8 +36,9 @@ test_install_copilot_hooks() { # Both hook events are registered with the right adapter scripts 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 + assert_contains "$content" "hook-entry.sh" "should reference the generic hook-entry shim" || return 1 + assert_contains "$content" "copilot pre" "preToolUse should pass the pre event" || return 1 + assert_contains "$content" "copilot post" "postToolUse should pass the post event" || return 1 # Each event should have exactly one entry (no accidental duplication) local pre_count post_count diff --git a/tests/helpers.sh b/tests/helpers.sh index 9802431..812aa96 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -303,11 +303,11 @@ run_pretool_hook() { if [[ "$mode" == "scan" ]]; then echo "$json_payload" | \ NVIM_LISTEN_ADDRESS= \ - bash "$REPO_ROOT/backends/claudecode/code-preview-diff.sh" 2>/dev/null || true + bash "$REPO_ROOT/bin/hook-entry.sh" claudecode pre 2>/dev/null || true else echo "$json_payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$REPO_ROOT/backends/claudecode/code-preview-diff.sh" 2>/dev/null || true + bash "$REPO_ROOT/bin/hook-entry.sh" claudecode pre 2>/dev/null || true fi } @@ -319,10 +319,10 @@ run_posttool_hook() { if [[ "$mode" == "scan" ]]; then echo "$json_payload" | \ NVIM_LISTEN_ADDRESS= \ - bash "$REPO_ROOT/backends/claudecode/code-close-diff.sh" 2>/dev/null || true + bash "$REPO_ROOT/bin/hook-entry.sh" claudecode post 2>/dev/null || true else echo "$json_payload" | \ NVIM_LISTEN_ADDRESS="$TEST_SOCKET" \ - bash "$REPO_ROOT/backends/claudecode/code-close-diff.sh" 2>/dev/null || true + bash "$REPO_ROOT/bin/hook-entry.sh" claudecode post 2>/dev/null || true fi }