← blog

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: insteadOfpushInsteadOf.

[url "git@github.com:"]
    pushInsteadOf = https://github.com/

The difference:

  • insteadOf rewrites everything: fetch, clone, push.
  • pushInsteadOf rewrites 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.

Neovim with the tokyonight colorscheme

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.