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-{personal,work} |
CLAUDE.md, settings.json, statusline, skills, plugin config; two logins via claude --work / --personal |
| 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_before_17-share-claude-session-state.sh # one-time: fold per-account state back into ~/.claude
│ │ └── 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/
│ ├── .chezmoitemplates/ # shared template bodies (claude-settings-merge.py)
│ ├── dot_claude/ # CURATED shared root: CLAUDE.md, settings.json, statusline, hooks, skills/, plugins/*.json
│ └── dot_claude-{personal,work}/ # per-login dirs; symlink into dot_claude for everything but auth
├── 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/formula/deja, the canary build as 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. Both are spelled deja, so the source is the tap, not the name. 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, check-comment-length.py, skills/, and
plugins/blocklist.json. Everything else (sessions, projects, history, caches,
settings.local.json, credentials) is left untouched.
check-comment-length.py is a PostToolUse hook on Edit/Write: it flags comment
runs longer than the two lines CLAUDE.md allows, scoped to the lines an edit actually
added, with file headers and doc comments exempt. It carries comment syntax for code
files only, so documents — .md and the like — are never inspected. hooks lives in the
merge script's FORCE block, so the repo owns the hook list and a live /hooks edit does
not survive the next apply.
Two logins on one machine. Claude Code keys its entire identity — auth,
.claude.json, projects, history — off CLAUDE_CONFIG_DIR, so a second account needs a
second directory. ~/.claude stays the one store and each account dir symlinks into it,
so switching accounts does not fork your conversations:
~/.claude/ everything: CLAUDE.md, skills/, settings*.json, statusline,
projects/ (incl. per-project memory/), sessions/, history.jsonl,
plans/, tasks/, teams/, file-history/, paste-cache/, …
~/.claude-personal/ ─┐ all of the above are symlinks back into ~/.claude; only
~/.claude-work/ ─┘ .claude.json, credentials and the org-pushed
policy-limits.json / remote-settings.json are per-account
Sharing projects/ is what makes -c and --resume reach a conversation from either
login, and it carries memory/ with it. .claude.json is the one piece that cannot be
shared: it fuses oauthAccount with the project list, so linking it would collapse the
account distinction.
Caveat: only one account can be signed in at a time. The macOS Keychain entry is shared across config dirs for writes, so a
/loginon one account signs the other out. The split gives you two settings profiles and one shared history, not two concurrent sessions.
Caveat: Claude Code rewrites
settings.jsonon/config,/modeland/theme. If it ever does so atomically it will replace the symlink with a real file;chezmoi applyputs the link back, but a toggle made in between is lost.
~/.zsh/zz-env exports CLAUDE_CONFIG_DIR=~/.claude-$DOTFILES_ENV, so the machine's
environment picks the default account and anything launched from the shell agrees with
it. The claude wrapper in ~/.zsh/function overrides that per invocation:
claude # the machine default
claude --work -c # work account, --work eaten, -c passed through
claude --personal --model opus "…"It also adds --dangerously-skip-permissions by default, skipping that for subcommands
(claude mcp list) and whenever you pass your own --permission-mode.
Caveat: anything started without the shell — a GUI-launched editor, cron — still resolves
~/.claudeand its own~/.claude.json, so it keeps working but accumulates a second, separate history.launchctl setenv CLAUDE_CONFIG_DIR …would close that gap on macOS if it ever matters.
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.