Neovim from scratch / part 3
Rebuilding my Neovim config from scratch (Part 3: My first plugin, and the bug that ate the afternoon)
Installing my first Neovim plugin with vim.pack, the built-in plugin manager in Neovim 0.12, and the sneaky git config bug that broke it first.
(Part 3 of rebuilding my Neovim config from zero on 0.12, no plugin manager, understanding every line. Part 1: options. Part 2: keymaps.)
Two parts in, my editor still looked like a 1990s terminal. Today that changes: first plugin, actual colours. And here’s the spicy part of doing this in 2026: I’m not installing a plugin manager to install plugins.
Wait, no plugin manager?
For years the move was: step one, install lazy.nvim (or packer, or vim-plug). Step two, let it install everything else. A plugin manager to manage your plugins. Fine, but it’s one more big dependency doing things I’d never actually read.
Neovim 0.12 quietly changed that. It now ships its own built-in plugin manager: vim.pack. No bootstrap, no third party, it’s just there. And since this whole project is about removing magic, that’s exactly what I want. (It’s still marked experimental, but “stable enough for daily use” per the docs, and it’s been rock solid.)
The entire API is four functions: add, update, del, get. That’s it. Compare that to the surface area of a full-blown plugin manager and you start to see the appeal.
The mental model that finally made it click
A plugin is just a git repository. That’s the whole secret nobody says out loud.
When you “install a plugin,” all that happens is this: a git repo gets cloned to a folder Neovim knows to look in, and Neovim adds that folder to its search path. vim.pack does exactly this: it clones into ~/.local/share/nvim/site/pack/core/opt/, puts it on the path, done. No daemon, no lockfile sorcery, no 2,000 lines of manager code. Just git clone with a nice wrapper.
Here’s my first plugin, a colour scheme, living in lua/dilee/plugins/colorscheme.lua:
vim.pack.add({
{ src = "https://github.com/folke/tokyonight.nvim" },
}, { confirm = false })
require("tokyonight").setup({
style = "night", -- "night" | "storm" | "moon" | "day"
})
vim.cmd.colorscheme("tokyonight")
That’s a real, complete plugin install. add clones the repo. require(...).setup{} is tokyonight’s own config. colorscheme flips it on. confirm = false just skips the “are you sure?” prompt, because the spec sitting right there in my file is the “yes.”
I gave the file one job: be totally self-contained, with the install and the config living together. Add another plugin later? New file, same shape.
The reproducibility bit I actually love
After that runs, vim.pack writes a lockfile at ~/.config/nvim/nvim-pack-lock.json:
{
"plugins": {
"tokyonight.nvim": {
"rev": "cdc07ac...",
"src": "https://github.com/folke/tokyonight.nvim"
}
}
}
It pins the exact commit. My Neovim config is a git repo (dotfiles, symlinked into place), and that lockfile lands right inside it. So I commit it, and any machine I clone to rebuilds the identical plugin set, down to the SHA. Updating is then a deliberate thing I choose to do: run vim.pack.update(), get a diff buffer showing what changed, :write to accept or :quit to bail. No surprise breakage on a random Tuesday. I’ve wanted this for years.
And now, the bug
I wrote the file. I ran Neovim. And got this:
git@github.com: Permission denied (publickey).
fatal: Could not read from remote repository.
Hold on. My spec clearly says https://github.com/.... So why is git trying SSH?
Here’s the trap, and it’s a good one. Years ago I’d added this to my global git config:
[url "git@github.com:"]
insteadOf = https://github.com/
Translation: “any time you see an https://github.com/ URL, secretly use SSH instead.” Past-me thought this was clever: clone anything, always use my key. And it worked great, right up until a tool (vim.pack) tried to clone a public repo over HTTPS, and git rewrote it into an SSH clone that needed a key the process didn’t have. A colorscheme problem was actually a git-config problem wearing a trench coat.
The fix is one word: insteadOf → pushInsteadOf.
[url "git@github.com:"]
pushInsteadOf = https://github.com/
The difference:
insteadOfrewrites everything: fetch, clone, push.pushInsteadOfrewrites only pushes.
So now: cloning public stuff stays on HTTPS and just works on any machine, no key needed (great for installing plugins). Pushes to my own repos still go over SSH, so I never type a password. This is the config I should’ve had all along. insteadOf was always the wrong tool for “I just want passwordless pushes.”
If you’ve got the same rewrite (a lot of dotfiles do), this is the command:
git config --global --unset-all url."git@github.com:".insteadOf
git config --global url."git@github.com:".pushInsteadOf "https://github.com/"
Re-ran Neovim. Clean clone over HTTPS in about two seconds. Colours. Finally.

What this layer actually taught me
Less “how to install a plugin,” more what a plugin even is: a git repo on a path. Once that clicked, vim.pack stopped feeling like a tool I had to trust and started feeling like something I could’ve written a crude version of myself. Which is the whole point of this exercise.
Next up: Treesitter, the thing that makes code actually look like code (real syntax highlighting, not regex guesswork). It’s the first plugin with a build step, so we’ll meet vim.pack’s hook system too.
Cheers!
Comments
Comments require a GitHub account. No GitHub? Reply on Substack or email me.