I spend more hours in a terminal than in any other window, and for years the
configuration behind it lived nowhere: a .zshrc I had edited so many times I
no longer knew which line did what, a colour scheme picked in a preferences
dialog, an SSH config that only existed on one laptop.
What follows is what that turned into. Not a list of everything installed on my Mac, but the terminal I actually type in and the two-part trick that keeps it reproducible: every setting is a plain-text file, and every plain-text file is in a repository.
The terminal: Ghostty Link to heading
I moved off iTerm2 because I wanted a terminal that was fast and, more importantly, one whose entire appearance I could describe in text.
Ghostty’s config is a single file,
~/.config/ghostty/config.ghostty, with key = value on each line and # for
comments. That is the whole format:
theme = dracula
font-family = "MesloLGS NF"
font-size = 14
window-padding-x = 8
window-padding-y = 6
macos-option-as-alt = true
copy-on-select = true
The interesting part is theme. A Ghostty
theme is just another config
file, so any file dropped in ~/.config/ghostty/themes/ can be selected by name.
Mine is Dracula and starts like this:
palette = 0=#21222c
palette = 1=#ff5555
palette = 2=#50fa7b
background = #282a36
foreground = #f8f8f2
cursor-color = #f8f8f2
selection-background = #44475a
Sixteen palette entries and a handful of colours: that is the difference between
“my terminal looks right” and “my terminal looked right on the old machine”.
cmd+shift+, reloads the config without restarting, so tuning it is a live
loop rather than a quit-and-relaunch cycle.
The shell: zsh, Oh My Zsh, Powerlevel10k Link to heading
macOS ships zsh, Oh My Zsh handles plugin loading, and Powerlevel10k draws the prompt.
Two lines do most of the work in my .zshrc:
ZSH_THEME="powerlevel10k/powerlevel10k"
plugins=(git zsh-syntax-highlighting z fzf-tab)
Those four plugins are the ones that survived. git for the aliases and the
branch state, zsh-syntax-highlighting
because a command turning red before I press Enter has caught more typos than I
would like to admit, z
to jump to a directory I visited last week by typing three letters of its name,
and fzf-tab to hand tab-completion over to
fzf, turning a wall of candidates into a
fuzzy-searchable list.
Powerlevel10k is configured with p10k configure once, and the wizard writes
~/.p10k.zsh — which then goes into the dotfiles repo like everything else. Mine
runs in nerdfont-v3 mode with the lean prompt, which means the prompt shows the
git branch, the Python environment and the exit code of the last command. Most of
my terminal mistakes used to be context mistakes: wrong branch, wrong virtualenv,
wrong machine.
One detail that is easy to miss: the p10k instant-prompt block has to stay at the
very top of .zshrc, above anything that might print or ask for input. It is
the reason the prompt appears immediately instead of after the plugins finish
loading.
History that is actually searchable Link to heading
The single biggest upgrade is not the prompt. It is replacing Ctrl-R with
something that remembers.
One line in .zshrc:
eval "$(atuin init zsh)"
After that, every command is recorded in SQLite along with its directory, exit
code and duration, and Ctrl-R becomes a real search across all of it —
end-to-end encrypted between machines, with a
sync server you can host yourself.
The small stuff that adds up Link to heading
Aliases live in their own file rather than sprawling through .zshrc, sourced
at the end:
if [ -f ~/.zsh_aliases ]; then
source ~/.zsh_aliases
fi
Most of them are unremarkable — gs, ga, gc, gp for git,
grep --color=auto, rm -i because I would rather be asked. The ones I actually
notice are the navigation shortcuts:
alias .="cd .."
alias ...="cd ../../.."
alias ....="cd ../../../.."
Two more lines worth stealing. micro as the
editor, because I want a terminal editor that behaves like every other text field
when I am editing a commit message at 11pm:
export EDITOR='micro'
export VISUAL='micro'
And the binding that opens the current command line in that editor, which turns a long unreadable one-liner into something you can actually see:
autoload -U edit-command-line
zle -N edit-command-line
bindkey '^x^e' edit-command-line
Making all of it reproducible Link to heading
Everything above is text files. The part that makes it a setup rather than a collection is chezmoi.
My dotfiles repository maps one-to-one onto my home directory —
dot_zshrc becomes ~/.zshrc, dot_config/ghostty/themes/dracula becomes
~/.config/ghostty/themes/dracula — plus three
scripts that
only run the first time chezmoi is applied on a machine:
- one installs Homebrew if missing and runs
brew bundleagainst aBrewfilethat carries every formula, cask, App Store app and VS Code extension; - one clones Oh My Zsh, Powerlevel10k,
fzf-tabandzsh-syntax-highlightinginto the right places; - one applies the macOS defaults I would otherwise click through in System Settings — dark mode, Dock size, Finder path bar, key repeat.
The naming does the work: a file called run_once_install-deps.sh runs once and
chezmoi remembers its hash, so applying the dotfiles again does not reinstall
Homebrew.
Secrets stay out of the repository Link to heading
The reason a dotfiles repo usually stops at “shell config” is SSH keys. chezmoi solves that with templates: a managed file can call out to a password manager at apply time. My signing key is a two-line file in the repo that contains no key at all:
{{- (bitwarden "item" "ssh-git-signing").sshKey.privateKey }}
The public keys and ~/.ssh/config are committed as plain files, the private
halves are rendered from Bitwarden at apply time and
never touch git. The same mechanism fills in per-machine values — my
.chezmoi.toml.tmpl pulls a few cloud subscription IDs from a Bitwarden item so
the helper scripts in ~/.local/bin know which subscription to talk to without
those IDs living in a public repo.
Once the keys are in place, git signs commits with SSH rather than GPG, which is three lines and no keyring:
[user]
signingkey = ~/.ssh/id_ed25519_signing.pub
[gpg]
format = ssh
[commit]
gpgsign = true
A new machine, start to finish Link to heading
# 1. Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 2. The three tools needed to bootstrap the rest
brew install chezmoi bitwarden-cli gh
# 3. Auth: gh for the private repo, Bitwarden for the secrets
gh auth login && gh auth setup-git
bw login && export BW_SESSION="$(bw unlock --raw)"
# 4. Everything else
chezmoi init --apply gh:<user>/dotfiles
exec zsh
Step two is the whole bootstrap surface: chezmoi, the Bitwarden CLI and the GitHub CLI. Step four clones the repo, installs the Brewfile, applies the macOS defaults and renders every dotfile including the secrets. It takes as long as Homebrew takes.
Day to day it stays out of the way: chezmoi edit ~/.zshrc to change something,
chezmoi diff to see what would change, chezmoi apply to commit to it,
chezmoi update on the other machine to pull it down.
The part that is still manual Link to heading
Powerlevel10k needs a Nerd Font, and mine is not in
the Brewfile — I installed
MesloLGS NF by
hand at some point and never went back. So on a fresh machine the prompt comes up
with boxes where the icons should be until I fix it, which is exactly the kind of
thing this whole setup is supposed to prevent. It is one line:
cask "font-meslo-lg-nerd-font"
Which is a decent summary of how this works in practice. The setup is not finished and never will be; it just gets one line less manual every time something annoys me enough.