diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 53fe1e2..0e6b864 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + # Manual trigger for running the suite against a branch on demand. + workflow_dispatch: jobs: test: @@ -50,3 +52,76 @@ jobs: chmod +x tests/run.sh tests/run_lua.sh tests/helpers.sh chmod +x bin/*.sh ./tests/run.sh + + # Windows runs a different, narrower strategy than the Unix matrix above: + # - No bash E2E suite (tests/run.sh is bash + jq; deliberately not ported — #46). + # - Lua specs run per-file: PlenaryBustedDirectory hangs headless on Windows, + # but PlenaryBustedFile works (issue #46 handoff). + # - A PowerShell shim syntax/load check runs under Windows PowerShell 5.1 + # (`shell: powershell`), the floor the installed Claude Code hook actually uses. + windows-test: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Neovim + uses: rhysd/action-setup-vim@v1 + with: + neovim: true + # Pinned to the version the claudecode slice was manually validated on. + version: v0.11.2 + + - name: Install plenary.nvim (test dependency) + shell: powershell + run: git clone --depth 1 https://github.com/nvim-lua/plenary.nvim deps/plenary.nvim + + - name: Verify Neovim + shell: powershell + run: nvim --version | Select-Object -First 3 + + - name: PowerShell shim syntax + load check (Windows PowerShell 5.1) + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + . .\bin\nvim-socket.ps1 + . .\bin\nvim-call.ps1 + if (-not (Get-Command Find-NvimSocket -ErrorAction SilentlyContinue)) { + throw 'Find-NvimSocket not defined after dot-sourcing nvim-socket.ps1' + } + if (-not (Get-Command Invoke-NvimCall -ErrorAction SilentlyContinue)) { + throw 'Invoke-NvimCall not defined after dot-sourcing nvim-call.ps1' + } + Write-Host 'PowerShell shims parse and define their expected functions.' + + - name: Run Lua specs (per-file) + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + # Run each spec in its own headless nvim by calling plenary.busted.run + # directly (in-process). We can't use PlenaryBustedFile here: it's + # nargs=1 and spawns a CHILD nvim that wouldn't load tests/minimal_init.lua + # (so the plugin rtp would be missing); and PlenaryBustedDirectory hangs + # headless on Windows. Running busted.run in a process already started + # with -u tests/minimal_init.lua sidesteps both, and it sets the exit + # code (0 = pass, non-zero = fail/error) via :cq, so no output parsing. + # + # bash_detect specs are Unix-path-only today (issue #46 handoff item 3): + # looks_like_path rejects backslashes and resolve() only treats + # /-prefixed strings as absolute. Excluded until ported to Windows paths. + $specs = Get-ChildItem tests/plugin -Filter *_spec.lua | + Where-Object { $_.Name -ne 'pre_tool_bash_detect_spec.lua' } + $failed = @() + foreach ($spec in $specs) { + Write-Host "-- $($spec.Name) --" + # Forward-slash the path: it is spliced into a Lua [[...]] literal. + $abs = $spec.FullName -replace '\\', '/' + & nvim --headless --clean -u tests/minimal_init.lua ` + -c "lua require('plenary.busted').run([[$abs]])" + if ($LASTEXITCODE -ne 0) { $failed += $spec.Name } + } + if ($failed.Count -gt 0) { + throw "Lua specs failed on Windows: $($failed -join ', ')" + } + Write-Host 'All Windows Lua specs passed.' diff --git a/CONTEXT.md b/CONTEXT.md index 5b335a0..abcc53e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -59,19 +59,23 @@ The act of finding which running Neovim instance to address an RPC call at. Reso If multiple instances match, prefer the one whose cwd matches (or is a parent of) the project cwd passed in by the calling hook. +On **Windows** (issue #46) the addressable target is a named pipe (`\\.\pipe\nvim..0`) rather than a Unix socket. The [pidfile](#pidfile) is the primary path; the Unix glob fallbacks (steps 3–5) are replaced by a single **pipe-enumeration fallback** — list `\\.\pipe\`, keep names matching `nvim.*`, probe each for responsiveness. Pipe enumeration cannot run the cwd tiebreak (Windows has no `/proc` or `lsof` to read a process's cwd, and a pipe found this way has no pidfile cwd line), so it degrades to "first responsive pipe," mirroring how the Unix glob path degrades when the cwd lookup fails. The stale-pipe responsiveness probe (`nvim --server --remote-expr "1"`) is unchanged in shape; only the is-socket precheck is dropped (named pipes have no reliable existence test). + ## Pidfile One file per running Neovim that has called `code-preview.setup()`. Path: `${XDG_STATE_HOME:-$HOME/.local/state}/code-preview/sockets/`. Contents: line 1 is the RPC socket path, line 2 is the Neovim's cwd. Pidfiles self-register on `setup()`, refresh on `DirChanged`, and are removed on `VimLeavePre`. Crashed Neovims leave stale pidfiles behind; `socket discovery` self-heals by probing each socket with `--remote-expr "1"` before using it. +The pidfile *directory* is computed independently — and must agree byte-for-byte — on both the Lua writer (`pidfile.lua`) and the shim reader, so it can only use values both sides can derive without an RPC. On **Windows** (issue #46) that base is `%LOCALAPPDATA%\code-preview\sockets` (not the Unix `$XDG_STATE_HOME`/`$HOME` formula, which yields a driveless garbage path on Windows); line 1 of the file is the named-pipe path instead of a socket path. + The pidfile is *one of several* socket discovery paths, not a synonym for socket discovery. ## 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//`. -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. Whatever language the agent demands (today: shell) is the language of the hook entry. +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. ## Core handler diff --git a/backends/claudecode/code-close-diff.ps1 b/backends/claudecode/code-close-diff.ps1 new file mode 100644 index 0000000..9ae68ae --- /dev/null +++ b/backends/claudecode/code-close-diff.ps1 @@ -0,0 +1,30 @@ +# 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-preview-diff.ps1 b/backends/claudecode/code-preview-diff.ps1 new file mode 100644 index 0000000..be3b9b9 --- /dev/null +++ b/backends/claudecode/code-preview-diff.ps1 @@ -0,0 +1,42 @@ +# 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/bin/nvim-call.ps1 b/bin/nvim-call.ps1 new file mode 100644 index 0000000..834a2c9 --- /dev/null +++ b/bin/nvim-call.ps1 @@ -0,0 +1,64 @@ +# nvim-call.ps1 — Windows counterpart to nvim-call.sh. Structured RPC into the +# running Neovim over a named pipe. See issue #46 / ADR-0007. +# +# Usage (dot-source after nvim-socket.ps1, then call): +# $result = Invoke-NvimCall -Server $socket -Module code-preview.pre_tool ` +# -Function handle -ArgsJson $argsJson +# +# $ArgsJson is a JSON array string. It is written to a tempfile VERBATIM and +# never re-serialised (the depth-truncation invariant in ADR-0007: round-tripping +# the payload through ConvertTo-Json would silently truncate deep MultiEdit / +# ApplyPatch structures at depth 2). The receiving Lua decodes it with +# vim.json.decode in lua/code-preview/rpc.lua. + +function Invoke-NvimCall { + param( + [string]$Server, + [string]$Module, + [string]$Function, + [string]$ArgsJson = "[]" + ) + if ([string]::IsNullOrEmpty($Server)) { return $null } + + # Tempfile in %TEMP% (atomic creation; the Windows analogue of mktemp). + $tmp = [System.IO.Path]::GetTempFileName() + try { + # Write the args JSON verbatim, UTF-8 with NO BOM — a BOM would choke + # vim.json.decode on the receiving side. + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($tmp, $ArgsJson, $utf8NoBom) + + # Forward-slash the path: it is spliced into a Lua *source string* below, and + # Windows backslashes are Lua escape sequences (\U, \T, ...). Lua's io.open + # accepts forward slashes on Windows, so this is lossless. + $tmpLua = $tmp -replace '\\', '/' + + # Only Module / Function / tmp — all controlled by us — enter the Lua source. + # User data flows through the tempfile as JSON, decoded by the dispatcher. + # + # Quoting (the ADR-0007 5.1 spike, now RESOLVED): the expression must contain + # NO double-quote characters. Windows PowerShell 5.1 lacks + # PSNativeCommandArgumentPassing and strips embedded double quotes when handing + # an argument to nvim.exe; a `luaeval("...")` form arrives as a bare + # `luaeval(require(...))`, which nvim parses as Vimscript and rejects with + # E117 (validated empirically on 5.1). So we use a single-quoted Vimscript + # string for the luaeval body and Lua long-bracket strings ([[...]]) for the + # module/function/path literals — zero quote characters of either kind, so + # there is nothing for 5.1 to mangle, and it is equally correct under pwsh 7. + # Safe because Module/Function/tmpLua are all our own values and never + # contain the long-bracket terminator ]]. + $expr = "luaeval('require([[code-preview.rpc]]).dispatch([[$Module]], [[$Function]], [[$tmpLua]])')" + + # --headless is REQUIRED on Windows: without it nvim starts a local TUI on + # this invocation (emitting terminal escape sequences to stdout and NOT + # returning the --remote-expr result) instead of acting as a pure remote + # client. With --headless the result is returned cleanly. See nvim-socket.ps1 + # for the matching rationale; validated on nvim 0.11, Windows. The Unix shim + # does not need this flag. + $out = & nvim --headless --server $Server --remote-expr $expr 2>$null + return $out + } + finally { + Remove-Item -Path $tmp -ErrorAction SilentlyContinue + } +} diff --git a/bin/nvim-socket.ps1 b/bin/nvim-socket.ps1 new file mode 100644 index 0000000..5b06057 --- /dev/null +++ b/bin/nvim-socket.ps1 @@ -0,0 +1,100 @@ +# nvim-socket.ps1 — Windows counterpart to nvim-socket.sh. Discovers the +# running Neovim's named-pipe address (\\.\pipe\nvim..0) and exposes it +# via Find-NvimSocket. See issue #46 / docs/adr/0007-windows-shim-via-shared-powershell-discovery.md. +# +# Usage (dot-source, then call): +# . "$PSScriptRoot\nvim-socket.ps1" +# $socket = Find-NvimSocket -ProjectCwd $cwd +# +# Discovery order mirrors the Unix resolver, minus the Unix-only globs: +# 1. $env:NVIM_LISTEN_ADDRESS, if responsive. +# 2. Pidfile lookup under %LOCALAPPDATA%\code-preview\sockets (preferred path; +# written by lua/code-preview/pidfile.lua, same dir formula). +# 3. Named-pipe enumeration fallback (\\.\pipe\nvim.*). +# Every candidate is validated with a `--remote-expr "1"` responsiveness probe, +# which self-heals stale pidfiles left by crashed Neovims. There is no +# is-socket precheck (named pipes have no reliable existence test on Windows). + +# Probe a server address for responsiveness. We only care about the exit code; +# all stdout/stderr is discarded. +# +# --headless is REQUIRED on Windows: without it, `nvim --server +# --remote-expr ...` starts a local TUI instead of acting purely as a remote +# client, and that local instance exits 0 even when is dead — a false +# positive that would make this probe accept stale pidfiles. With --headless, +# a dead server correctly yields a non-zero exit. (Validated on nvim 0.11, +# Windows; the Unix shim does not need this flag.) +function Test-NvimResponsive { + param([string]$Server) + if ([string]::IsNullOrEmpty($Server)) { return $false } + try { + & nvim --headless --server $Server --remote-expr "1" *> $null + return ($LASTEXITCODE -eq 0) + } catch { + return $false + } +} + +# Pidfile directory — MUST match lua/code-preview/pidfile.lua's Windows branch +# (%LOCALAPPDATA%\code-preview\sockets). Both sides compute it independently. +function Get-PidfileDir { + return (Join-Path $env:LOCALAPPDATA "code-preview\sockets") +} + +function Find-NvimSocket { + param([string]$ProjectCwd = "") + + # 1. Explicit env var — probe it directly (no is-socket precheck on Windows). + $envAddr = $env:NVIM_LISTEN_ADDRESS + if (-not [string]::IsNullOrEmpty($envAddr) -and (Test-NvimResponsive $envAddr)) { + return $envAddr + } + + $live = New-Object System.Collections.Generic.List[string] + + # 2. Pidfile lookup. File format (two lines): line 1 = pipe path, line 2 = cwd. + $pidDir = Get-PidfileDir + if (Test-Path $pidDir) { + foreach ($pf in (Get-ChildItem -Path $pidDir -File -ErrorAction SilentlyContinue)) { + $lines = @(Get-Content -Path $pf.FullName -ErrorAction SilentlyContinue) + if ($lines.Count -lt 1) { continue } + $pipe = $lines[0] + $cwd = if ($lines.Count -ge 2) { $lines[1] } else { "" } + if ([string]::IsNullOrEmpty($pipe)) { continue } + + # Responsiveness probe self-heals stale pidfiles (crashed nvim, recycled PID). + if (-not (Test-NvimResponsive $pipe)) { continue } + + # cwd match-or-parent rule, using the cwd the nvim itself reported. + if (-not [string]::IsNullOrEmpty($ProjectCwd) -and -not [string]::IsNullOrEmpty($cwd)) { + if ($ProjectCwd -eq $cwd -or $ProjectCwd.StartsWith($cwd + '\')) { + return $pipe + } + } + $live.Add($pipe) + } + } + + # 3. Named-pipe enumeration fallback. Cannot run the cwd tiebreak (a pipe found + # this way has no associated cwd — Windows has no /proc or lsof), so it only + # contributes live candidates, degrading to "first responsive pipe". + try { + foreach ($path in [System.IO.Directory]::GetFiles('\\.\pipe\')) { + $leaf = Split-Path $path -Leaf + if ($leaf -like 'nvim.*') { + $pipe = "\\.\pipe\$leaf" + if (-not $live.Contains($pipe) -and (Test-NvimResponsive $pipe)) { + $live.Add($pipe) + } + } + } + } catch { + # Pipe enumeration can throw on exotic pipe names; treat as "no fallback hits". + } + + if ($live.Count -eq 0) { return "" } + + # 4. Prefer a cwd match among enumerated candidates is not possible (no cwd), + # so fall back to the first live pipe. + return $live[0] +} diff --git a/docs/adr/0006-opencode-defers-os-independence-to-46.md b/docs/adr/0006-opencode-defers-os-independence-to-46.md index 946512e..f6a740d 100644 --- a/docs/adr/0006-opencode-defers-os-independence-to-46.md +++ b/docs/adr/0006-opencode-defers-os-independence-to-46.md @@ -1,3 +1,8 @@ + + + # OpenCode's integration keeps the bash shim, deferring OS-independence to issue #46 Issue #47 phase 3 flips OpenCode's [hook entry](../../CONTEXT.md#hook-entry) from `execSync`ing the bash [core handler](../../CONTEXT.md#core-handler) to a single [RPC](../../CONTEXT.md#rpc) into the in-process Lua orchestrator (see [ADR-0005](0005-core-handler-runs-in-process.md)). The natural next question: OpenCode's plugin is TypeScript, which runs natively on Windows. Should the flip also make OpenCode's integration the *first* bash-free [integration](../../CONTEXT.md#integration) — TS calls `nvim --server` directly, or speaks msgpack-rpc, with [socket discovery](../../CONTEXT.md#socket-discovery) reimplemented in TS? diff --git a/docs/adr/0007-windows-shim-via-shared-powershell-discovery.md b/docs/adr/0007-windows-shim-via-shared-powershell-discovery.md new file mode 100644 index 0000000..c41c32c --- /dev/null +++ b/docs/adr/0007-windows-shim-via-shared-powershell-discovery.md @@ -0,0 +1,20 @@ +# Windows support uses a shared PowerShell shim; all agents (including OpenCode) reuse it + +Issue #46 adds Windows 11 support. Windows forces a second [socket discovery](../../CONTEXT.md#socket-discovery) implementation regardless of agent, because Windows nvim is addressed via a named pipe (`\\.\pipe\nvim..0`) and none of the Unix discovery tooling (`lsof`, `compgen`, `kill -0`, the `/var/folders`/`/tmp`/`$XDG_RUNTIME_DIR` globs) exists. We implement that second discovery+RPC layer **once, in PowerShell**, and have all four agents — claudecode, codex, copilot, *and* opencode — share it via a per-OS [hook entry](../../CONTEXT.md#hook-entry): a `.sh` shim on Unix, a `.ps1` shim on Windows. + +PowerShell (Windows PowerShell 5.1, in-box on every Windows 11) is the single Windows logic language: it is the only stock tool that parses JSON natively (replacing the Unix shims' `jq`), enumerates named pipes, and probes the RPC socket. The installer writes the interpreter explicitly into each agent's `command` field (`powershell -NoProfile -ExecutionPolicy Bypass -File .ps1`); a thin `.cmd` trampoline is added only for an agent whose `command` field turns out to raw-exec a bare path and reject a multi-token command. + +## Considered Options (OpenCode specifically) + +[ADR-0006](0006-opencode-defers-os-independence-to-46.md) deferred OpenCode's OS-independence to this issue, predicting OpenCode would be "among the first integrations to drop the bash dependency." This ADR **supersedes that prediction**: OpenCode does drop bash *on Windows*, but not by going shim-free — by switching to the shared PowerShell shim. + +- **A — OpenCode `execSync`s a per-OS shim** *(chosen)*. The TS plugin selects the `.sh` or `.ps1` shim by `process.platform`. Discovery logic lives only in the two per-OS shims, shared by all four agents. OpenCode adds zero new discovery code, and its Windows dependency improves from bash (not in-box) to PowerShell (in-box) — which is exactly the improvement ADR-0006's deferral was protecting. +- **B — TS-native `nvim --server` path, no shim.** OpenCode's hook entry is already a real language running in-process, so it *could* do discovery + RPC directly in TS. Rejected: this makes discovery a *third* implementation (bash + PowerShell + TS) that must track every change to the other two — precisely the divergence ADR-0006 was written to prevent, now made worse by the existence of the PowerShell impl. The fact that OpenCode *can* speak nvim directly does not mean it should own a private discovery implementation. +- **C — Full TS msgpack-rpc client.** Rejected in ADR-0006 (new runtime dependency, third protocol implementation); nothing in #46 changes that. + +## Consequences + +- The "one discovery implementation" principle becomes "one per **OS**" (bash + PowerShell), not one per agent. All four agents share both. This is the floor: Windows itself forces the second implementation, and we refuse to add a third. +- The Windows shim **never deserializes-and-reserializes the agent payload**. It parses only the shallow fields it needs (`cwd` for discovery, the tool name for the fast-path filter) with `ConvertFrom-Json`, and splices the raw stdin JSON *verbatim* into the RPC args array — exactly as the Unix shims' `jq --argjson` does. This sidesteps `ConvertTo-Json`'s default depth-2 truncation, which would otherwise silently drop deep MultiEdit/ApplyPatch structures. +- The tempfile path handed to the in-process [dispatcher](../../CONTEXT.md#dispatcher) is forward-slashed before being spliced into the `luaeval(...)` source string, because Windows backslashes are Lua escape sequences. The dispatcher (`rpc.lua`) itself is transport-agnostic and needs **no** changes — the payoff of the #47 phase-2/3 design. +- Two PowerShell 5.1 behaviours are validated by a spike before the shim is finalised: per-agent `command`-field invocation semantics (shell vs raw-exec, arguments accepted?), and native-command argument quoting of the `--remote-expr` value (5.1 lacks `PSNativeCommandArgumentPassing`). diff --git a/lua/code-preview/backends/claudecode.lua b/lua/code-preview/backends/claudecode.lua index 743bee9..e5192b5 100644 --- a/lua/code-preview/backends/claudecode.lua +++ b/lua/code-preview/backends/claudecode.lua @@ -25,6 +25,21 @@ end local HOOK_MARKER = "code-preview" local LEGACY_HOOK_MARKER = "claude-preview" -- match old entries during transition +-- 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 @@ -65,8 +80,9 @@ end function M.install() local dir = scripts_dir() - local preview = dir .. "/code-preview-diff.sh" - local close = dir .. "/code-close-diff.sh" + 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 @@ -85,14 +101,15 @@ function M.install() data.hooks.PreToolUse = remove_ours(data.hooks.PreToolUse) data.hooks.PostToolUse = remove_ours(data.hooks.PostToolUse) - -- Add our entries + -- 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. table.insert(data.hooks.PreToolUse, { matcher = "Edit|Write|MultiEdit|Bash", - hooks = { { type = "command", command = preview } }, + hooks = { { type = "command", command = hook_command(preview) } }, }) table.insert(data.hooks.PostToolUse, { matcher = "Edit|Write|MultiEdit|Bash", - hooks = { { type = "command", command = close } }, + hooks = { { type = "command", command = hook_command(close) } }, }) write_settings(path, data) diff --git a/lua/code-preview/backends/codex.lua b/lua/code-preview/backends/codex.lua index 1b1952d..c60fc9b 100644 --- a/lua/code-preview/backends/codex.lua +++ b/lua/code-preview/backends/codex.lua @@ -19,12 +19,15 @@ 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 *path fragment* so the --- check works for both the pre-hook (code-preview-diff.sh) and the post-hook --- (code-close-diff.sh) — the latter doesn't share the "code-preview" prefix. +-- 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. local HOOK_MARKERS = { - "backends/codex/code-preview-diff.sh", - "backends/codex/code-close-diff.sh", + "code-preview-diff", + "code-close-diff", } local function is_our_command(cmd) @@ -112,6 +115,14 @@ local function global_config_path() -- user's real ~/.codex/config.toml. Production callers don't set this. local override = vim.env.CODE_PREVIEW_CODEX_GLOBAL_CONFIG if override and override ~= "" then return override end + -- Codex resolves its global config dir from $CODEX_HOME (default ~/.codex). + -- Honour it so users who relocate their Codex home — more common on Windows + -- (issue #46) — get the right path. expand("~") already resolves the + -- platform home, so the fallback works on Windows too. + local codex_home = vim.env.CODEX_HOME + if codex_home and codex_home ~= "" then + return codex_home .. "/config.toml" + end return vim.fn.expand("~/.codex/config.toml") end @@ -132,7 +143,12 @@ local function ensure_executable(path) vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) return false end - vim.fn.system({ "chmod", "+x", path }) + -- 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 return true end diff --git a/lua/code-preview/backends/copilot.lua b/lua/code-preview/backends/copilot.lua index 9aa38a5..94503b9 100644 --- a/lua/code-preview/backends/copilot.lua +++ b/lua/code-preview/backends/copilot.lua @@ -22,9 +22,11 @@ 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 name — every install() invocation --- writes it verbatim, and it's specific enough that user-authored hook --- files are unlikely to collide. Guards status display and uninstall from +-- 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. function M.is_our_config(path) if vim.fn.filereadable(path) == 0 then return false end @@ -32,7 +34,7 @@ function M.is_our_config(path) if not f then return false end local content = f:read("*a") f:close() - return content and content:find("code-preview-diff.sh", 1, true) ~= nil + return content and content:find("code-preview-diff", 1, true) ~= nil end local function ensure_executable(path) @@ -40,7 +42,12 @@ local function ensure_executable(path) vim.notify("[code-preview] script not found: " .. path, vim.log.levels.ERROR) return false end - vim.fn.system({ "chmod", "+x", path }) + -- 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 return true end diff --git a/lua/code-preview/health.lua b/lua/code-preview/health.lua index 1d93961..5d5d344 100644 --- a/lua/code-preview/health.lua +++ b/lua/code-preview/health.lua @@ -8,6 +8,23 @@ function M.check() local error = h.error or h.report_error 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" + + -- Report a shim/script artifact. On Windows there is no executable bit (the + -- hook command invokes the interpreter explicitly), so readability is the + -- correct check; on Unix we additionally require the executable bit. + local function check_script(label, path) + if vim.fn.filereadable(path) == 0 then + error(label .. " not found at " .. path) + elseif is_win or vim.fn.executable(path) == 1 then + ok(label .. (is_win and " is present" or " is executable")) + else + warn(label .. " exists but is not executable (run: chmod +x " .. path .. ")") + end + end + -- ── Common ──────────────────────────────────────────────────── start("code-preview.nvim") @@ -54,11 +71,19 @@ function M.check() start("Claude Code backend") - -- jq (required by Claude Code shell hooks) - if vim.fn.executable("jq") == 1 then + -- 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 + 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)") + end + elseif vim.fn.executable("jq") == 1 then ok("jq is available") else - warn("jq not found in PATH (required by Claude Code hook scripts)") + warn("jq not found in PATH (required by the Unix hook scripts)") end -- Hook scripts executable @@ -69,36 +94,18 @@ function M.check() local bin = plugin_root .. "/bin" local claudecode_dir = plugin_root .. "/backends/claudecode" - -- Claude Code adapter scripts - for _, script in ipairs({ - "code-preview-diff.sh", - "code-close-diff.sh", - }) do - local path = claudecode_dir .. "/" .. script - if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then - ok(script .. " is executable") - elseif vim.fn.filereadable(path) == 1 then - warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") - else - error(script .. " not found at " .. path) - end + -- 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 - for _, script in ipairs({ - "nvim-socket.sh", - "nvim-call.sh", - "apply-edit.lua", - "apply-multi-edit.lua", - }) do - local path = bin .. "/" .. script - if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then - ok(script .. " is executable") - elseif vim.fn.filereadable(path) == 1 then - warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") - else - error(script .. " not found at " .. path) - 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 + check_script(stem .. shim_ext, bin .. "/" .. stem .. shim_ext) + end + for _, script in ipairs({ "apply-edit.lua", "apply-multi-edit.lua" }) do + check_script(script, bin .. "/" .. script) end -- .claude/settings.local.json @@ -169,16 +176,13 @@ function M.check() warn("copilot not found in PATH (install from https://github.com/github/copilot-cli)") end - -- Adapter scripts + -- Adapter scripts (Unix only — Copilot's Windows shim is pending, issue #46) local copilot_dir = plugin_root .. "/backends/copilot" - for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do - local path = copilot_dir .. "/" .. script - if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then - ok(script .. " is executable") - elseif vim.fn.filereadable(path) == 1 then - warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") - else - error(script .. " not found at " .. path) + 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 @@ -201,14 +205,11 @@ function M.check() end local codex_dir = plugin_root .. "/backends/codex" - for _, script in ipairs({ "code-preview-diff.sh", "code-close-diff.sh" }) do - local path = codex_dir .. "/" .. script - if vim.fn.filereadable(path) == 1 and vim.fn.executable(path) == 1 then - ok(script .. " is executable") - elseif vim.fn.filereadable(path) == 1 then - warn(script .. " exists but is not executable (run: chmod +x " .. path .. ")") - else - error(script .. " not found at " .. path) + 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 end diff --git a/lua/code-preview/log.lua b/lua/code-preview/log.lua index 13ce09a..1e507c7 100644 --- a/lua/code-preview/log.lua +++ b/lua/code-preview/log.lua @@ -15,7 +15,11 @@ local enabled = false function M.init(opts) enabled = opts and opts.debug or false if enabled then - log_file_path = vim.fn.stdpath("log") .. "/code-preview.log" + -- vim.fs.normalize keeps the separator consistent: on Windows stdpath("log") + -- is backslashed (…\nvim-data) and the "/code-preview.log" suffix would + -- otherwise leave a mixed-separator path. Normalising yields all forward + -- slashes (which io.open accepts on every OS); on Unix it's a no-op. + log_file_path = vim.fs.normalize(vim.fn.stdpath("log") .. "/code-preview.log") end end diff --git a/lua/code-preview/pidfile.lua b/lua/code-preview/pidfile.lua index 2769c22..b71665c 100644 --- a/lua/code-preview/pidfile.lua +++ b/lua/code-preview/pidfile.lua @@ -11,6 +11,19 @@ local M = {} function M.dir() + -- This path must be computed identically by the shim reader (bin/nvim-socket.sh + -- on Unix, the PowerShell shim on Windows), which has no running Neovim and so + -- cannot call stdpath(). That is why we build the path from raw env vars rather + -- than vim.fn.stdpath('state'): on Windows the two would NOT agree (stdpath + -- resolves to %LOCALAPPDATA%\nvim-data), and the shim couldn't replicate it. + -- See issue #46 / ADR-0007. + if vim.fn.has("win32") == 1 then + -- The Unix $XDG_STATE_HOME/$HOME formula yields a driveless garbage path on + -- Windows; use %LOCALAPPDATA% (always set on Windows 11, machine-local — + -- correct for per-machine named-pipe registration). + local local_appdata = vim.env.LOCALAPPDATA or "" + return local_appdata .. "\\code-preview\\sockets" + end local state = vim.env.XDG_STATE_HOME if not state or state == "" then state = (vim.env.HOME or "") .. "/.local/state" diff --git a/lua/code-preview/pre_tool/init.lua b/lua/code-preview/pre_tool/init.lua index 08b2b60..3bdf9ce 100644 --- a/lua/code-preview/pre_tool/init.lua +++ b/lua/code-preview/pre_tool/init.lua @@ -34,6 +34,18 @@ local function next_id() end local function tmpdir() + -- Windows has no /tmp, and $TMPDIR is usually unset there (it's a POSIX + -- convention); a normal user nvim would otherwise fall through to "/tmp", + -- which resolves to a nonexistent C:\tmp and makes every diff tempfile write + -- fail. Use the standard Windows temp vars, falling back to nvim's own temp + -- dir, and forward-slash it so it composes cleanly with the "/code-preview-*" + -- suffixes below (issue #46). The Unix branch is left byte-identical — the + -- macOS path and the shell E2E suite depend on $TMPDIR/"/tmp" exactly. + if vim.fn.has("win32") == 1 then + local dir = os.getenv("TMP") or os.getenv("TEMP") + or vim.fn.fnamemodify(vim.fn.tempname(), ":h") + return (dir:gsub("\\", "/")) + end return os.getenv("TMPDIR") or "/tmp" end diff --git a/tests/plugin/pre_tool_handle_spec.lua b/tests/plugin/pre_tool_handle_spec.lua index b5326da..a575278 100644 --- a/tests/plugin/pre_tool_handle_spec.lua +++ b/tests/plugin/pre_tool_handle_spec.lua @@ -33,6 +33,14 @@ describe("pre_tool.handle (Bash)", function() end) it("redirect to existing file marks bash_modified", function() + -- Skipped on Windows: this case needs to create the file on disk (io.open + -- of a /tmp path fails on Windows) AND have bash_detect resolve a Windows + -- path, which is Unix-path-only today (issue #46, handoff item 3). The + -- bash-on-Windows work will re-enable this. The new-file/rm cases above use + -- forward-slash paths that don't need an on-disk file, so they still run. + if vim.fn.has("win32") == 1 then + return pending("bash_detect is Unix-path-only on Windows (issue #46)") + end local p = "/tmp/code-preview-test-existing-" .. tostring(vim.loop.hrtime()) local fh = assert(io.open(p, "w")); fh:write("hi"); fh:close() pre_tool.handle(payload("Bash", { command = "echo x > " .. p }), "claudecode")