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
10 changes: 5 additions & 5 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,11 @@ jobs:
# 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' }
# The shell detector is now Windows-aware (issue #46 follow-up):
# pre_tool_shell_detect_spec.lua (renamed from *_bash_detect_spec) runs
# its Windows-path + PowerShell rows here and marks the POSIX rows
# pending, so the whole tests/plugin directory runs on Windows.
$specs = Get-ChildItem tests/plugin -Filter *_spec.lua
$failed = @()
foreach ($spec in $specs) {
Write-Host "-- $($spec.Name) --"
Expand Down
26 changes: 26 additions & 0 deletions docs/adr/0009-shell-detect-split-axes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Shell write/delete detection splits path-convention from command-grammar

Status: accepted

Windows support (issue #46) needs the Tier-1 shell-write/delete detector (`pre_tool/shell_detect.lua`, renamed from `bash_detect.lua`; it feeds the [change](../../CONTEXT.md#change) [status](../../CONTEXT.md#status) values `deleted` / `bash_modified` / `bash_created`) to work on Windows. The obvious framings — "make it per-OS" or "add a Windows branch" — are both wrong, because the detector entangles **two independent axes that do not align onto OS**: *path conventions* (`/`-absolute, `~/`, `/dev/`) and *command grammar* (which verbs/operators write or delete). A git-bash shell on Windows has POSIX grammar with Windows-shaped paths; a PowerShell shell has PowerShell grammar with Windows paths — so OS is not the seam.

We therefore split the two axes: rename `bash_detect` → `shell_detect` behind the unchanged `M.detect(cmd, cwd)` interface; extract an OS-selected **path-convention adapter** (Unix, byte-identical to the historical behaviour, plus Windows: `C:\`/`C:/`, UNC `\\…`, backslash separators, relative-against-a-backslash-cwd); and add a **PowerShell command grammar** alongside the POSIX one. Both grammars run on every command — they share the redirect operator and differ only in verbs, and the PowerShell tables deliberately exclude the bash aliases (`rm`/`cp`/`mv`/`tee`), so adding PowerShell provably cannot change a POSIX result.

The PowerShell grammar was **informed by an empirical finding, not guessed** — the same discipline [ADR-0007](0007-windows-shim-via-shared-powershell-discovery.md) forced for the RPC layer. Ground truth (raw hook stdin teed on a Windows box, cross-checked against Claude Code session transcripts) corrected two premises we held going in:

- **It is a separate tool, not the Bash tool emitting PowerShell.** Claude Code on Windows exposes a distinct **`PowerShell`** tool alongside `Bash` and routes shell file ops through it (the Haiku model deletes via `Remove-Item`, moves via `Move-Item`, writes via `Set-Content`/`Out-File`/`Add-Content`); `tool_name` is `"PowerShell"` with a Bash-shaped `{command}` payload. (Opus, by contrast, drives git-bash and emits POSIX `rm` with Windows-shaped paths — exactly the cross-cutting case the axis split predicted.)
- **Routing was therefore not "fine"** — the detector was not the only gap. The PreToolUse matcher was `Edit|Write|MultiEdit|Bash`, so the hook never fired for the `PowerShell` tool. The full fix is three layers: **matcher** (add `PowerShell` to the claudecode Pre/PostToolUse matchers so the hook fires), **normaliser** (fold `tool_name="PowerShell"` onto canonical `Bash`, so the dispatcher/emitters and `shell_detect` need no awareness of a separate tool), and the **detection split** itself.

## Considered Options

- **Per-OS `shell_detect` (grammar tables keyed by OS)** — rejected: cuts along the wrong axis. OS conflates path conventions and command grammar, and the git-bash-on-Windows case (POSIX grammar, Windows paths) breaks the mapping outright.
- **"Just a Windows branch"** — rejected: scatters OS conditionals through a delicate, edge-case-heavy module (its cases come from real historical bugs) along that same wrong axis.
- **Split the two axes** *(chosen)* — path conventions become an OS-selected adapter; command grammars are added by vocabulary, independent of OS.

## Consequences

- The path-convention adapter is the highest-value extraction and is needed whatever shell the agent uses (Windows paths show up even under git-bash). The Windows adapter emits canonical backslash paths, because `fnamemodify(":p")` is a no-op on an already-absolute Windows path, so the detector's output must already match the form Edit/Write key the changes registry with — otherwise the neo-tree indicator keys a different entry and misses.
- POSIX behaviour stays byte-identical through the restructure; `pre_tool_shell_detect_spec.lua` (renamed alongside the module) is the safety net, with POSIX rows and Windows rows each marked `pending` on the other OS.
- `M.detect(cmd, cwd)` stays stable, so callers (`pre_tool.handle`) are untouched.
- **Scope honesty:** the PowerShell vocabulary is table-shaped (verb→category tables), but the POSIX matchers remain the original per-verb functions rather than one unified grammar table. "A second grammar slots in as data" is met functionally — PowerShell was added without disturbing POSIX — not as a single shared table; a full POSIX grammar-table refactor was not needed to land Windows and stays out of scope. The broader integration-registry / install-engine consolidation from the same architecture review is likewise deferred.
- This refines [ADR-0007](0007-windows-shim-via-shared-powershell-discovery.md)'s "one implementation per OS" principle for the *detection* layer: detection is split by *capability axis*, not by OS.
14 changes: 12 additions & 2 deletions lua/code-preview/backends/claudecode.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ 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
-- Haiku model, which deletes via `Remove-Item`. Without `PowerShell` in the
-- matcher the PreToolUse hook never fires for those proposals, so a shell
-- delete/write never marks neo-tree (issue #46 follow-up). The normaliser
-- folds `PowerShell` into the canonical `Bash` path; here we just make sure
-- 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
Expand Down Expand Up @@ -104,11 +114,11 @@ function M.install()
-- 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",
matcher = TOOL_MATCHER,
hooks = { { type = "command", command = hook_command(preview) } },
})
table.insert(data.hooks.PostToolUse, {
matcher = "Edit|Write|MultiEdit|Bash",
matcher = TOOL_MATCHER,
hooks = { { type = "command", command = hook_command(close) } },
})

Expand Down
7 changes: 4 additions & 3 deletions lua/code-preview/diff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ local function mark_change_and_reveal(abs_file_path, action)
local reveal_dir = nil
if cfg.neo_tree.reveal_root == "git" then
local parent = vim.fn.fnamemodify(abs_file_path, ":h")
local git_out = vim.fn.systemlist(
"git -C " .. vim.fn.shellescape(parent) .. " rev-parse --show-toplevel 2>/dev/null"
)
-- List-form (no shell): avoids the POSIX-only `2>/dev/null` redirect, which
-- misbehaves under Windows cmd. `shell_error` still gates the result, so a
-- non-repo parent (git's stderr + non-zero exit) simply leaves reveal_dir nil.
local git_out = vim.fn.systemlist({ "git", "-C", parent, "rev-parse", "--show-toplevel" })
if vim.v.shell_error == 0 and git_out[1] and git_out[1] ~= "" then
reveal_dir = git_out[1]
end
Expand Down
254 changes: 0 additions & 254 deletions lua/code-preview/pre_tool/bash_detect.lua

This file was deleted.

4 changes: 2 additions & 2 deletions lua/code-preview/pre_tool/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ local M = {}

local normalisers = require("code-preview.pre_tool.normalisers")
local emitters = require("code-preview.pre_tool.emitters")
local bash_detect = require("code-preview.pre_tool.bash_detect")
local shell_detect = require("code-preview.pre_tool.shell_detect")

local apply_edit = require("code-preview.apply.edit")
local apply_multi_edit = require("code-preview.apply.multi_edit")
Expand Down Expand Up @@ -167,7 +167,7 @@ end

local function handle_bash(input)
local cmd = input.tool_input.command or ""
local detected = bash_detect.detect(cmd, input.cwd or "")
local detected = shell_detect.detect(cmd, input.cwd or "")

local touched = false

Expand Down
23 changes: 22 additions & 1 deletion lua/code-preview/pre_tool/normalisers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,27 @@ local function identity(raw)
return raw
end

-- Claude Code's hook payload is already canonical ({tool_name, cwd, tool_input}),
-- so its normaliser is almost identity. The one translation is Windows-only: in
-- addition to `Bash`, Claude Code exposes a distinct `PowerShell` tool and routes
-- shell file ops through it (e.g. the Haiku model deletes via `Remove-Item …`,
-- moves via `Move-Item`, writes via `Set-Content`/`Out-File`). Semantically those
-- are shell proposals — Tier-1 indicator-only, `bash_*` origin prefix — identical
-- in handling to `Bash`. Folding `PowerShell` onto the canonical `Bash` tool here
-- means the dispatcher, emitters, and shell_detect need no awareness of a separate
-- tool: shell_detect's grammar is what tells PowerShell and POSIX commands apart.
-- (STEP 0 of the shell-detect work confirmed tool_name="PowerShell"; the hook
-- matcher gained `PowerShell` so the proposal reaches us at all.)
local function claudecode(raw)
if raw and raw.tool_name == "PowerShell" then
local out = {}
for k, v in pairs(raw) do out[k] = v end
out.tool_name = "Bash"
return out
end
return raw
end

-- OpenCode tools as of 2026-05-19: edit, write, multiedit, bash, apply_patch
-- (plus read, glob, grep, which the TS-side allowlist filters out before they
-- ever reach this normaliser). Update this map when OpenCode adds a tool the
Expand Down Expand Up @@ -234,7 +255,7 @@ local function codex(raw)
end

M.normalisers = {
claudecode = identity,
claudecode = claudecode,
opencode = opencode,
copilot = copilot,
codex = codex,
Expand Down
Loading
Loading