Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

cade’s dotfiles

Personal dotfiles for macOS and Linux — one command sets up a complete, reproducible dev environment on any machine.

curl -fsSL https://raw.githubusercontent.com/cadebrown/dotfiles/main/bootstrap.sh | bash

What this gives you

  • One bootstrap command — installs every tool, dotfile, and language runtime from scratch
  • PLAT isolation — compiled binaries live under ~/.local/$(uname -m)-$(uname -s)/, so two machines sharing an NFS home directory never conflict
  • No sudo on Linux — Homebrew runs inside a rootless container; everything installs to user paths
  • Idempotent — every script is safe to re-run; running bootstrap on a second machine just installs that machine’s arch-specific tools
  • Single source of truth — one Brewfile for both macOS and Linux; if OS.mac? blocks handle the differences automatically
  • Fast shell startup — lazy nvm loading, single compinit, ~140ms warm startup

How it works

chezmoi manages dotfiles as templates in home/ and applies them to ~/. The bootstrap script wires everything together:

bootstrap.sh
  ↓ chezmoi apply        dotfiles → ~/
  ↓ install/zsh.sh       oh-my-zsh + plugins
  ↓ homebrew.sh          packages from Brewfile (macOS)
    linux-packages.sh    packages from Brewfile (Linux, via container)
  ↓ install/node.sh      nvm → Node.js
  ↓ install/rust.sh      rustup → cargo tools
  ↓ install/python.sh    uv → Python venv
  ↓ install/claude.sh    Claude Code + plugins + MCP servers

Compiled tools land under ~/.local/$PLAT/ — a different directory per arch+OS, so a shared home has no conflicts. Text configs (dotfiles) are shared freely; they’re arch-neutral by design.


Sections

Setup — getting started on a new machine

PageWhat it covers
BootstrapSystem requirements, what gets installed, platform-specific steps
Managing dotfilesHow chezmoi works, editing dotfiles, template variables
Package managementAdding tools (Homebrew, cargo, npm, pip), why each layer exists

Usage — ongoing updates and maintenance

PageWhat it covers
Day-to-day workflowAdding packages, editing dotfiles, deploying docs, updating tools
TroubleshootingQuick reference — when tools aren’t found, builds fail, etc.

Bootstrap a new machine

One-liner

curl -fsSL https://raw.githubusercontent.com/cadebrown/dotfiles/main/bootstrap.sh | bash

Or from a clone:

git clone https://github.com/cadebrown/dotfiles ~/dotfiles
~/dotfiles/bootstrap.sh

No sudo required on either platform. The script detects the OS and runs the right steps.


macOS

Requirements

RequirementHow to get it
macOS 13+
Xcode Command Line ToolsHomebrew prompts automatically, or: xcode-select --install
Internet access

What gets installed

  1. chezmoi~/.local/$PLAT/bin/chezmoi
  2. Dotfiles applied via chezmoi apply — prompts for name + email on first run
  3. oh-my-zsh + plugins (pure prompt, autosuggestions, fast-syntax-highlighting, completions)
  4. Homebrew/opt/homebrew (Apple Silicon) or /usr/local (Intel)
  5. Packages from packages/Brewfile — CLI tools, casks, macOS services
  6. colima registered as a login service (rootless Docker runtime)
  7. Node.js via nvm → ~/.local/$PLAT/nvm/
  8. Rust via rustup → ~/.local/$PLAT/rustup/, cargo tools → ~/.local/$PLAT/cargo/
  9. Python via uv → ~/.local/$PLAT/venv/
  10. Claude Code via Homebrew cask + plugins + MCP servers

Linux

Requirements

RequirementNotes
x86_64 or aarch64
Docker (rootless) or PodmanRequired for the package install step. See below.
git and curlPre-installed on most systems
Internet access

No sudo is needed after the initial Docker/Podman setup. Packages install inside a manylinux_2_28 container (AlmaLinux 8, glibc 2.28) — most pour as precompiled bottles; Homebrew bundles its own glibc so the binaries are self-contained on any host.

Docker (rootless)

Rootless Docker runs entirely as your user — no root daemon.

curl -fsSL https://get.docker.com/rootless | sh
export PATH="$HOME/bin:$PATH"
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"
systemctl --user enable --now docker

Full docs: docs.docker.com/engine/security/rootless

Podman (rootless)

Podman is rootless by design — no daemon, no sudo.

apt install podman    # Debian/Ubuntu (may need sudo once)
dnf install podman    # RHEL/Fedora
which podman          # HPC clusters: often already available

The bootstrap script checks for docker first, then podman. Either works.

What gets installed

  1. chezmoi~/.local/$PLAT/bin/chezmoi
  2. Dotfiles applied via chezmoi apply
  3. oh-my-zsh + plugins
  4. Homebrew~/.local/$PLAT/brew/ (inside manylinux container; casks skipped)
  5. Node.js via nvm → ~/.local/$PLAT/nvm/
  6. Rust via rustup → ~/.local/$PLAT/rustup/, ~/.local/$PLAT/cargo/
  7. Python via uv → ~/.local/$PLAT/venv/
  8. Claude Code native binary → ~/.local/$PLAT/bin/claude + plugins + MCP servers

First run takes ~10 minutes (glibc and a few others compile from source). Subsequent runs skip already-installed packages.


Skipping steps

Any step can be skipped with an environment variable:

INSTALL_PACKAGES=0   # skip Homebrew + brew bundle
INSTALL_ZSH=0        # skip oh-my-zsh
INSTALL_NODE=0       # skip nvm + Node.js
INSTALL_RUST=0       # skip rustup + cargo tools
INSTALL_PYTHON=0     # skip uv + venv
INSTALL_CLAUDE=0     # skip Claude Code plugins + MCP servers

Example — skip everything except dotfiles:

INSTALL_PACKAGES=0 INSTALL_ZSH=0 INSTALL_NODE=0 \
INSTALL_RUST=0 INSTALL_PYTHON=0 INSTALL_CLAUDE=0 \
~/dotfiles/bootstrap.sh

First-run prompts

chezmoi asks for display name and email once. Values are cached in ~/.config/chezmoi/chezmoi.toml. To skip the prompts, pre-seed them:

CHEZMOI_NAME="Your Name" CHEZMOI_EMAIL="[email protected]" ~/dotfiles/bootstrap.sh

To re-prompt (e.g. after changing email): chezmoi init --data=false


Shared home directories

If two machines share a home directory (NFS), run bootstrap.sh independently on each:

  • chezmoi finds the cached config — no prompts the second time
  • Dotfiles are already applied — no changes needed
  • All tool installs use ~/.local/$PLAT/, so each machine gets its own arch-specific binaries without conflict

All tools installed here (Homebrew bottles, rustup, uv, nvm) ship self-contained binaries — they don’t link against the host glibc — so different glibc versions on the same arch are not a problem.

Managing dotfiles

chezmoi manages the files in home/ and applies them to ~/, resolving templates along the way.

The quick version

chezmoi edit ~/.zshrc          # edit a dotfile (opens in $EDITOR, applies on save)
chezmoi apply                  # apply all pending changes
chezmoi diff                   # preview what would change before applying
chezmoi update                 # git pull + apply (sync from repo)

How files map

Files in home/ map to ~/ by chezmoi’s naming rules:

SourceTarget
home/dot_zshrc.tmpl~/.zshrc
home/dot_config/git/ignore~/.config/git/ignore
home/dot_ssh/config.tmpl~/.ssh/config
home/dot_claude/CLAUDE.md~/.claude/CLAUDE.md
  • dot_ prefix → . in target
  • .tmpl suffix → rendered as a Go template before writing

Template variables

Use these in any .tmpl file:

{{ .name }}              display name (prompted on first run)
{{ .email }}             email (prompted on first run)
{{ .chezmoi.os }}        "darwin" or "linux"
{{ .chezmoi.arch }}      "amd64" or "arm64"
{{ .chezmoi.username }}  system login name (auto-detected)
{{ .chezmoi.homeDir }}   home directory path

Example — Linux-only alias:

{{ if eq .chezmoi.os "linux" -}}
alias open='xdg-open'
{{ end -}}

Editing dotfiles

Via chezmoi (recommended — auto-applies on save):

chezmoi edit ~/.zshrc
chezmoi edit ~/.zprofile

Directly in the repo (then apply manually):

$EDITOR ~/dotfiles/home/dot_zshrc.tmpl
chezmoi apply

Never edit ~/.zshrc directly — chezmoi will overwrite it on the next apply.


Shared home directory safety

On a shared NFS home, all machines run chezmoi apply against the same target files. Templates must render identically on every machine that shares the home — otherwise machines overwrite each other on every apply.

Rule: never use {{ .chezmoi.arch }} or any per-machine value in a template. Arch-specific logic belongs in shell runtime code instead:

# Good — evaluated at shell startup on each machine independently
export PATH="$HOME/.local/$(uname -m)-$(uname -s)/bin:$PATH"

# Bad — baked into the file at chezmoi apply time; machines fight each other
export PATH="$HOME/.local/{{ .chezmoi.arch }}-{{ .chezmoi.os }}/bin:$PATH"

The existing templates only branch on {{ .chezmoi.os }} (darwin vs linux), which is stable for all machines sharing a home.


Files that other tools also write

Some tracked files are mutated at runtime. chezmoi won’t auto-apply — drift is intentional until you decide what to do:

chezmoi diff                          # see what changed
chezmoi add ~/.claude/settings.json   # pull the live version back into the repo

Notable examples:

  • ~/.claude/settings.json — updated by Claude Code when plugins are installed
  • ~/.codex/config.toml — Codex appends project trust levels at runtime; managed with create_ prefix so chezmoi writes it once and never overwrites

Package management

Every package layer has a declarative text file and an idempotent install script. All scripts skip already-installed items — safe to re-run at any time.

The layers

LayerFileInstall scriptPlatform
System packagespackages/Brewfileinstall/homebrew.sh / install/linux-packages.shmacOS (bottles) / Linux (manylinux container)
Rust toolspackages/cargo.txtinstall/rust.shAll
Python packagespackages/pip.txtinstall/python.shAll
Global npmpackages/npm.txtinstall/npm.shAll
Claude pluginspackages/claude-plugins.txtinstall/claude.shAll
Claude MCP serverspackages/claude-mcp.txtinstall/claude.shAll

Adding a package — priority order

Choose the first layer that applies:

1. cargo — Rust crates

# packages/cargo.txt
ripgrep
fd-find

Re-run: bash ~/dotfiles/install/rust.sh

2. Homebrew — everything else

# packages/Brewfile
brew "tool-name"

# macOS-only (casks, GUI apps, macOS services)
if OS.mac?
  cask "some-app"
end

Re-run: brew bundle --file=~/dotfiles/packages/Brewfile

3. pip — Python packages

# packages/pip.txt
requests
black

Re-run: bash ~/dotfiles/install/python.sh

4. npm — npm-specific tools

# packages/npm.txt
@scope/package-name

Re-run: bash ~/dotfiles/install/npm.sh

5. Custom install script

Look at an existing install/ script for patterns, follow them, and add an INSTALL_* flag to bootstrap.sh.


Why cargo over Homebrew for some tools

fd, sd, and zoxide are in cargo.txt instead of Brewfile because:

  • $CARGO_HOME/bin is already under $LOCAL_PLAT/ — PLAT isolation is automatic
  • Rust crates compile cleanly from source on any platform
  • The Homebrew formula for these often just calls cargo install anyway

Do not install the same tool in both places. PLAT paths win on PATH — the Homebrew copy would install but never be used.


Why Homebrew for Linux

Homebrew on Linux installs inside a manylinux_2_28 container (AlmaLinux 8, glibc 2.28) so the compiled binaries work on any Linux host since ~2018. Most packages pour as precompiled bottles — no compilation needed. Homebrew bundles its own glibc 2.35, so the binaries are self-contained regardless of the host’s glibc version.

The same Brewfile works on macOS and Linux. if OS.mac? blocks (casks, GUI apps) are silently skipped on Linux.


Updating all packages

# Homebrew (macOS)
brew bundle --file=~/dotfiles/packages/Brewfile

# Homebrew (Linux) — re-run in container
bash ~/dotfiles/install/linux-packages.sh

# Cargo tools
bash ~/dotfiles/install/rust.sh

# Python venv
bash ~/dotfiles/install/python.sh

# Claude plugins + MCP servers
bash ~/dotfiles/install/claude.sh

Day-to-day workflow


Add a package

See Package management for the priority order. Quick reference:

# Rust tool → packages/cargo.txt, then:
bash ~/dotfiles/install/rust.sh

# Homebrew formula/cask → packages/Brewfile, then:
brew bundle --file=~/dotfiles/packages/Brewfile

# Python package → packages/pip.txt, then:
bash ~/dotfiles/install/python.sh

Edit a dotfile

chezmoi edit ~/.zshrc          # opens in $EDITOR, applies on save
chezmoi edit ~/.zprofile
chezmoi edit ~/.gitconfig

Or edit the source directly and apply:

$EDITOR ~/dotfiles/home/dot_zshrc.tmpl
chezmoi apply

Preview before applying: chezmoi diff


Sync dotfiles from the repo

chezmoi update                 # git pull + chezmoi apply

Update AI agent instructions

Claude (~/.claude/CLAUDE.md) and Codex (~/.codex/AGENTS.md) mirror each other — edit both:

chezmoi edit ~/.claude/CLAUDE.md
chezmoi edit ~/.codex/AGENTS.md

Add an env var or PATH entry

Edit home/dot_zprofile.tmpl. For anything arch-specific use $_LOCAL_PLAT (set at shell startup):

export MY_TOOL_HOME="$_LOCAL_PLAT/my-tool"
export PATH="$MY_TOOL_HOME/bin:$PATH"

Also add the variable to install/_lib.sh so install scripts can reference the same path.


Work on the docs

cd ~/dotfiles/docs && mdbook serve --open   # live reload at localhost:3000

Every push to main auto-deploys to dotfiles.cade.io via Cloudflare Pages.


Deploy infrastructure changes

cd ~/dotfiles/infra/cloudflare
export CLOUDFLARE_API_TOKEN=...
tofu plan     # preview
tofu apply    # apply

terraform.tfvars is gitignored — it holds account_id and stays local.


Commit and push

cd ~/dotfiles
git add -p                    # stage selectively
git commit -m "description"
git push

Natural commit points: one commit per feature, config change, or coherent set of package additions.

Troubleshooting

Quick reference for when things go wrong. Check here before digging into scripts.


Tool not found after bootstrap

echo "$_PLAT"                         # confirm which platform the shell resolved
ls ~/.local/$_PLAT/bin/               # chezmoi, uv, claude should be here
ls ~/.local/$_PLAT/cargo/bin/         # fd, sd, zoxide, etc.
which fd                              # should point into ~/.local/$_PLAT/

If $_PLAT is empty or wrong, .zprofile wasn’t sourced. Open a new login shell (zsh -l) or source it:

source ~/.zprofile

nvm or node not available in a script

nvm.sh is lazy-loaded in interactive shells only. Non-interactive shells get node/npm via the PATH entry .zprofile adds from the highest installed version. If node is missing in a script, either:

# Option 1: source zprofile at the top of your script
source ~/.zprofile

# Option 2: use the full path
NODE="$NVM_DIR/versions/node/$(ls $NVM_DIR/versions/node | sort -V | tail -1)/bin/node"

Homebrew on Linux: Docker/Podman not found

linux-packages.sh requires a container runtime. Options:

# Rootless Docker
curl -fsSL https://get.docker.com/rootless | sh

# Podman (Debian/Ubuntu)
apt install podman

# Skip packages entirely and install manually
INSTALL_PACKAGES=0 ~/dotfiles/bootstrap.sh

See Bootstrap → Linux for full setup instructions.


chezmoi keeps prompting for name/email

The cached values live in ~/.config/chezmoi/chezmoi.toml. To reset:

chezmoi init --data=false

To pre-seed without prompting:

CHEZMOI_NAME="Your Name" CHEZMOI_EMAIL="[email protected]" chezmoi init

chezmoi diff shows unexpected changes

Another program modified a managed file (e.g. Claude Code updated ~/.claude/settings.json). Options:

chezmoi diff                          # see what changed
chezmoi apply                         # overwrite with repo version
chezmoi add ~/.claude/settings.json   # pull the live version into the repo

PATH order is wrong — wrong binary is resolving

Expected priority (highest to lowest):

~/.local/$PLAT/venv/bin      Python venv
~/.local/$PLAT/cargo/bin     Rust tools (fd, sd, zoxide, etc.)
~/.local/$PLAT/nvm/.../bin   Node.js
~/.local/$PLAT/bin           chezmoi, uv, claude (Linux)
~/.local/bin                 arch-neutral scripts
/opt/homebrew/bin            Homebrew (macOS)
/usr/bin                     system

Diagnose with:

which <tool>                  # where it's resolving from
type -a <tool>                # all locations on PATH
echo $PATH | tr ':' '\n'      # full PATH in order

If a Homebrew tool is shadowing a cargo tool, check packages/cargo.txt and packages/Brewfile for duplicates — remove the one you don’t want.


Cloudflare Pages build failing

Check the build log via the API:

ACCOUNT="5afb385ba43e1a082b138554dfdb141c"
TOKEN="..."
# List recent deployments
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/pages/projects/dotfiles/deployments" \
  -H "Authorization: Bearer $TOKEN" | python3 -m json.tool | grep -E '"id"|"status"'

# Get logs for a specific deployment
DEPLOY_ID="..."
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT/pages/projects/dotfiles/deployments/$DEPLOY_ID/history/logs" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
for e in json.load(sys.stdin)['result']['data']: print(e['line'])
"

Common causes:

  • cargo-binstall: command not found/opt/buildhome/.cargo/bin not on PATH; check infra/cloudflare/build.sh
  • mdbook: command not found — binstall failed; check network or fall back to cargo install mdbook --locked
  • Build output not found — confirm destination_dir = "docs/book" in infra/cloudflare/main.tf

Two machines fighting over dotfiles on a shared home

This happens when a template renders differently on each machine (e.g. using {{ .chezmoi.arch }}). The rule: templates must be arch-neutral. Arch-specific logic belongs in shell runtime code, not templates.

Check which template is causing the conflict:

chezmoi diff        # shows what chezmoi wants to change vs what's on disk

The fix is almost always to replace a template variable with a shell runtime expression. See Managing dotfiles → Shared home safety.