Verify Claudecode Nvim
DSecurityDrive the real claudecode.nvim plugin in a real Neovim (an agent-tty terminal) while a scripted WebSocket client (scripts/ws-client.mjs) plays the Claude Code CLI over the plugin's WebSocket MCP server, and capture screen text, PNG screenshots, WebM video, JSON-RPC traffic, and file state as evidence. Use to prove a claudecode.nvim feature works end to end (openDiff accept/reject, openFile, selection tracking, :ClaudeCodeSend at-mentions, closing diffs, the Claude terminal commands, the edito...
- 3,158 stars
- 0 votes
- 0 copies
- 0 views
- Added November 9, 2026
Works with
Security analysis
52/100- Performs destructive filesystem operations
- Sends environment variables or credentials to an external URL
- Installs packages at runtime which could introduce malicious dependencies
Pro scans all 12 files and shows the line behind each finding
npx -y skills add coder/claudecode.nvim --skill verify-claudecode-nvim --agent claude-codeAre you the author of Verify Claudecode Nvim?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/coder-verify-claudecode-nvim)---
name: verify-claudecode-nvim
description: Drive the real claudecode.nvim plugin in a real Neovim (an agent-tty terminal) while a scripted WebSocket client (scripts/ws-client.mjs) plays the Claude Code CLI over the plugin's WebSocket MCP server, and capture screen text, PNG screenshots, WebM video, JSON-RPC traffic, and file state as evidence. Use to prove a claudecode.nvim feature works end to end (openDiff accept/reject, openFile, selection tracking, :ClaudeCodeSend at-mentions, closing diffs, the Claude terminal commands, the editor-state tools, server start/stop) without the claude CLI or network. Also use it to reproduce a bug report, and for any change that can raise a prompt or change the editor mode.
---
# Verify claudecode.nvim
The user surface is Neovim plus the Claude Code CLI. This skill keeps Neovim real: an [agent-tty](https://github.com/coder/agent-tty) terminal session, keystrokes through `agent-tty type` and `send-keys`, state read over Neovim RPC. Only the CLI is replaced, by `scripts/ws-client.mjs`, at the boundary the CLI uses: it reads the lock file the plugin writes (the token stays out of argv), sends the `x-claude-code-ide-authorization` header, and speaks MCP JSON-RPC.
Each run gets its own HOME, XDG dirs, `CLAUDE_CONFIG_DIR`, agent-tty home, and nvim socket. Runs never touch the user's Neovim or `~/.claude/ide`, and two runs can go side by side.
Needs:
- `nvim` 0.12.3, `jq`, and Node 26, all from `mise install`. `VCN_NVIM` runs another Neovim, 0.8 or newer (see Launch).
- `agent-tty` 0.5.0 (`npm install -g agent-tty`). It needs Node 24 to 26, so the harness runs it on the repo's mise Node. Launch keeps the first agent-tty that passes `agent-tty doctor` (see `VCN_AGENT_TTY`).
- Playwright Chromium for PNG and WebM: `npx playwright install chromium`.
- `setsid` from util-linux. Launch resolves `jq` and `setsid` once, through mise first, and every call uses that path, so a failing `jq` or `setsid` earlier on `PATH` does not break the run or its teardown.
- `perl`, for `scripts/proof-agent-tty-resolution.sh`. It resets SIGINT and SIGHUP before it starts launch, because bash cannot trap a signal that was ignored at entry.
Run every command from the repo root. Read [features/README.md](features/README.md) before driving: it is the map of what to verify and how.
```bash
V=.agents/skills/verify-claudecode-nvim/scripts/vcn.sh
```
## Launch
```bash
$V launch # opens hello.txt; `$V launch other.txt` opens the other seed file
```
Launch prints two lines, for example:
```text
VCN_STATE=/tmp/vcn.AbC123
OUT=/home/coder/.xum/sessions/<ws>/scratch/verify-claudecode-nvim/20261031T120000Z-AbC123
```
Every bash call is a fresh shell. Start each later call with the `VCN_STATE` value that launch printed, in single quotes because a path can hold a space or an apostrophe (`TMPDIR` and `VERIFY_OUT` allow both): `export VCN_STATE='/tmp/vcn.AbC123'`. Inside the quotes, write each `'` of the path as `'\''`. A path can hold a newline too, and launch then prints it over two lines: copy all of it, inside the quotes. Use a new request id for every `rpc` or `tool` call, because the harness refuses an id the run already used. Do not `source` `$VCN_STATE/run.env` or `$OUT/run.env` in your own calls: bash runs every line of a file it sources, as you. `launch` appends version lines such as `agent-tty 0.5.0` to `$OUT/run.env`, and bash would run those as commands. Read a path of the run with `get -z`, which keeps a newline at the end of the path that a command substitution would cut: `V=.agents/skills/verify-claudecode-nvim/scripts/vcn.sh; getz() { IFS= read -r -d '' "$1" < <("$V" get -z "$1") || { echo "vcn.sh get $1 failed" >&2; return 1; }; }; getz OUT && getz WORK || exit 1`. When `get` refuses, `getz` prints `vcn.sh get <NAME> failed` and the call stops with status 1, so no later command of the call runs. A value that holds no newline, such as `PORT` or `SID`, can be read through `$($V get PORT)`. `get` answers only a name that `run.env` holds, parses no printed text, and runs the check that every `vcn.sh` command makes of `$VCN_STATE` before it sources `run.env` (it must be its own resolved path and a directory that this user owns, with no group or other write, and no dir above it may be owned by another user (root is fine) or writable by others without the sticky bit). The five `journey-*.sh` scripts read `OUT` and `WORK` through `get`, so they make that check too, and an `OUT` that you exported does not change where they write. Export a `VCN_STATE` that your own `launch` printed, never one from an earlier or another session. Get the other values from the run:
- `WORK` is `$VCN_STATE/work`, where `hello.txt` and `other.txt` live.
- `$V doctor` prints the agent-tty session id (`SID`) in its `agent-tty session <SID> is running` line and the port of the plugin server (`PORT`) in its `plugin server is running on lock port <PORT>` line. The port is also the name of the lock file, `$VCN_STATE/claude/ide/<port>.lock`, so `PORT=$(basename "$VCN_STATE"/claude/ide/*.lock .lock)` gives it while the server runs.
- `$V expr 'getpid()'` prints the nvim pid (`NVIM_PID`).
Launch first runs `agent-tty doctor` and saves it to `$OUT/agent-tty-doctor.json`. Launch keeps the first agent-tty that passes (see `VCN_AGENT_TTY`). When none passes, it stops and names each candidate and its failed check, such as the Node version, `pty.node`, or the Chromium launch. Launch then returns only when the run is usable: nvim answers RPC, the plugin wrote `<port>.lock` naming the nvim pid, the client finished the handshake, and the MCP `initialize` call answered with a result. If a step misses its 10 s deadline, or `initialize` answers without a string `result.protocolVersion`, launch saves `$OUT/launch-failure.screen.txt`, tears the run down, and exits 1. Once launch has made its state dir, SIGTERM, SIGINT or SIGHUP tears the run down too, and launch exits 143. That teardown saves no evidence (no final screen, PNG or WebM), because those take long on a loaded host. Launch picks it by whether a signal reached launch, not by the status 143. A launch that fails by itself takes the full teardown and saves its evidence, and so does a launch whose child someone else killed with SIGTERM, because no signal reached launch. Its exit status is 143 when launch ran the killed command itself (the `cp` of `run.env`), and 1 when the killed command is an agent-tty call that launch checks, such as `create`. A signal that reaches launch after the teardown of a failure of its own has started changes nothing: launch exits 1 and keeps the evidence, the saved failure screen included. A signal that reaches launch before that teardown has started, even in the microseconds after the failure, counts as a signal: the signal trap runs the fast teardown itself, and launch exits 143 with its `vcn: launch failed` line. A teardown ignores a second signal until it ends. Launch's own shell ignores it, but Node resets an ignored signal when it starts, so a signal sent to launch's process group would kill `agent-tty home forget` and leave the home in `~/.local/state/agent-tty/homes.json`. The teardown therefore starts every agent-tty call it makes (the evidence steps, `destroy` and `home forget`) in its own session (`setsid`), where that signal does not reach it. It also removes the socket directory `/tmp/agent-tty/<8 hex digits of the sha256 of the real path of $VCN_STATE/att>` that agent-tty makes at its first call, even when the signal came before `inspect` named it. agent-tty hashes the real path of its home. `$VCN_STATE` is its own real path (launch resolves `TMPDIR`, and `load` refuses a `VCN_STATE` that is not), so the cleanup hashes `$VCN_STATE/att` as it is. A signal before launch sets its first trap ends launch with the default status (130 for SIGINT, 129 for SIGHUP, 143 for SIGTERM) and leaves nothing behind. A signal from then until the state dir exists is recorded: launch removes the dir and exits 143 for all three. Launch prints `VCN_STATE=` before it stops handling signals, so a signal never leaves a running run whose name was not printed.
- Seed files in `$WORK` (nvim's cwd and the workspace folder): `hello.txt` is `alpha`, `beta`, `gamma`. `other.txt` is `one`, `two`, `three`, `four`.
- The terminal is 160 columns by 40 rows.
- `VERIFY_OUT=<dir>` sets the evidence dir. It must be missing or an empty directory, and its path must not have a `.` or `..` part. Launch refuses it if it is a symlink, is not a directory, is owned by another user, or is group- or world-writable, or if a dir above it is unsafe (see Evidence). It also refuses a path that `mkdir` cannot create. When another launch makes a missing dir above the evidence dir first, launch uses that dir, and the check after `mkdir` decides whether it is safe. When it refuses a dir after `mkdir`, it runs `rmdir` on each dir that it made itself. That never deletes a file, a non-empty dir, or a dir that another process made. Launch creates a missing evidence dir, and each missing dir above it, with mode `0700`, so a default ACL on a parent never gives the group access to them. It removes any ACL, default ACLs included, from the evidence dir and from each dir above it that it made, with `setfacl -b -k`, and makes the evidence dir `0700`, and it refuses the dir when a new file there still does not get mode `0600`, for example under a default ACL with no working `setfacl`. A relative path is taken from the current directory and recorded as an absolute `OUT`.
- `VCN_AGENT_TTY=<path>` runs that agent-tty, and only that one. Without it, launch runs `agent-tty doctor` on each `agent-tty` on `PATH`, then on `mise which agent-tty`, and keeps the first that passes, so an agent-tty on `PATH` wins over mise's. A mise-installed `npm-agent-tty` can lack node-pty's native build, and then every command dies with `Failed to load native module: pty.node`. When no candidate passes, launch exits 1 with one message that lists each candidate's failure and the fix, which leaves mise's install alone: `npm install --prefix <dir> agent-tty@0.5.0`, then `VCN_AGENT_TTY=<dir>/node_modules/.bin/agent-tty $V launch`. That is enough: with `VCN_AGENT_TTY` set, launch never looks at `PATH` or mise for agent-tty, so mise's broken copy does not matter and `MISE_DISABLE_TOOLS` is not needed.
- `VCN_NVIM=<path>` runs that Neovim binary instead of the repo's mise `nvim`, for example a release tarball's `bin/nvim` to check Neovim 0.8. The harness uses it for its RPC calls too. On Neovim 0.8, one process is both the TUI and the server that answers RPC, so doctor checks that the server pid is the session's TUI pid. Neovim 0.8 prints a `--remote-expr` result on stderr, so on 0.8 `$V expr` prints an error message as its output, where 0.9+ prints it on stderr.
- CI does not run the harness, on 0.8 or any other version. A run needs `agent-tty` from npm and Playwright Chromium (about 400 MB unpacked), and CI installs neither. Node is not the obstacle: the `unit-tests` job already gets the repo's Node 26 from mise. The two would add minutes and two more downloads to every PR. CI's `integration-tests (v0.8.0)` job already runs the plugin's integration tests on Neovim 0.8. After a change to `vcn.sh`, check the 0.8 path by hand: do the full run at the end of this file, with `VCN_NVIM=<0.8 nvim> $V launch` as its launch step.
- `VCN_RTP=<dir>[:<dir>...]` adds plugin dirs to `runtimepath` after this checkout. A `terminal.provider = "snacks"` run needs a snacks.nvim clone there, because the harness installs no plugin. Pin the clone as [features/send-at-mention.md](features/send-at-mention.md) shows. A relative entry resolves against the directory you launch from, because nvim runs in `$WORK`, and symlinks are followed. Launch exits 1 for an entry that is not a directory, so a typo cannot pass as a snacks run on a native terminal. A float run is in [features/terminal-float-diff.md](features/terminal-float-diff.md).
- `VCN_INIT_JSONRPC=<version>` sets the `jsonrpc` field of the `initialize` request (default `2.0`). Only `scripts/proof-launch-refusals.sh` sets it, to get an error answer from the plugin.
- `TMPDIR=<dir>` makes launch put its state dir, `vcn.XXXXXX`, in `<dir>` instead of `/tmp`. The path must be absolute, name an existing directory (a missing one could be made by another user after the checks) and have no `.` or `..` part (one after a symlink would hide the symlink from the checks). Launch resolves it once, with `realpath -e`, and gives `mktemp` only the result, so `VCN_STATE` has no symlink in it. Launch refuses a `TMPDIR` that another user could write, and it checks two paths: the path as you give it (the symlink itself, if it is one, and every dir above it) and the resolved path. In each, it refuses a dir or symlink that another user owns (root is fine) and a dir that is group- or world-writable without the sticky bit. The check of the path as given is the rule that main applied. `vcn.sh` never gives `agent-tty` your `TMPDIR`: every `agent-tty` call that `vcn.sh` makes runs with `TMPDIR=$VCN_STATE/tmp`, a dir that launch makes in the state dir (mode `0700`), so a symlink in your `TMPDIR` that another user repoints after the launch cannot redirect a WebM export or a screenshot, and a command can run with any `TMPDIR`. A `TMPDIR` that is a symlink to a second symlink is accepted when both paths pass, because nothing follows the symlinks after launch has resolved them: `mktemp` gets the resolved path. `/tmp` is sticky, so the default passes. Keep it short: nvim's socket, `nvim.sock`, lives in the state dir, and a Unix socket path is limited to 108 bytes. Neovim 0.12.3 accepts a longer path. Older versions are untested. `agent-tty` keeps every socket in the fixed dir `/tmp/agent-tty`, whatever `TMPDIR` says, and does not check who owns it, so another user who owns that dir or can rename entries in it could swap the socket dir of a run and answer every command. Launch makes `/tmp/agent-tty` with mode `0700` when it is missing, and refuses one that is a symlink, is owned by another user or is group- or world-writable (a `0755` dir of yours is fine, and a `0775` dir that a plain `agent-tty` run made under `umask 002` is refused: the message names `chmod go-w /tmp/agent-tty`). `cleanup` accepts a state dir from any `TMPDIR`: an absolute path that ends in `/vcn.` and six letters or digits. Every `vcn.sh` command that reads a run first checks `$VCN_STATE`, because it sources `run.env` from it (the journeys read through `get`, so they check it too): `$VCN_STATE` must be its own resolved path (no symlink, `.` or `..` part, as launch prints it) and a directory that this user owns with no group or other write, and no dir above it may be owned by another user (root is fine) or writable by others without the sticky bit. A dir or symlink that another user made at the name of a cleaned-up run fails that check. There is no separate check of the file: after the check of the dir, no other user can swap it or anything in it. A run that an older `vcn.sh` started has no `JQ_BIN` in `run.env`, so the command resolves `jq` again. `scripts/proof-agent-tty-resolution.sh` runs every launch with a private `TMPDIR`.
- `VCN_SETUP='<json>'` is deep-merged into `require("claudecode").setup()` over the base `{ terminal = { provider = "none" } }`. Example: `VCN_SETUP='{"diff_opts":{"layout":"unified"}}' $V launch`.
- For a `log_level = "debug"` run, launch with `VCN_SETUP='{"log_level":"debug"}' $V launch --cmd 'set messagesopt=wait:0,history:10000' hello.txt`. Without it, one openDiff call at debug level parks Neovim at `Press ENTER`, and RPC stops answering. Launch arguments replace the default file, so name `hello.txt`. Neovim 0.8 has no `messagesopt`, so plain `set` fails launch with E518. Use `--cmd 'silent! set messagesopt=wait:0,history:10000'` there. Launch then works, but debug echoes still stop at `Press ENTER` (see Doctor).
## Doctor
```bash
$V doctor
```
Read-only. Prints one `ok` or `FAIL` line per check and exits 1 on any `FAIL`: agent-tty session running, nvim server running under the session's TUI process, the `ws-client.mjs` client alive, RPC answers as the recorded pid, nvim not stuck in a prompt, lock file names that pid, plugin server running on the lock port, lock `authToken` matches the server token (compared by sha256), a client finished the handshake. Run it first whenever a step misbehaves.
- A `FAIL` on the client or handshake line means the client died. Clean up and relaunch, because a new client cannot receive answers to calls the old one made.
- A `FAIL` on the RPC line usually means nvim waits at `Press ENTER`, where it answers no RPC. `$V expr` then prints `vcn: nvim RPC gave no answer in 5s`. `$V capture blocked` shows the message. Keep that capture as evidence, then `$V keys Enter` dismisses it.
## Drive
The user acts through keys. Claude acts through tool calls. Read state only to observe.
- **User.** `$V keys <arg>...` sends input to nvim, in order, as one `agent-tty batch`:
| Argument | agent-tty step | Examples |
| -------------------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------------------------- |
| A key name: `Enter`, `Escape`, `Tab`, `Backspace`, `Space`, arrows, `Home`, `End`, `PageUp`, `F1`-`F12`, or `Ctrl+`/`Alt+` a key | `send-keys` | `Enter`, `Escape`, `Ctrl+W`, `Up` |
| Any other argument | `type`, verbatim | `':w'`, `gg`, `cc`, `V` |
| Every argument after `-l` | `type`, verbatim | `-l 'Enter the name'` |
So `$V keys ':3' Enter cc` types `:3`, presses Enter, and types `cc`. Key names match case-sensitively: `Enter` is a key, `enter` is text. tmux names such as `C-w` or `BSpace` are typed as text, so write `Ctrl+W` and `Backspace`.
- **Claude.** `$V tool <id> <toolName> '<arguments json>'` sends `tools/call`. `$V rpc <id> <method> '<params json>'` sends any request, for example `$V rpc t1 tools/list`. `$V await <id> [secs]` prints that response line or exits 1 after the timeout (default 10 s). Build arguments with `jq -nc --arg ...` so paths and newlines stay valid JSON.
- **A second Claude.** `VCN_CLIENT=b $V connect` starts one more client, named `b`, and finishes its handshake. Put `VCN_CLIENT=b` before `rpc`, `tool`, or `await` to act as `b`. Its messages land in `$OUT/ws-out-b.jsonl` and `$OUT/ws-in-b.jsonl`, and request ids are per client. `$V disconnect` kills the launch client by its recorded PID, the way an exiting Claude CLI drops its socket. `VCN_CLIENT=b $V disconnect` kills `b`.
- **Notifications.** The plugin pushes `selection_changed` and `at_mentioned` to the client. They land in `$OUT/ws-in.jsonl`: `jq -c 'select(.method == "at_mentioned")' "$OUT/ws-in.jsonl"`.
- **Observe.** `$V expr '<vimscript>'` evaluates over RPC. `$V until '<expr>' '<want>' [secs]` waits for a state instead of sleeping. Examples: `$V until "bufname() =~# ' (proposed)$'" 1`, `$V expr "join(getline(1, '\$'), '|')"`, `$V expr 'luaeval("vim.json.encode(require(\"claudecode.server.init\").get_status())")'`.
- **Capture.** `$V capture <name>` writes the screen text to `$OUT/<name>.screen.txt` and prints it. `$V screenshot <name>` writes `$OUT/<name>.png`. `$V record <name>` writes `$OUT/<name>.webm`, a video of the whole session from launch to now, sped up. All three first wait up to 5 s for the screen to stay unchanged for 300 ms.
Rules:
- Change state only through keys or tool calls. Calling plugin Lua over RPC to change state skips the path you are proving.
- openDiff answers only after the user acts. `$V await <id> 2` exiting 1 before the action is the proof that it blocks.
- After `Escape`, wait for `$V until 'mode()' n` before typing a `:` command.
- After `FILE_SAVED`, Claude writes the file itself: write `result.content[1].text` to disk, then call `close_tab`. The plugin never writes it.
- `$V until` confirms nvim state, not the screen. Use `$V capture` or `$V screenshot`, which wait for the screen, when the evidence is what the user sees.
- To watch live, a human runs `$V dashboard` in a terminal, with `VCN_STATE` exported. The dashboard is read-only and leaves the session size alone.
- agent-tty answers no DA1 (`ESC[c`) query. Neovim 0.12's TUI stops a `:suspend` only when the terminal answers its DA1 query, so in this harness `:suspend` and Ctrl-Z fire `VimSuspend` and detach the UI, but the TUI process never stops (state `S`, not `T`) and the screen stops updating while nvim keeps taking keys. Count `VimSuspend` events, and read buffer text through `$V expr`, instead of watching the process state or the screen.
## Evidence
Everything lands in `$OUT`, which cleanup never deletes. The default is `${XUM_SCRATCH_DIR:-/tmp}/verify-claudecode-nvim/<UTC time>-<id>`. The harness runs with `umask 077`, so `$OUT` is `0700` and its files are `0600`: they hold buffer text and accepted diffs. A default ACL on `$OUT` would override the umask, so launch removes it first (see `VERIFY_OUT`). Launch refuses a default parent dir (`${XUM_SCRATCH_DIR:-/tmp}/verify-claudecode-nvim`) that is a symlink, is owned by another user, or is group- or world-writable. It also refuses either dir when a dir above it, as given or as resolved, is owned by a user other than you or root, or is writable by others without the sticky bit. On a shared host, set `VERIFY_OUT` to a dir you own.
- `ws-out.jsonl` holds every JSON-RPC message the client sent. `ws-in.jsonl` holds every message it received, responses and notifications. A client from `connect` writes `ws-out-<name>.jsonl`, `ws-in-<name>.jsonl`, and `ws-client-<name>.log`.
- `<name>.screen.txt`, `<name>.png`, and `<name>.webm` come from `$V capture`, `$V screenshot`, and `$V record`.
- `run.env` records paths, PIDs, port, session id, and the nvim and agent-tty versions. It holds no auth token. `agent-tty-doctor.json` and `ws-client.log` sit next to it.
- Cleanup adds `final.screen.txt`, `final.png`, `messages.txt` (`:messages`), and `session.webm`.
- The `⧉` in diff tab names draws as an empty box in PNG and WebM, because neither agent-tty's fonts nor this host's fonts have that glyph. The `.screen.txt` file holds the real character.
A proof:
- Captures the screen text and a PNG before and after each user action, plus the response JSON, and ends with a WebM.
- Checks side effects directly: `sha256sum "$WORK/<file>"` before and after, buffer text through `$V expr`.
- Replaces nothing but the Claude CLI, and only at its WebSocket boundary. No stubbed plugin modules, no internal setters.
- Names the feature ID from `features/` and the entry point (key, command, or tool) next to each artifact it cites. A path that was not driven is reported as not verified, never covered by a different entry point.
- Proves a change that can raise a prompt or change the editor mode in a live run. After the call, it types the user's next keys with `$V keys` and checks the buffer text. A headless `nvim -u NONE` run answers a file-changed dialog at once with its default choice and hides the bug. Before #408, headless openFile kept the user's unsaved text, while live the W13 dialog (the file appeared after its buffer was opened) held the reply and the user's next `l` loaded the file over that text.
## Cleanup
```bash
$V cleanup
```
Cleanup saves the final screen text, a PNG, `:messages`, and a WebM of the whole session into `$OUT`. Then it destroys the agent-tty session, which ends nvim, and stops the client by its recorded PID. It waits for every process tagged `VCN_RUN_TAG=$VCN_STATE` to exit, and after 3 s kills only those. Then it drops the run's agent-tty home from the per-user home registry, removes its empty socket directory under `/tmp/agent-tty`, deletes `$VCN_STATE`, and leaves `$OUT` alone. It takes 10 to 15 s. Rerunning it prints `nothing to clean`. Run it after every failed iteration too.
To confirm, `grep -lzxF "VCN_RUN_TAG=$VCN_STATE" /proc/[0-9]*/environ 2>/dev/null` prints nothing (the `2>/dev/null` hides other users' unreadable processes). Never `pkill` by name or by `-f` pattern: other agents' nvim and agent-tty hosts run on the same host, and a `-f` pattern can match your own shell.
## Helpers
- `scripts/vcn.sh` is the harness. `$V help` lists its subcommands.
- `scripts/init.lua` is the nvim config. It puts this checkout first on `runtimepath`, appends each `VCN_RTP` dir, sets `terminal.provider = "none"` so no `claude` CLI starts, and merges `VCN_SETUP`.
- `scripts/proof-agent-tty-resolution.sh` proves how launch picks an agent-tty and loads `VCN_RTP`, with stand-ins for a broken agent-tty and for mise: launch skips an agent-tty that fails `agent-tty doctor`, `VCN_AGENT_TTY` wins, launch exits 1 with the fix message when none passes, `require()` finds a module in each `VCN_RTP` dir, a `run.env` without `JQ_BIN` still runs `jq`, a `run.env` whose `NODE_DIR` is empty, relative or has a colon is refused, `cleanup` included, which names the `rm -rf` of the state dir for stopping the run by hand (an absolute `NODE_DIR` that holds no node is not refused, so `cleanup` can still run), a `VCN_STATE` that is not its own resolved path (a `.` or `..` part, a symlink, a trailing `/`, `/.` or `//`), is group-writable, sits under a dir that others can write (also when the dir's name ends in a space, a tab or a newline), belongs to another user, or sits under a dir that another user owns, is refused and its `run.env` is not sourced, and a `state_checked` in the environment does not skip that check (the two refusals for another user's dir run in a `bwrap` user namespace and print `SKIP` where `bwrap` is missing, cannot build the namespace, or no root-owned `0755` dir (`/srv`, `/opt`, `/usr` or `/etc`) exists for the top layer of its overlay), a state dir or a dir above it that only the group, or only others, can write is refused, a walk that gets one `stat` record too few refuses the state dir, a state dir whose name ends in a newline is accepted, a symlink in `TMPDIR` does not leave the socket dir, `record` writes none of another user's bytes after a second symlink of `TMPDIR` is repointed, `get` answers the values of a hand-made `run.env` exactly (a path with a space, an equals sign, quotes and a newline, at its end too with `-z`) and refuses a name that `run.env` does not hold, `get` and each of the five journeys refuse a state dir that others can write before they source its `run.env`, `journey-opendiff-accept.sh` writes all its files into the `OUT` of its run for a `VERIFY_OUT` with ` OUT=` in it, a newline in it or at its end, and for an `OUT` that the caller exported (`journey-out`), `$V dashboard` shows the running session in a terminal (`script(1)` stands in for one, and the case prints `SKIP` without it), a command run with a `TMPDIR` that does not exist still works, and `record` on a run without `$VCN_STATE/tmp` makes the dir (mode `0700`) and writes a WebM, SIGTERM to a running launch leaves no state dir or process, a second signal does not stop the teardown (a Node stand-in for `agent-tty home forget` finishes), a storm of signals leaves no run behind, the socket directory that agent-tty made is removed, a child killed from outside gets the full teardown, a signal during the failure screen of a launch that failed by itself leaves exit 1 and a screen with text, a signal before the teardown of such a failure has started gets the fast teardown, `cleanup` removes a run that has no agent-tty, the proof never reads or cleans a `/tmp/vcn.*` dir that it did not start, because it runs every launch with a private `TMPDIR`. It starts real runs, so it needs everything under Needs. It prints `ok <case>` per case and ends with `CASES <n>`, or prints `FAIL <case>: <reason>` and exits 1.
- `scripts/proof-static.sh` proves five things by reading the skill's own files, with no run and no `agent-tty`. Each `ok` line says only what the scanner sees. `vcn.sh` has no code line that names `jq` or `setsid` as a bare call or as a default (`${JQ_BIN:-jq}`), apart from the two resolutions. Every code block, and the inline code of each doc taken together, in `SKILL.md` and `features/*.md` that expands `PORT`, `SID`, `NVIM_PID` or `NODE_DIR` with a `$` (also in a `$(( ))` that holds no parenthesis before it) sets it with `VAR=$(` in that block (for inline code, in that doc), because a fresh shell has none of them. No script and no code block or span has a `source`, `.` or `eval` of a `run.env` that the scanner sees, apart from `load` in `vcn.sh`. A code block or an inline span of `SKILL.md` or `features/*.md` that has the word `get`, an optional `-z`, and a path word (`OUT`, `WORK`, ...) is allowed only as the one-line recipe of `features/README.md` (the line that defines `getz`), and `SKILL.md` must repeat that line byte for byte, because a command substitution cuts a newline at the end of a path. The word after `get` or `get -z` is no path word when it is `PORT`, `SID`, `NVIM_PID`, `TUI_PID`, `NVIM_08`, a name that starts with `CLIENT_PID`, a lowercase word (`claude mcp get nvim`), or the placeholder `NAME` or `<NAME>`. The check reads text and parses no shell: it joins a line that ends in a backslash to the next line and deletes every quote mark and backslash first, so `'get'`, `g"et"` and `O'UT'` count as written, and a `get` of a path in a comment or in a string fails too. The recipe that `features/README.md` and this file give for reading `OUT` and `WORK` returns both whole for a `VERIFY_OUT` that ends in a newline and holds a backslash (the case runs the documented line on a hand-made `run.env`, with no `V` of its own), and the call stops with status 1, after it prints `vcn.sh get <NAME> failed`, when `get` refuses the state dir or when `run.env` holds no `OUT` or no `WORK`. A code block is a fence of three or more backticks or tildes with any indent, and an inline span that a line break splits is one span. A comment sets and sources nothing, and an assignment or a source in one does not count. A source after `builtin`, `command` or `eval`, in backticks, or in a quoted string that starts with it, is found. The checks do not see an indented code block, `<pre>`, a command that a variable holds, a source in a `.lua`, `.py` or `.mjs` file, a `get` or a name that a shell expansion builds (brace expansion, `$'..'`, parameter expansion, an alias, an eval), a `source` whose `run.env` sits on the next line after a backslash in a code block or a script, a quoted command word (`"source" run.env`), a `run.env` named through `BASH_ENV`, or a use of `PORT`, `SID`, `NVIM_PID` or `NODE_DIR` without a `$` (`let`, `(( ))`, `${#PORT}`). No code block and no code line of a script has one of the last four of these shapes. A missing `SKILL.md` or `features/*.md` fails them. A block or span that sources a `run.env` is left out of `doc-vars`, `doc-get-exact` and `doc-recipe`, and `no-source-run-env` fails it. `mise run check` runs it, so CI does. It prints `ok <case>` per case, then `CASES <n>`.
- `scripts/proof-launch-refusals.sh` proves that launch exits 1 and leaves no run behind when `initialize` fails (it sends `jsonrpc` `1.0` and the plugin answers -32600), and when `VERIFY_OUT` or the default parent dir is unsafe or cannot be created, and when `TMPDIR` or a dir above it is world-writable and not sticky, or `TMPDIR` does not exist or has a `.` or `..` part (`G/L/../y`), or `TMPDIR` is a symlink in a dir that others can write, or a symlink that another user owns (a `bwrap` case that prints `SKIP` where `bwrap` or a root-owned `/var/run` symlink to `/run` is missing), or a symlink to a loose dir whose name ends in a newline, and when `mktemp` answers with a path that `load` refuses and launch leaves no state dir and no evidence dir behind, and when `node` is found through a relative `PATH` entry (no mise), because its dir would go on `PATH` in every `agent-tty` call, and when a dir that others can write sits above `VERIFY_OUT` as given or as resolved (a symlink in its path leads into one, or sits in one), and when `/tmp/agent-tty` is a symlink, is group- or world-writable or is owned by another user (`bwrap` cases with a `/tmp` of their own, which print `SKIP` where `bwrap`, a root-owned `0755` dir to bind, or a user namespace is missing). It also proves that launch makes `/tmp/agent-tty` with mode `0700` when it is missing and accepts one of yours with mode `0700` or `0755`: each such case requires the launch to go on to `agent-tty doctor`, so a guard that refuses a safe dir with any message fails it, and that launch refuses your `0700` dir when `/tmp` is world-writable without the sticky bit, that a sticky `TMPDIR` is accepted, that a `TMPDIR` symlink and a safe dir whose names end in a newline are read whole, and that a symlink to it gives a state dir with no symlink in its path, and that a symlink named `vcn` in a sticky `TMPDIR` does not change which dirs are checked, that `TMPDIR=/` gives `mktemp` the template `/vcn.XXXXXX` (not `//vcn.XXXXXX`), and that a `VERIFY_OUT` under a symlink works when `stat` prints translated file types. It also proves that a refused launch leaves the proof's temp dir as it found it, and that a relative `VERIFY_OUT` that starts with `-` works. It prints `PASS <case>` per case or `FAIL: <reason>`. It takes 2 minutes or more, 149 s to 966 s measured, depending on the load of the host, because most cases start a launch.
- The `out-dash`, `out-acl`, `out-made` and `init` cases start a real run, so they need everything under Needs, including Playwright Chromium for the user who runs the proof. Without it, `out-dash` fails at `agent-tty doctor`.
- The `out-acl-unremovable`, `out-acl` and `out-made` cases need `setfacl` and a filesystem with default ACLs. Without them, the proof prints `SKIP` for all three. `out-acl` checks that every evidence file of a run in a `0500` dir with a default ACL is `0600`. `out-made` checks that launch creates a new `VERIFY_OUT` two levels below a dir with a default ACL, makes both new dirs `0700` with no ACL left on either, and that every evidence file is `0600`.
- The `race` case puts a `mkdir` shim first on `PATH`. The shim plays another launch that makes the dir above `VERIFY_OUT` first, group-writable. Launch must refuse that dir in the check after `mkdir` and leave it in place.
- The `init` case writes its evidence, with `init-refusal-launch.txt`, to a fresh `mktemp -d` dir. The proof deletes that dir when every case passes. When the `init` case fails, the proof keeps it and prints `init evidence kept in <dir>`. Delete it when you are done.
- `scripts/fake-terminal.sh` is a `terminal_cmd` that prints a banner and sleeps, so a plugin-managed terminal runs without the `claude` CLI.
- `scripts/observe-windows.lua` prints the window state as JSON (focus, mode, rectangles, floats, how many cells of each diff window a float covers). Run it with `$V expr "luaeval('dofile(\"<path>\")')"`.
- `scripts/journey-snacks-float-reload.sh` proves that a float the user hid stays hidden after a plugin reload. It needs the same launch as the next script and prints `PASS snacks-float-reload` or `FAIL: <reason>`.
- `scripts/journey-snacks-float-diff.sh` proves the float terminal hides when a diff opens and stays hidden after `close_tab`. It needs the launch in [features/terminal-float-diff.md](features/terminal-float-diff.md) and prints `PASS snacks-float-diff <layout> <nokeep|keep>` or `FAIL: <reason>`.
- `scripts/proof-ctrl-z-suspend.sh native|snacks` proves that Ctrl-Z in the Claude terminal runs Neovim's `:suspend` and never reaches the Claude job (#194). It launches its own run with `scripts/ctrl-z-probe-job.mjs` as `terminal_cmd`, a raw-mode stand-in for the Claude CLI that logs each byte it reads. It then runs `scripts/proof-ctrl-z-stop.py`, which starts nvim as a foreground job of `bash -i` in a pty that answers the terminal queries, and checks that Ctrl-Z stops nvim, that `fg` resumes it, and that the job never reads 0x1a. agent-tty cannot show that stop, because it sends no reply to the query that nvim waits for. See [features/terminal-ctrl-z.md](features/terminal-ctrl-z.md).
- `scripts/proof-pty-child-hangup.sh [tree]` proves the hangup race of Neovim's pty jobs that the `termopen` wrapper in `tests/helpers/termopen_wait.lua` guards against (`tests/minimal_init.lua` installs it). An `LD_PRELOAD` shim holds the child of a terminal job in `init_child()`, after `setsid()` and before it resets Neovim's signal handlers. Stopping the job in that gap makes Neovim exit with `Caught deadly signal 'SIGHUP'`, and the wrapper makes the child run exec before `termopen` returns, so Neovim survives. It needs Linux, a C compiler and a Neovim (`VCN_NVIM`), and prints one `ok:` or `FAIL:` line per case and a `COUNT` line.
- `fixtures/term-keys-windows.lua` and `fixtures/term-keys-quit.lua` hold the two Lua blocks of the README section "Leaving Terminal Mode in the Claude Terminal". The `term-keys` recipe in `features/terminal.md` loads these files with `:luafile`, compares them with the README by text (`cmp`), and never runs README text.
- `scripts/journey-opendiff-accept.sh` proves openDiff accept through `:w` on a fresh launch. It prints `PASS opendiff-accept` or `FAIL: <reason>` and writes `opendiff-accept-*` files to `$OUT`: screen text at each step, PNGs at open, after the edit, and after `:w`, a WebM, and the file's sha256 at each step.
- `scripts/journey-diff-close-save.sh [existing|new]` proves what a proposed diff leaves behind when the user runs `:wq`, `:x`, `ZZ`, `:w` twice, `:q`, `:b hello.txt` or `:e other.txt` in the proposal window, and sets a sign column of its own after the `:e`, for a launch in either layout. For an existing file it also shows the proposal in the original window with `:b`, closes the diff, and checks that the last window shows hello.txt out of diff mode. It prints one `ok:` or `FAIL:` line per check, then `PASS diff-close-save` or `FAIL: <count> of <total> checks failed`, and writes `diff-close-save-*` screen captures to `$OUT`. See [features/open-diff.md](features/open-diff.md).
- `scripts/journey-new-tab-diff-mode.sh` proves that two diffs that open in new tabs leave the cursor in the proposal in Normal mode. It needs the launch in [features/open-diff.md](features/open-diff.md) (a real Claude terminal and `diff_opts.open_in_new_tab`), prints `PASS new-tab-diff-mode` or `FAIL: <reason>`, and writes `new-tab-diff-mode-*` screen captures to `$OUT`.
A full run, with the `VCN_STATE` value from launch:
```bash
V=.agents/skills/verify-claudecode-nvim/scripts/vcn.sh
$V launch
export VCN_STATE='/tmp/vcn.AbC123'
$V doctor
.agents/skills/verify-claudecode-nvim/scripts/journey-opendiff-accept.sh
$V cleanup
```
## Real CLI handshake
This checks that the real Claude Code CLI accepts the plugin's WebSocket server. It registers the run's server in a scratch CLI config and asks the CLI for its status. Run it in any shell, with the `VCN_STATE` value launch printed in the first line:
```bash
export VCN_STATE='/tmp/vcn.AbC123'
PORT=$(basename "$VCN_STATE"/claude/ide/*.lock .lock)
cfg=$(mktemp -d)
helper="jq -c '{\"x-claude-code-ide-authorization\": .authToken}' \"$VCN_STATE/claude/ide/$PORT.lock\""
json=$(jq -nc --arg u "ws://127.0.0.1:$PORT" --arg h "$helper" '{type: "ws", url: $u, headersHelper: $h}')
cli() { env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN -u CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CONFIG_DIR="$cfg" claude "$@" </dev/null; }
cli mcp add-json -s user nvim "$json"
cli mcp get nvim | grep Status
rm -rf "$cfg"
```
`claude mcp get nvim` must print `Status: ✔ Connected`. A wrong token prints `Status: ✘ Failed to connect`. The CLI refuses to store `type: "ws-ide"`. The `headersHelper` reads the token from the `0600` lock file when the CLI connects, so the token stays out of argv, the CLI config, and the `mcp get` output. Checks never start the claude TUI or `claude -p` with host credentials.
Files in this skill
- SKILL.md
- features/README.md
- features/close-diffs.md
- features/open-diff.md
- features/open-file.md
- features/selection.md
- features/send-at-mention.md
- scripts/init.lua
- scripts/journey-opendiff-accept.sh
- scripts/proof-launch-refusals.sh
- scripts/vcn.sh
- scripts/ws-client.mjs
Attribution
Related skills
View all in ai-agents →Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.