Personal dotfiles managed with chezmoi. The chezmoi
source lives under home/ (set via .chezmoiroot);
repo tooling (this README, Dockerfile, .spr.yml) stays at the top level.
One command — installs chezmoi (if needed), then inits + applies:
sh -c "$(curl -fsLS https://raw.githubusercontent.com/Serubin/dotfiles/main/install.sh)"Or from a local clone (./install.sh uses the checkout as the source):
git clone https://github.com/Serubin/dotfiles.git && dotfiles/install.shOr drive chezmoi directly without the wrapper:
sh -c "$(curl -fsLS get.chezmoi.io)" && chezmoi init --apply Serubin/dotfileschezmoi init prompts once for the machine environment/class (see
Machine targeting) and your git name/email/signingkey
(press Enter to accept defaults), clones the plugin managers, installs packages
for your OS, and writes the managed files into $HOME.
This repo previously used GNU Stow. To switch a machine over:
brew install chezmoi # or: sh -c "$(curl -fsLS get.chezmoi.io)"
chezmoi init --source="$HOME/.dotfiles" # use this checkout as the source
chezmoi diff # PREVIEW every change first
chezmoi apply # applies; auto-removes legacy Stow symlinksThe first apply runs a run_once_before hook that removes the old Stow
symlinks (only symlinks pointing into .dotfiles; real files are untouched).
You can also run it manually beforehand: scripts/uninstall-stow.sh.
Review the diff.
chezmoi applyoverwrites managed paths. In particular~/.claude/CLAUDE.mdmay differ from the repo copy — confirm viachezmoi diffbefore applying.
- macOS (Homebrew)
- Debian / Ubuntu (apt; Neovim via Homebrew if present, else built from source)
Package installs use sudo on Linux.
Every machine resolves two facts at chezmoi init, persisted in the local config
(~/.config/chezmoi/chezmoi.toml) and used to gate config, scripts, and packages:
environment— the trust boundary, validated topersonalorwork. The axis behind personal-only vs work-only gating.class— a free-form role tag, not validated. Defaults to a value derived fromenvironment+ OS, but you can override it with any string.
The four default classes are the cross-product of environment and OS:
| macOS | Linux | |
|---|---|---|
| personal | personal-mac |
personal-server |
| work | work-mac |
work-devbox |
Custom classes (e.g. homelab, work-ci) are allowed — they just won't match the
default-class gates, so add your own gate for them.
chezmoi init prompts once (press Enter to accept the derived class default). To
answer non-interactively — scripted installs, CI, curl | sh:
DOTFILES_ENV=work chezmoi init --apply # environment only
DOTFILES_ENV=work DOTFILES_CLASS=work-ci chezmoi init --apply # custom class
./install.sh --env work # bootstrap wrapper
./install.sh --env work --class work-ci # env + custom classinstall.sh also accepts --env/--class as --env=work, and a bare
personal/work positional still works as an --env shorthand.
To change a machine later, edit [data] in ~/.config/chezmoi/chezmoi.toml
(environment / class) and chezmoi apply, or re-init. Both values are exported
into your shell as $DOTFILES_ENV / $DOTFILES_CLASS (via ~/.zsh/zz-env), so
scripts and interactive config can branch on them.
Existing machines: a machine initialized before this feature has no
environment/classin its config. Runchezmoi initonce (git identity is remembered; you'll only be asked the new prompts) to populate them before the nextchezmoi apply.
Hand-maintained — keep in sync with the gating logic.
| Feature | personal-mac | personal-server | work-mac | work-devbox |
|---|---|---|---|---|
| Core: zsh, git, tmux, Neovim | ✅ | ✅ | ✅ | ✅ |
| Homebrew base packages | ✅ | — | ✅ | — |
| yabai + skhd (config + install) | ✅ | — | ✅ | — |
~/.local/bin on PATH |
✅ | ✅ | ✅ | ✅ |
Homebrew on PATH before ~/.zsh/* (~/.zprofile) |
✅ | opt-in | ✅ | opt-in |
Per-class packages (run_once_after_21) |
— | — | opt-in | opt-in |
/etc/zsh startup-cost optimization (apply-etc-zsh-perf.sh) |
— | — | — | ✅ |
example-work-mac script |
— | — | ✅ | — |
example-work-devbox script |
— | — | — | ✅ |
Drop the file in the chezmoi source (e.g. home/dot_local/bin/executable_foo
→ ~/.local/bin/foo), then gate it in home/.chezmoiignore
at whichever level fits — patterns match target paths and can branch on any
[data] value:
{{ if ne .chezmoi.os "darwin" }}.config/yabai{{ end }} # by OS (any mac)
{{ if ne .environment "work" }}.local/bin/vpn{{ end }} # by environment
{{ if ne .class "work-mac" }}.local/bin/foo{{ end }} # by exact class tag
For per-machine shell config, add it to
home/dot_zsh/zz-env.tmpl (which branches on .class)
rather than ignore-gating a sourced file: .chezmoiignore never removes an
already-applied file, so a reclassified machine would keep sourcing a stale one.
chezmoi manages copies, not symlinks — editing ~/.zshrc directly does not
update the repo. Instead:
chezmoi edit ~/.zshrc # edit the source for a file, then apply
chezmoi apply # write pending changes into $HOME
chezmoi diff # preview pending changes
chezmoi cd # drop into the source dir (home/) to edit/commit
chezmoi re-add # pull live edits of already-managed files back into the source
chezmoi update # git pull the source + apply| Tool | Target | Description |
|---|---|---|
| Git | ~/.config/git/{config,ignore}, ~/.gitconfig_local |
Aliases, templated identity, global ignore, gh credential helper, machine-local overrides |
| Zsh | ~/.zshrc, ~/.zshenv, ~/.zsh/ |
Modular config, zinit plugins, custom prompt |
| tmux | ~/.tmux.conf |
256-color, TPM plugins, session restore |
| Neovim | ~/.config/nvim/ |
Lua config with lazy.nvim |
| Claude Code | ~/.claude/ (curated) |
CLAUDE.md, settings.json, statusline, skills, plugin config |
| yabai + skhd | ~/.config/{yabai,skhd} |
macOS tiling WM + hotkey daemon (macs only) |
.dotfiles/
├── .chezmoiroot # → home (chezmoi source root)
├── home/
│ ├── .chezmoi.toml.tmpl # init prompts (environment/class + git identity)
│ ├── .chezmoiignore # runtime/secret paths chezmoi must not manage
│ ├── .chezmoiexternal.toml.tmpl # zinit, tpm, gitstatus (cloned & auto-updated)
│ ├── .chezmoiscripts/
│ │ ├── run_once_before_10-uninstall-stow.sh
│ │ ├── run_once_before_20-install-packages.sh.tmpl # base tools (incl. gh) BEFORE configs render
│ │ ├── run_once_after_15-migrate-git-xdg.sh # one-time: drop legacy ~/.gitconfig
│ │ └── run_once_after_21-install-env-packages.sh.tmpl # per-class packages
│ ├── dot_zshenv dot_zshrc dot_zprofile.tmpl
│ ├── dot_zsh/ # 00-os executable_02-zinit alias env function promptrc zz-env prompt/
│ ├── .chezmoiremove # targets to delete on apply (e.g. retired ~/.zsh files)
│ ├── dot_local/bin/ # → ~/.local/bin (on PATH); class-gated scripts
│ ├── dot_tmux.conf
│ ├── create_dot_custom # ~/.custom (created once, never overwritten)
│ ├── create_dot_gitconfig_local # ~/.gitconfig_local (created once; machine-local git overrides)
│ ├── dot_config/git/ # → ~/.config/git/{config.tmpl,ignore} (XDG git config)
│ ├── dot_config/nvim/
│ └── dot_claude/ # CURATED: CLAUDE.md, settings.json, statusline, skills/, plugins/*.json
├── scripts/
│ ├── uninstall-stow.sh # remove legacy Stow symlinks (manual)
│ └── docker-test.sh # → `dotfiles-test` in the container: apply + login zsh
├── Dockerfile docker-compose.yml # Debian test harness (non-root sudo user)
├── LICENSE .spr.yml README.md
- Source naming. chezmoi maps source names to targets:
dot_→.,executable_→+x,create_→ create-if-absent,private_→0600,*.tmpl→ Go-templated. OS differences are handled by templates (.chezmoi.os,.chezmoi.osRelease.id). - Externals. zinit, tpm, and (on Linux) gitstatus are declared in
.chezmoiexternal.toml.tmplasgit-repoexternals — chezmoi clones them and keeps them updated. macOS gets gitstatus via Homebrew. Cloning only installs the managers: zinit auto-installs its plugins on first interactive shell; for tmux runprefix + Ionce. - Package installs.
run_once_before_20-install-packages.sh.tmplinstalls base packages per-OS (Homebrew / apt);run_once_after_21-install-env-packagesadds per-class packages. Each re-runs only if its rendered content changes. The base install is arun_before_hook so its tools exist before configs render — e.g. gh must be present for the git template'slookPath "gh"credential-helper block to render on the first apply, so a single apply converges. - Machine targeting.
environment(personal/work) andclassare set at init and gate templates,.chezmoiignore, and the package scripts — see Machine targeting.
- Aliases:
g s(status),g l(pretty graph log),g ap(add -p),g co(checkout),g br(branch), pluslg,ll,lm,su,reorder,contrib. - Identity (name/email/signingkey) is templated and prompted once on
chezmoi init. No name or address is hardcoded in the repo, so fill them in when prompted (values persist afterward). - GPG commit signing is enabled automatically when you provide a signingkey at
init (
[commit] gpgsign = true); leave the signingkey blank to keep it off. pull.rebase = true,rebase.autoStash = true.- Editor set to
nvim.
.zshrc sources every file in ~/.zsh/ in lexicographic order (00-os,
02-zinit, alias, env, function, promptrc, …) for interactive shells only,
making it easy to add or reorder config. ~/.zprofile (dot_zprofile.tmpl) runs
before that, for every login shell — that's where Homebrew lands on PATH:
prepended along with GNU coreutils on macOS, appended on Linux where linuxbrew is
present. The asymmetry is forced, not stylistic: on the Debian devbox
/etc/zsh/zshrc re-sources /etc/zsh/zprofile.d/*sh* (rust, nvm, goenv, pyenv,
spark, …) after ~/.zprofile, so nothing ~/.zprofile prepends can hold the
front. Appending is enough for what's actually needed — 02-zinit gates deja on
${commands[deja]}, which only requires the binary to be findable — and it keeps
brew from displacing the system toolchain in the non-interactive login shells that
never reach /etc/zshrc or ~/.zsh/*. .zshrc then has the last word for
interactive shells: it moves Homebrew to the front of PATH and collapses any
duplicate entries left behind by scalar PATH=… assignments.
Plugins (via zinit):
zsh-syntax-highlighting— command highlightingzsh-jump-target— quick directory jumpingdircolors-solarized— solarized color scheme forls
Inline suggestions come from deja
— predictive ghost-text autosuggestions (replaces zsh-autosuggestions).
It's a standalone Go binary + daemon, initialized in 02-zinit, not a zinit
plugin. On Debian/Ubuntu it comes from Homebrew when brew is available
(brew install serubin/tools/deja-canary, a checksum-verified prebuilt binary), falling
back to upstream's prebuilt release into ~/.local/bin on brew-less boxes — the difference
is which release you get, canary or upstream, not prebuilt versus source. On work-devbox the
brew prefix is wiped by a host restart, so ~/personalize reinstalls it each boot.
The prompt is a custom theme powered by gitstatus for fast git status.
Startup cost on work-devbox. The Coder base image initializes goenv, pyenv and nvm
eagerly in /etc/zsh/zprofile.d, and /etc/zsh/zshrc re-sources that whole directory
even for login shells, which /etc/zprofile has already done. That was ~874ms of a
~1018ms tmux pane. ~/.local/bin/apply-etc-zsh-perf.sh, run at startup by
~/personalize (root-owned /etc does not survive a host restart), regenerates those
snippets with each tool's init output cached rather than re-derived per shell, taking a
pane to ~150ms. Two behavior changes follow from it:
goenv rehashis manual — run it after installing a new Go version.nvmloads lazily, so its first call in a shell pays ~250ms.node,npm,npxand the nvm-installedclaudestay onPATHregardless.
Every step is guarded on the image's current content and validated afterward by
re-deriving the full command-resolution map, so an image change or an unexpected PATH
effect restores /etc instead of leaving a broken shell. Re-run it by hand after
switching a Go/Python/node version, since the selected versions are baked in.
Notable aliases: vim → nvim, cd → cdls (auto-ls after cd), _ →
sudo, cm → chezmoi, extract → aunpack. Named directories (hash -d) give
cd ~dotfiles plus work-gated shortcuts to the ~/lwcode checkouts; the prompt still
shows real paths.
Customization: Add personal/private overrides to ~/.custom (sourced at the
end of .zshrc). chezmoi creates it once from a template (create_dot_custom)
and never overwrites it.
See home/dot_zsh/README.md for the full breakdown
(per-file structure, functions, and the complete alias reference).
- 256-color terminal with a blue status bar
- 1-based window/pane indexing
prefix + \/prefix + -for horizontal/vertical splits (preserves the working directory)- Session auto-save every 5 minutes and auto-restore on start
Plugins (via TPM):
tmux-sensible— sensible defaultstmux-prefix-highlight— visual prefix indicatortmux-resurrect— session save/restoretmux-continuum— automatic session saving
TPM itself is cloned by chezmoi (external); run prefix + I once to install the
plugins.
Lua-based configuration on LazyVim using
lazy.nvim for plugin management. Plugin
versions are intentionally unpinned (lazy-lock.json is not managed).
Plugins:
neo-tree— file explorersolarized— color schemeblink— completionbetter-whitespace— trailing whitespace highlightingsnacks— UI utilities / picker
See home/dot_config/nvim/README.md for the
full breakdown (options, keymaps, per-plugin notes, autocommands).
Only curated config is managed — CLAUDE.md, settings.json,
statusline-command.sh, skills/, and plugins/blocklist.json. Everything else in
~/.claude (sessions, projects, history, caches, settings.local.json,
credentials) is left untouched.
Caveat:
plugins/blocklist.jsoncarries an app-maintainedfetchedAtfield that Claude Code rewrites, sochezmoi statusmay show it as drifted andapplyreverts it. Its blocked-plugin entries are worth tracking anyway.plugins/known_marketplaces.jsonis deliberately not managed: on top of alastUpdatedstamp it records each marketplace's clone path, which is absolute and per-machine, making it generated state rather than config.
Spins up a Debian container as a non-root user with passwordless sudo (so the
sudo package installs are actually exercised) with the repo mounted read-only at
/workspace.
docker compose up -d --build
docker compose exec debian dotfiles-test # bootstrap (prompts) + drop into login zsh
# or, without applying:
docker compose exec debian zshdotfiles-test runs chezmoi init --apply against the mounted source and drops
you into the configured login zsh; it's idempotent, so re-running is a fast
re-apply. Seed the env/class prompts non-interactively via the commented
environment: block in docker-compose.yml (or DOTFILES_ENV=… DOTFILES_CLASS=… docker compose exec debian dotfiles-test).
Neovim caveat: the image pre-installs Debian's
nvimso the harness skips the multi-minute from-source build (run_once_before_20). That apt build is older than what a real machine gets; it's fine for testing the dotfiles config, but to exercise the source-build path, dropneovimfrom theDockerfile.
MIT — see LICENSE.