stow/shared/.claude/skills/nvim-plugin/SKILL.md
Guide for writing Neovim plugins in Lua following official Neovim conventions (https://neovim.io/doc/user/lua-plugin/). Use this skill whenever the user is creating, modifying, or reviewing a Neovim plugin — including when they mention plugin structure, ftplugin, health checks, keymaps, setup() functions, vimdoc, LuaCATS annotations, or lazy loading in the context of Neovim plugin development. Also trigger when the user is working in a directory that looks like a Neovim plugin (contains plugin/, lua/, ftplugin/ subdirectories).
npx skillsauth add fredrikaverpil/dotfiles nvim-pluginInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference: https://neovim.io/doc/user/lua-plugin/
A standard Neovim plugin layout:
myplugin.nvim/
├── plugin/
│ └── myplugin.lua ← eagerly loaded at startup (keep minimal)
├── lua/
│ └── myplugin/
│ ├── init.lua ← main module (required as 'myplugin')
│ ├── config.lua ← option defaults + validation
│ └── health.lua ← health checks (:checkhealth)
├── ftplugin/
│ └── rust.lua ← filetype-specific init (optional)
├── doc/
│ └── myplugin.txt ← vimdoc (generate with panvimdoc)
└── README.md
Neovim auto-discovers files in these paths — no registration needed.
Keep plugin/myplugin.lua minimal. Defer require() into command/mapping
bodies, not at the top of the file. This preserves startup time.
-- BAD: eager load
local myplugin = require("myplugin")
vim.api.nvim_create_user_command("MyCommand", function()
myplugin.run()
end, {})
-- GOOD: deferred load
vim.api.nvim_create_user_command("MyCommand", function()
require("myplugin").run()
end, {})
Avoid creating keymaps automatically — it conflicts with user config. Two preferred approaches:
<Plug> Mappings (recommended for simple actions)-- In plugin/myplugin.lua
vim.keymap.set("n", "<Plug>(MyPluginAction)", function()
require("myplugin").do_action()
end)
Users then bind it themselves:
vim.keymap.set("n", "<leader>a", "<Plug>(MyPluginAction)")
-- Expose the function; let users decide the mapping
require("myplugin").do_action() -- callable directly
For buffer-local mappings (custom UI, ftplugin), always pass buffer = bufnr:
vim.keymap.set("n", "<Plug>(MyPluginBufferAction)", function()
require("myplugin").buffer_action()
end, { buffer = bufnr })
setup() PatternsPlugin works out-of-the-box. setup() only overrides defaults — no require()
calls, side effects, or expensive work. Initialization happens in plugin/ or
ftplugin/ scripts, not inside setup().
-- lua/myplugin/config.lua
local M = {}
M.defaults = {
enabled = true,
timeout = 500,
}
M.options = {}
function M.setup(opts)
M.options = vim.tbl_deep_extend("force", M.defaults, opts or {})
M.validate()
end
function M.validate()
vim.validate({
enabled = { M.options.enabled, "boolean" },
timeout = { M.options.timeout, "number" },
})
end
return M
setup() (use when init is complex/risky)Requires the user to call setup() explicitly — even with defaults. Only choose
this when misconfiguration risk is high.
-- lua/myplugin/init.lua
local M = {}
function M.setup(opts)
local config = require("myplugin.config")
config.setup(opts)
-- initialization logic here
M._initialized = true
end
return M
Prevent re-initialization (e.g. from sourcing the same file twice):
-- plugin/myplugin.lua
if vim.g.loaded_myplugin then
return
end
vim.g.loaded_myplugin = true
For ftplugin (per-buffer, not per-session):
-- ftplugin/rust.lua
local bufnr = vim.api.nvim_get_current_buf()
-- no session-level guard needed; ftplugin is intentionally per-buffer
Set filetype as late as possible in custom UI buffers so users can
override buffer-local settings via FileType autocmds.
Create lua/{plugin}/health.lua. :checkhealth {plugin} auto-discovers it.
-- lua/myplugin/health.lua
local M = {}
function M.check()
vim.health.start("myplugin")
-- Check initialization
local ok, config = pcall(require, "myplugin.config")
if not ok then
vim.health.error("myplugin not loaded")
return
end
-- Check config
if config.options.timeout < 100 then
vim.health.warn("timeout < 100ms may cause issues")
else
vim.health.ok("configuration looks good")
end
-- Check external deps
if vim.fn.executable("some-tool") == 1 then
vim.health.ok("some-tool found")
else
vim.health.error("some-tool not found in PATH")
end
end
return M
Annotate public APIs with LuaCATS for lua-language-server (luals):
---@class MyPlugin.Config
---@field enabled boolean
---@field timeout integer
---@param opts? MyPlugin.Config
function M.setup(opts) end
---@return MyPlugin.Config
function M.get_config() end
Integrate lua-typecheck-action in CI to catch type errors before users do.
For plugins with custom UIs, expose actions as LSP code-actions so users can
invoke them via standard vim.lsp.buf.code_action():
-- Users can then filter and apply specific actions:
vim.lsp.buf.code_action({
apply = true,
filter = function(a)
return a.title == "My Plugin Action"
end,
})
MAJOR.MINOR.PATCHvim.deprecate() when removing or renaming APIs:function M.old_function(opts)
vim.deprecate("myplugin.old_function", "myplugin.new_function", "2.0.0", "myplugin")
return M.new_function(opts)
end
luarocks-tag-release or release-please-actionProvide vimdoc so users can access :h myplugin in Neovim.
Generate from Markdown using panvimdoc, then regenerate help-tags:
:helptags doc/
:restart to reload plugin changes during developmentnvim --startuptime /tmp/nvim-startup.logdev = true to your lazy.nvim spec to load from local path:{
"username/myplugin.nvim",
dev = true, -- loads from opts.dev.path/myplugin.nvim
}
Follow the project's Lua style (per .stylua.toml):
styluadevelopment
Google API Improvement Proposals (AIP) reference. Use BEFORE designing or reviewing APIs, protobuf definitions, or any work involving Google API design standards. Fetches relevant AIP rules from https://google.aip.dev for the task at hand.
tools
Native Neovim config idioms and conventions — use whenever writing, reviewing, or modifying any Neovim configuration that uses Neovim's built-in conventions WITHOUT a plugin manager framework (no lazy.nvim, packer, etc.). Covers directory structure, vim.pack plugin management, lsp/ auto-discovery, plugin/ loading order, keymaps, and standard paths. Trigger on any task involving init.lua, plugin/*.lua, lsp/*.lua, vim.pack.add(), vim.lsp.enable(), or "native neovim config" — even if the user just says "add a plugin" or "configure LSP" in a native-style config.
development
Analyze a git repo's history to surface high-churn files, ownership risks, bug hotspots, momentum trends, and firefighting patterns. Use this skill whenever the user wants to understand a codebase, assess repo health, or orient themselves before reading code — even if they don't explicitly say "audit".
tools
When done with implementation, perform self-review of the work