From d8d475900585c03054af03de1ee1139c5aa60e12 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 5 Jun 2026 20:30:59 +0530 Subject: [PATCH 1/8] refactor: platform module + generic hook-entry shim, claudecode slice (#46) First slice of the hook-entry consolidation (ADR-0008): replace the per-backend code-{preview,close}-diff shims with one generic, parameterized shim per OS. - lua/code-preview/platform.lua: single home for the per-OS branch (script_ext / hook_command / make_executable / shim_dependency), replacing logic that was duplicated across installers and health.lua. - bin/hook-entry.{sh,ps1}: generic hook entry, invoked as ` `. Reads stdin, per-backend fast-path filter, discovers nvim, single RPC. The .sh handles all backends; the .ps1 is drafted for Windows validation. - claudecode.lua: install writes `hook-entry. claudecode pre|post` via platform; marker matching is stem-based (hook-entry + legacy code-preview). - Delete backends/claudecode/code-{preview,close}-diff.{sh,ps1}. - health.lua: check the shared hook-entry shim; use platform.shim_dependency. - Tests: helpers + stale-socket + install tests point at the generic shim. codex/copilot/opencode still use their own shims (migrated in follow-up commits). Full suite green on macOS (65 E2E + Lua specs). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 82 +++ PRD-inline-apply.md | 39 ++ PRD-neo-tree.md | 222 +++++++ PRD-opencode.md | 301 ++++++++++ PRD.md | 307 ++++++++++ REFACTOR-PLAN.md | 556 ++++++++++++++++++ ROADMAP.md | 69 +++ backends/claudecode/code-close-diff.ps1 | 30 - backends/claudecode/code-close-diff.sh | 30 - backends/claudecode/code-preview-diff.ps1 | 42 -- backends/claudecode/code-preview-diff.sh | 37 -- bin/hook-entry.ps1 | 62 ++ bin/hook-entry.sh | 68 +++ lua/code-preview/backends/claudecode.lua | 62 +- lua/code-preview/health.lua | 34 +- lua/code-preview/platform.lua | 54 ++ pr-description.md | 50 ++ tests/backends/claudecode/test_install.sh | 15 +- .../backends/claudecode/test_stale_socket.sh | 2 +- tests/helpers.sh | 8 +- 20 files changed, 1862 insertions(+), 208 deletions(-) create mode 100644 CLAUDE.md create mode 100644 PRD-inline-apply.md create mode 100644 PRD-neo-tree.md create mode 100644 PRD-opencode.md create mode 100644 PRD.md create mode 100644 REFACTOR-PLAN.md create mode 100644 ROADMAP.md delete mode 100644 backends/claudecode/code-close-diff.ps1 delete mode 100755 backends/claudecode/code-close-diff.sh delete mode 100644 backends/claudecode/code-preview-diff.ps1 delete mode 100755 backends/claudecode/code-preview-diff.sh create mode 100644 bin/hook-entry.ps1 create mode 100755 bin/hook-entry.sh create mode 100644 lua/code-preview/platform.lua create mode 100644 pr-description.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..18ea75c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# code-preview.nvim — Developer Notes + +### Testing + +```bash +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +nvim --headless -l bin/apply-edit.lua +``` + +**Important:** After making code changes, do NOT immediately edit a file to +trigger a test. The user must restart Neovim first to pick up the new code. +Wait for the user to confirm they have restarted and ask you to make a test +edit before suggesting any changes. + +--- + +## Neo-tree Integration (v1.1.0) + +See `PRD-neo-tree.md` for full design document including v2/v3 roadmap. + +### Tasks + +- [ ] Task 8: Changes registry module (`lua/code-preview/changes.lua`) + - Key-value store: `{ [abs_path] = "modified" | "created" }` + - API: `set()`, `clear()`, `clear_all()`, `get()`, `get_all()` + - Pure Lua, no dependencies + - **Test:** `:lua` calls to set/get/clear, verify state + +- [ ] Task 9: Neo-tree integration module (`lua/code-preview/neo_tree.lua`) + - All neo-tree interaction behind `pcall` guard (soft dependency) + - Subscribe to `BEFORE_RENDER` event to inject `state.code_preview_status_lookup` + - Register `code_preview_status` component (reads lookup, returns icon + highlight) + - `refresh()` helper to trigger neo-tree filesystem re-render + - Define highlight groups: `CodePreviewTreeModified`, `CodePreviewTreeCreated` + - **Test:** Set a change via `changes.set()`, verify icon appears in neo-tree + +- [ ] Task 10: Wire up `setup()` and config + - Add `neo_tree` section to default config (enabled, symbols, highlights) + - Call `neo_tree.setup()` from `init.lua setup()` when neo-tree is available + - **Test:** `:CodePreviewStatus` reflects neo-tree integration state + +- [ ] Task 11: Shell hook changes + - `code-preview-diff.sh`: call `changes.set(path, status)` + `neo_tree.refresh()` + - `code-close-diff.sh`: call `changes.clear_all()` + `neo_tree.refresh()` + - Detect `"created"` vs `"modified"` based on whether original file is empty + - **Test:** End-to-end — Claude proposes edit, icon appears in tree, clears on accept/reject + +- [ ] Task 12: Documentation + - Update README with neo-tree setup instructions (renderer config snippet) + - Document config options for neo-tree section + - Update CLAUDE.md key files table + +### Design Decisions + +- **Soft dependency** — neo-tree is optional; all interaction guarded by `pcall` +- **No separate tree** — decorates the existing filesystem source +- **Same pattern as git_status** — inject lookup into state via `BEFORE_RENDER`, component reads it +- **User adds component to renderer** — explicit opt-in, no surprise side effects + +--- + +## Key Notes + +- Backend modules (`backends/claudecode.lua`, `backends/opencode.lua`) use `debug.getinfo(1, "S").source` to locate `bin/` +- Highlights are lazy-initialized inside `show_diff()`, not at module load +- `apply-edit.lua` / `apply-multi-edit.lua` run via `nvim --headless -l` (no Python) + +--- + +## Agent skills + +### Issue tracker + +Issues live in the `Cannon07/claude-preview` GitHub repo; skills use the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default canonical label vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context layout — `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/PRD-inline-apply.md b/PRD-inline-apply.md new file mode 100644 index 0000000..7698c94 --- /dev/null +++ b/PRD-inline-apply.md @@ -0,0 +1,39 @@ +# PRD: Inline Apply — Direct Buffer Changes with Review Queue + +## Problem + +Current diff preview uses temporary files. This causes: +- Navigating away from a diff tab loses the preview +- Temp file collisions during rapid multi-file edits +- No persistent view of all pending changes across files + +## Core Idea + +Apply proposed changes directly to the actual file/buffer instead of showing them in temp files. The original content is backed up so changes can be reverted on reject. + +## High-Level Flow + +1. **Pre-hook** — Apply proposed changes to the real buffer, store original content for revert, mark file as "pending review" in neo-tree +2. **Review** — User navigates freely via neo-tree; any marked file shows the applied changes with diff highlights (similar to git gutter) +3. **Accept (post-hook)** — Keep the changes, clear the marker +4. **Reject / manual close** — Revert buffer to original content, clear the marker + +## Neo-tree "Proposed Changes" View + +- New source or filter (like git status tab) showing only files with pending changes +- User can cycle through pending files to review +- Markers clear as files are accepted via CLI post-hook +- When all files are accepted/rejected, the view empties + +## Key Design Questions (for later) + +- Revert mechanism: buffer-only undo vs stored original content? +- What happens if the user manually edits a file with pending changes? +- Should the buffer be read-only while changes are pending? +- How to handle accept/reject for individual hunks vs whole file? +- Interaction with undo history (`u` in nvim) + +## Related + +- GitHub issue: navigating away loses diff preview +- Current neo-tree integration: `PRD-neo-tree.md` diff --git a/PRD-neo-tree.md b/PRD-neo-tree.md new file mode 100644 index 0000000..5a2d882 --- /dev/null +++ b/PRD-neo-tree.md @@ -0,0 +1,222 @@ +# Neo-tree Integration — Product Requirements Document + +## Overview + +Add optional neo-tree integration to code-preview.nvim so that proposed file +changes from Claude Code are visually indicated in the existing file tree. +When Claude proposes an edit, the affected file gets a status icon/highlight +in neo-tree — similar to how git status markers work today. + +## Motivation + +The diff tab already answers "what changed in this file?" but when Claude +touches multiple files (common for refactors), users have no overview of +*which* files are affected. A tree-level indicator solves this at a glance, +without leaving the editor or opening each diff individually. + +## Phased Approach + +### V1 — Highlight existing files (this PR) + +Decorate files in the **existing** neo-tree filesystem tree with a status +icon when Claude proposes a change. No separate tree, no new source. + +**What the user sees:** + +``` + lua/ + claude-preview/ + init.lua 󰏫 <-- modified by Claude + new-module.lua <-- new file proposed by Claude + utils.lua +``` + +**Design decisions:** + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Dependency on neo-tree | Soft/optional (`pcall` guard) | Plugin works without neo-tree | +| Separate tree? | No — decorates existing filesystem tree | Less UI clutter, familiar location | +| Pattern to follow | Same as `git_status` component | Proven, documented, minimal code | +| User opt-in | User adds `claude_status` to renderer config | Explicit, no surprise side effects | + +--- + +### V2 — Ghost nodes for new files (future) + +Show proposed new files/directories as virtual nodes in the tree, even +before they exist on disk. These would appear dimmed or with a distinct +icon to indicate they are proposed, not yet created. + +**Challenges:** +- Neo-tree's filesystem source scans the disk; virtual nodes require + injecting items into the tree data before render +- Need to handle the case where the file is accepted (becomes real) or + rejected (node disappears) +- Directory creation proposals need parent directories expanded/created + as virtual nodes too + +**Likely approach:** +- Use neo-tree's `BEFORE_RENDER` event to inject synthetic nodes into + `state.tree` for paths that don't exist on disk yet +- Mark them with a flag so the component can render them distinctly + +--- + +### V3 — Clickable tree nodes open diff (future) + +Clicking a file with a `claude_status` indicator opens the diff preview +for that specific file. This connects the tree overview with the detailed +diff view. + +**Requirements:** +- Store proposed content (or temp file paths) per file in the changes + registry, not just the status string +- Add a custom neo-tree command/action that calls `show_diff()` with + the stored paths +- Handle the case where the diff is already open for a different file + +--- + +## V1 Implementation Detail + +### Architecture + +``` +Claude CLI (tmux) Neovim + | | + PreToolUse hook fires | + | | + claude-preview-diff.sh ----RPC----> changes.set(path, "modified"|"created") + | | --> neo-tree refresh (tree re-renders) + | | --> show_diff() (existing behavior) + | | + PostToolUse hook fires | + | | + claude-close-diff.sh ---RPC------> changes.clear(path) + | | --> neo-tree refresh + | | --> close_diff() (existing behavior) +``` + +### New files + +| File | Purpose | +|------|---------| +| `lua/claude-preview/changes.lua` | Registry: tracks `{ [abs_path] = status }` | +| `lua/claude-preview/neo_tree.lua` | Neo-tree integration: event subscription, component registration | + +### Modified files + +| File | Change | +|------|--------| +| `lua/claude-preview/init.lua` | Call `neo_tree.setup()` from `setup()` if neo-tree available | +| `bin/claude-preview-diff.sh` | Add RPC call to `changes.set()` with file status | +| `bin/claude-close-diff.sh` | Add RPC call to `changes.clear()` | + +### Module: `changes.lua` + +Simple key-value registry mapping absolute file paths to their proposed +change status. + +``` +API: + changes.set(filepath, status) -- status: "modified" | "created" + changes.clear(filepath) -- remove single entry + changes.clear_all() -- reset everything + changes.get(filepath) -- returns status or nil + changes.get_all() -- returns full table +``` + +### Module: `neo_tree.lua` + +Handles all neo-tree interaction behind a `pcall` guard. + +**Responsibilities:** +1. Subscribe to neo-tree's `BEFORE_RENDER` event to inject + `state.claude_status_lookup` from the changes registry +2. Register a `claude_status` component that reads the lookup and + returns `{ text, highlight }` for affected nodes +3. Provide a `refresh()` helper that triggers neo-tree filesystem refresh +4. Define highlight groups: `ClaudePreviewTreeModified`, `ClaudePreviewTreeCreated` + +**Component behavior:** +- Reads `state.claude_status_lookup[node.path]` +- `"modified"` -> icon `󰏫` with modified highlight +- `"created"` -> icon `` with added highlight +- `nil` -> returns `{}` (no decoration) + +### Shell hook changes + +**`claude-preview-diff.sh`** — after computing the diff, before sending +`show_diff()`: + +```bash +# Determine status +if [[ -s "$ORIG_FILE" ]]; then + STATUS="modified" +else + STATUS="created" +fi + +nvim_send "require('claude-preview.changes').set('$FILE_PATH_ESC', '$STATUS')" +``` + +Then after `show_diff()`, trigger a neo-tree refresh: + +```bash +nvim_send "pcall(function() require('claude-preview.neo_tree').refresh() end)" +``` + +**`claude-close-diff.sh`** — before `close_diff()`: + +```bash +nvim_send "require('claude-preview.changes').clear_all()" +nvim_send "pcall(function() require('claude-preview.neo_tree').refresh() end)" +``` + +### User configuration + +User adds the component to their neo-tree renderer config: + +```lua +require("neo-tree").setup({ + filesystem = { + renderers = { + file = { + { "indent" }, + { "icon" }, + { "name" }, + { "claude_status" }, -- add this line + { "git_status" }, + }, + }, + }, +}) +``` + +### Config additions to `setup()` + +```lua +neo_tree = { + enabled = true, -- set false to disable even if neo-tree is installed + refresh_on_change = true, -- auto-refresh tree when changes are set/cleared + symbols = { + modified = "󰏫", + created = "", + }, + highlights = { + modified = "NeoTreeGitModified", -- reuse familiar colors + created = "NeoTreeGitAdded", + }, +}, +``` + +### Testing + +1. Open Neovim with neo-tree and claude-preview loaded +2. Run `:lua require("claude-preview.changes").set(vim.fn.expand("%:p"), "modified")` +3. Verify the current file gets the `󰏫` icon in neo-tree +4. Run `:lua require("claude-preview.changes").clear_all()` +5. Verify the icon disappears +6. End-to-end: ask Claude to edit a file, verify icon appears in tree + alongside the diff tab diff --git a/PRD-opencode.md b/PRD-opencode.md new file mode 100644 index 0000000..2c74fe3 --- /dev/null +++ b/PRD-opencode.md @@ -0,0 +1,301 @@ +# OpenCode Integration — Product Requirements Document + +## Overview + +Add support for [OpenCode](https://github.com/anomalyco/opencode) as an +alternative backend alongside Claude Code. Users running OpenCode instead of +(or alongside) Claude Code should get the same Neovim diff preview and +neo-tree indicators without any changes to the core Lua modules. + +**GitHub issue:** [#7](https://github.com/Cannon07/claude-preview.nvim/issues/7) + +## Motivation + +OpenCode is a popular open-source AI coding agent (130K+ stars) that is +provider-agnostic (Anthropic, OpenAI, Gemini, local models). Multiple users +have requested support. Since the core value of this plugin is the **Neovim +diff preview experience**, supporting multiple CLI backends significantly +expands the user base. + +## How OpenCode Hooks Work + +OpenCode has a TypeScript/JavaScript plugin system with hooks analogous to +Claude Code's shell hooks: + +| Claude Code | OpenCode | Description | +|---|---|---| +| `PreToolUse` shell script | `tool.execute.before` JS/TS plugin | Called before any tool executes | +| `PostToolUse` shell script | `tool.execute.after` JS/TS plugin | Called after tool executes | +| `.claude/settings.local.json` | `.opencode/plugins/` directory | Where hooks are configured | + +### Key differences from Claude Code + +1. **Plugin format** — OpenCode plugins are TS/JS modules (not shell scripts) +2. **Plugin location** — loaded from `.opencode/plugins/` (project) or + `~/.config/opencode/plugins/` (global) +3. **Edit tools** — OpenCode has `edit` (find/replace), `multiedit` + (sequential edits), and `apply_patch` (unified diff across files) +4. **Hook signature** — `(input, output) => Promise` where `input` + contains tool name and args, `output` is mutable +5. **Shell access** — plugins receive a `$` (Bun shell) utility for running + shell commands + +### OpenCode plugin structure + +```typescript +import type { Plugin } from "@opencode-ai/plugin" + +export default: Plugin = async ({ client, project, directory, worktree, $ }) => { + return { + "tool.execute.before": async (input, output) => { + // input.tool = "edit" | "multiedit" | "apply_patch" | "write" | "bash" + // input.sessionID, input.callID + // output.args = { filePath, oldString, newString, ... } + }, + "tool.execute.after": async (input, output) => { + // input.args = original args + // output.metadata = { diff, filediff: { before, after, additions, deletions } } + }, + } +} +``` + +## Architecture + +The core principle: **the Lua side is backend-agnostic**. Both Claude Code +and OpenCode ultimately call the same Lua functions (`show_diff()`, +`close_diff()`, `changes.set()`, etc.) via Neovim RPC. Only the hook/plugin +layer differs. + +``` +Claude Code (tmux) Neovim OpenCode (tmux) + │ │ │ + PreToolUse hook fires │ tool.execute.before fires + │ │ │ + claude-preview-diff.sh ──RPC──→ show_diff() ←──RPC── opencode-plugin.ts + │ │ │ + CLI: "Accept? (y/n)" User reviews diff CLI: permission prompt + │ │ │ + PostToolUse hook fires │ tool.execute.after fires + │ │ │ + claude-close-diff.sh ──RPC───→ close_diff() ←──RPC── opencode-plugin.ts +``` + +## What stays the same + +These modules are backend-agnostic and require **no changes**: + +| Module | Reason | +|---|---| +| `lua/claude-preview/diff.lua` | Receives temp file paths, doesn't care who created them | +| `lua/claude-preview/changes.lua` | Pure key-value store, no backend coupling | +| `lua/claude-preview/neo_tree.lua` | Reads from changes registry, no backend coupling | +| `lua/claude-preview/health.lua` | Will be extended (not modified) for OpenCode checks | +| `bin/nvim-socket.sh` | Socket discovery is backend-agnostic | +| `bin/nvim-send.sh` | RPC helper is backend-agnostic | + +## What's new + +### New files + +| File | Purpose | +|---|---| +| `opencode-plugin/index.ts` | OpenCode plugin — intercepts tool events, computes diffs, sends to Neovim via RPC | +| `opencode-plugin/package.json` | Plugin package metadata | +| `opencode-plugin/tsconfig.json` | TypeScript config | + +### Modified files + +| File | Change | +|---|---| +| `lua/claude-preview/hooks.lua` | Add `install_opencode()` / `uninstall_opencode()` functions | +| `lua/claude-preview/init.lua` | Add `:CodePreviewInstallOpenCodeHooks` / `:CodePreviewUninstallOpenCodeHooks` commands | +| `lua/claude-preview/health.lua` | Add OpenCode-specific health checks | + +--- + +## Implementation Tasks + +### Task 1: OpenCode plugin — core structure + +Create the TypeScript plugin that OpenCode will load from `.opencode/plugins/`. + +**Files to create:** +``` +opencode-plugin/ +├── index.ts # Plugin entry point +├── package.json # Package metadata +└── tsconfig.json # TypeScript config +``` + +**`index.ts` should:** +- Export a default plugin function matching OpenCode's `Plugin` type +- Register `tool.execute.before` and `tool.execute.after` hooks +- Discover the Neovim socket (reuse `nvim-socket.sh` via `$` shell utility) +- Provide helpers for sending Lua commands to Neovim via RPC + +**How to test:** +- Place plugin in `.opencode/plugins/`, run OpenCode, verify it loads without errors + +--- + +### Task 2: OpenCode plugin — edit interception (`tool.execute.before`) + +Implement the `tool.execute.before` hook to intercept file edits and show +diffs in Neovim. + +**Handle these OpenCode tools:** + +| Tool | Args | How to compute proposed content | +|---|---|---| +| `edit` | `filePath`, `oldString`, `newString` | Find-and-replace (same as Claude Code's Edit) | +| `multiedit` | `filePath`, `edits[]` | Sequential find-and-replace | +| `apply_patch` | `patch` (unified diff string) | Apply patch to get proposed content | +| `write` | `filePath`, `content` | Content is the proposed file | +| `bash` | `command` | Detect `rm` commands (same as Claude Code) | + +**For each edit tool:** +1. Read the original file content +2. Compute the proposed content by applying the edit +3. Write original and proposed to temp files +4. Send RPC to Neovim: `changes.set()`, `neo_tree.refresh()`, `show_diff()` + +**Key decision:** The edit computation (find-and-replace) should be done +in TypeScript directly, rather than shelling out to `apply-edit.lua`. This +avoids the `nvim --headless` dependency for OpenCode users and keeps the +plugin self-contained. + +**How to test:** +- Run OpenCode, ask it to edit a file +- Verify diff preview appears in Neovim before the edit is applied + +--- + +### Task 3: OpenCode plugin — cleanup (`tool.execute.after`) + +Implement the `tool.execute.after` hook to clean up after the user +accepts/rejects. + +**Should:** +1. Send RPC to Neovim: `changes.clear_all()`, `close_diff()`, `neo_tree.refresh()` +2. Clean up temp files +3. Handle the `bash` tool (rm detection) — only clear deletion markers + +**How to test:** +- Accept/reject an edit in OpenCode +- Verify diff closes and neo-tree indicators clear in Neovim + +--- + +### Task 4: Hook installer for OpenCode + +Add Lua functions to copy the plugin into the project's `.opencode/plugins/` +directory. + +**Modify `lua/claude-preview/hooks.lua`:** + +```lua +function M.install_opencode() + -- 1. Resolve plugin source: /opencode-plugin/ + -- 2. Resolve target: /.opencode/plugins/claude-preview/ + -- 3. Copy index.ts (and package.json if needed) to target + -- 4. Notify user +end + +function M.uninstall_opencode() + -- 1. Remove /.opencode/plugins/claude-preview/ + -- 2. Notify user +end +``` + +**Modify `lua/claude-preview/init.lua`:** +- Register `:CodePreviewInstallOpenCodeHooks` → `hooks.install_opencode()` +- Register `:CodePreviewUninstallOpenCodeHooks` → `hooks.uninstall_opencode()` + +**How to test:** +- `:CodePreviewInstallOpenCodeHooks` — verify plugin files copied to `.opencode/plugins/claude-preview/` +- `:CodePreviewUninstallOpenCodeHooks` — verify plugin directory removed +- Restart OpenCode, verify plugin loads + +--- + +### Task 5: Health check updates + +Extend `:checkhealth claude-preview` to report OpenCode integration status. + +**Add checks for:** +- OpenCode installed and in PATH (`opencode` executable) +- OpenCode plugin installed (`.opencode/plugins/claude-preview/` exists) +- Node.js/Bun available (required by OpenCode plugins) + +**How to test:** +- `:checkhealth claude-preview` — verify OpenCode section appears + +--- + +### Task 6: Documentation + +Update README with OpenCode setup instructions. + +**Add sections for:** +- OpenCode installation +- `:CodePreviewInstallOpenCodeHooks` usage +- Configuration differences (if any) +- Troubleshooting OpenCode-specific issues + +--- + +## File structure (after implementation) + +``` +claude-preview.nvim/ +├── lua/ +│ └── claude-preview/ +│ ├── init.lua # setup(), commands (Claude + OpenCode) +│ ├── diff.lua # show_diff(), close_diff() (unchanged) +│ ├── changes.lua # change registry (unchanged) +│ ├── neo_tree.lua # neo-tree integration (unchanged) +│ ├── hooks.lua # install/uninstall for both backends +│ └── health.lua # health checks for both backends +├── bin/ # Claude Code hook scripts (unchanged) +│ ├── claude-preview-diff.sh +│ ├── claude-close-diff.sh +│ ├── nvim-socket.sh +│ ├── nvim-send.sh +│ ├── apply-edit.lua +│ └── apply-multi-edit.lua +├── opencode-plugin/ # OpenCode plugin (NEW) +│ ├── index.ts +│ ├── package.json +│ └── tsconfig.json +├── README.md +├── LICENSE +├── PRD.md +├── PRD-neo-tree.md +└── PRD-opencode.md # This document +``` + +## Resolved decisions + +1. **Plugin format** — Ship as raw TypeScript. OpenCode uses Bun internally, + so TS is natively supported. No pre-compilation step needed. + +2. **apply_patch handling** — For V1, show one diff at a time (last file), + consistent with Claude Code's MultiEdit behavior. Multi-file simultaneous + diff view is a V2 candidate. + +3. **Permission hook** — V2 candidate. The `permission.ask` hook could enable + accepting/rejecting edits from within Neovim (instead of switching to the + CLI pane), but this changes the UX model and adds complexity. V1 keeps the + same "review in Neovim, accept in CLI" flow as Claude Code. + +## Out of scope (V1) + +- **`permission.ask` hook integration** — blocking until Neovim review is + complete (V2 candidate) +- **SDK event subscription** — connecting to OpenCode's HTTP server for + real-time events (alternative to plugin approach) +- **Multi-file diff view** — showing all files affected by `apply_patch` + simultaneously +- **Auto-detection** — automatically detecting whether Claude Code or OpenCode + is running and loading the appropriate hooks diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..a2ca561 --- /dev/null +++ b/PRD.md @@ -0,0 +1,307 @@ +# claude-preview.nvim — Product Requirements Document + +## What is this? + +A Neovim plugin that bridges Claude Code CLI (running in an external tmux pane) with Neovim's diff view. When Claude proposes a file change, a side-by-side diff appears in Neovim **before** the file is written — letting you review exactly what's changing before accepting. + +## Why does this exist? + +Claude Code has first-class IDE integration for VS Code (diff preview, accept/reject UI). For Neovim, the official `claudecode.nvim` plugin works — but only when Claude runs inside Neovim's built-in terminal via WebSocket/MCP. + +Many developers prefer running Claude Code CLI in a separate tmux pane alongside Neovim. In this setup, there's no way to preview proposed changes in the editor before accepting. This plugin solves that gap. + +## How it works (high level) + +``` +Claude CLI (tmux pane) Neovim (tmux pane) + │ │ + Proposes an Edit │ + │ │ + PreToolUse hook fires ──→ hook script ──→ RPC → show_diff() + │ │ (new tab, side-by-side) + CLI: "Accept? (y/n)" │ + │ User reviews diff + User accepts/rejects │ + │ │ + PostToolUse hook fires ─→ hook script ──→ RPC → close_diff() +``` + +Three mechanisms working together: +1. **Claude Code Hooks** — `PreToolUse` intercepts edits before they happen, `PostToolUse` cleans up after +2. **Neovim RPC** — hook scripts send Lua commands to Neovim via its Unix socket (`nvim --server --remote-send`) +3. **Neovim diff mode** — native side-by-side diff in a dedicated tab + +## User experience + +### Installation + +```lua +-- lazy.nvim +{ + "jayshitre/claude-preview.nvim", + config = function() + require("claude-preview").setup() + end, +} +``` + +### Setup (one-time) + +After installing, run inside Neovim: +``` +:ClaudePreviewInstallHooks +``` + +This writes the hook configuration to `.claude/settings.local.json` in the current project. The user never manually edits hook files or scripts. + +### Daily workflow + +1. Open tmux, split into two panes +2. Left pane: `nvim .` +3. Right pane: `claude` +4. Ask Claude to make changes +5. Diff tab auto-opens in Neovim with CURRENT (left) vs PROPOSED (right) +6. Review in Neovim, switch to CLI pane, accept or reject +7. Diff tab auto-closes + +### Uninstall hooks + +``` +:ClaudePreviewUninstallHooks +``` + +Removes the hooks from `.claude/settings.local.json`. + +--- + +## Implementation Tasks + +### Task 1: Plugin scaffold and entry point + +Create the basic plugin structure and the `setup()` function. + +**Files to create:** +``` +claude-preview.nvim/ +├── lua/ +│ └── claude-preview/ +│ └── init.lua # setup(), config merging, health check +├── LICENSE +└── README.md +``` + +**`setup()` should:** +- Accept an optional config table with defaults +- Store the merged config in a module-level variable +- Register user commands (`:ClaudePreviewInstallHooks`, `:ClaudePreviewUninstallHooks`, `:ClaudePreviewStatus`) +- NOT auto-install hooks (explicit user action via command) + +**Default config:** +```lua +{ + diff = { + layout = "tab", -- "tab" (new tab) or "vsplit" (in current tab) + labels = { current = "CURRENT", proposed = "PROPOSED" }, + auto_close = true, -- close diff after accept/reject + equalize = true, -- 50/50 split widths + full_file = true, -- show full file, not just hunks + }, + highlights = { + current = { + DiffAdd = { bg = "#4c2e2e" }, + DiffDelete = { bg = "#2e4c2e" }, + DiffChange = { bg = "#4c3a2e" }, + DiffText = { bg = "#5c3030" }, + }, + proposed = { + DiffAdd = { bg = "#2e4c2e" }, + DiffDelete = { bg = "#4c2e2e" }, + DiffChange = { bg = "#2e3c4c" }, + DiffText = { bg = "#3e5c3e" }, + }, + }, +} +``` + +**How to test:** +- `:lua require("claude-preview").setup()` — should not error +- `:ClaudePreviewStatus` — should show "Hooks: not installed, Neovim RPC: " + +--- + +### Task 2: Diff module + +Port `claude-diff.lua` into the plugin as `lua/claude-preview/diff.lua`. + +**Files to create:** +``` +lua/claude-preview/ + └── diff.lua # show_diff(), close_diff() +``` + +**What it does:** +- `show_diff(original_path, proposed_path, display_name)` — opens diff view using config from `setup()` +- `close_diff()` — closes diff view and cleans up +- Reads highlight config from the merged setup config (not hardcoded) +- Respects `config.diff.layout` ("tab" or "vsplit") +- Respects `config.diff.full_file`, `config.diff.equalize` +- Handles `VimResized` for re-equalizing + +**How to test:** +- Create two temp files with different content +- `:lua require("claude-preview.diff").show_diff("/tmp/a.lua", "/tmp/b.lua", "test.lua")` +- Verify diff appears with correct layout, highlights, labels +- `:lua require("claude-preview.diff").close_diff()` + +--- + +### Task 3: Hook scripts + +Port the shell/Python scripts into the plugin's `bin/` directory. These get bundled with the plugin and referenced by absolute path when hooks are installed. + +**Files to create:** +``` +bin/ +├── claude-preview-diff.sh # PreToolUse hook entry point +├── claude-close-diff.sh # PostToolUse hook entry point +├── nvim-socket.sh # Socket discovery helper +├── nvim-send.sh # RPC send helper +├── apply-edit.py # Single Edit string replacement +└── apply-multi-edit.py # MultiEdit sequential replacement +``` + +**Key change from prototype:** The preview script needs to call `require("claude-preview.diff").show_diff(...)` instead of `require("custom.claude-diff").show_diff(...)`. + +**How to test:** +- Simulate a PreToolUse call by piping JSON into the script +- Verify diff opens in Neovim and script outputs correct JSON +- Simulate a PostToolUse call, verify diff closes + +--- + +### Task 4: Hook installer commands + +Implement `:ClaudePreviewInstallHooks` and `:ClaudePreviewUninstallHooks`. + +**Files to modify:** +``` +lua/claude-preview/ + ├── init.lua # Register commands + └── hooks.lua # NEW: install/uninstall logic +``` + +**`:ClaudePreviewInstallHooks` should:** +1. Determine the plugin's `bin/` directory path (where the hook scripts live) +2. Read existing `.claude/settings.local.json` (or create it) +3. Add/update the PreToolUse and PostToolUse hook entries +4. Write the file back +5. Print confirmation message + +**`:ClaudePreviewUninstallHooks` should:** +1. Read `.claude/settings.local.json` +2. Remove the claude-preview hook entries (leave other hooks intact) +3. Write the file back +4. Print confirmation message + +**Hook paths must be absolute** — the plugin resolves its own `bin/` directory at install time using `debug.getinfo` or Neovim's `runtimepath`. + +**How to test:** +- Run `:ClaudePreviewInstallHooks` — verify `.claude/settings.local.json` is created with correct paths +- Run `:ClaudePreviewUninstallHooks` — verify hooks are removed +- Restart Claude CLI, make an edit — verify hooks fire + +--- + +### Task 5: Status command and health check + +Implement `:ClaudePreviewStatus` and `:checkhealth claude-preview`. + +**Files to create/modify:** +``` +lua/claude-preview/ + ├── init.lua # :ClaudePreviewStatus command + ├── health.lua # NEW: checkhealth integration + └── socket.lua # NEW: Lua-native socket discovery (optional) +``` + +**`:ClaudePreviewStatus` should display:** +- Hooks installed: yes/no (check `.claude/settings.local.json`) +- Neovim RPC socket: path or "not found" +- Dependencies: jq (found/missing), python3 (found/missing) +- Diff tab: open/closed + +**`:checkhealth claude-preview` should verify:** +- `jq` is available in PATH +- `python3` is available in PATH +- Hook scripts are executable +- `.claude/settings.local.json` exists and has valid hooks +- Neovim RPC socket is accessible + +**How to test:** +- `:ClaudePreviewStatus` — verify output is accurate +- `:checkhealth claude-preview` — verify all checks pass + +--- + +### Task 6: README and documentation + +**Files to create:** +``` +README.md # Installation, usage, configuration, troubleshooting +LICENSE # MIT +``` + +**README sections:** +- What it does (with a GIF/screenshot if possible) +- Requirements (Neovim 0.9+, tmux, jq, python3, Claude Code CLI) +- Installation (lazy.nvim, packer, manual) +- Quick start (setup + install hooks) +- Configuration reference (all options with defaults) +- Commands reference +- How it works (brief architecture) +- Troubleshooting (common issues) +- Differences from claudecode.nvim and nvim-claude + +--- + +### Task 7: Testing and polish + +- Test all tool types: Edit, Write, MultiEdit +- Test edge cases: new file, Neovim closed, rapid sequential edits +- Test with different Neovim versions (0.9, 0.10, nightly) +- Test on macOS and Linux (socket paths differ) +- Clean up temp files properly +- Ensure no side effects on Neovim startup (lazy loading) + +--- + +## File structure (final) + +``` +claude-preview.nvim/ +├── lua/ +│ └── claude-preview/ +│ ├── init.lua # setup(), commands, config +│ ├── diff.lua # show_diff(), close_diff() +│ ├── hooks.lua # install/uninstall hooks +│ ├── health.lua # :checkhealth integration +│ └── socket.lua # Lua-native socket discovery (optional) +├── bin/ +│ ├── claude-preview-diff.sh +│ ├── claude-close-diff.sh +│ ├── nvim-socket.sh +│ ├── nvim-send.sh +│ ├── apply-edit.py +│ └── apply-multi-edit.py +├── README.md +├── LICENSE +└── PRD.md +``` + +## Out of scope (for now) + +- **Inline diff** (like nvim-claude) — we use a tab-based approach for simplicity +- **Accept/reject from Neovim** — acceptance happens in the CLI; Neovim is read-only preview +- **MCP/WebSocket server** — we use hooks + RPC, not the MCP protocol +- **Non-tmux setups** — the plugin works in any terminal setup, but the naming and docs target tmux users +- **Auto-installing hooks on setup()** — explicit user action required for transparency diff --git a/REFACTOR-PLAN.md b/REFACTOR-PLAN.md new file mode 100644 index 0000000..42f64f9 --- /dev/null +++ b/REFACTOR-PLAN.md @@ -0,0 +1,556 @@ +# Refactor Plan: Rename + Restructure (Issue #13) + +**Goal:** Rename `claude-preview` to `code-preview` and restructure the codebase +to separate backend-specific code from shared/core code. + +**PR:** Single PR covering both rename and restructure. + +--- + +## Current Structure + +``` +lua/claude-preview/ +├── init.lua -- setup, config, commands (mixed backends) +├── hooks.lua -- Claude + OpenCode install/uninstall (mixed) +├── diff.lua -- diff view (shared) +├── changes.lua -- change tracking (shared) +├── neo_tree.lua -- neo-tree integration (shared) +└── health.lua -- healthcheck (mixed backends) + +bin/ +├── core-pre-tool.sh -- unified PreToolUse logic (shared core) +├── core-post-tool.sh -- unified PostToolUse logic (shared core) +├── claude-preview-diff.sh -- Claude Code adapter (thin, execs core-pre-tool.sh) +├── claude-close-diff.sh -- Claude Code adapter (thin, execs core-post-tool.sh) +├── nvim-send.sh -- shared RPC helper +├── nvim-socket.sh -- shared socket discovery +├── apply-edit.lua -- shared edit transformer +└── apply-multi-edit.lua -- shared edit transformer + +opencode-plugin/ +├── index.ts -- OpenCode adapter (translates format, calls core scripts) +├── package.json +└── tsconfig.json +``` + +## Target Structure + +``` +lua/code-preview/ +├── init.lua -- setup, config, shared commands +├── diff.lua -- diff view (shared) +├── changes.lua -- change tracking (shared) +├── neo_tree.lua -- neo-tree integration (shared) +├── health.lua -- healthcheck (checks all backends) +└── backends/ + ├── claudecode.lua -- Claude Code hook install/uninstall + └── opencode.lua -- OpenCode plugin install/uninstall + +bin/ +├── core-pre-tool.sh -- unified PreToolUse logic (shared core) +├── core-post-tool.sh -- unified PostToolUse logic (shared core) +├── nvim-send.sh -- shared RPC helper +├── nvim-socket.sh -- shared socket discovery +├── apply-edit.lua -- shared edit transformer +└── apply-multi-edit.lua -- shared edit transformer + +backends/ +├── claudecode/ +│ ├── code-preview-diff.sh -- Claude Code adapter (thin, execs ../../bin/core-pre-tool.sh) +│ └── code-close-diff.sh -- Claude Code adapter (thin, execs ../../bin/core-post-tool.sh) +└── opencode/ + ├── index.ts -- OpenCode adapter (translates format, calls core scripts) + ├── package.json + └── tsconfig.json +``` + +## Naming Mappings + +### Module requires +| Old | New | +|-----|-----| +| `require("claude-preview")` | `require("code-preview")` | +| `require("claude-preview.diff")` | `require("code-preview.diff")` | +| `require("claude-preview.hooks")` | `require("code-preview.backends.claudecode")` / `require("code-preview.backends.opencode")` | +| `require("claude-preview.changes")` | `require("code-preview.changes")` | +| `require("claude-preview.neo_tree")` | `require("code-preview.neo_tree")` | +| `require("claude-preview.health")` | `require("code-preview.health")` | + +### User commands +| Old | New | +|-----|-----| +| `:ClaudePreviewInstallHooks` | `:CodePreviewInstallClaudeCodeHooks` | +| `:ClaudePreviewUninstallHooks` | `:CodePreviewUninstallClaudeCodeHooks` | +| `:ClaudePreviewCloseDiff` | `:CodePreviewCloseDiff` | +| `:ClaudePreviewStatus` | `:CodePreviewStatus` | +| `:ClaudePreviewToggleVisibleOnly` | `:CodePreviewToggleVisibleOnly` | +| `:CodePreviewInstallOpenCodeHooks` | `:CodePreviewInstallOpenCodeHooks` (unchanged) | +| `:CodePreviewUninstallOpenCodeHooks` | `:CodePreviewUninstallOpenCodeHooks` (unchanged) | + +### Deprecated aliases (keep for one release) +Old commands should still work but print a deprecation warning pointing to the new name. +- `:ClaudePreviewInstallHooks` -> warns, calls `:CodePreviewInstallClaudeCodeHooks` +- `:ClaudePreviewUninstallHooks` -> warns, calls `:CodePreviewUninstallClaudeCodeHooks` +- `:ClaudePreviewCloseDiff` -> warns, calls `:CodePreviewCloseDiff` +- `:ClaudePreviewStatus` -> warns, calls `:CodePreviewStatus` +- `:ClaudePreviewToggleVisibleOnly` -> warns, calls `:CodePreviewToggleVisibleOnly` + +### Highlight groups +| Old | New | +|-----|-----| +| `ClaudePreviewTreeModified` | `CodePreviewTreeModified` | +| `ClaudePreviewTreeCreated` | `CodePreviewTreeCreated` | +| `ClaudePreviewTreeDeleted` | `CodePreviewTreeDeleted` | +| `ClaudePreviewTreeVirtual` | `CodePreviewTreeVirtual` | +| `ClaudePreviewDiffResize` (augroup) | `CodePreviewDiffResize` | + +### Shell scripts +| Old | New | +|-----|-----| +| `bin/claude-preview-diff.sh` | `backends/claudecode/code-preview-diff.sh` | +| `bin/claude-close-diff.sh` | `backends/claudecode/code-close-diff.sh` | +| `bin/core-pre-tool.sh` | `bin/core-pre-tool.sh` (stays, shared) | +| `bin/core-post-tool.sh` | `bin/core-post-tool.sh` (stays, shared) | + +### Shell script internal references +| Old | New | +|-----|-----| +| `require('claude-preview.changes')` | `require('code-preview.changes')` | +| `require('claude-preview.diff')` | `require('code-preview.diff')` | +| `require('claude-preview.neo_tree')` | `require('code-preview.neo_tree')` | +| `require('claude-preview')` | `require('code-preview')` | + +These references exist in `core-pre-tool.sh` and `core-post-tool.sh` (the core scripts). +The Claude adapters have no `require()` calls — they only `exec` the core scripts. + +### Hook marker +| Old | New | +|-----|-----| +| `HOOK_MARKER = "claude-preview"` | `HOOK_MARKER = "code-preview"` | + +### Notification prefix +| Old | New | +|-----|-----| +| `[claude-preview]` | `[code-preview]` | + +### OpenCode package +| Old | New | +|-----|-----| +| `"name": "claude-preview-opencode"` | `"name": "code-preview-opencode"` | +| `opencode-plugin/` directory | `backends/opencode/` directory | + +--- + +## Steps + +### Step 1: Rename Lua module directory + +Move `lua/claude-preview/` to `lua/code-preview/`. + +**Files affected:** +- Directory rename: `lua/claude-preview/` -> `lua/code-preview/` + +**Manual test:** +```vim +" Restart Neovim with the plugin loaded +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" + +" Verify module loads from new path +:lua print(vim.inspect(require("code-preview"))) +" Expected: table with setup function (may error on internal requires -- that's OK at this step) +``` + +--- + +### Step 2: Update all internal `require()` calls in Lua files + +Replace every `require("claude-preview` with `require("code-preview` across all Lua files. + +**Files affected:** +- `lua/code-preview/init.lua` -- requires for hooks, diff, neo_tree +- `lua/code-preview/diff.lua` -- `require("claude-preview").config` (line 365), `require("claude-preview.changes").clear_all()` (line 516), `require("claude-preview.neo_tree").refresh()` (line 517) +- `lua/code-preview/health.lua` -- `require("claude-preview")` for config (line 24) +- `lua/code-preview/neo_tree.lua` -- `require("claude-preview.changes")` (line 3), `require("claude-preview")` for config (line 354) + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" + +" Full setup should work without errors +:lua require("code-preview").setup() + +" Verify submodules load +:lua print(require("code-preview.diff").is_open()) +:lua print(vim.inspect(require("code-preview.changes").get_all())) +``` + +--- + +### Step 3: Split `hooks.lua` into `backends/claudecode.lua` and `backends/opencode.lua` + +Split the monolithic hooks module into two backend-specific modules. + +**`lua/code-preview/backends/claudecode.lua`** gets: +- `scripts_dir()` -- resolves to `backends/claudecode/` (adapter script paths for settings.json) +- `bin_dir()` -- resolves to `bin/` (shared utilities, used for script existence checks) +- `HOOK_MARKER` -- changed to `"code-preview"` +- `LEGACY_HOOK_MARKER` -- `"claude-preview"` (used by `remove_ours()` during transition) +- `settings_path()`, `read_settings()`, `write_settings()` +- `M.install()`, `M.uninstall()` +- **Dual-marker uninstall:** `remove_ours()` must match entries containing either + `"code-preview"` OR `"claude-preview"` so users who installed with the old name + can uninstall after upgrading. Remove the legacy check after one release cycle. + +**`lua/code-preview/backends/opencode.lua`** gets: +- `plugin_source_dir()` -- resolves to `backends/opencode/` +- `opencode_target_dir()` +- `M.install()`, `M.uninstall()` +- `bin_dir()` -- needed to write `bin-path.txt` during install + +**Delete:** `lua/code-preview/hooks.lua` (after splitting) + +**Update `init.lua`:** Change requires from `require("code-preview.hooks")` to +`require("code-preview.backends.claudecode")` and `require("code-preview.backends.opencode")`. + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +" Verify backend modules load +:lua print(vim.inspect(require("code-preview.backends.claudecode"))) +:lua print(vim.inspect(require("code-preview.backends.opencode"))) + +" Verify install commands exist +:CodePreviewInstallClaudeCodeHooks +" Expected: hooks written to .claude/settings.local.json (or error if no project) + +:CodePreviewInstallOpenCodeHooks +" Expected: plugin files copied to .opencode/plugins/ +``` + +--- + +### Step 4: Rename user commands and add deprecated aliases + +Rename commands in `init.lua` and add deprecated aliases for the old names. + +**Files affected:** +- `lua/code-preview/init.lua` + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +" New commands should work +:CodePreviewStatus +:CodePreviewCloseDiff + +" Old commands should work but show deprecation warning +:ClaudePreviewStatus +" Expected: status output + "[code-preview] :ClaudePreviewStatus is deprecated, use :CodePreviewStatus" warning + +:ClaudePreviewCloseDiff +" Expected: closes diff + deprecation warning + +:ClaudePreviewToggleVisibleOnly +" Expected: toggles + deprecation warning +``` + +--- + +### Step 5: Move Claude adapters and update core script references + +- Move `bin/claude-preview-diff.sh` -> `backends/claudecode/code-preview-diff.sh` +- Move `bin/claude-close-diff.sh` -> `backends/claudecode/code-close-diff.sh` +- Update adapters to reference core scripts at `../../bin/core-pre-tool.sh` +- Update `require('claude-preview.` to `require('code-preview.` in `core-pre-tool.sh` and `core-post-tool.sh` +- Update `bin_dir()` in `backends/claudecode.lua` to resolve to `backends/claudecode/` + +**Claude adapter example after move:** +```bash +#!/usr/bin/env bash +# code-preview-diff.sh — PreToolUse hook adapter for Claude Code +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_DIR="$SCRIPT_DIR/../../bin" +export CODE_PREVIEW_BACKEND="claudecode" +exec "$BIN_DIR/core-pre-tool.sh" +``` + +**Files affected:** +- `backends/claudecode/code-preview-diff.sh` (moved + updated path) +- `backends/claudecode/code-close-diff.sh` (moved + updated path) +- `bin/core-pre-tool.sh` -- rename all `require('claude-preview.` to `require('code-preview.` +- `bin/core-post-tool.sh` -- rename all `require('claude-preview.` to `require('code-preview.` +- `lua/code-preview/backends/claudecode.lua` -- bin_dir() path, script name references + +**Manual test:** +```bash +# Verify scripts are executable +ls -la backends/claudecode/ +# Expected: code-preview-diff.sh and code-close-diff.sh with +x + +# Verify old locations are gone +ls bin/claude-preview-diff.sh bin/claude-close-diff.sh +# Expected: No such file or directory +``` +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +" Reinstall hooks (they should point to new script paths) +:CodePreviewInstallClaudeCodeHooks + +" Verify hooks point to new paths +:!cat .claude/settings.local.json | jq . +" Expected: commands point to backends/claudecode/code-preview-diff.sh and backends/claudecode/code-close-diff.sh +``` + +--- + +### Step 6: Move OpenCode plugin to `backends/opencode/` + +- Move `opencode-plugin/index.ts` -> `backends/opencode/index.ts` +- Move `opencode-plugin/package.json` -> `backends/opencode/package.json` +- Move `opencode-plugin/tsconfig.json` -> `backends/opencode/tsconfig.json` +- Update `require('claude-preview.` strings in core scripts (already done in Step 5) +- Update `package.json` name and description +- Update `backends/opencode.lua` path resolution for `plugin_source_dir()` +- The `bin-path.txt` mechanism remains the same — `install()` writes the absolute `bin/` path + +**Files affected:** +- `backends/opencode/index.ts` (moved + rename `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND`) +- `backends/opencode/package.json` (moved + updated name) +- `backends/opencode/tsconfig.json` (moved) +- `lua/code-preview/backends/opencode.lua` -- source dir path + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +:CodePreviewInstallOpenCodeHooks +" Expected: plugin files copied from backends/opencode/ to .opencode/plugins/ + +:CodePreviewUninstallOpenCodeHooks +" Expected: plugin files removed from .opencode/plugins/ +``` + +--- + +### Step 7: Rename highlight groups and augroup + +Update all `ClaudePreview*` highlight groups and augroup names to `CodePreview*`. + +**Files affected:** +- `lua/code-preview/neo_tree.lua` -- highlight group definitions and references (`ClaudePreviewTreeModified`, `ClaudePreviewTreeCreated`, `ClaudePreviewTreeDeleted`, `ClaudePreviewTreeVirtual`) +- `lua/code-preview/diff.lua` -- augroup name `ClaudePreviewDiffResize` (line 437) + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +" Check highlight groups exist +:hi CodePreviewTreeModified +:hi CodePreviewTreeCreated +:hi CodePreviewTreeDeleted +" Expected: each shows the configured colors + +" Verify old names don't exist +:hi ClaudePreviewTreeModified +" Expected: "E411: highlight group not found" +``` + +--- + +### Step 8: Update notification prefixes and status text + +Replace `[claude-preview]` with `[code-preview]` in all `vim.notify()` calls, +status output, and error messages. + +**Files affected:** +- `lua/code-preview/init.lua` -- status function title, notify calls +- `lua/code-preview/backends/claudecode.lua` -- all notify messages +- `lua/code-preview/backends/opencode.lua` -- all notify messages + +**Manual test:** +```vim +:CodePreviewStatus +" Expected: header says "code-preview.nvim status" +``` + +--- + +### Step 9: Update `health.lua` + +- Update `start()` text to `"code-preview.nvim"` +- Update script name references (`code-preview-diff.sh`) +- Update hook detection: check for BOTH `"code-preview"` and `"claude-preview"` markers + in settings.local.json (line 80), so health reports correctly for users who haven't + re-installed hooks yet. Show a warning if only the old marker is found. +- Update command references in warnings (`:CodePreviewInstallClaudeCodeHooks`) +- Update paths: health.lua needs to find scripts in `backends/claudecode/` now + +**Files affected:** +- `lua/code-preview/health.lua` + +**Manual test:** +```vim +nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" +:lua require("code-preview").setup() + +:checkhealth code-preview +" Expected: all checks pass, correct script paths, correct command names in warnings +``` + +--- + +### Step 10: Update shell script comments + +Update comments in core scripts and shared utilities: +- `bin/core-pre-tool.sh` -- header comment +- `bin/core-post-tool.sh` -- header comment +- `bin/nvim-send.sh` -- example `require('code-preview.diff')` in comment + +**Files affected:** +- `bin/core-pre-tool.sh`, `bin/core-post-tool.sh`, `bin/nvim-send.sh` + +**Manual test:** N/A (comments only) + +--- + +### Step 11: Update keymap description + +Update `dq` description from `"Close claude-preview diff"` to `"Close code-preview diff"`. + +**Files affected:** +- `lua/code-preview/init.lua` + +**Manual test:** +```vim +:map dq +" Expected: description says "Close code-preview diff" +``` + +--- + +### Step 12: Update tests + +- Rename test directory: `tests/backends/claude/` -> `tests/backends/claudecode/` +- Update all `require('claude-preview.` references in test specs to `require('code-preview.` +- Update test file paths if test helpers reference `bin/claude-preview-diff.sh` +- Update install test assertions (script paths, file lists) +- Rename `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND` in core scripts, adapters, and tests + +**Files affected:** +- Directory rename: `tests/backends/claude/` -> `tests/backends/claudecode/` +- `tests/plugin/changes_registry_spec.lua` -- `require("claude-preview.changes")` +- `tests/plugin/diff_lifecycle_spec.lua` -- `require("claude-preview.diff")`, `require("claude-preview.changes")` +- `tests/minimal_init.lua` -- `require("claude-preview").setup()` +- `tests/helpers.sh` -- hook script paths (`claude-preview-diff.sh`), `require('claude-preview')` in nvim setup, temp path prefixes (`claude-preview-test-*`, `claude-diff-*`) +- `tests/run.sh` -- title string `"claude-preview.nvim E2E Test Suite"`, comment header +- `tests/backends/claudecode/test_edit.sh` -- comment header, all `require('claude-preview.*')` in nvim_eval calls +- `tests/backends/claudecode/test_install.sh` -- `require('claude-preview.hooks')`, assertions for script names (`claude-preview-diff.sh`, `claude-close-diff.sh`) +- `tests/backends/claudecode/test_stale_socket.sh` -- `require('claude-preview.diff')`, `claude-preview-diff.sh` reference +- `tests/backends/opencode/test_edit.sh` -- all `require('claude-preview.*')` in nvim_eval calls +- `tests/backends/opencode/test_install.sh` -- `require('claude-preview.hooks')` +- `tests/backends/opencode/harness.ts` -- import path to `opencode-plugin/index.ts` → `backends/opencode/index.ts` +- `bin/core-pre-tool.sh` -- rename env var `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND` +- `bin/core-post-tool.sh` -- rename env var (comment only, not used in logic yet) +- `backends/claudecode/code-preview-diff.sh` -- rename env var +- `backends/claudecode/code-close-diff.sh` -- rename env var +- `backends/opencode/index.ts` -- rename env var (already called out in Step 6) + +**Manual test:** +```bash +bash tests/run.sh +# Expected: all tests pass +``` + +--- + +### Step 13: Update documentation + +- `README.md` -- all references, setup examples, command names, directory structure +- `CLAUDE.md` -- developer notes, file paths, task references +- `PRD.md` -- requirements doc references +- `PRD-neo-tree.md` -- neo-tree design doc references +- `PRD-opencode.md` -- opencode design doc references + +**Manual test:** Read through each doc and verify no stale `claude-preview` references remain. + +```bash +# Final grep to catch any stragglers (excluding git history) +grep -r "claude-preview" --include="*.lua" --include="*.sh" --include="*.ts" --include="*.md" --include="*.json" . +grep -r "ClaudePreview" --include="*.lua" --include="*.sh" --include="*.ts" --include="*.md" . +# Expected: zero results (or only intentional references like migration notes) +``` + +--- + +### Step 14: End-to-end test + +Full integration test with Claude Code backend: + +1. Restart Neovim: `nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim"` +2. Run `:lua require("code-preview").setup()` +3. Run `:CodePreviewInstallClaudeCodeHooks` +4. Verify `.claude/settings.local.json` has correct paths (`backends/claudecode/code-preview-diff.sh`) +5. Open Claude Code in a tmux pane, ask it to edit a file +6. Verify diff preview appears in Neovim +7. Accept/reject and verify diff closes +8. Run `:CodePreviewStatus` -- should show hooks installed +9. Run `:checkhealth code-preview` -- all green + +Full integration test with OpenCode backend: + +1. Run `:CodePreviewInstallOpenCodeHooks` +2. Verify files copied to `.opencode/plugins/` +3. Open OpenCode, trigger an edit +4. Verify diff preview appears +5. Run `:CodePreviewUninstallOpenCodeHooks` -- files removed + +--- + +## Notes + +- **Hook marker change:** Users who have existing hooks installed will need to + re-run the install command. The old `"claude-preview"` marker won't match + the new `"code-preview"` marker, so `uninstall` won't find old entries. + **Decision:** `remove_ours()` in `backends/claudecode.lua` checks for BOTH + `"code-preview"` and `"claude-preview"` markers. `health.lua` also detects + both, warning if only the old marker is found. Remove legacy checks after + one release cycle. +- **Deprecated aliases:** Remove after one release cycle. +- **User migration:** Users must update `require("claude-preview")` to + `require("code-preview")` in their Neovim config and re-run hook install. +- **Core scripts stay in `bin/`:** The unified `core-pre-tool.sh` and + `core-post-tool.sh` are backend-agnostic and shared. They stay in `bin/` + alongside other shared utilities. Only the thin backend adapters move to + `backends//`. +- **Adapter file count differs by design:** Claude needs 2 adapter scripts + (hook API requires separate commands per event), OpenCode needs 1 file + (plugin API exports both hooks from one entry point). Both follow the same + pattern: translate format and delegate to core scripts. +- **`bin-path.txt` for OpenCode:** During `install_opencode()`, the absolute + path to `bin/` is written to `bin-path.txt` alongside the installed plugin. + This lets the OpenCode adapter find the core scripts at runtime. +- **Claude Code adapter path resolution:** After moving to `backends/claudecode/`, + adapters use `$SCRIPT_DIR/../../bin/core-pre-tool.sh` to reach core scripts. + `backends/claudecode.lua` needs two resolvers: `scripts_dir()` for adapter + paths written to settings.json (`backends/claudecode/`), and `bin_dir()` for + shared utilities (`bin/`). +- **Env var rename:** `CLAUDE_PREVIEW_BACKEND` → `CODE_PREVIEW_BACKEND` with + values `"claudecode"` or `"opencode"`. Must update core scripts, adapters, + and test harnesses. +- **Temp file names:** `claude-diff-original` and `claude-diff-proposed` in + `core-pre-tool.sh` / `core-post-tool.sh` / `tests/helpers.sh` are internal + temp files with no user visibility. **Decision:** Keep as-is to avoid + unnecessary churn — they don't leak into user-facing names or configs. +- **Test temp path prefixes:** `claude-preview-test-*` prefixes in + `tests/helpers.sh` (socket path, mktemp templates) are also internal. + **Decision:** Rename to `code-preview-test-*` for consistency since we're + touching these files anyway in Step 12. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..846fcb5 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,69 @@ +# code-preview.nvim — Roadmap + +Planned work items, roughly in priority order. + +--- + +## Configurable Logging + +**Status:** Next up (PR 2) + +Add opt-in debug logging following Neovim plugin conventions. + +- Create `lua/code-preview/log.lua` — thin logging module, no external dependencies +- Add `debug = false` config option to enable/disable +- Log file location: `vim.fn.stdpath("log") .. "/code-preview.log"` +- Use `vim.notify()` for WARN/ERROR (user-facing), file logging for DEBUG/INFO +- Wire into `diff.lua` (replace the ad-hoc logging removed in v2.0.0) +- Wire into `neo_tree.lua` — setup, virtual node injection, reveal +- Shell scripts read debug flag from `hook_context()` RPC, skip logging when disabled + +## diff.lua Refactoring + +**Status:** Planned (after logging PR) + +`diff.lua` has grown too large after the multi-tab rewrite. Break it into smaller modules: + +- Inline layout logic (build_inline_diff, show_inline_diff, statuscolumn) +- Tab/vsplit layout logic +- Active diff management (active_diffs table, close_for_file, close_diff_and_clear) +- Neo-tree bridge (mark_change_and_reveal) + +## Neo-tree Test Harness + +**Status:** Planned + +Add neo-tree + nui.nvim to the test environment for proper unit testing of neo-tree interactions. + +- Clone neo-tree and nui.nvim into `deps/` +- Add to rtp in `tests/minimal_init.lua` +- Enables tests for: indicator lifecycle, virtual node injection, reveal, stale tabpage regression +- Currently neo-tree interactions are only tested via E2E shell tests, not Plenary unit tests + +## Inline Apply (v3) + +**Status:** Design phase — see [PRD-inline-apply.md](PRD-inline-apply.md) + +Apply proposed changes directly to real buffers instead of temp files. Original content is backed up for revert on reject. Includes a neo-tree "Proposed Changes" view. + +## New Backends + +### Copilot CLI + +**Status:** In progress — hook system is similar to existing backends, should be straightforward + +### Pi Coding Harness (pi.dev) + +**Status:** Evaluating — need to investigate hook system + +--- + +## Completed (v2.0.0) + +- Multi-tab simultaneous diffs (#37) +- Unified backend architecture — Claude Code + OpenCode (#33) +- Rename claude-preview -> code-preview (#34, #35) +- `visible_only` mode (#24) +- Configurable neo-tree reveal (#21) +- E2E test suite with CI (#19) +- Stale socket recovery (#17) 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/bin/hook-entry.ps1 b/bin/hook-entry.ps1 new file mode 100644 index 0000000..bd486bd --- /dev/null +++ b/bin/hook-entry.ps1 @@ -0,0 +1,62 @@ +# 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. + +param([string]$Backend, [string]$Event) + +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 ($Event -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/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/health.lua b/lua/code-preview/health.lua index 5d5d344..d4c6f32 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,36 +73,29 @@ 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 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/pr-description.md b/pr-description.md new file mode 100644 index 0000000..755c953 --- /dev/null +++ b/pr-description.md @@ -0,0 +1,50 @@ +## Summary + +- Adds OpenAI Codex CLI as the fourth supported AI backend alongside Claude Code, OpenCode, and Copilot CLI. +- Install writes `.codex/hooks.json` and detects the required `codex_hooks = true` feature flag in `.codex/config.toml` (project or global) — `:CodePreviewStatus` and `:checkhealth` both surface flag state so users can self-diagnose silent-no-op failures. +- Adds shell-write detection to the unified Bash hook: `>` / `>>` / `&>` / `&>>`, `mv X.tmp X`, `cp`, `tee`, and `sed -i` targets are flagged in the changes registry as `bash_modified` / `bash_created` so users get neo-tree feedback for shell-driven edits — important for Codex GPT models, which prefer the atomic-replace idiom (`{ printf …; cat F; } > F.tmp && mv F.tmp F`). +- Fixes ApplyPatch `*** Delete File:` showing the orange "modified" pencil instead of the red "deleted" trash icon. + +## What's included + +**Codex backend** +- `backends/codex/{code-preview-diff,code-close-diff}.sh` — translate Codex's payload (which delivers `apply_patch` text in `tool_input.command`) into the normalized shape consumed by `bin/core-{pre,post}-tool.sh`. `Bash` passes through. +- `lua/code-preview/backends/codex.lua` — install/uninstall, plus `feature_flag_state()` that checks `.codex/config.toml` (project) and falls back to `~/.codex/config.toml` (global). +- `:CodePreviewInstall/UninstallCodexCliHooks` commands; Codex rows in `:CodePreviewStatus` and `:checkhealth`, including feature-flag detection. + +**Shell-write detection (Bash hook)** +- New block in `bin/core-pre-tool.sh` that extracts likely write targets from a Bash command and marks each one `bash_modified` (file exists) or `bash_created` (file doesn't exist) in the changes registry. +- `looks_like_path` filters false positives leaked from quoted strings (e.g. `printf '\n\n'`); `is_transient_path` skips `.tmp`/`.bak`/`.swp`/`/dev/*`/`/tmp/*`; tilde paths expand to `$HOME` before the relative-path resolver to avoid `$CWD/~/foo`. +- rm-wins reveal precedence — when a command both `rm`s and writes, only the rm branch queues a `defer_fn` reveal so we don't double-fire. +- Acknowledged limitations (in-code comments): `mv -t DST` flag-inverted form, `tee FILE OTHER_FILE` multi-target, and the always-on cost of the detector for read-only Bash invocations. + +**Neo-tree integration for shell writes** +- `bash_modified` and `bash_created` render with the same icons/highlights as `modified` and `created` for v1 — documented in `neo_tree.lua` as a deliberate simplification. +- New `changes.clear_by_statuses({...})` helper; the Bash post-hook now batches `deleted` + `bash_modified` + `bash_created` cleanup into a single RPC instead of three. + +**ApplyPatch delete fix** +- `show_diff` accepts an optional `action` hint; the Codex/ApplyPatch hook passes `"delete"` for `*** Delete File:` directives. `mark_change_and_reveal` only emits `"deleted"` when explicitly told — a legitimate truncate-to-empty edit still shows as `modified`. +- `vim.loop.fs_stat` switched to `vim.uv.fs_stat` in `diff.lua` to match the convention used elsewhere in the codebase. + +**Docs / housekeeping** +- README: Codex Quick Start section, backend list updated to all four, Neovim floor aligned to `>= 0.10` (matches actual `vim.uv` usage), `:checkhealth` wording, test-runner examples for `backends/copilot` and `backends/codex`. +- `.gitignore`: ignore `test_output.log`. + +## Tests + +22 new shell tests in the Codex suite plus 2 plenary regressions: + +- `tests/backends/codex/test_install.sh` — `.codex/hooks.json` layout, idempotent re-install, user-authored Pre/PostToolUse entries survive install/uninstall, feature-flag detection (project + global, missing flag, no config.toml). +- `tests/backends/codex/test_edit.sh` — Codex `apply_patch` translation, Bash `rm`, shell-write detection (modified/created/atomic-replace/.tmp filter), HTML-comment false-positive guard, read-only no-op, noise-tool skip, malformed-payload skip. +- `tests/backends/codex/test_apply_patch.sh` — Update / Add / mixed Update+Add+Delete. +- `tests/plugin/diff_lifecycle_spec.lua` — `show_diff(..., "delete")` marks the file `deleted`; truncate-to-empty without an action stays `modified` (regression guard against the false-positive that the action hint replaced). +- `test_install_preserves_user_hooks` extended to assert PostToolUse mirroring (was previously only checking PreToolUse). + +## Test plan + +- [ ] `bash tests/run.sh all` passes locally +- [ ] `./tests/run_lua.sh diff_lifecycle` — plenary regressions pass +- [ ] Install/uninstall cycle in a real Codex CLI session, with and without `codex_hooks = true` +- [ ] `:CodePreviewStatus` and `:checkhealth code-preview` correctly report flag state for: project config, global config (`~/.codex/config.toml`), missing flag, no config file +- [ ] Codex `apply_patch` Update / Add / Delete — diffs open on pre, close on accept, `*** Delete File:` shows the red trash icon in neo-tree +- [ ] Codex Bash atomic-replace (`{ … } > F.tmp && mv F.tmp F`) — neo-tree shows the orange pencil on `F` during the approval window, clears on post 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/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 } From 2ca95e1eea16eb88fe41fcbee3c09a3339d171a2 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 5 Jun 2026 23:27:09 +0530 Subject: [PATCH 2/8] refactor: migrate codex to the generic hook-entry shim (#46) codex.lua now writes `hook-entry. codex pre|post` via platform (its fast-path tool filter already lives in hook-entry.sh); markers gain "hook-entry" (legacy code-preview-diff/code-close-diff kept for upgrade cleanup); chmod goes through platform.make_executable. Deletes backends/codex/*.sh. health.lua drops the codex adapter check (shared shim covers it). Tests point at the generic shim. Codex E2E 22/22, full suite 65/65 on macOS. Co-Authored-By: Claude Opus 4.8 --- backends/codex/code-close-diff.sh | 37 ----------------- backends/codex/code-preview-diff.sh | 53 ------------------------ lua/code-preview/backends/codex.lua | 35 +++++++--------- lua/code-preview/health.lua | 10 ++--- tests/backends/codex/test_apply_patch.sh | 8 ++-- tests/backends/codex/test_edit.sh | 16 +++---- tests/backends/codex/test_install.sh | 10 ++--- 7 files changed, 36 insertions(+), 133 deletions(-) delete mode 100755 backends/codex/code-close-diff.sh delete mode 100755 backends/codex/code-preview-diff.sh 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/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/health.lua b/lua/code-preview/health.lua index d4c6f32..8e1bddb 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -198,13 +198,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/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 ── From 30d955d3eb1a90e21bcb8892d0ad7a46c9c06746 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Fri, 5 Jun 2026 23:41:01 +0530 Subject: [PATCH 3/8] refactor: migrate copilot to the generic hook-entry shim (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copilot.lua now writes its `bash` hook field as `'' copilot pre|post` (the field runs under bash, so it always invokes the .sh — no PowerShell wrapper; copilot-on-Windows would need git-bash and stays deferred). is_our_config matches "hook-entry" (legacy code-preview-diff kept); chmod via platform.make_executable. Deletes backends/copilot/*.sh. health.lua drops the copilot adapter check. Tests point at the generic shim. Copilot E2E 15/15, full suite 65/65 on macOS. Co-Authored-By: Claude Opus 4.8 --- backends/copilot/code-close-diff.sh | 36 --------------- backends/copilot/code-preview-diff.sh | 51 ---------------------- lua/code-preview/backends/copilot.lua | 42 +++++++++--------- lua/code-preview/health.lua | 9 ++-- tests/backends/copilot/test_apply_patch.sh | 12 ++--- tests/backends/copilot/test_edit.sh | 12 ++--- tests/backends/copilot/test_install.sh | 5 ++- 7 files changed, 40 insertions(+), 127 deletions(-) delete mode 100755 backends/copilot/code-close-diff.sh delete mode 100755 backends/copilot/code-preview-diff.sh 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/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/health.lua b/lua/code-preview/health.lua index 8e1bddb..85ebc8b 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -170,14 +170,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 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 From c7c8c758fde06bd7e44ad285c3b015212c33b112 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 6 Jun 2026 00:02:20 +0530 Subject: [PATCH 4/8] refactor: migrate opencode to the generic hook-entry shim (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.ts now execSyncs the shared bin/hook-entry.{sh,ps1} as `opencode pre|post` (per-OS: powershell -File on Windows, direct on Unix) instead of the per-backend backends/opencode/code-*-diff.sh. opencode.lua copies plugin files with a portable libuv copy (drops `cp`, a Windows blocker) and makes bin/hook-entry.sh executable via platform. Deletes backends/opencode/*.sh — backends/ now holds only opencode's TS plugin. The opencode test harness imports index.ts directly, so it exercises the new shim resolution unchanged. OpenCode E2E 13/13, full suite 65/65 on macOS. Co-Authored-By: Claude Opus 4.8 --- backends/opencode/code-close-diff.sh | 29 ------------- backends/opencode/code-preview-diff.sh | 36 ---------------- backends/opencode/index.ts | 58 +++++++++++++------------- lua/code-preview/backends/opencode.lua | 29 +++++-------- 4 files changed, 40 insertions(+), 112 deletions(-) delete mode 100755 backends/opencode/code-close-diff.sh delete mode 100755 backends/opencode/code-preview-diff.sh 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/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 From 4e2996d5de01f7776755e503859be8d7eae2ffcb Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 6 Jun 2026 00:04:46 +0530 Subject: [PATCH 5/8] docs: ADR-0008 + CONTEXT for the one-hook-entry-per-OS consolidation (#46) Records the decision to collapse the per-agent hook entries into one generic parameterized shim per OS (bin/hook-entry.{sh,ps1}), and updates the Hook entry / Integration glossary terms to match. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 8 +++++--- docs/adr/0008-one-hook-entry-per-os.md | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0008-one-hook-entry-per-os.md 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/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..6447cee --- /dev/null +++ b/docs/adr/0008-one-hook-entry-per-os.md @@ -0,0 +1,21 @@ +# 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. +- **Risk (to validate on Windows):** the installed command passes ` ` as positional arguments to `powershell -File hook-entry.ps1`. Windows PowerShell 5.1 lacks `PSNativeCommandArgumentPassing`; the args are simple alphanumeric tokens (no spaces/quotes), so the risk is low, but it must be confirmed on a real box before the Windows side of this is trusted. From 4ada81d813a077bc0bc59ce62165daf0507c0035 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 6 Jun 2026 00:09:13 +0530 Subject: [PATCH 6/8] chore: untrack stray pre-existing docs accidentally swept into this branch (#46) CLAUDE.md, the PRD-*.md / ROADMAP.md / REFACTOR-PLAN.md / pr-description.md working notes were untracked in the repo before this work and got picked up by a `git add -A`. Untrack them (kept on disk) so the PR contains only the consolidation changes. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 82 ------- PRD-inline-apply.md | 39 ---- PRD-neo-tree.md | 222 ------------------ PRD-opencode.md | 301 ------------------------ PRD.md | 307 ------------------------ REFACTOR-PLAN.md | 556 -------------------------------------------- ROADMAP.md | 69 ------ pr-description.md | 50 ---- 8 files changed, 1626 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 PRD-inline-apply.md delete mode 100644 PRD-neo-tree.md delete mode 100644 PRD-opencode.md delete mode 100644 PRD.md delete mode 100644 REFACTOR-PLAN.md delete mode 100644 ROADMAP.md delete mode 100644 pr-description.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 18ea75c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,82 +0,0 @@ -# code-preview.nvim — Developer Notes - -### Testing - -```bash -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -nvim --headless -l bin/apply-edit.lua -``` - -**Important:** After making code changes, do NOT immediately edit a file to -trigger a test. The user must restart Neovim first to pick up the new code. -Wait for the user to confirm they have restarted and ask you to make a test -edit before suggesting any changes. - ---- - -## Neo-tree Integration (v1.1.0) - -See `PRD-neo-tree.md` for full design document including v2/v3 roadmap. - -### Tasks - -- [ ] Task 8: Changes registry module (`lua/code-preview/changes.lua`) - - Key-value store: `{ [abs_path] = "modified" | "created" }` - - API: `set()`, `clear()`, `clear_all()`, `get()`, `get_all()` - - Pure Lua, no dependencies - - **Test:** `:lua` calls to set/get/clear, verify state - -- [ ] Task 9: Neo-tree integration module (`lua/code-preview/neo_tree.lua`) - - All neo-tree interaction behind `pcall` guard (soft dependency) - - Subscribe to `BEFORE_RENDER` event to inject `state.code_preview_status_lookup` - - Register `code_preview_status` component (reads lookup, returns icon + highlight) - - `refresh()` helper to trigger neo-tree filesystem re-render - - Define highlight groups: `CodePreviewTreeModified`, `CodePreviewTreeCreated` - - **Test:** Set a change via `changes.set()`, verify icon appears in neo-tree - -- [ ] Task 10: Wire up `setup()` and config - - Add `neo_tree` section to default config (enabled, symbols, highlights) - - Call `neo_tree.setup()` from `init.lua setup()` when neo-tree is available - - **Test:** `:CodePreviewStatus` reflects neo-tree integration state - -- [ ] Task 11: Shell hook changes - - `code-preview-diff.sh`: call `changes.set(path, status)` + `neo_tree.refresh()` - - `code-close-diff.sh`: call `changes.clear_all()` + `neo_tree.refresh()` - - Detect `"created"` vs `"modified"` based on whether original file is empty - - **Test:** End-to-end — Claude proposes edit, icon appears in tree, clears on accept/reject - -- [ ] Task 12: Documentation - - Update README with neo-tree setup instructions (renderer config snippet) - - Document config options for neo-tree section - - Update CLAUDE.md key files table - -### Design Decisions - -- **Soft dependency** — neo-tree is optional; all interaction guarded by `pcall` -- **No separate tree** — decorates the existing filesystem source -- **Same pattern as git_status** — inject lookup into state via `BEFORE_RENDER`, component reads it -- **User adds component to renderer** — explicit opt-in, no surprise side effects - ---- - -## Key Notes - -- Backend modules (`backends/claudecode.lua`, `backends/opencode.lua`) use `debug.getinfo(1, "S").source` to locate `bin/` -- Highlights are lazy-initialized inside `show_diff()`, not at module load -- `apply-edit.lua` / `apply-multi-edit.lua` run via `nvim --headless -l` (no Python) - ---- - -## Agent skills - -### Issue tracker - -Issues live in the `Cannon07/claude-preview` GitHub repo; skills use the `gh` CLI. See `docs/agents/issue-tracker.md`. - -### Triage labels - -Default canonical label vocabulary (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`. - -### Domain docs - -Single-context layout — `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`. diff --git a/PRD-inline-apply.md b/PRD-inline-apply.md deleted file mode 100644 index 7698c94..0000000 --- a/PRD-inline-apply.md +++ /dev/null @@ -1,39 +0,0 @@ -# PRD: Inline Apply — Direct Buffer Changes with Review Queue - -## Problem - -Current diff preview uses temporary files. This causes: -- Navigating away from a diff tab loses the preview -- Temp file collisions during rapid multi-file edits -- No persistent view of all pending changes across files - -## Core Idea - -Apply proposed changes directly to the actual file/buffer instead of showing them in temp files. The original content is backed up so changes can be reverted on reject. - -## High-Level Flow - -1. **Pre-hook** — Apply proposed changes to the real buffer, store original content for revert, mark file as "pending review" in neo-tree -2. **Review** — User navigates freely via neo-tree; any marked file shows the applied changes with diff highlights (similar to git gutter) -3. **Accept (post-hook)** — Keep the changes, clear the marker -4. **Reject / manual close** — Revert buffer to original content, clear the marker - -## Neo-tree "Proposed Changes" View - -- New source or filter (like git status tab) showing only files with pending changes -- User can cycle through pending files to review -- Markers clear as files are accepted via CLI post-hook -- When all files are accepted/rejected, the view empties - -## Key Design Questions (for later) - -- Revert mechanism: buffer-only undo vs stored original content? -- What happens if the user manually edits a file with pending changes? -- Should the buffer be read-only while changes are pending? -- How to handle accept/reject for individual hunks vs whole file? -- Interaction with undo history (`u` in nvim) - -## Related - -- GitHub issue: navigating away loses diff preview -- Current neo-tree integration: `PRD-neo-tree.md` diff --git a/PRD-neo-tree.md b/PRD-neo-tree.md deleted file mode 100644 index 5a2d882..0000000 --- a/PRD-neo-tree.md +++ /dev/null @@ -1,222 +0,0 @@ -# Neo-tree Integration — Product Requirements Document - -## Overview - -Add optional neo-tree integration to code-preview.nvim so that proposed file -changes from Claude Code are visually indicated in the existing file tree. -When Claude proposes an edit, the affected file gets a status icon/highlight -in neo-tree — similar to how git status markers work today. - -## Motivation - -The diff tab already answers "what changed in this file?" but when Claude -touches multiple files (common for refactors), users have no overview of -*which* files are affected. A tree-level indicator solves this at a glance, -without leaving the editor or opening each diff individually. - -## Phased Approach - -### V1 — Highlight existing files (this PR) - -Decorate files in the **existing** neo-tree filesystem tree with a status -icon when Claude proposes a change. No separate tree, no new source. - -**What the user sees:** - -``` - lua/ - claude-preview/ - init.lua 󰏫 <-- modified by Claude - new-module.lua <-- new file proposed by Claude - utils.lua -``` - -**Design decisions:** - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Dependency on neo-tree | Soft/optional (`pcall` guard) | Plugin works without neo-tree | -| Separate tree? | No — decorates existing filesystem tree | Less UI clutter, familiar location | -| Pattern to follow | Same as `git_status` component | Proven, documented, minimal code | -| User opt-in | User adds `claude_status` to renderer config | Explicit, no surprise side effects | - ---- - -### V2 — Ghost nodes for new files (future) - -Show proposed new files/directories as virtual nodes in the tree, even -before they exist on disk. These would appear dimmed or with a distinct -icon to indicate they are proposed, not yet created. - -**Challenges:** -- Neo-tree's filesystem source scans the disk; virtual nodes require - injecting items into the tree data before render -- Need to handle the case where the file is accepted (becomes real) or - rejected (node disappears) -- Directory creation proposals need parent directories expanded/created - as virtual nodes too - -**Likely approach:** -- Use neo-tree's `BEFORE_RENDER` event to inject synthetic nodes into - `state.tree` for paths that don't exist on disk yet -- Mark them with a flag so the component can render them distinctly - ---- - -### V3 — Clickable tree nodes open diff (future) - -Clicking a file with a `claude_status` indicator opens the diff preview -for that specific file. This connects the tree overview with the detailed -diff view. - -**Requirements:** -- Store proposed content (or temp file paths) per file in the changes - registry, not just the status string -- Add a custom neo-tree command/action that calls `show_diff()` with - the stored paths -- Handle the case where the diff is already open for a different file - ---- - -## V1 Implementation Detail - -### Architecture - -``` -Claude CLI (tmux) Neovim - | | - PreToolUse hook fires | - | | - claude-preview-diff.sh ----RPC----> changes.set(path, "modified"|"created") - | | --> neo-tree refresh (tree re-renders) - | | --> show_diff() (existing behavior) - | | - PostToolUse hook fires | - | | - claude-close-diff.sh ---RPC------> changes.clear(path) - | | --> neo-tree refresh - | | --> close_diff() (existing behavior) -``` - -### New files - -| File | Purpose | -|------|---------| -| `lua/claude-preview/changes.lua` | Registry: tracks `{ [abs_path] = status }` | -| `lua/claude-preview/neo_tree.lua` | Neo-tree integration: event subscription, component registration | - -### Modified files - -| File | Change | -|------|--------| -| `lua/claude-preview/init.lua` | Call `neo_tree.setup()` from `setup()` if neo-tree available | -| `bin/claude-preview-diff.sh` | Add RPC call to `changes.set()` with file status | -| `bin/claude-close-diff.sh` | Add RPC call to `changes.clear()` | - -### Module: `changes.lua` - -Simple key-value registry mapping absolute file paths to their proposed -change status. - -``` -API: - changes.set(filepath, status) -- status: "modified" | "created" - changes.clear(filepath) -- remove single entry - changes.clear_all() -- reset everything - changes.get(filepath) -- returns status or nil - changes.get_all() -- returns full table -``` - -### Module: `neo_tree.lua` - -Handles all neo-tree interaction behind a `pcall` guard. - -**Responsibilities:** -1. Subscribe to neo-tree's `BEFORE_RENDER` event to inject - `state.claude_status_lookup` from the changes registry -2. Register a `claude_status` component that reads the lookup and - returns `{ text, highlight }` for affected nodes -3. Provide a `refresh()` helper that triggers neo-tree filesystem refresh -4. Define highlight groups: `ClaudePreviewTreeModified`, `ClaudePreviewTreeCreated` - -**Component behavior:** -- Reads `state.claude_status_lookup[node.path]` -- `"modified"` -> icon `󰏫` with modified highlight -- `"created"` -> icon `` with added highlight -- `nil` -> returns `{}` (no decoration) - -### Shell hook changes - -**`claude-preview-diff.sh`** — after computing the diff, before sending -`show_diff()`: - -```bash -# Determine status -if [[ -s "$ORIG_FILE" ]]; then - STATUS="modified" -else - STATUS="created" -fi - -nvim_send "require('claude-preview.changes').set('$FILE_PATH_ESC', '$STATUS')" -``` - -Then after `show_diff()`, trigger a neo-tree refresh: - -```bash -nvim_send "pcall(function() require('claude-preview.neo_tree').refresh() end)" -``` - -**`claude-close-diff.sh`** — before `close_diff()`: - -```bash -nvim_send "require('claude-preview.changes').clear_all()" -nvim_send "pcall(function() require('claude-preview.neo_tree').refresh() end)" -``` - -### User configuration - -User adds the component to their neo-tree renderer config: - -```lua -require("neo-tree").setup({ - filesystem = { - renderers = { - file = { - { "indent" }, - { "icon" }, - { "name" }, - { "claude_status" }, -- add this line - { "git_status" }, - }, - }, - }, -}) -``` - -### Config additions to `setup()` - -```lua -neo_tree = { - enabled = true, -- set false to disable even if neo-tree is installed - refresh_on_change = true, -- auto-refresh tree when changes are set/cleared - symbols = { - modified = "󰏫", - created = "", - }, - highlights = { - modified = "NeoTreeGitModified", -- reuse familiar colors - created = "NeoTreeGitAdded", - }, -}, -``` - -### Testing - -1. Open Neovim with neo-tree and claude-preview loaded -2. Run `:lua require("claude-preview.changes").set(vim.fn.expand("%:p"), "modified")` -3. Verify the current file gets the `󰏫` icon in neo-tree -4. Run `:lua require("claude-preview.changes").clear_all()` -5. Verify the icon disappears -6. End-to-end: ask Claude to edit a file, verify icon appears in tree - alongside the diff tab diff --git a/PRD-opencode.md b/PRD-opencode.md deleted file mode 100644 index 2c74fe3..0000000 --- a/PRD-opencode.md +++ /dev/null @@ -1,301 +0,0 @@ -# OpenCode Integration — Product Requirements Document - -## Overview - -Add support for [OpenCode](https://github.com/anomalyco/opencode) as an -alternative backend alongside Claude Code. Users running OpenCode instead of -(or alongside) Claude Code should get the same Neovim diff preview and -neo-tree indicators without any changes to the core Lua modules. - -**GitHub issue:** [#7](https://github.com/Cannon07/claude-preview.nvim/issues/7) - -## Motivation - -OpenCode is a popular open-source AI coding agent (130K+ stars) that is -provider-agnostic (Anthropic, OpenAI, Gemini, local models). Multiple users -have requested support. Since the core value of this plugin is the **Neovim -diff preview experience**, supporting multiple CLI backends significantly -expands the user base. - -## How OpenCode Hooks Work - -OpenCode has a TypeScript/JavaScript plugin system with hooks analogous to -Claude Code's shell hooks: - -| Claude Code | OpenCode | Description | -|---|---|---| -| `PreToolUse` shell script | `tool.execute.before` JS/TS plugin | Called before any tool executes | -| `PostToolUse` shell script | `tool.execute.after` JS/TS plugin | Called after tool executes | -| `.claude/settings.local.json` | `.opencode/plugins/` directory | Where hooks are configured | - -### Key differences from Claude Code - -1. **Plugin format** — OpenCode plugins are TS/JS modules (not shell scripts) -2. **Plugin location** — loaded from `.opencode/plugins/` (project) or - `~/.config/opencode/plugins/` (global) -3. **Edit tools** — OpenCode has `edit` (find/replace), `multiedit` - (sequential edits), and `apply_patch` (unified diff across files) -4. **Hook signature** — `(input, output) => Promise` where `input` - contains tool name and args, `output` is mutable -5. **Shell access** — plugins receive a `$` (Bun shell) utility for running - shell commands - -### OpenCode plugin structure - -```typescript -import type { Plugin } from "@opencode-ai/plugin" - -export default: Plugin = async ({ client, project, directory, worktree, $ }) => { - return { - "tool.execute.before": async (input, output) => { - // input.tool = "edit" | "multiedit" | "apply_patch" | "write" | "bash" - // input.sessionID, input.callID - // output.args = { filePath, oldString, newString, ... } - }, - "tool.execute.after": async (input, output) => { - // input.args = original args - // output.metadata = { diff, filediff: { before, after, additions, deletions } } - }, - } -} -``` - -## Architecture - -The core principle: **the Lua side is backend-agnostic**. Both Claude Code -and OpenCode ultimately call the same Lua functions (`show_diff()`, -`close_diff()`, `changes.set()`, etc.) via Neovim RPC. Only the hook/plugin -layer differs. - -``` -Claude Code (tmux) Neovim OpenCode (tmux) - │ │ │ - PreToolUse hook fires │ tool.execute.before fires - │ │ │ - claude-preview-diff.sh ──RPC──→ show_diff() ←──RPC── opencode-plugin.ts - │ │ │ - CLI: "Accept? (y/n)" User reviews diff CLI: permission prompt - │ │ │ - PostToolUse hook fires │ tool.execute.after fires - │ │ │ - claude-close-diff.sh ──RPC───→ close_diff() ←──RPC── opencode-plugin.ts -``` - -## What stays the same - -These modules are backend-agnostic and require **no changes**: - -| Module | Reason | -|---|---| -| `lua/claude-preview/diff.lua` | Receives temp file paths, doesn't care who created them | -| `lua/claude-preview/changes.lua` | Pure key-value store, no backend coupling | -| `lua/claude-preview/neo_tree.lua` | Reads from changes registry, no backend coupling | -| `lua/claude-preview/health.lua` | Will be extended (not modified) for OpenCode checks | -| `bin/nvim-socket.sh` | Socket discovery is backend-agnostic | -| `bin/nvim-send.sh` | RPC helper is backend-agnostic | - -## What's new - -### New files - -| File | Purpose | -|---|---| -| `opencode-plugin/index.ts` | OpenCode plugin — intercepts tool events, computes diffs, sends to Neovim via RPC | -| `opencode-plugin/package.json` | Plugin package metadata | -| `opencode-plugin/tsconfig.json` | TypeScript config | - -### Modified files - -| File | Change | -|---|---| -| `lua/claude-preview/hooks.lua` | Add `install_opencode()` / `uninstall_opencode()` functions | -| `lua/claude-preview/init.lua` | Add `:CodePreviewInstallOpenCodeHooks` / `:CodePreviewUninstallOpenCodeHooks` commands | -| `lua/claude-preview/health.lua` | Add OpenCode-specific health checks | - ---- - -## Implementation Tasks - -### Task 1: OpenCode plugin — core structure - -Create the TypeScript plugin that OpenCode will load from `.opencode/plugins/`. - -**Files to create:** -``` -opencode-plugin/ -├── index.ts # Plugin entry point -├── package.json # Package metadata -└── tsconfig.json # TypeScript config -``` - -**`index.ts` should:** -- Export a default plugin function matching OpenCode's `Plugin` type -- Register `tool.execute.before` and `tool.execute.after` hooks -- Discover the Neovim socket (reuse `nvim-socket.sh` via `$` shell utility) -- Provide helpers for sending Lua commands to Neovim via RPC - -**How to test:** -- Place plugin in `.opencode/plugins/`, run OpenCode, verify it loads without errors - ---- - -### Task 2: OpenCode plugin — edit interception (`tool.execute.before`) - -Implement the `tool.execute.before` hook to intercept file edits and show -diffs in Neovim. - -**Handle these OpenCode tools:** - -| Tool | Args | How to compute proposed content | -|---|---|---| -| `edit` | `filePath`, `oldString`, `newString` | Find-and-replace (same as Claude Code's Edit) | -| `multiedit` | `filePath`, `edits[]` | Sequential find-and-replace | -| `apply_patch` | `patch` (unified diff string) | Apply patch to get proposed content | -| `write` | `filePath`, `content` | Content is the proposed file | -| `bash` | `command` | Detect `rm` commands (same as Claude Code) | - -**For each edit tool:** -1. Read the original file content -2. Compute the proposed content by applying the edit -3. Write original and proposed to temp files -4. Send RPC to Neovim: `changes.set()`, `neo_tree.refresh()`, `show_diff()` - -**Key decision:** The edit computation (find-and-replace) should be done -in TypeScript directly, rather than shelling out to `apply-edit.lua`. This -avoids the `nvim --headless` dependency for OpenCode users and keeps the -plugin self-contained. - -**How to test:** -- Run OpenCode, ask it to edit a file -- Verify diff preview appears in Neovim before the edit is applied - ---- - -### Task 3: OpenCode plugin — cleanup (`tool.execute.after`) - -Implement the `tool.execute.after` hook to clean up after the user -accepts/rejects. - -**Should:** -1. Send RPC to Neovim: `changes.clear_all()`, `close_diff()`, `neo_tree.refresh()` -2. Clean up temp files -3. Handle the `bash` tool (rm detection) — only clear deletion markers - -**How to test:** -- Accept/reject an edit in OpenCode -- Verify diff closes and neo-tree indicators clear in Neovim - ---- - -### Task 4: Hook installer for OpenCode - -Add Lua functions to copy the plugin into the project's `.opencode/plugins/` -directory. - -**Modify `lua/claude-preview/hooks.lua`:** - -```lua -function M.install_opencode() - -- 1. Resolve plugin source: /opencode-plugin/ - -- 2. Resolve target: /.opencode/plugins/claude-preview/ - -- 3. Copy index.ts (and package.json if needed) to target - -- 4. Notify user -end - -function M.uninstall_opencode() - -- 1. Remove /.opencode/plugins/claude-preview/ - -- 2. Notify user -end -``` - -**Modify `lua/claude-preview/init.lua`:** -- Register `:CodePreviewInstallOpenCodeHooks` → `hooks.install_opencode()` -- Register `:CodePreviewUninstallOpenCodeHooks` → `hooks.uninstall_opencode()` - -**How to test:** -- `:CodePreviewInstallOpenCodeHooks` — verify plugin files copied to `.opencode/plugins/claude-preview/` -- `:CodePreviewUninstallOpenCodeHooks` — verify plugin directory removed -- Restart OpenCode, verify plugin loads - ---- - -### Task 5: Health check updates - -Extend `:checkhealth claude-preview` to report OpenCode integration status. - -**Add checks for:** -- OpenCode installed and in PATH (`opencode` executable) -- OpenCode plugin installed (`.opencode/plugins/claude-preview/` exists) -- Node.js/Bun available (required by OpenCode plugins) - -**How to test:** -- `:checkhealth claude-preview` — verify OpenCode section appears - ---- - -### Task 6: Documentation - -Update README with OpenCode setup instructions. - -**Add sections for:** -- OpenCode installation -- `:CodePreviewInstallOpenCodeHooks` usage -- Configuration differences (if any) -- Troubleshooting OpenCode-specific issues - ---- - -## File structure (after implementation) - -``` -claude-preview.nvim/ -├── lua/ -│ └── claude-preview/ -│ ├── init.lua # setup(), commands (Claude + OpenCode) -│ ├── diff.lua # show_diff(), close_diff() (unchanged) -│ ├── changes.lua # change registry (unchanged) -│ ├── neo_tree.lua # neo-tree integration (unchanged) -│ ├── hooks.lua # install/uninstall for both backends -│ └── health.lua # health checks for both backends -├── bin/ # Claude Code hook scripts (unchanged) -│ ├── claude-preview-diff.sh -│ ├── claude-close-diff.sh -│ ├── nvim-socket.sh -│ ├── nvim-send.sh -│ ├── apply-edit.lua -│ └── apply-multi-edit.lua -├── opencode-plugin/ # OpenCode plugin (NEW) -│ ├── index.ts -│ ├── package.json -│ └── tsconfig.json -├── README.md -├── LICENSE -├── PRD.md -├── PRD-neo-tree.md -└── PRD-opencode.md # This document -``` - -## Resolved decisions - -1. **Plugin format** — Ship as raw TypeScript. OpenCode uses Bun internally, - so TS is natively supported. No pre-compilation step needed. - -2. **apply_patch handling** — For V1, show one diff at a time (last file), - consistent with Claude Code's MultiEdit behavior. Multi-file simultaneous - diff view is a V2 candidate. - -3. **Permission hook** — V2 candidate. The `permission.ask` hook could enable - accepting/rejecting edits from within Neovim (instead of switching to the - CLI pane), but this changes the UX model and adds complexity. V1 keeps the - same "review in Neovim, accept in CLI" flow as Claude Code. - -## Out of scope (V1) - -- **`permission.ask` hook integration** — blocking until Neovim review is - complete (V2 candidate) -- **SDK event subscription** — connecting to OpenCode's HTTP server for - real-time events (alternative to plugin approach) -- **Multi-file diff view** — showing all files affected by `apply_patch` - simultaneously -- **Auto-detection** — automatically detecting whether Claude Code or OpenCode - is running and loading the appropriate hooks diff --git a/PRD.md b/PRD.md deleted file mode 100644 index a2ca561..0000000 --- a/PRD.md +++ /dev/null @@ -1,307 +0,0 @@ -# claude-preview.nvim — Product Requirements Document - -## What is this? - -A Neovim plugin that bridges Claude Code CLI (running in an external tmux pane) with Neovim's diff view. When Claude proposes a file change, a side-by-side diff appears in Neovim **before** the file is written — letting you review exactly what's changing before accepting. - -## Why does this exist? - -Claude Code has first-class IDE integration for VS Code (diff preview, accept/reject UI). For Neovim, the official `claudecode.nvim` plugin works — but only when Claude runs inside Neovim's built-in terminal via WebSocket/MCP. - -Many developers prefer running Claude Code CLI in a separate tmux pane alongside Neovim. In this setup, there's no way to preview proposed changes in the editor before accepting. This plugin solves that gap. - -## How it works (high level) - -``` -Claude CLI (tmux pane) Neovim (tmux pane) - │ │ - Proposes an Edit │ - │ │ - PreToolUse hook fires ──→ hook script ──→ RPC → show_diff() - │ │ (new tab, side-by-side) - CLI: "Accept? (y/n)" │ - │ User reviews diff - User accepts/rejects │ - │ │ - PostToolUse hook fires ─→ hook script ──→ RPC → close_diff() -``` - -Three mechanisms working together: -1. **Claude Code Hooks** — `PreToolUse` intercepts edits before they happen, `PostToolUse` cleans up after -2. **Neovim RPC** — hook scripts send Lua commands to Neovim via its Unix socket (`nvim --server --remote-send`) -3. **Neovim diff mode** — native side-by-side diff in a dedicated tab - -## User experience - -### Installation - -```lua --- lazy.nvim -{ - "jayshitre/claude-preview.nvim", - config = function() - require("claude-preview").setup() - end, -} -``` - -### Setup (one-time) - -After installing, run inside Neovim: -``` -:ClaudePreviewInstallHooks -``` - -This writes the hook configuration to `.claude/settings.local.json` in the current project. The user never manually edits hook files or scripts. - -### Daily workflow - -1. Open tmux, split into two panes -2. Left pane: `nvim .` -3. Right pane: `claude` -4. Ask Claude to make changes -5. Diff tab auto-opens in Neovim with CURRENT (left) vs PROPOSED (right) -6. Review in Neovim, switch to CLI pane, accept or reject -7. Diff tab auto-closes - -### Uninstall hooks - -``` -:ClaudePreviewUninstallHooks -``` - -Removes the hooks from `.claude/settings.local.json`. - ---- - -## Implementation Tasks - -### Task 1: Plugin scaffold and entry point - -Create the basic plugin structure and the `setup()` function. - -**Files to create:** -``` -claude-preview.nvim/ -├── lua/ -│ └── claude-preview/ -│ └── init.lua # setup(), config merging, health check -├── LICENSE -└── README.md -``` - -**`setup()` should:** -- Accept an optional config table with defaults -- Store the merged config in a module-level variable -- Register user commands (`:ClaudePreviewInstallHooks`, `:ClaudePreviewUninstallHooks`, `:ClaudePreviewStatus`) -- NOT auto-install hooks (explicit user action via command) - -**Default config:** -```lua -{ - diff = { - layout = "tab", -- "tab" (new tab) or "vsplit" (in current tab) - labels = { current = "CURRENT", proposed = "PROPOSED" }, - auto_close = true, -- close diff after accept/reject - equalize = true, -- 50/50 split widths - full_file = true, -- show full file, not just hunks - }, - highlights = { - current = { - DiffAdd = { bg = "#4c2e2e" }, - DiffDelete = { bg = "#2e4c2e" }, - DiffChange = { bg = "#4c3a2e" }, - DiffText = { bg = "#5c3030" }, - }, - proposed = { - DiffAdd = { bg = "#2e4c2e" }, - DiffDelete = { bg = "#4c2e2e" }, - DiffChange = { bg = "#2e3c4c" }, - DiffText = { bg = "#3e5c3e" }, - }, - }, -} -``` - -**How to test:** -- `:lua require("claude-preview").setup()` — should not error -- `:ClaudePreviewStatus` — should show "Hooks: not installed, Neovim RPC: " - ---- - -### Task 2: Diff module - -Port `claude-diff.lua` into the plugin as `lua/claude-preview/diff.lua`. - -**Files to create:** -``` -lua/claude-preview/ - └── diff.lua # show_diff(), close_diff() -``` - -**What it does:** -- `show_diff(original_path, proposed_path, display_name)` — opens diff view using config from `setup()` -- `close_diff()` — closes diff view and cleans up -- Reads highlight config from the merged setup config (not hardcoded) -- Respects `config.diff.layout` ("tab" or "vsplit") -- Respects `config.diff.full_file`, `config.diff.equalize` -- Handles `VimResized` for re-equalizing - -**How to test:** -- Create two temp files with different content -- `:lua require("claude-preview.diff").show_diff("/tmp/a.lua", "/tmp/b.lua", "test.lua")` -- Verify diff appears with correct layout, highlights, labels -- `:lua require("claude-preview.diff").close_diff()` - ---- - -### Task 3: Hook scripts - -Port the shell/Python scripts into the plugin's `bin/` directory. These get bundled with the plugin and referenced by absolute path when hooks are installed. - -**Files to create:** -``` -bin/ -├── claude-preview-diff.sh # PreToolUse hook entry point -├── claude-close-diff.sh # PostToolUse hook entry point -├── nvim-socket.sh # Socket discovery helper -├── nvim-send.sh # RPC send helper -├── apply-edit.py # Single Edit string replacement -└── apply-multi-edit.py # MultiEdit sequential replacement -``` - -**Key change from prototype:** The preview script needs to call `require("claude-preview.diff").show_diff(...)` instead of `require("custom.claude-diff").show_diff(...)`. - -**How to test:** -- Simulate a PreToolUse call by piping JSON into the script -- Verify diff opens in Neovim and script outputs correct JSON -- Simulate a PostToolUse call, verify diff closes - ---- - -### Task 4: Hook installer commands - -Implement `:ClaudePreviewInstallHooks` and `:ClaudePreviewUninstallHooks`. - -**Files to modify:** -``` -lua/claude-preview/ - ├── init.lua # Register commands - └── hooks.lua # NEW: install/uninstall logic -``` - -**`:ClaudePreviewInstallHooks` should:** -1. Determine the plugin's `bin/` directory path (where the hook scripts live) -2. Read existing `.claude/settings.local.json` (or create it) -3. Add/update the PreToolUse and PostToolUse hook entries -4. Write the file back -5. Print confirmation message - -**`:ClaudePreviewUninstallHooks` should:** -1. Read `.claude/settings.local.json` -2. Remove the claude-preview hook entries (leave other hooks intact) -3. Write the file back -4. Print confirmation message - -**Hook paths must be absolute** — the plugin resolves its own `bin/` directory at install time using `debug.getinfo` or Neovim's `runtimepath`. - -**How to test:** -- Run `:ClaudePreviewInstallHooks` — verify `.claude/settings.local.json` is created with correct paths -- Run `:ClaudePreviewUninstallHooks` — verify hooks are removed -- Restart Claude CLI, make an edit — verify hooks fire - ---- - -### Task 5: Status command and health check - -Implement `:ClaudePreviewStatus` and `:checkhealth claude-preview`. - -**Files to create/modify:** -``` -lua/claude-preview/ - ├── init.lua # :ClaudePreviewStatus command - ├── health.lua # NEW: checkhealth integration - └── socket.lua # NEW: Lua-native socket discovery (optional) -``` - -**`:ClaudePreviewStatus` should display:** -- Hooks installed: yes/no (check `.claude/settings.local.json`) -- Neovim RPC socket: path or "not found" -- Dependencies: jq (found/missing), python3 (found/missing) -- Diff tab: open/closed - -**`:checkhealth claude-preview` should verify:** -- `jq` is available in PATH -- `python3` is available in PATH -- Hook scripts are executable -- `.claude/settings.local.json` exists and has valid hooks -- Neovim RPC socket is accessible - -**How to test:** -- `:ClaudePreviewStatus` — verify output is accurate -- `:checkhealth claude-preview` — verify all checks pass - ---- - -### Task 6: README and documentation - -**Files to create:** -``` -README.md # Installation, usage, configuration, troubleshooting -LICENSE # MIT -``` - -**README sections:** -- What it does (with a GIF/screenshot if possible) -- Requirements (Neovim 0.9+, tmux, jq, python3, Claude Code CLI) -- Installation (lazy.nvim, packer, manual) -- Quick start (setup + install hooks) -- Configuration reference (all options with defaults) -- Commands reference -- How it works (brief architecture) -- Troubleshooting (common issues) -- Differences from claudecode.nvim and nvim-claude - ---- - -### Task 7: Testing and polish - -- Test all tool types: Edit, Write, MultiEdit -- Test edge cases: new file, Neovim closed, rapid sequential edits -- Test with different Neovim versions (0.9, 0.10, nightly) -- Test on macOS and Linux (socket paths differ) -- Clean up temp files properly -- Ensure no side effects on Neovim startup (lazy loading) - ---- - -## File structure (final) - -``` -claude-preview.nvim/ -├── lua/ -│ └── claude-preview/ -│ ├── init.lua # setup(), commands, config -│ ├── diff.lua # show_diff(), close_diff() -│ ├── hooks.lua # install/uninstall hooks -│ ├── health.lua # :checkhealth integration -│ └── socket.lua # Lua-native socket discovery (optional) -├── bin/ -│ ├── claude-preview-diff.sh -│ ├── claude-close-diff.sh -│ ├── nvim-socket.sh -│ ├── nvim-send.sh -│ ├── apply-edit.py -│ └── apply-multi-edit.py -├── README.md -├── LICENSE -└── PRD.md -``` - -## Out of scope (for now) - -- **Inline diff** (like nvim-claude) — we use a tab-based approach for simplicity -- **Accept/reject from Neovim** — acceptance happens in the CLI; Neovim is read-only preview -- **MCP/WebSocket server** — we use hooks + RPC, not the MCP protocol -- **Non-tmux setups** — the plugin works in any terminal setup, but the naming and docs target tmux users -- **Auto-installing hooks on setup()** — explicit user action required for transparency diff --git a/REFACTOR-PLAN.md b/REFACTOR-PLAN.md deleted file mode 100644 index 42f64f9..0000000 --- a/REFACTOR-PLAN.md +++ /dev/null @@ -1,556 +0,0 @@ -# Refactor Plan: Rename + Restructure (Issue #13) - -**Goal:** Rename `claude-preview` to `code-preview` and restructure the codebase -to separate backend-specific code from shared/core code. - -**PR:** Single PR covering both rename and restructure. - ---- - -## Current Structure - -``` -lua/claude-preview/ -├── init.lua -- setup, config, commands (mixed backends) -├── hooks.lua -- Claude + OpenCode install/uninstall (mixed) -├── diff.lua -- diff view (shared) -├── changes.lua -- change tracking (shared) -├── neo_tree.lua -- neo-tree integration (shared) -└── health.lua -- healthcheck (mixed backends) - -bin/ -├── core-pre-tool.sh -- unified PreToolUse logic (shared core) -├── core-post-tool.sh -- unified PostToolUse logic (shared core) -├── claude-preview-diff.sh -- Claude Code adapter (thin, execs core-pre-tool.sh) -├── claude-close-diff.sh -- Claude Code adapter (thin, execs core-post-tool.sh) -├── nvim-send.sh -- shared RPC helper -├── nvim-socket.sh -- shared socket discovery -├── apply-edit.lua -- shared edit transformer -└── apply-multi-edit.lua -- shared edit transformer - -opencode-plugin/ -├── index.ts -- OpenCode adapter (translates format, calls core scripts) -├── package.json -└── tsconfig.json -``` - -## Target Structure - -``` -lua/code-preview/ -├── init.lua -- setup, config, shared commands -├── diff.lua -- diff view (shared) -├── changes.lua -- change tracking (shared) -├── neo_tree.lua -- neo-tree integration (shared) -├── health.lua -- healthcheck (checks all backends) -└── backends/ - ├── claudecode.lua -- Claude Code hook install/uninstall - └── opencode.lua -- OpenCode plugin install/uninstall - -bin/ -├── core-pre-tool.sh -- unified PreToolUse logic (shared core) -├── core-post-tool.sh -- unified PostToolUse logic (shared core) -├── nvim-send.sh -- shared RPC helper -├── nvim-socket.sh -- shared socket discovery -├── apply-edit.lua -- shared edit transformer -└── apply-multi-edit.lua -- shared edit transformer - -backends/ -├── claudecode/ -│ ├── code-preview-diff.sh -- Claude Code adapter (thin, execs ../../bin/core-pre-tool.sh) -│ └── code-close-diff.sh -- Claude Code adapter (thin, execs ../../bin/core-post-tool.sh) -└── opencode/ - ├── index.ts -- OpenCode adapter (translates format, calls core scripts) - ├── package.json - └── tsconfig.json -``` - -## Naming Mappings - -### Module requires -| Old | New | -|-----|-----| -| `require("claude-preview")` | `require("code-preview")` | -| `require("claude-preview.diff")` | `require("code-preview.diff")` | -| `require("claude-preview.hooks")` | `require("code-preview.backends.claudecode")` / `require("code-preview.backends.opencode")` | -| `require("claude-preview.changes")` | `require("code-preview.changes")` | -| `require("claude-preview.neo_tree")` | `require("code-preview.neo_tree")` | -| `require("claude-preview.health")` | `require("code-preview.health")` | - -### User commands -| Old | New | -|-----|-----| -| `:ClaudePreviewInstallHooks` | `:CodePreviewInstallClaudeCodeHooks` | -| `:ClaudePreviewUninstallHooks` | `:CodePreviewUninstallClaudeCodeHooks` | -| `:ClaudePreviewCloseDiff` | `:CodePreviewCloseDiff` | -| `:ClaudePreviewStatus` | `:CodePreviewStatus` | -| `:ClaudePreviewToggleVisibleOnly` | `:CodePreviewToggleVisibleOnly` | -| `:CodePreviewInstallOpenCodeHooks` | `:CodePreviewInstallOpenCodeHooks` (unchanged) | -| `:CodePreviewUninstallOpenCodeHooks` | `:CodePreviewUninstallOpenCodeHooks` (unchanged) | - -### Deprecated aliases (keep for one release) -Old commands should still work but print a deprecation warning pointing to the new name. -- `:ClaudePreviewInstallHooks` -> warns, calls `:CodePreviewInstallClaudeCodeHooks` -- `:ClaudePreviewUninstallHooks` -> warns, calls `:CodePreviewUninstallClaudeCodeHooks` -- `:ClaudePreviewCloseDiff` -> warns, calls `:CodePreviewCloseDiff` -- `:ClaudePreviewStatus` -> warns, calls `:CodePreviewStatus` -- `:ClaudePreviewToggleVisibleOnly` -> warns, calls `:CodePreviewToggleVisibleOnly` - -### Highlight groups -| Old | New | -|-----|-----| -| `ClaudePreviewTreeModified` | `CodePreviewTreeModified` | -| `ClaudePreviewTreeCreated` | `CodePreviewTreeCreated` | -| `ClaudePreviewTreeDeleted` | `CodePreviewTreeDeleted` | -| `ClaudePreviewTreeVirtual` | `CodePreviewTreeVirtual` | -| `ClaudePreviewDiffResize` (augroup) | `CodePreviewDiffResize` | - -### Shell scripts -| Old | New | -|-----|-----| -| `bin/claude-preview-diff.sh` | `backends/claudecode/code-preview-diff.sh` | -| `bin/claude-close-diff.sh` | `backends/claudecode/code-close-diff.sh` | -| `bin/core-pre-tool.sh` | `bin/core-pre-tool.sh` (stays, shared) | -| `bin/core-post-tool.sh` | `bin/core-post-tool.sh` (stays, shared) | - -### Shell script internal references -| Old | New | -|-----|-----| -| `require('claude-preview.changes')` | `require('code-preview.changes')` | -| `require('claude-preview.diff')` | `require('code-preview.diff')` | -| `require('claude-preview.neo_tree')` | `require('code-preview.neo_tree')` | -| `require('claude-preview')` | `require('code-preview')` | - -These references exist in `core-pre-tool.sh` and `core-post-tool.sh` (the core scripts). -The Claude adapters have no `require()` calls — they only `exec` the core scripts. - -### Hook marker -| Old | New | -|-----|-----| -| `HOOK_MARKER = "claude-preview"` | `HOOK_MARKER = "code-preview"` | - -### Notification prefix -| Old | New | -|-----|-----| -| `[claude-preview]` | `[code-preview]` | - -### OpenCode package -| Old | New | -|-----|-----| -| `"name": "claude-preview-opencode"` | `"name": "code-preview-opencode"` | -| `opencode-plugin/` directory | `backends/opencode/` directory | - ---- - -## Steps - -### Step 1: Rename Lua module directory - -Move `lua/claude-preview/` to `lua/code-preview/`. - -**Files affected:** -- Directory rename: `lua/claude-preview/` -> `lua/code-preview/` - -**Manual test:** -```vim -" Restart Neovim with the plugin loaded -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" - -" Verify module loads from new path -:lua print(vim.inspect(require("code-preview"))) -" Expected: table with setup function (may error on internal requires -- that's OK at this step) -``` - ---- - -### Step 2: Update all internal `require()` calls in Lua files - -Replace every `require("claude-preview` with `require("code-preview` across all Lua files. - -**Files affected:** -- `lua/code-preview/init.lua` -- requires for hooks, diff, neo_tree -- `lua/code-preview/diff.lua` -- `require("claude-preview").config` (line 365), `require("claude-preview.changes").clear_all()` (line 516), `require("claude-preview.neo_tree").refresh()` (line 517) -- `lua/code-preview/health.lua` -- `require("claude-preview")` for config (line 24) -- `lua/code-preview/neo_tree.lua` -- `require("claude-preview.changes")` (line 3), `require("claude-preview")` for config (line 354) - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" - -" Full setup should work without errors -:lua require("code-preview").setup() - -" Verify submodules load -:lua print(require("code-preview.diff").is_open()) -:lua print(vim.inspect(require("code-preview.changes").get_all())) -``` - ---- - -### Step 3: Split `hooks.lua` into `backends/claudecode.lua` and `backends/opencode.lua` - -Split the monolithic hooks module into two backend-specific modules. - -**`lua/code-preview/backends/claudecode.lua`** gets: -- `scripts_dir()` -- resolves to `backends/claudecode/` (adapter script paths for settings.json) -- `bin_dir()` -- resolves to `bin/` (shared utilities, used for script existence checks) -- `HOOK_MARKER` -- changed to `"code-preview"` -- `LEGACY_HOOK_MARKER` -- `"claude-preview"` (used by `remove_ours()` during transition) -- `settings_path()`, `read_settings()`, `write_settings()` -- `M.install()`, `M.uninstall()` -- **Dual-marker uninstall:** `remove_ours()` must match entries containing either - `"code-preview"` OR `"claude-preview"` so users who installed with the old name - can uninstall after upgrading. Remove the legacy check after one release cycle. - -**`lua/code-preview/backends/opencode.lua`** gets: -- `plugin_source_dir()` -- resolves to `backends/opencode/` -- `opencode_target_dir()` -- `M.install()`, `M.uninstall()` -- `bin_dir()` -- needed to write `bin-path.txt` during install - -**Delete:** `lua/code-preview/hooks.lua` (after splitting) - -**Update `init.lua`:** Change requires from `require("code-preview.hooks")` to -`require("code-preview.backends.claudecode")` and `require("code-preview.backends.opencode")`. - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -" Verify backend modules load -:lua print(vim.inspect(require("code-preview.backends.claudecode"))) -:lua print(vim.inspect(require("code-preview.backends.opencode"))) - -" Verify install commands exist -:CodePreviewInstallClaudeCodeHooks -" Expected: hooks written to .claude/settings.local.json (or error if no project) - -:CodePreviewInstallOpenCodeHooks -" Expected: plugin files copied to .opencode/plugins/ -``` - ---- - -### Step 4: Rename user commands and add deprecated aliases - -Rename commands in `init.lua` and add deprecated aliases for the old names. - -**Files affected:** -- `lua/code-preview/init.lua` - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -" New commands should work -:CodePreviewStatus -:CodePreviewCloseDiff - -" Old commands should work but show deprecation warning -:ClaudePreviewStatus -" Expected: status output + "[code-preview] :ClaudePreviewStatus is deprecated, use :CodePreviewStatus" warning - -:ClaudePreviewCloseDiff -" Expected: closes diff + deprecation warning - -:ClaudePreviewToggleVisibleOnly -" Expected: toggles + deprecation warning -``` - ---- - -### Step 5: Move Claude adapters and update core script references - -- Move `bin/claude-preview-diff.sh` -> `backends/claudecode/code-preview-diff.sh` -- Move `bin/claude-close-diff.sh` -> `backends/claudecode/code-close-diff.sh` -- Update adapters to reference core scripts at `../../bin/core-pre-tool.sh` -- Update `require('claude-preview.` to `require('code-preview.` in `core-pre-tool.sh` and `core-post-tool.sh` -- Update `bin_dir()` in `backends/claudecode.lua` to resolve to `backends/claudecode/` - -**Claude adapter example after move:** -```bash -#!/usr/bin/env bash -# code-preview-diff.sh — PreToolUse hook adapter for Claude Code -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -BIN_DIR="$SCRIPT_DIR/../../bin" -export CODE_PREVIEW_BACKEND="claudecode" -exec "$BIN_DIR/core-pre-tool.sh" -``` - -**Files affected:** -- `backends/claudecode/code-preview-diff.sh` (moved + updated path) -- `backends/claudecode/code-close-diff.sh` (moved + updated path) -- `bin/core-pre-tool.sh` -- rename all `require('claude-preview.` to `require('code-preview.` -- `bin/core-post-tool.sh` -- rename all `require('claude-preview.` to `require('code-preview.` -- `lua/code-preview/backends/claudecode.lua` -- bin_dir() path, script name references - -**Manual test:** -```bash -# Verify scripts are executable -ls -la backends/claudecode/ -# Expected: code-preview-diff.sh and code-close-diff.sh with +x - -# Verify old locations are gone -ls bin/claude-preview-diff.sh bin/claude-close-diff.sh -# Expected: No such file or directory -``` -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -" Reinstall hooks (they should point to new script paths) -:CodePreviewInstallClaudeCodeHooks - -" Verify hooks point to new paths -:!cat .claude/settings.local.json | jq . -" Expected: commands point to backends/claudecode/code-preview-diff.sh and backends/claudecode/code-close-diff.sh -``` - ---- - -### Step 6: Move OpenCode plugin to `backends/opencode/` - -- Move `opencode-plugin/index.ts` -> `backends/opencode/index.ts` -- Move `opencode-plugin/package.json` -> `backends/opencode/package.json` -- Move `opencode-plugin/tsconfig.json` -> `backends/opencode/tsconfig.json` -- Update `require('claude-preview.` strings in core scripts (already done in Step 5) -- Update `package.json` name and description -- Update `backends/opencode.lua` path resolution for `plugin_source_dir()` -- The `bin-path.txt` mechanism remains the same — `install()` writes the absolute `bin/` path - -**Files affected:** -- `backends/opencode/index.ts` (moved + rename `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND`) -- `backends/opencode/package.json` (moved + updated name) -- `backends/opencode/tsconfig.json` (moved) -- `lua/code-preview/backends/opencode.lua` -- source dir path - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -:CodePreviewInstallOpenCodeHooks -" Expected: plugin files copied from backends/opencode/ to .opencode/plugins/ - -:CodePreviewUninstallOpenCodeHooks -" Expected: plugin files removed from .opencode/plugins/ -``` - ---- - -### Step 7: Rename highlight groups and augroup - -Update all `ClaudePreview*` highlight groups and augroup names to `CodePreview*`. - -**Files affected:** -- `lua/code-preview/neo_tree.lua` -- highlight group definitions and references (`ClaudePreviewTreeModified`, `ClaudePreviewTreeCreated`, `ClaudePreviewTreeDeleted`, `ClaudePreviewTreeVirtual`) -- `lua/code-preview/diff.lua` -- augroup name `ClaudePreviewDiffResize` (line 437) - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -" Check highlight groups exist -:hi CodePreviewTreeModified -:hi CodePreviewTreeCreated -:hi CodePreviewTreeDeleted -" Expected: each shows the configured colors - -" Verify old names don't exist -:hi ClaudePreviewTreeModified -" Expected: "E411: highlight group not found" -``` - ---- - -### Step 8: Update notification prefixes and status text - -Replace `[claude-preview]` with `[code-preview]` in all `vim.notify()` calls, -status output, and error messages. - -**Files affected:** -- `lua/code-preview/init.lua` -- status function title, notify calls -- `lua/code-preview/backends/claudecode.lua` -- all notify messages -- `lua/code-preview/backends/opencode.lua` -- all notify messages - -**Manual test:** -```vim -:CodePreviewStatus -" Expected: header says "code-preview.nvim status" -``` - ---- - -### Step 9: Update `health.lua` - -- Update `start()` text to `"code-preview.nvim"` -- Update script name references (`code-preview-diff.sh`) -- Update hook detection: check for BOTH `"code-preview"` and `"claude-preview"` markers - in settings.local.json (line 80), so health reports correctly for users who haven't - re-installed hooks yet. Show a warning if only the old marker is found. -- Update command references in warnings (`:CodePreviewInstallClaudeCodeHooks`) -- Update paths: health.lua needs to find scripts in `backends/claudecode/` now - -**Files affected:** -- `lua/code-preview/health.lua` - -**Manual test:** -```vim -nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim" -:lua require("code-preview").setup() - -:checkhealth code-preview -" Expected: all checks pass, correct script paths, correct command names in warnings -``` - ---- - -### Step 10: Update shell script comments - -Update comments in core scripts and shared utilities: -- `bin/core-pre-tool.sh` -- header comment -- `bin/core-post-tool.sh` -- header comment -- `bin/nvim-send.sh` -- example `require('code-preview.diff')` in comment - -**Files affected:** -- `bin/core-pre-tool.sh`, `bin/core-post-tool.sh`, `bin/nvim-send.sh` - -**Manual test:** N/A (comments only) - ---- - -### Step 11: Update keymap description - -Update `dq` description from `"Close claude-preview diff"` to `"Close code-preview diff"`. - -**Files affected:** -- `lua/code-preview/init.lua` - -**Manual test:** -```vim -:map dq -" Expected: description says "Close code-preview diff" -``` - ---- - -### Step 12: Update tests - -- Rename test directory: `tests/backends/claude/` -> `tests/backends/claudecode/` -- Update all `require('claude-preview.` references in test specs to `require('code-preview.` -- Update test file paths if test helpers reference `bin/claude-preview-diff.sh` -- Update install test assertions (script paths, file lists) -- Rename `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND` in core scripts, adapters, and tests - -**Files affected:** -- Directory rename: `tests/backends/claude/` -> `tests/backends/claudecode/` -- `tests/plugin/changes_registry_spec.lua` -- `require("claude-preview.changes")` -- `tests/plugin/diff_lifecycle_spec.lua` -- `require("claude-preview.diff")`, `require("claude-preview.changes")` -- `tests/minimal_init.lua` -- `require("claude-preview").setup()` -- `tests/helpers.sh` -- hook script paths (`claude-preview-diff.sh`), `require('claude-preview')` in nvim setup, temp path prefixes (`claude-preview-test-*`, `claude-diff-*`) -- `tests/run.sh` -- title string `"claude-preview.nvim E2E Test Suite"`, comment header -- `tests/backends/claudecode/test_edit.sh` -- comment header, all `require('claude-preview.*')` in nvim_eval calls -- `tests/backends/claudecode/test_install.sh` -- `require('claude-preview.hooks')`, assertions for script names (`claude-preview-diff.sh`, `claude-close-diff.sh`) -- `tests/backends/claudecode/test_stale_socket.sh` -- `require('claude-preview.diff')`, `claude-preview-diff.sh` reference -- `tests/backends/opencode/test_edit.sh` -- all `require('claude-preview.*')` in nvim_eval calls -- `tests/backends/opencode/test_install.sh` -- `require('claude-preview.hooks')` -- `tests/backends/opencode/harness.ts` -- import path to `opencode-plugin/index.ts` → `backends/opencode/index.ts` -- `bin/core-pre-tool.sh` -- rename env var `CLAUDE_PREVIEW_BACKEND` to `CODE_PREVIEW_BACKEND` -- `bin/core-post-tool.sh` -- rename env var (comment only, not used in logic yet) -- `backends/claudecode/code-preview-diff.sh` -- rename env var -- `backends/claudecode/code-close-diff.sh` -- rename env var -- `backends/opencode/index.ts` -- rename env var (already called out in Step 6) - -**Manual test:** -```bash -bash tests/run.sh -# Expected: all tests pass -``` - ---- - -### Step 13: Update documentation - -- `README.md` -- all references, setup examples, command names, directory structure -- `CLAUDE.md` -- developer notes, file paths, task references -- `PRD.md` -- requirements doc references -- `PRD-neo-tree.md` -- neo-tree design doc references -- `PRD-opencode.md` -- opencode design doc references - -**Manual test:** Read through each doc and verify no stale `claude-preview` references remain. - -```bash -# Final grep to catch any stragglers (excluding git history) -grep -r "claude-preview" --include="*.lua" --include="*.sh" --include="*.ts" --include="*.md" --include="*.json" . -grep -r "ClaudePreview" --include="*.lua" --include="*.sh" --include="*.ts" --include="*.md" . -# Expected: zero results (or only intentional references like migration notes) -``` - ---- - -### Step 14: End-to-end test - -Full integration test with Claude Code backend: - -1. Restart Neovim: `nvim --cmd "set rtp+=/Users/jayshitre/Projects/claude-preview.nvim"` -2. Run `:lua require("code-preview").setup()` -3. Run `:CodePreviewInstallClaudeCodeHooks` -4. Verify `.claude/settings.local.json` has correct paths (`backends/claudecode/code-preview-diff.sh`) -5. Open Claude Code in a tmux pane, ask it to edit a file -6. Verify diff preview appears in Neovim -7. Accept/reject and verify diff closes -8. Run `:CodePreviewStatus` -- should show hooks installed -9. Run `:checkhealth code-preview` -- all green - -Full integration test with OpenCode backend: - -1. Run `:CodePreviewInstallOpenCodeHooks` -2. Verify files copied to `.opencode/plugins/` -3. Open OpenCode, trigger an edit -4. Verify diff preview appears -5. Run `:CodePreviewUninstallOpenCodeHooks` -- files removed - ---- - -## Notes - -- **Hook marker change:** Users who have existing hooks installed will need to - re-run the install command. The old `"claude-preview"` marker won't match - the new `"code-preview"` marker, so `uninstall` won't find old entries. - **Decision:** `remove_ours()` in `backends/claudecode.lua` checks for BOTH - `"code-preview"` and `"claude-preview"` markers. `health.lua` also detects - both, warning if only the old marker is found. Remove legacy checks after - one release cycle. -- **Deprecated aliases:** Remove after one release cycle. -- **User migration:** Users must update `require("claude-preview")` to - `require("code-preview")` in their Neovim config and re-run hook install. -- **Core scripts stay in `bin/`:** The unified `core-pre-tool.sh` and - `core-post-tool.sh` are backend-agnostic and shared. They stay in `bin/` - alongside other shared utilities. Only the thin backend adapters move to - `backends//`. -- **Adapter file count differs by design:** Claude needs 2 adapter scripts - (hook API requires separate commands per event), OpenCode needs 1 file - (plugin API exports both hooks from one entry point). Both follow the same - pattern: translate format and delegate to core scripts. -- **`bin-path.txt` for OpenCode:** During `install_opencode()`, the absolute - path to `bin/` is written to `bin-path.txt` alongside the installed plugin. - This lets the OpenCode adapter find the core scripts at runtime. -- **Claude Code adapter path resolution:** After moving to `backends/claudecode/`, - adapters use `$SCRIPT_DIR/../../bin/core-pre-tool.sh` to reach core scripts. - `backends/claudecode.lua` needs two resolvers: `scripts_dir()` for adapter - paths written to settings.json (`backends/claudecode/`), and `bin_dir()` for - shared utilities (`bin/`). -- **Env var rename:** `CLAUDE_PREVIEW_BACKEND` → `CODE_PREVIEW_BACKEND` with - values `"claudecode"` or `"opencode"`. Must update core scripts, adapters, - and test harnesses. -- **Temp file names:** `claude-diff-original` and `claude-diff-proposed` in - `core-pre-tool.sh` / `core-post-tool.sh` / `tests/helpers.sh` are internal - temp files with no user visibility. **Decision:** Keep as-is to avoid - unnecessary churn — they don't leak into user-facing names or configs. -- **Test temp path prefixes:** `claude-preview-test-*` prefixes in - `tests/helpers.sh` (socket path, mktemp templates) are also internal. - **Decision:** Rename to `code-preview-test-*` for consistency since we're - touching these files anyway in Step 12. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 846fcb5..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,69 +0,0 @@ -# code-preview.nvim — Roadmap - -Planned work items, roughly in priority order. - ---- - -## Configurable Logging - -**Status:** Next up (PR 2) - -Add opt-in debug logging following Neovim plugin conventions. - -- Create `lua/code-preview/log.lua` — thin logging module, no external dependencies -- Add `debug = false` config option to enable/disable -- Log file location: `vim.fn.stdpath("log") .. "/code-preview.log"` -- Use `vim.notify()` for WARN/ERROR (user-facing), file logging for DEBUG/INFO -- Wire into `diff.lua` (replace the ad-hoc logging removed in v2.0.0) -- Wire into `neo_tree.lua` — setup, virtual node injection, reveal -- Shell scripts read debug flag from `hook_context()` RPC, skip logging when disabled - -## diff.lua Refactoring - -**Status:** Planned (after logging PR) - -`diff.lua` has grown too large after the multi-tab rewrite. Break it into smaller modules: - -- Inline layout logic (build_inline_diff, show_inline_diff, statuscolumn) -- Tab/vsplit layout logic -- Active diff management (active_diffs table, close_for_file, close_diff_and_clear) -- Neo-tree bridge (mark_change_and_reveal) - -## Neo-tree Test Harness - -**Status:** Planned - -Add neo-tree + nui.nvim to the test environment for proper unit testing of neo-tree interactions. - -- Clone neo-tree and nui.nvim into `deps/` -- Add to rtp in `tests/minimal_init.lua` -- Enables tests for: indicator lifecycle, virtual node injection, reveal, stale tabpage regression -- Currently neo-tree interactions are only tested via E2E shell tests, not Plenary unit tests - -## Inline Apply (v3) - -**Status:** Design phase — see [PRD-inline-apply.md](PRD-inline-apply.md) - -Apply proposed changes directly to real buffers instead of temp files. Original content is backed up for revert on reject. Includes a neo-tree "Proposed Changes" view. - -## New Backends - -### Copilot CLI - -**Status:** In progress — hook system is similar to existing backends, should be straightforward - -### Pi Coding Harness (pi.dev) - -**Status:** Evaluating — need to investigate hook system - ---- - -## Completed (v2.0.0) - -- Multi-tab simultaneous diffs (#37) -- Unified backend architecture — Claude Code + OpenCode (#33) -- Rename claude-preview -> code-preview (#34, #35) -- `visible_only` mode (#24) -- Configurable neo-tree reveal (#21) -- E2E test suite with CI (#19) -- Stale socket recovery (#17) diff --git a/pr-description.md b/pr-description.md deleted file mode 100644 index 755c953..0000000 --- a/pr-description.md +++ /dev/null @@ -1,50 +0,0 @@ -## Summary - -- Adds OpenAI Codex CLI as the fourth supported AI backend alongside Claude Code, OpenCode, and Copilot CLI. -- Install writes `.codex/hooks.json` and detects the required `codex_hooks = true` feature flag in `.codex/config.toml` (project or global) — `:CodePreviewStatus` and `:checkhealth` both surface flag state so users can self-diagnose silent-no-op failures. -- Adds shell-write detection to the unified Bash hook: `>` / `>>` / `&>` / `&>>`, `mv X.tmp X`, `cp`, `tee`, and `sed -i` targets are flagged in the changes registry as `bash_modified` / `bash_created` so users get neo-tree feedback for shell-driven edits — important for Codex GPT models, which prefer the atomic-replace idiom (`{ printf …; cat F; } > F.tmp && mv F.tmp F`). -- Fixes ApplyPatch `*** Delete File:` showing the orange "modified" pencil instead of the red "deleted" trash icon. - -## What's included - -**Codex backend** -- `backends/codex/{code-preview-diff,code-close-diff}.sh` — translate Codex's payload (which delivers `apply_patch` text in `tool_input.command`) into the normalized shape consumed by `bin/core-{pre,post}-tool.sh`. `Bash` passes through. -- `lua/code-preview/backends/codex.lua` — install/uninstall, plus `feature_flag_state()` that checks `.codex/config.toml` (project) and falls back to `~/.codex/config.toml` (global). -- `:CodePreviewInstall/UninstallCodexCliHooks` commands; Codex rows in `:CodePreviewStatus` and `:checkhealth`, including feature-flag detection. - -**Shell-write detection (Bash hook)** -- New block in `bin/core-pre-tool.sh` that extracts likely write targets from a Bash command and marks each one `bash_modified` (file exists) or `bash_created` (file doesn't exist) in the changes registry. -- `looks_like_path` filters false positives leaked from quoted strings (e.g. `printf '\n\n'`); `is_transient_path` skips `.tmp`/`.bak`/`.swp`/`/dev/*`/`/tmp/*`; tilde paths expand to `$HOME` before the relative-path resolver to avoid `$CWD/~/foo`. -- rm-wins reveal precedence — when a command both `rm`s and writes, only the rm branch queues a `defer_fn` reveal so we don't double-fire. -- Acknowledged limitations (in-code comments): `mv -t DST` flag-inverted form, `tee FILE OTHER_FILE` multi-target, and the always-on cost of the detector for read-only Bash invocations. - -**Neo-tree integration for shell writes** -- `bash_modified` and `bash_created` render with the same icons/highlights as `modified` and `created` for v1 — documented in `neo_tree.lua` as a deliberate simplification. -- New `changes.clear_by_statuses({...})` helper; the Bash post-hook now batches `deleted` + `bash_modified` + `bash_created` cleanup into a single RPC instead of three. - -**ApplyPatch delete fix** -- `show_diff` accepts an optional `action` hint; the Codex/ApplyPatch hook passes `"delete"` for `*** Delete File:` directives. `mark_change_and_reveal` only emits `"deleted"` when explicitly told — a legitimate truncate-to-empty edit still shows as `modified`. -- `vim.loop.fs_stat` switched to `vim.uv.fs_stat` in `diff.lua` to match the convention used elsewhere in the codebase. - -**Docs / housekeeping** -- README: Codex Quick Start section, backend list updated to all four, Neovim floor aligned to `>= 0.10` (matches actual `vim.uv` usage), `:checkhealth` wording, test-runner examples for `backends/copilot` and `backends/codex`. -- `.gitignore`: ignore `test_output.log`. - -## Tests - -22 new shell tests in the Codex suite plus 2 plenary regressions: - -- `tests/backends/codex/test_install.sh` — `.codex/hooks.json` layout, idempotent re-install, user-authored Pre/PostToolUse entries survive install/uninstall, feature-flag detection (project + global, missing flag, no config.toml). -- `tests/backends/codex/test_edit.sh` — Codex `apply_patch` translation, Bash `rm`, shell-write detection (modified/created/atomic-replace/.tmp filter), HTML-comment false-positive guard, read-only no-op, noise-tool skip, malformed-payload skip. -- `tests/backends/codex/test_apply_patch.sh` — Update / Add / mixed Update+Add+Delete. -- `tests/plugin/diff_lifecycle_spec.lua` — `show_diff(..., "delete")` marks the file `deleted`; truncate-to-empty without an action stays `modified` (regression guard against the false-positive that the action hint replaced). -- `test_install_preserves_user_hooks` extended to assert PostToolUse mirroring (was previously only checking PreToolUse). - -## Test plan - -- [ ] `bash tests/run.sh all` passes locally -- [ ] `./tests/run_lua.sh diff_lifecycle` — plenary regressions pass -- [ ] Install/uninstall cycle in a real Codex CLI session, with and without `codex_hooks = true` -- [ ] `:CodePreviewStatus` and `:checkhealth code-preview` correctly report flag state for: project config, global config (`~/.codex/config.toml`), missing flag, no config file -- [ ] Codex `apply_patch` Update / Add / Delete — diffs open on pre, close on accept, `*** Delete File:` shows the red trash icon in neo-tree -- [ ] Codex Bash atomic-replace (`{ … } > F.tmp && mv F.tmp F`) — neo-tree shows the orange pencil on `F` during the approval window, clears on post From 204ea279369856a1b8dddeaebde54a9328c55fd4 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 6 Jun 2026 01:40:52 +0530 Subject: [PATCH 7/8] fix: health.lua claudecode detection + hook-entry.ps1 var hygiene (PR review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. health.lua: the inline .claude/settings parse keyed off "code-preview" / "claude-preview" substrings, which mis-fired after the hook-entry rename — a fresh install was flagged "legacy" whenever the plugin lived under a claude-preview.nvim directory (the path, not the command, matched). Delegate to claudecode.install_state() (matches the "hook-entry" marker by command shape), mirroring the codex pattern. Removes the bug and the duplication. 2. hook-entry.ps1: rename param $Event -> $HookEvent ($Event is a PowerShell automatic variable; harmless here but a known foot-gun). Verified on macOS: checkhealth reports "installed" correctly; full suite 65/65. Co-Authored-By: Claude Opus 4.8 --- bin/hook-entry.ps1 | 6 ++++-- lua/code-preview/health.lua | 43 +++++++++---------------------------- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/bin/hook-entry.ps1 b/bin/hook-entry.ps1 index bd486bd..b979396 100644 --- a/bin/hook-entry.ps1 +++ b/bin/hook-entry.ps1 @@ -9,7 +9,9 @@ # 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. -param([string]$Backend, [string]$Event) +# $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() @@ -48,7 +50,7 @@ try { # Verbatim splice of the raw payload into [payload, backend]. $argsJson = "[$raw,""$Backend""]" - if ($Event -eq 'post') { + if ($HookEvent -eq 'post') { $null = Invoke-NvimCall -Server $socket -Module 'code-preview.post_tool' ` -Function 'handle' -ArgsJson $argsJson } else { diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index 85ebc8b..de4d23a 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -102,42 +102,19 @@ function M.check() 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 ────────────────────────────────────────── From 11fce7717d56d038c2727b29fefaeff1090a28b9 Mon Sep 17 00:00:00 2001 From: Cannon07 Date: Sat, 6 Jun 2026 09:39:13 +0530 Subject: [PATCH 8/8] docs: record multi-token command-field validation in ADR-0008 (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "must validate on a real box" risk: the multi-token command field is confirmed on the two paths that needed it — claudecode on Windows (PS 5.1, powershell -File hook-entry.ps1 claudecode pre) and codex on macOS (bare-path → hook-entry.sh codex pre). Both shell-exec, so the args arrive intact and no .cmd trampoline is needed; this also settles ADR-0007's per-agent-invocation spike for codex. Copilot (bash field) and opencode (explicit execSync) are multi-token -safe by construction. Codex/copilot/opencode on Windows remain deferred. Co-Authored-By: Claude Opus 4.8 --- docs/adr/0008-one-hook-entry-per-os.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/adr/0008-one-hook-entry-per-os.md b/docs/adr/0008-one-hook-entry-per-os.md index 6447cee..24b5418 100644 --- a/docs/adr/0008-one-hook-entry-per-os.md +++ b/docs/adr/0008-one-hook-entry-per-os.md @@ -18,4 +18,8 @@ We collapse the hook entry to **one parameterized shim per OS** — `bin/hook-en - 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. -- **Risk (to validate on Windows):** the installed command passes ` ` as positional arguments to `powershell -File hook-entry.ps1`. Windows PowerShell 5.1 lacks `PSNativeCommandArgumentPassing`; the args are simple alphanumeric tokens (no spaces/quotes), so the risk is low, but it must be confirmed on a real box before the Windows side of this is trusted. +- **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).