Down the dotfiles rabbit hole
I started looking at my dotfiles again because a new Zsh shell took 1.508 seconds to load. I open new shells often enough that the delay was noticeable.
I used Claude Code with Opus 5 and Sonnet 5, and Codex with GPT 5.6 Sol to profile the startup time and refactor most of the repository. I did my part by drinking coffee and opening too many terminal tabs xD.
What was slowing down Zsh
Zsh completion uses compinit to scan the functions in $fpath and build a completion dump. A full scan is useful when completions have changed, but it does not need to happen every time a shell starts.
Prezto was already running compinit through its completion module. My config ran it a second time. I removed the duplicate call and used the existing dump for 24 hours before allowing another full scan.
1if [[ -n ${ZDOTDIR:-$HOME}/.zcompdump(#qN.mh+24) ]]; then
2 compinit
3else
4 compinit -C
5fi
That reduced startup from 1.508s to 1.121s.
The larger cost came from language version managers. I was initializing nvm, pyenv, and rbenv in every shell. Their initialization changes the environment and installs shell functions, but I do not use Node, Python, and Ruby in every terminal session.
I replaced the eager initialization with wrapper functions. This is the wrapper for pyenv:
1pyenv() {
2 unset -f pyenv
3 eval "$(command pyenv init - zsh)"
4 pyenv "$@"
5}
At startup, Zsh only defines this small function. The first pyenv command removes the wrapper, initializes the real command, and runs it with the original arguments. Later calls use the initialized version directly. I used the same approach for rbenv, while nvm has wrappers for nvm, node, npm, and npx.
Lazy-loading nvm reduced startup to 0.361s. Doing the same for pyenv and rbenv brought it to 0.239s.
I later replaced all three version managers with mise. mise now handles the Go, Node, Python, and Ruby versions through one tool and needs much less shell setup.
| Change | Startup time |
|---|---|
| Before | 1.508s |
Cache compinit | 1.121s |
Lazy-load nvm | 0.361s |
Lazy-load pyenv and rbenv | 0.239s |
Antidote and Starship | 0.144s |
| Add the new plugins | 0.183s |
Replacing Prezto
Prezto gave me a prompt, completions, history search, autosuggestions, and syntax highlighting. Once I knew which features I was using, keeping the complete framework did not make much sense for my config.
I replaced it with Antidote. The plugins are listed in a text file, and Antidote builds them into a static Zsh script. That bundle is regenerated only when the plugin list changes, so normal shell startup only has to source the generated file.
Most plugins can load before completion is initialized. fzf-tab cannot, because it replaces completion widgets created by compinit. I keep it in a separate post-compinit bundle rather than depending on the order in which files happen to load.
The plugin list now includes the completion and history features I had before, along with fzf-tab, zsh-you-should-use, zsh-autopair, and forgit. I configured Starship to keep the Powerlevel10k-style prompt I had with Prezto, including the Git branch, language versions, command status, and duration.
At the time of writing, after the rest of the dotfiles changes, a new shell starts in about 0.18s.
Modernizing the terminal setup
While replacing older commands, I noticed that many of the alternatives I preferred happened to be written in Rust. I had picked tools such as bat, eza, fd, and ripgrep for their output and defaults, not for the language they were written in. Once I noticed the pattern, checking whether a tool was written in Rust became part of the search. That is how tools such as dust, duf, xh, and eventually Yazi became part of the setup.
I set up the same directional navigation in tmux and Neovim, so the same keys move between splits and panes. fzf completion uses fd for searching, eza for directory previews, and bat for file previews.
I moved my editor configuration from Vim to Neovim and rebuilt it around lazy.nvim, LSP, Treesitter, formatting, and Git support.
I also added Yazi for browsing files in the terminal. It uses bat for text previews, shows Git status, and can preview images in the third pane. For quick file navigation, it is faster than opening Finder on macOS.
Reworking the setup scripts
The repository had setup scripts from different machines and different periods. Each script had its own version of backing up a file and creating a symlink. Running them again was not always safe, and several assumed either macOS or Linux.
I moved the repeated setup code into shared functions that take care of backups, symlinks, and dry runs.
Package installation and configuration are separate. Package lists support Homebrew, apt, and pacman, while profiles describe the tools needed for a type of machine.
1./bootstrap.sh --dry-run cli
2./bootstrap.sh cli
3
4./configure.sh --profile macos --all
5./install.sh --profile linux-desktop
The complete config is in my dotfiles repository.