neovain docs
the Neovim Agent Interface
neovain is a command-line tool that lets AI coding agents edit files with Vim commands. One call applies a whole sequence of steps. If any step fails, the file is left untouched.
Install
On Linux and macOS:
$ curl -fsSL https://neovain.dev/install.sh | sh
On Windows, in PowerShell:
> irm https://neovain.dev/install.ps1 | iex
The installer downloads the prebuilt binary for your machine from the releases page and verifies its checksum. neovain drives Neovim, so the installer then checks for Neovim 0.9 or newer. If it is missing or too old, the installer asks before installing the latest one. Everything goes into your user directory, so nothing needs sudo or administrator rights.
For scripts and agents, answer the questions ahead of time with NEOVAIN_INSTALL_NVIM=yes (or no), and on Windows NEOVAIN_ADD_TO_PATH=yes (or no). NEOVAIN_VERSION=v0.1.0 pins a release.
To build from source with Rust instead:
$ cargo install --git https://github.com/kbrock84/neovain
If nvim isn't on your PATH, point NEOVAIN_NVIM at it.
Usage
neovain [OPTIONS] FILE STEP [STEP ...]
neovain opens FILE in a headless Neovim and applies the steps in order. If every step succeeds, the file is written and a unified diff is printed, or a summary of the changes if the diff is long. If any step fails, neovain stops, prints which step failed and why, and leaves the file untouched.
$ neovain app.py '@^def load' 'wciwread_file<Esc>' ':%s/\<load(/read_file(/g' -def load(path): +def read_file(path): data = open(path).read() return data def main(): - d = load("in.txt") + d = read_file("in.txt") save("out.txt", d)
Output
A diff that changes at most 60 lines, and is at most 80 lines long with two lines of context, is printed in full. For a longer one neovain prints a summary of at most 80 lines, because a 2,000-line diff goes unread. --diff full always prints the diff, --diff summary always prints the summary, and both work with --dry-run. -C sets the context of the diff that is printed. It has no part in the choice between the two.
This is the summary of four changes to a 205-line file, made in one call: a rename, a method body wrapped in a with block, a function deleted and a class moved to the end.
replaced on 42 lines: fetch_value -> lookup (none left) inserted 1 line: 36 (with self.source.lock():) 35: """Reserve.""" +36: with self.source.lock(): 37: if item.get("stock") is None: reindented 7 lines: 36-42 -> 37-43, indent +4 spaces (if item.get("stock") is None:) 36: with self.source.lock(): +37: if item.get("stock") is None: +38: self.lookup(item, "stock") +42: self.lookup(item, "weight") +43: return item 45: def release(self, item): deleted 13 lines: 145-157 (def old_report(rows):) 75: return out -145:def old_report(rows): -146: """Deprecated: the 2019 report format.""" -156: out.append(row.get("supplier")) -157: return out 78:class Shipping: moved 66 lines: 65-130 -> 126-191 (class Pricing:) down past 58 lines: 66-123 (def load_items(rows):) 123: return out +126:class Pricing: +127: """Prices and discounts.""" +190: self.lookup(item, "price") +191: return item (end of file)
| line | meaning |
|---|---|
moved N lines: A-B -> C-D | Lines A-B of the old file are lines C-D of the new file. The line below says what the block passed on its way. |
deleted N lines: A-B | Lines A-B of the old file are gone. |
inserted N lines: C-D | Lines C-D of the new file are new. |
changed N lines to M | Lines of the old file were replaced by other text. |
reindented N lines | The same text with other leading whitespace, and by how much. |
replaced on N lines | The same token replacement on N lines, and how many lines still contain the old token. The five most frequent ones are named and the others counted. |
line endings: | The same lines with other line endings, as in CRLF -> LF on 100 lines. |
whitespace-only lines changed: | Blank lines with other whitespace in them: how many, and the first one. |
trailing whitespace changed | The same text with other whitespace at the end of the line. |
spacing: | A run of blank lines has another length, and that may be meant. |
WARNING: | Something that is very likely wrong. See below. |
A block starts and ends on a line that is not blank. Its first line follows in parentheses, then an excerpt: the line before the block, its first and last lines, and the line after it. A moved or deleted block that holds more than one unit says so, as in (def load(path):) +2 more at this indent for three functions, and the excerpt shows where the others start. A moved or re-indented block that holds more or fewer blank lines than before gives both sizes, as in reindented 9 lines to 7. When the summary would get too long, the excerpts get shorter, and then blocks are left out and counted.
The warnings are about the two mistakes that a valid command makes most often:
WARNING: file ends with 3 newlines, was 1 (2 blank lines at the end) WARNING: no blank line between 137 and 138, was 2 reindented 27 lines: 36-62 -> 36-62, indent +4 spaces (if item.get("stock") is None:) WARNING: 2 lines are indented less than the block's first line, from +44
The first two say that blank lines went to the wrong place. Where two blocks were joined, the blank lines between them are compared with the blank lines each block had next to it before. The last one says that a range ran past the end of the block it started in: a method body that is indented together with the methods after it holds lines indented less than its first line, and the first line of the method did not move with them.
A warning is given only where the old file shows what the spacing should be. Next to text that is new it does not, so a run of blank lines that changed there is a spacing: line at most. A line on its own that moved is treated the same way.
Warnings are not kept for the summary. After a diff that is printed in full they follow it, below an empty line. A diff with nothing to warn about is followed by nothing.
$ neovain app.py '@^def save' ':.,/^def main/-1m$' return data +def main(): + d = load("in.txt") + save("out.txt", d) def save(path, data): open(path, "w").write(data) -def main(): - d = load("in.txt") - save("out.txt", d) WARNING: file ends with 2 newlines, was 1 (1 blank line at the end) WARNING: no blank line between 7 and 8, was 1
The diff behind a summary and the analysis of the change have two seconds each, and less if the run has taken as long as --timeout by then. If the time runs out, the first line says about +N -M lines, counted roughly, a line that starts with note: says what is missing, and what was found by then is printed. The diff for --diff full takes the time it needs.
The summary works on lines and knows nothing about the language of the file.
Steps
| step | meaning |
|---|---|
@regex | Move the cursor to the one line matching regex. Fails if no line or more than one line matches. |
@N@regex | Move the cursor to the Nth matching line. |
:excmd | Run an ex command: :%s/old/new/g, :g/pat/d, :10,20m$ |
| anything else | Normal-mode keys, with <Esc>, <CR> and <Tab> notation. |
Anchors use Vim's regex syntax and match content, not line numbers, so an edit still lands in the right place after earlier steps have shifted lines around. A steps list typically starts each edit with an anchor:
$ neovain store.py \ ':%s/\<fetch_value\>/lookup/g' \ ':g/^def old_/-2,/^\S/-3d' \ '@^class Pricing' ':-2,/^\S/-3m$'
Literal text
Normal-mode steps go through Neovim's key notation, so <Tab>, <Del> or <Home> inside typed text become keys, and so do HTML tags like <del>. For literal text, use the ex commands :a (append after the cursor line), :i (insert before it) or :c (replace a range). The text goes on the following lines, ending with a line containing only .
$ neovain page.html '@</main>' $':i\n <p>New <del>old</del> text</p>\n.' $ neovain page.html '@<h1>' $':c\n <h1>New title</h1>\n.' $ neovain new.html $':0a\n<!doctype html>\n<title>New page</title>\n.'
:0a writes into an empty file. New and empty files always get LF line endings. This site was built this way: its pages were written with neovain.
Guarantees
- Transactional. The first failing step (a search miss, an ambiguous or missing anchor, an ex error) aborts the run. The file is not touched.
- Literal typing. Autoindent, formatoptions, textwidth and filetype plugins are off. Text typed in insert mode lands exactly as typed.
- No wraparound.
wrapscanis off, so/pat<CR>only searches forward from the cursor. - Indentation that matches the file.
>and<use tabs if the file already indents with tabs. Otherwise they use--swspaces (default 4). - Faithful writes. CRLF/LF endings, a missing final newline and file permissions are preserved. The file is written atomically.
- No-effect warning. A normal-mode step that changes neither the buffer nor the cursor prints a warning. That usually means a command was left incomplete.
For agents
neovain is built to be called by coding agents through a shell tool. Paste this into your agent's instructions (CLAUDE.md, AGENTS.md or a system prompt):
To edit files, run `neovain FILE STEP...` (see `neovain --help`). - Plan the whole change, then send every step in one call. If any step fails, nothing is written. - Start each edit with a content anchor: '@^def name' (must match exactly one line). - Use ex commands for structural or bulk edits: :%s, :g/pat/d, :m (move), :t (copy), :> - A range can end at a pattern: ':.,/^class Next/-1d' deletes up to the line before it. - A block owns the blank lines above it. Select both: ':-2,/^\S/-3' runs from two lines above the anchor to the block's last line of code. Then moves and deletes leave the spacing intact. - Insert literal text with :a / :i / :c, ending with a line containing only "." - Preview with --dry-run, then send the same command without it. - Read the output. A wrong edit that is still valid vim does not fail. A long diff is printed as a summary: check each block's line range, size and first line against what you meant. Act on every line that starts with WARNING, in a summary or below a diff. --diff full prints every changed line.
Where it helps most: moving, deleting, re-indenting and reordering large blocks, and changes that repeat across a file. For a small local change, exact string replacement is just as good. See the benchmarks.
Ex-only mode
With NEOVAIN_EX_ONLY=1, only anchors and ex commands are accepted. Normal-mode keys, :normal and :execute are rejected, and text goes in with :a, :i or :c. The benchmark harness uses it to test whether keystroke-level editing costs agents extra reasoning.
Windows
Under Git Bash, MSYS rewrites arguments that contain /… (like /pat<CR>) into Windows paths. Run neovain with MSYS_NO_PATHCONV=1. neovain detects the mangling and refuses to run rather than edit the wrong thing. With conversion off, pass a relative or Windows-style (C:/…) path for FILE. PowerShell and WSL have none of these issues.
Reference
| option | meaning |
|---|---|
-n, --dry-run | Print the changes without writing. |
--diff MODE | What to print. auto (default): the diff if it is short, a summary of the changes if it is long. full: always the diff. summary: always the summary. |
-C N | Lines of context in the diff (default 2). |
--sw N | Shiftwidth for > and < in space-indented files (default 4). |
--timeout SECS | Abort if Neovim takes longer (default 10). The diff behind a summary and the analysis of the change stop at it as well. |
-- | Treat everything after it as steps, for steps that look like flags. |
NEOVAIN_NVIM | Path to the nvim binary. |
NEOVAIN_EX_ONLY=1 | Allow only anchors and ex commands. |
Exit status: 0 success, 1 a step failed or timed out (file unchanged), 2 usage or setup error.