Overview
Neovim (nvim) is a Vim-based editor that runs in a terminal and is configured with Lua. It ships an LSP client (diagnostics, go-to-definition, rename, formatting), Treesitter (syntax trees for highlighting, folding and queries) and, from 0.12, vim.pack, a Git-based plugin manager with a lockfile. The current stable line is 0.12.x (0.12.5 at the time of writing).
An agent cannot usefully drive the interactive UI, but it can do most of the setup work: install the binary, write init.lua, pin plugins, configure language servers, check the config for errors and run Neovim headless for batch edits. This skill covers those jobs.
Instructions
Installation
Package managers first; check the version afterwards, because distribution packages often lag behind and vim.pack needs 0.12.
brew install neovim # macOS or Linux with Homebrew
winget install Neovim.Neovim # Windows
sudo pacman -S neovim # Arch
nvim --version | head -1 # expect NVIM v0.12.x
If the distro package is too old, use the official release archive (download, then extract as a separate step):
VER=v0.12.5
curl -LO https://github.com/neovim/neovim/releases/download/$VER/nvim-linux-x86_64.tar.gz
# GitHub records a SHA-256 digest for every release asset; compare before extracting
WANT=$(curl -s https://api.github.com/repos/neovim/neovim/releases/tags/$VER \
| jq -r '.assets[] | select(.name == "nvim-linux-x86_64.tar.gz") | .digest' | cut -d: -f2)
echo "$WANT nvim-linux-x86_64.tar.gz" | sha256sum -c - # must print OK
sudo tar -C /opt -xzf nvim-linux-x86_64.tar.gz
echo 'export PATH="/opt/nvim-linux-x86_64/bin:$PATH"' >> ~/.bashrc
Prepending puts the new binary ahead of an older /usr/bin/nvim. Archives exist for linux-arm64, macos-arm64 and macos-x86_64 too.
Config layout
~/.config/nvim/init.lua— entry point (:echo stdpath('config')shows the real path).~/.config/nvim/lua/<name>.lua— modules loaded withrequire('<name>').~/.config/nvim/lsp/<server>.lua— a language-server config returned as a table.~/.config/nvim/after/lsp/<server>.lua— overrides that win over plugin-provided configs.~/.config/nvim/nvim-pack-lock.json— thevim.packlockfile; commit it with the config.
To try a config without touching the user's, set NVIM_APPNAME: Neovim then reads ~/.config/<name> and keeps separate data and state directories.
NVIM_APPNAME=nvim-trial nvim
Plugins with vim.pack (0.12+)
vim.pack.add() clones missing plugins into the data directory (site/pack/core/opt) and loads them. It needs git and is marked experimental in the docs.
vim.pack.add({
'https://github.com/neovim/nvim-lspconfig',
{ src = 'https://github.com/nvim-treesitter/nvim-treesitter', version = 'main' },
})
vim.pack.update()fetches updates and opens a review buffer::writeapplies,:quitdiscards.vim.pack.update(nil, { force = true })skips the review.vim.pack.update(nil, { target = 'lockfile' })moves plugins back to the lockfile revisions.- To remove a plugin, first delete it from the
add()list and:restart, then runvim.pack.del({ 'plugin-name' }).del()refuses a plugin that is still loaded unless you pass{ force = true }. - Build steps go in a
PackChangedautocommand defined beforevim.pack.add().
Third-party managers such as lazy.nvim still work; do not mix two managers for the same plugin.
Language servers (vim.lsp.config / vim.lsp.enable)
Neovim provides the client; servers are separate programs on PATH. nvim-lspconfig ships ready-made configs in its lsp/ directory, so after installing it one line per server is enough:
vim.lsp.enable({ 'pyright', 'ruff', 'ts_ls', 'lua_ls' })
Customise with vim.lsp.config(); it merges over the plugin's defaults:
vim.lsp.config('lua_ls', {
settings = { Lua = { runtime = { version = 'LuaJIT' } } },
})
vim.lsp.config('pyright', { root_markers = { 'pyproject.toml', '.git' } })
vim.lsp.config('*', { -- fallback for fields a server's own config leaves unset
capabilities = { textDocument = { semanticTokens = { multilineTokenSupport = true } } },
})
'*' has the lowest priority: nvim-lspconfig's lsp/<server>.lua files override it, so setting root_markers there changes nothing. Set root markers per server, as above, or in after/lsp/<server>.lua.
A server that nvim-lspconfig does not know needs cmd, filetypes and root_markers. The old require('lspconfig').<server>.setup{} framework is deprecated; replace each call with vim.lsp.config (settings) plus vim.lsp.enable (activation).
Built-in keymaps once a server attaches: K hover, grn rename, grr references, gri implementation, gra code action, gO document symbols, CTRL-S signature help in insert mode, and CTRL-] jumps to the definition through 'tagfunc'. :lsp restart and :lsp stop manage clients.
Format on save and completion
Some servers (ruff, for example) register formatting only after they attach, so check capability when saving rather than inside LspAttach:
vim.api.nvim_create_autocmd('BufWritePre', {
group = vim.api.nvim_create_augroup('my.format', {}),
callback = function(ev)
if #vim.lsp.get_clients({ bufnr = ev.buf, method = 'textDocument/formatting' }) > 0 then
vim.lsp.buf.format({ bufnr = ev.buf, timeout_ms = 2000 })
end
end,
})
vim.api.nvim_create_autocmd('LspAttach', {
group = vim.api.nvim_create_augroup('my.lsp', {}),
callback = function(ev)
local client = assert(vim.lsp.get_client_by_id(ev.data.client_id))
if client:supports_method('textDocument/completion') then
vim.lsp.completion.enable(true, client.id, ev.buf, { autotrigger = true })
end
end,
})
With several formatters attached, pass name = 'ruff' or a filter function to vim.lsp.buf.format().
Treesitter
Neovim bundles parsers for C, Lua, Markdown, Vimscript, Vimdoc and Treesitter queries. Other languages need parsers, usually from nvim-treesitter (its main branch requires Neovim 0.12, tree-sitter-cli and a C compiler; the old master branch is frozen).
require('nvim-treesitter').install({ 'python', 'typescript', 'tsx' })
vim.api.nvim_create_autocmd('FileType', {
pattern = { 'python', 'typescript', 'typescriptreact', 'lua' },
callback = function(ev)
-- pcall: the parser may not be installed yet (install() is asynchronous)
if pcall(vim.treesitter.start, ev.buf) then
vim.wo[0][0].foldexpr = 'v:lua.vim.treesitter.foldexpr()'
vim.wo[0][0].foldmethod = 'expr'
end
end,
})
Without the pcall, every buffer of a language whose parser is missing raises "Parser could not be created". To install parsers before first use, for example on a new machine, wait for the install in a headless run:
nvim --headless -c "lua require('nvim-treesitter').install({ 'python', 'typescript', 'tsx' }):wait(300000)" -c qa
After updating nvim-treesitter, run :TSUpdate so parsers match the plugin.
Headless and scripted use
nvim --headless +qa # load the config, install vim.pack plugins, exit
nvim --headless "+checkhealth vim.lsp" "+write! lsp-health.txt" +qa
nvim --headless -c 'lua vim.pack.update(nil, { force = true })' -c qa
nvim -l scripts/report.lua src/app.lua # run a Lua script with Neovim's API, then exit
nvim -l script.lua args…skips the user config, exposes arguments asarg[1]…, and sendsprint()to stderr; useio.write()for stdout.nvim --cleanstarts with no user config or plugins — the fastest way to tell a config bug from a Neovim bug.nvim --headless +qaexits 0 even wheninit.luafails. To get a failing exit code, testv:errmsg:nvim --headless -c 'if v:errmsg != "" | cquit 1 | endif' -c qa.
Examples
Example 1: Python IDE features over SSH
User request: "Set up Neovim on the build box for our invoicing Python service: pyright for types, ruff for lint and format on save."
uv tool install pyright # PyPI wrapper; provides pyright-langserver
uv tool install ruff
~/.config/nvim/init.lua:
vim.g.mapleader = ' '
vim.o.number = true
vim.o.expandtab = true
vim.o.shiftwidth = 4
vim.o.undofile = true
vim.pack.add({ 'https://github.com/neovim/nvim-lspconfig' })
vim.lsp.enable({ 'pyright', 'ruff' })
vim.diagnostic.config({ virtual_text = true })
vim.keymap.set('n', '<leader>e', vim.diagnostic.open_float, { desc = 'Show diagnostic' })
vim.api.nvim_create_autocmd('BufWritePre', {
group = vim.api.nvim_create_augroup('my.format', {}),
callback = function(ev)
if #vim.lsp.get_clients({ bufnr = ev.buf, method = 'textDocument/formatting' }) > 0 then
vim.lsp.buf.format({ bufnr = ev.buf, timeout_ms = 2000 })
end
end,
})
nvim --headless +qa # clones nvim-lspconfig and writes nvim-pack-lock.json
Result: opening billing.py inside the project (which has a pyproject.toml) attaches both servers with the project as root. Ruff flags "os imported but unused" and "Undefined name totl" inline, and :w rewrites def total(items:list[float])->float: as def total(items: list[float]) -> float: with two blank lines around top-level definitions.
Example 2: Remove the lspconfig deprecation warning
User request: "After updating plugins I get 'The require('lspconfig') framework is deprecated, use vim.lsp.config'. Fix my config for TypeScript and Lua."
Old code:
require('lspconfig').ts_ls.setup({ on_attach = on_attach })
require('lspconfig').lua_ls.setup({ settings = { Lua = { diagnostics = { globals = { 'vim' } } } } })
Replacement (keep nvim-lspconfig installed — its lsp/ configs are still used):
vim.lsp.config('lua_ls', {
settings = { Lua = { diagnostics = { globals = { 'vim' } } } },
})
vim.lsp.enable({ 'ts_ls', 'lua_ls' })
-- on_attach logic moves into an LspAttach autocommand (see Instructions)
nvim --headless -c 'if v:errmsg != "" | cquit 1 | endif' -c qa && echo config-ok
Result: config-ok prints, the warning is gone, and :checkhealth vim.lsp lists ts_ls and lua_ls under "Enabled Configurations".
Example 3: Rename a class across a folder without opening the UI
User request: "Rename BillingClient to LedgerClient in every file under handlers/."
nvim --clean --headless -c 'argdo %s/\<BillingClient\>/LedgerClient/ge | update' -c qa handlers/*.py
grep -rn "LedgerClient" handlers/
Result: handlers/refunds.py and handlers/invoices.py are rewritten; handlers/health.py, which had no match, is left untouched because update writes only modified buffers. \<…\> keeps BillingClientError from being changed. For symbol-aware renames that follow imports, use grn with a language server instead.
Guidelines
- Check
nvim --versionbefore writing config:vim.packneeds 0.12,vim.lsp.config/vim.lsp.enableneed 0.11. Current nvim-lspconfig requires 0.11.3 or newer. - Language servers are not bundled. Install them with the ecosystem's package manager and confirm the binary (
pyright-langserver,ruff,typescript-language-server) is onPATHfor the process that starts Neovim. - Root markers (
pyproject.toml,package.json,.git…) decide the workspace root. Without one, most servers (pyright, ruff, lua_ls) still attach in single-file mode with no root, so cross-file features are weaker. Servers markedworkspace_required(eslint, biome, tailwindcss) do not attach at all outside a project.:checkhealth vim.lspshows the resolvedcmd, filetypes and root markers. - Put the user's own overrides in
vim.lsp.config()orafter/lsp/, not in the plugin directory; plugin updates overwrite it. - Commit
nvim-pack-lock.jsonso another machine installs the same plugin revisions; do not edit it by hand. - Before editing someone's config, back it up by copying, or work under
NVIM_APPNAME. Do not delete the data directory to "fix" plugins; usevim.pack.del()orvim.pack.update(nil, { target = 'lockfile' }). - Plugins are code from third parties that runs with the user's permissions. Pin to tags with
version = vim.version.range('1.0')where a plugin publishes semver tags, and reviewvim.pack.update()changes before:write. - Headless regex edits are text replacements, not refactors: they ignore scope, strings and comments. Use them for mechanical changes and check the diff afterwards.
- Do not use this skill for editor-agnostic tasks (formatting in CI, linting) — call the formatter or linter directly; for VS Code or Zed, use their own settings.