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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 replace these with Lua equivalents run via `nvim --headless -l`; the role stays the same.
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 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.

Expand Down Expand Up @@ -174,6 +174,8 @@ An [RPC](#rpc) call the [core handler](#core-handler) issues to the running Neov

The pattern exists because the bash layer holds no config of its own — see [ADR-0004](docs/adr/0004-config-lives-only-in-neovim.md). If Neovim is unreachable, the hook degrades safely (no logging, no [review gate](#review-gate), no visibility filter).

After issue #47 phase 3, the hook context query collapses into a local function call inside the in-process [core handler](#core-handler); the RPC form survives only for callers that still live outside the user's Neovim (e.g. a backend that hasn't yet flipped to the Lua entry point).

## Headless worker

A short-lived Neovim spawned with `nvim --headless -l <script>.lua` to do work *outside* the user's running Neovim. Headless workers have no UI, no access to the user's `M.config` or open buffers, and communicate via stdin / stdout / exit code or via [RPC](#rpc) back to the user's instance.
Expand All @@ -184,7 +186,7 @@ Today's headless workers:
- `bin/apply-multi-edit.lua` — same for `MultiEdit`.
- `bin/apply-patch.lua` — parses the custom patch format and emits per-file orig/prop tempfiles for `ApplyPatch`.

After issue #47 phases 3 and 4, `bin/core-pre-tool.lua` and `bin/core-post-tool.lua` will join this category — the entire [core handler](#core-handler) becomes a headless worker. At that point, "bash core handler" goes away as a concept and "headless worker" is *the* extra-process model.
Issue #47 phases 3 and 4 do **not** add the core handler to this list. After an early design pass we chose to fold the handler into in-process Lua instead of a new headless worker, to eliminate the per-proposal cold-start and the chain of small RPC calls back into the user's Neovim. See [ADR-0005](docs/adr/0005-core-handler-runs-in-process.md). After phases 3/4 land, "bash core handler" goes away and the apply-* scripts remain the canonical examples of headless workers.

## In-process Lua vs headless Lua

Expand Down
30 changes: 26 additions & 4 deletions backends/claudecode/code-close-diff.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
#!/usr/bin/env bash
# code-close-diff.sh — PostToolUse hook adapter for Claude Code
# Delegates to core-post-tool.sh with the Claude Code backend flag.
# 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"
export CODE_PREVIEW_BACKEND="claudecode"
exec "$BIN_DIR/core-post-tool.sh"

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
37 changes: 33 additions & 4 deletions backends/claudecode/code-preview-diff.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,37 @@
#!/usr/bin/env bash
# code-preview-diff.sh — PreToolUse hook adapter for Claude Code
# Delegates to core-pre-tool.sh with the Claude Code backend flag.
# 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.
#
# 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"
export CODE_PREVIEW_BACKEND="claudecode"
exec "$BIN_DIR/core-pre-tool.sh"

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"
55 changes: 12 additions & 43 deletions bin/apply-edit.lua
Original file line number Diff line number Diff line change
@@ -1,60 +1,29 @@
#!/usr/bin/env -S nvim --headless -l
-- apply-edit.lua — Apply a single Edit (old_string → new_string) to a file.
-- apply-edit.lua — Headless-CLI shim around lua/code-preview/apply/edit.lua.
--
-- Usage (via nvim --headless -l):
-- nvim --headless -l apply-edit.lua <file_path> <old_string> <new_string> <replace_all> <output_path>
--
-- replace_all: "true" or "false"
--
-- The real implementation is in-process Lua at lua/code-preview/apply/edit.lua.
-- This shim survives only for external callers (legacy hooks, tests invoking
-- the script directly). After issue #47 phase 3, pre_tool.lua calls the module
-- directly without spawning a second nvim.

local script_dir = debug.getinfo(1, "S").source:sub(2):match("(.*)/")
vim.opt.runtimepath:prepend(script_dir .. "/..")

local apply_edit = require("code-preview.apply.edit")

local file_path = arg[1]
local old_string = arg[2]
local new_string = arg[3]
local replace_all = arg[4] == "true"
local output_path = arg[5]

-- Read the file (empty string if it does not exist yet)
local content = ""
local fh = io.open(file_path, "r")
if fh then
content = fh:read("*a")
fh:close()
end

-- Literal replacement (string.find plain=true prevents pattern interpretation)
if replace_all then
-- Replace all occurrences
local result = {}
local search_start = 1
if old_string == "" then
-- Empty old_string: prepend new_string (handles "insert into empty file")
result = { new_string, content }
else
while true do
local s, e = string.find(content, old_string, search_start, true)
if not s then
table.insert(result, content:sub(search_start))
break
end
table.insert(result, content:sub(search_start, s - 1))
table.insert(result, new_string)
search_start = e + 1
end
end
content = table.concat(result)
else
-- Replace first occurrence only
if old_string == "" then
-- Empty old_string: prepend new_string (handles "insert into empty file")
content = new_string .. content
else
local s, e = string.find(content, old_string, 1, true)
if s then
content = content:sub(1, s - 1) .. new_string .. content:sub(e + 1)
end
end
end
local content = apply_edit.apply(file_path, old_string, new_string, replace_all)

-- Write the result
local out = assert(io.open(output_path, "w"))
out:write(content)
out:close()
Expand Down
41 changes: 9 additions & 32 deletions bin/apply-multi-edit.lua
Original file line number Diff line number Diff line change
@@ -1,50 +1,27 @@
#!/usr/bin/env -S nvim --headless -l
-- apply-multi-edit.lua — Apply a MultiEdit (multiple edits) to a file.
-- apply-multi-edit.lua — Headless-CLI shim around lua/code-preview/apply/multi_edit.lua.
--
-- Usage (via nvim --headless -l):
-- nvim --headless -l apply-multi-edit.lua <hook_json_string> <output_path>
--
-- arg[1]: full hook JSON (the same JSON that arrives on stdin for the hook)
-- arg[2]: path to write the resulting file content
-- The real implementation is in-process Lua at lua/code-preview/apply/multi_edit.lua.

local hook_json = arg[1]
local script_dir = debug.getinfo(1, "S").source:sub(2):match("(.*)/")
vim.opt.runtimepath:prepend(script_dir .. "/..")

local apply_multi_edit = require("code-preview.apply.multi_edit")

local hook_json = arg[1]
local output_path = arg[2]

-- Parse JSON via vim.json (always available in Neovim)
local ok, input = pcall(vim.json.decode, hook_json)
if not ok then
io.stderr:write("apply-multi-edit.lua: failed to parse JSON: " .. tostring(input) .. "\n")
os.exit(1)
end

local file_path = input.tool_input.file_path
local edits = input.tool_input.edits or {}

-- Read the file (empty string if it does not exist yet)
local content = ""
local fh = io.open(file_path, "r")
if fh then
content = fh:read("*a")
fh:close()
end

-- Apply each edit sequentially (literal, not pattern-based)
for _, edit in ipairs(edits) do
local old = edit.old_string or ""
local new = edit.new_string or ""
if old == "" then
-- Empty old_string → prepend new content (matches Python behaviour)
content = new .. content
else
local s, e = string.find(content, old, 1, true)
if s then
content = content:sub(1, s - 1) .. new .. content:sub(e + 1)
end
-- If not found, skip silently (matches Python behaviour)
end
end
local content = apply_multi_edit.apply(input.tool_input.file_path, input.tool_input.edits or {})

-- Write the result
local out = assert(io.open(output_path, "w"))
out:write(content)
out:close()
Expand Down
Loading
Loading