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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.'
6 changes: 5 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<pid>.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 <pipe> --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/<pid>`. 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/<agent>/`.

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 <path>.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

Expand Down
30 changes: 30 additions & 0 deletions backends/claudecode/code-close-diff.ps1
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 42 additions & 0 deletions backends/claudecode/code-preview-diff.ps1
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions bin/nvim-call.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
100 changes: 100 additions & 0 deletions bin/nvim-socket.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# nvim-socket.ps1 — Windows counterpart to nvim-socket.sh. Discovers the
# running Neovim's named-pipe address (\\.\pipe\nvim.<pid>.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 <addr>
# --remote-expr ...` starts a local TUI instead of acting purely as a remote
# client, and that local instance exits 0 even when <addr> 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]
}
5 changes: 5 additions & 0 deletions docs/adr/0006-opencode-defers-os-independence-to-46.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<!-- Status: superseded by ADR-0007 -->
<!-- This ADR's central prediction (OpenCode would drop bash by going shim-free in TS)
was revised by #46: OpenCode drops bash *on Windows* by switching to the shared
PowerShell shim, not by owning a TS-native discovery path. See ADR-0007. -->

# 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?
Expand Down
Loading
Loading