cade’s dotfiles
Personal dotfiles for macOS and Linux. One command bootstraps a complete dev environment — idempotent, no sudo on Linux, and (optionally) safe on shared NFS home directories across CPU architectures.
DF_NAME="Your Name" DF_EMAIL="you@example.com" \
curl -fsSL https://raw.githubusercontent.com/cadebrown/dotfiles/main/bootstrap.sh | bash
DF_NAME / DF_EMAIL are needed when piping into bash (the pipe occupies stdin, so chezmoi can’t prompt); from a local clone, ~/dotfiles/bootstrap.sh prompts interactively. Re-run anytime to converge.
Pick your path:
| Goal | Page |
|---|---|
| Set up a brand-new machine | Bootstrap |
| Sync the latest changes | Day-to-day workflow |
| Add or remove a tool | Package management |
| Understand PLAT isolation | PLAT isolation |
| Set up API tokens | Auth |
| Create a private extension | Overlays |
Look up a DF_* flag | Env-var reference |
Trace what bootstrap.sh actually does | Bootstrap flow |
What gets installed
Dotfiles and shell
chezmoi manages dotfiles as templates in home/ and applies them to ~/. Both zsh and bash get identical login profiles with PLAT detection, PATH setup, and tool activation.
- zsh: oh-my-zsh with pure prompt, autosuggestions, fast-syntax-highlighting, completions, and lazy nvm loading (~140ms startup)
- bash: minimal config with git branch prompt, shared aliases, zoxide, fzf completions
- git: global config with name/email from chezmoi data, delta as pager
- SSH: templated config from
home/dot_ssh/config.tmpl
Packages
A single packages/Brewfile drives both platforms. On macOS, Homebrew installs native bottles plus casks (GUI apps). On Linux, Homebrew installs to a custom prefix ($_LOCAL_PLAT/brew/) with its own glibc — fully self-contained, no sudo.
if OS.mac? blocks in the Brewfile handle macOS-only casks and tools; Linux skips them silently.
Languages
| Language | Tool | Install location | Package list |
|---|---|---|---|
| Rust | rustup + cargo-binstall | $LOCAL_PLAT/rustup/, $LOCAL_PLAT/cargo/ | packages/cargo.txt |
| Node.js | nvm (lazy-loaded in zsh) | $LOCAL_PLAT/nvm/ | packages/npm.txt |
| Python | uv tool install (per CLI tool) | $LOCAL_PLAT/uv/tools/, entrypoints in $LOCAL_PLAT/bin/ | packages/pip.txt + full profile |
Rust tools install via cargo-binstall (downloads pre-built binaries from GitHub releases when available, falls back to source). Python CLI tools each get their own isolated venv via uv tool install — no monolithic user-level environment. On macOS, rustup comes from Homebrew (code-signed, required on Sequoia+ where the linker enforces provenance).
AI tools
- Claude Code — native binary from Anthropic’s release bucket, plus plugins (
packages/claude-plugins.txt) and MCP servers (packages/mcp-servers.txt) - Codex CLI — npm-installed binary (
@openai/codexinnpm.txt), with managed config + hooks underhome/dot_codex/and[mcp_servers.*]blocks generated from the sharedpackages/mcp-servers.txt - Cursor / VS Code — extension lists in
packages/{cursor,vscode}-extensions.txt; Cursor settings symlinked fromhome/dot_cursor/
macOS-specific
- System settings (
install/macos-settings.sh) — Dock autohide, Finder extensions/path bar, fast key repeat, tap to click, PNG screenshots, Safari dev menu, iTerm2 prefs, Touch ID for sudo (works in tmux) with a one-auth-covers-all-terminals ticket policy - Services (
install/macos-services.sh) — optional auto-start for Colima (rootless Docker), Ollama, and mlxserve; off by default (DF_START_LOCAL_SERVICES=1to enable). Docker CLI plugins always linked. - Quick Actions (
install/macos-quick-actions.sh) — Finder right-click “Open in Cursor” and friends
Auth (opt-in)
install/auth.sh is a guided service-registry helper that creates ~/.<service>.env files (chmod 600) for GitHub, Anthropic, OpenAI, Cloudflare, and HuggingFace — plus a separate gh auth login flow for the Claude GitHub MCP. Sourced automatically by all install scripts and login shells. Run during bootstrap with DF_DO_AUTH=1 or standalone anytime.
Home directories
install/dirs.sh creates ~/dev, ~/bones, and ~/misc (configurable via DF_DIRS). On systems with scratch space, these become symlinks directly under $SCRATCH/ for fast local storage. See Scratch space.
PLAT isolation (optional)
By default $LOCAL_PLAT = $HOME/.local and everything lives under a flat ~/.local/. PLAT isolation is opt-in — set DF_USE_PLAT=1 (or use_plat = true in chezmoi data) and $LOCAL_PLAT becomes ~/.local/$PLAT/. The point: on a shared NFS home, each machine installs into its own PLAT directory; one home directory, many machines, no conflicts. Single-machine users get the simpler flat layout without the per-PLAT directory tax.
DF_USE_PLAT=0 (default, flat) DF_USE_PLAT=1 (NFS-shared homes)
───────────────────────────── ───────────────────────────────────
~/.local/ ~/.local/
├── bin/ ├── plat_Darwin_arm64/
│ ├── chezmoi │ ├── bin/{chezmoi,uv,claude}
│ ├── uv │ ├── brew/ (Apple Silicon)
│ └── claude │ ├── cargo/bin/ (arm64 binaries)
├── brew/ (one prefix) │ └── nvm/ (arm64 node)
├── cargo/bin/ (host arch) ├── plat_Linux_x86-64-v3/
└── nvm/ │ ├── brew/ (AVX2 glibc)
│ └── ...
$_LOCAL_PLAT = ~/.local └── plat_Linux_x86-64-v4/ (AVX-512)
└── ...
$_LOCAL_PLAT = ~/.local/$_PLAT
(set per-shell from CPU detection)
Capability detection still runs in flat mode — .plat_env.sh tunes compiler flags (-march=x86-64-v3, RUSTFLAGS=-Ctarget-cpu=apple-m1, etc.) for the host CPU even when directory isolation is off. See PLAT isolation for the decision matrix.
macOS vs Linux
| macOS | Linux | |
|---|---|---|
| Packages | Homebrew at /opt/homebrew | Homebrew at $LOCAL_PLAT/brew/ (custom prefix, bundled glibc) |
| Rust | Homebrew rustup (code-signed for Sequoia) | sh.rustup.rs |
| System settings | Dock, Finder, keyboard, trackpad, Safari, iTerm2 | – |
| Services | Colima (rootless Docker) | – |
| sudo required | Yes (Homebrew installer) | No |
Bootstrap modes
bootstrap.sh # install (default) — full idempotent setup
bootstrap.sh update # git pull + chezmoi apply + refresh tools
bootstrap.sh upgrade # update + brew upgrade + cargo upgrade
Any step can be skipped with DF_DO_*=0 env vars. See Bootstrap for the full list.
Sections
Setup
| Page | What it covers |
|---|---|
| Bootstrap | System requirements, what gets installed, skip flags, modes |
| Managing dotfiles | chezmoi workflow, editing dotfiles, template variables, shared-home safety |
| Package management | Adding tools via cargo, npm, pip, or Homebrew |
| PLAT isolation | When to use it, layouts compared, decommissioning |
| Auth | Service registry, env-file flow, gh-derive trick |
| Scratch space | Symlink topology for NFS-quota relief |
| Overlays | Private extension repos (dotfiles-*/) |
Usage
| Page | What it covers |
|---|---|
| Day-to-day workflow | Updating, adding packages, editing dotfiles |
| AeroSpace window management | Tiling WM keymap (macOS) |
| Local AI coding | Ollama, mlx-lm, opencode, pi setup |
| Troubleshooting | Tools not found, PATH issues, build failures |
Reference
| Page | What it covers |
|---|---|
Env vars (DF_*) | Complete table of every flag and behavior var |
| Bootstrap flow | Step-by-step diagram of what bootstrap.sh does |
Infrastructure
| Page | What it covers |
|---|---|
| Docs and hosting | How this site is built, deployed, and managed |
Bootstrap a new machine
One-liner
DF_NAME="Your Name" DF_EMAIL="you@example.com" \
curl -fsSL https://raw.githubusercontent.com/cadebrown/dotfiles/main/bootstrap.sh | bash
Runs fully unattended. DF_NAME / DF_EMAIL are needed here because piping the
script into bash occupies stdin, leaving chezmoi no terminal to prompt on. The
values are cached in ~/.config/chezmoi/chezmoi.toml, so re-runs read from the
cache and need nothing.
Interactive (prompts for name + email)
To be prompted instead of pre-seeding, run from a local clone in a real terminal — chezmoi then has a TTY to read from:
git clone https://github.com/cadebrown/dotfiles ~/dotfiles
~/dotfiles/bootstrap.sh
Modes
bootstrap.sh # install (default) — full idempotent setup
bootstrap.sh update # git pull + chezmoi apply + refresh tools
bootstrap.sh upgrade # update + brew upgrade + cargo upgrade
update pulls the latest dotfiles, applies chezmoi, refreshes zsh plugins, and re-runs all install scripts (which skip already-installed tools). Skips scratch setup and repo cloning.
upgrade does everything update does, plus enables Homebrew upgrades (DF_BREW_UPGRADE=1) and forces cargo-binstall to re-check for newer binaries.
macOS
Requirements
| Requirement | How to get it |
|---|---|
| macOS 13+ (Ventura or later) | — |
| Xcode Command Line Tools | Homebrew prompts automatically, or: xcode-select --install |
| Internet access | — |
Sudo is required for the Homebrew installer.
What gets installed
Paths below use $LOCAL_PLAT, which is $HOME/.local by default and $HOME/.local/$PLAT when PLAT isolation is enabled. $ARCH_BIN is $LOCAL_PLAT/bin.
- chezmoi →
$ARCH_BIN/chezmoi - Dotfiles applied via
chezmoi apply- Shell configs for both zsh (
.zprofile) and bash (.bash_profile) - Both shells do identical PLAT capability detection and PATH setup
- Shell configs for both zsh (
- oh-my-zsh + plugins (pure prompt, autosuggestions, fast-syntax-highlighting, completions)
- Homebrew →
/opt/homebrew(Apple Silicon) or/usr/local(Intel)- All packages from
packages/Brewfile— CLI tools, casks, macOS-only apps - Includes
rustup(Homebrew’s code-signed build — required for macOS Sequoia+)
- All packages from
- Services: colima/ollama/mlxserve auto-start is opt-in (
DF_START_LOCAL_SERVICES=1); off by default. At the default, colima/ollama are simply left alone, but mlxserve is stopped andlaunchctl disabled — launchd re-loads its plist at every login otherwise, so a hand-started mlxserve will not survive a bootstrap run. Docker CLI plugins are always linked. - macOS defaults: Dock, Finder, keyboard, trackpad, screenshots, Safari, iTerm2 preferences
- Python via uv →
$LOCAL_PLAT/uv/tools/<tool>/(one isolated venv per CLI tool), entrypoints in$ARCH_BIN;DF_PROFILE=coreskipspip-full.txt - Node.js 24 LTS via pinned nvm →
$LOCAL_PLAT/nvm/- Uses uv’s Python for
node-gypfallbacks when an npm package has no prebuilt binary
- Uses uv’s Python for
- Rust toolchain →
$LOCAL_PLAT/rustup/+$LOCAL_PLAT/cargo/- Homebrew’s
rustup(code-signed), required on macOS Sequoia+ where the linker enforcescom.apple.provenance DF_PROFILE=corekeeps rustup and rust-analyzer but skips optionalcargo.txttoolscargo-binstalldownloads pre-built binaries from GitHub releases when available, falls back to source- Cargo tools install to
$LOCAL_PLAT/cargo/bin/
- Homebrew’s
- Go CLI tools from
packages/go.txt→$ARCH_BIN(Go itself comes from the Brewfile) - Lean 4 via elan →
$LOCAL_PLAT/elan/- Toolchains are arch-specific (~1.5 GB each), so
ELAN_HOMEis PLAT-isolated like rustup - Installs and defaults a pinned toolchain; projects override via their own
lean-toolchainfile
- Toolchains are arch-specific (~1.5 GB each), so
- TeX — MacTeX comes from the Brewfile cask; this step verifies it and puts
/Library/TeX/texbinon PATH - Claude Code native binary →
$ARCH_BIN/claude+ plugins + MCP servers + overlay skills - Codex CLI native binary →
$ARCH_BIN/codex, plus managed config + hooks under~/.codex/ - Cursor / VS Code — settings symlinked from
home/dot_cursor/; extensions installed frompackages/{cursor,vscode}-extensions.txt - CMake toolchain files →
$LOCAL_PLAT/cmake/toolchains/- Versioned files:
llvm-21.cmake,llvm-22.cmake,gcc-13.cmake,gcc-15.cmake, plus shared_brew.cmake ~/.profilesetsCMAKE_TOOLCHAIN_FILEto the highest installed LLVM toolchain automatically- Switch at runtime with the
tcshell function (e.g.tc gcc-15,tc llvm-22)
- Versioned files:
- Local LLM tooling — HuggingFace cache + binary checks
- Creates
$LOCAL_PLAT/.cache/huggingfacefor mlx-lm weights - Verifies ollama / mlx-lm / mlx-openai-server / opencode binaries
- Creates
- Agent memory stack — cass session-history archive, ~/kb + qmd knowledge index; cass indexing stays manual
- Agent skills — installs
packages/agent-skills.txtinto the shared~/.claude/skillstree - Blender MCP addon — installs
addon.pyinto the active Blender profile and enables it - Auth (opt-in:
DF_DO_AUTH=1) — guided service-token setup; see Auth - Overlays — runs
bootstrap.shof anydotfiles-*/overlay alongside this repo; see Overlays
Total time: ~2 minutes on subsequent runs (idempotent, mostly bottle pours); ~5–10 minutes on a fresh machine.
Linux
Requirements
| Requirement | Notes |
|---|---|
| x86_64 or aarch64 | — |
git, curl, and python3 | Pre-installed on most systems; Python runs the Homebrew formula patch layer before uv is installed |
| Internet access | — |
No sudo required. No Docker or Podman needed.
What gets installed
Paths use $LOCAL_PLAT, which is $HOME/.local by default (or $HOME/.local/$PLAT with PLAT isolation enabled — recommended for shared NFS homes).
- chezmoi →
$ARCH_BIN/chezmoi($ARCH_BIN=$LOCAL_PLAT/bin) - Dotfiles applied via
chezmoi apply- Shell configs for both zsh (
.zprofile) and bash (.bash_profile)
- Shell configs for both zsh (
- oh-my-zsh + plugins
- Homebrew →
$LOCAL_PLAT/brew/(native install, no Docker/Podman needed)- Installs Homebrew’s own glibc 2.35 first — binaries are fully self-contained
- Most packages pour as precompiled bottles; glibc builds from source (~2 min) on first run
- Custom Python@3.14 patches applied automatically for Linux compatibility
- Python via uv →
$LOCAL_PLAT/uv/tools/<tool>/(per-CLI-tool venvs), entrypoints in$ARCH_BIN - Node.js via nvm →
$LOCAL_PLAT/nvm/- Uses uv’s Python for
node-gypfallbacks when an npm package has no prebuilt binary
- Uses uv’s Python for
- Rust via
sh.rustup.rs→$LOCAL_PLAT/rustup/+$LOCAL_PLAT/cargo/cargo-binstalldownloads pre-built binaries from GitHub releases when available, falls back to source
- Go CLI tools from
packages/go.txt→$ARCH_BIN - Lean 4 via elan →
$LOCAL_PLAT/elan/(pinned default toolchain; projects override vialean-toolchain) - Julia via Juliaup, with release channels and depots under
$LOCAL_PLAT/julia/ - TeX via TinyTeX →
$LOCAL_PLAT/tex/.TinyTeX, withtlmgrsys_binpointed at$ARCH_BIN- ~200 MB base instead of multi-GB; missing packages install on demand with
tlmgr install <pkg>
- ~200 MB base instead of multi-GB; missing packages install on demand with
- Quarto via the macOS cask or a checksum-verified rootless Linux archive
- Claude Code native binary →
$ARCH_BIN/claude+ plugins + MCP servers - Codex CLI native binary →
$ARCH_BIN/codex - Cursor / VS Code — extensions from
packages/{cursor,vscode}-extensions.txt - CMake toolchain files →
$LOCAL_PLAT/cmake/toolchains/(llvm-21/22.cmake,gcc-13/15.cmake,_brew.cmake)~/.profileauto-setsCMAKE_TOOLCHAIN_FILEto the highest installed LLVM toolchain- Switch with the
tcshell function (e.g.tc gcc-15,tc llvm-22)
- Local LLM tooling — HuggingFace cache + ollama/mlx-lm/mlx-openai-server/opencode binary checks
- Agent memory stack — cass session-history archive, ~/kb + qmd knowledge index; cass indexing stays manual
- Agent skills — installs
packages/agent-skills.txtinto the shared~/.claude/skillstree - Auth (opt-in:
DF_DO_AUTH=1) — guided token setup; see Auth - Overlays — runs
bootstrap.shof anydotfiles-*/overlay; see Overlays
Total time: ~5 minutes on a fast connection.
Skipping steps
Any step can be disabled with an environment variable:
DF_DO_SCRATCH=0 # skip scratch space symlink setup
DF_DO_DIRS=0 # skip home directory creation (~/dev, ~/bones, ~/misc)
DF_DO_PACKAGES=0 # skip Homebrew + brew bundle
DF_DO_MACOS_SERVICES=0 # skip colima service setup (macOS)
DF_DO_MACOS_SETTINGS=0 # skip macOS settings (Dock, Finder, keyboard, etc.)
DF_DO_MACOS_QUICK_ACTIONS=0 # skip Finder Quick Actions install (macOS)
DF_DO_ZSH=0 # skip oh-my-zsh
DF_DO_NODE=0 # skip nvm + Node.js + global npm packages
DF_DO_RUST=0 # skip rustup + cargo tools
DF_DO_PYTHON=0 # skip uv + per-tool venvs
DF_DO_GO=0 # skip Go CLI tools from go.txt
DF_DO_JULIA=0 # skip Julia release-channel management
DF_DO_LEAN=0 # skip the Lean 4 toolchain (elan + pinned toolchain)
DF_DO_LATEX=0 # skip the TeX distribution (MacTeX verify / TinyTeX)
DF_DO_QUARTO=0 # skip Quarto verification/install
DF_DO_CLAUDE=0 # skip Claude Code install + plugins + MCP servers
DF_DO_CODEX=0 # skip Codex CLI install
DF_DO_CLAUDE_DESKTOP=0 # skip Claude Desktop tracked preferences (macOS)
DF_DO_CODEX_DESKTOP=0 # skip Codex desktop app tracked preferences (macOS)
DF_DO_LINEARMOUSE=0 # skip LinearMouse tracked settings (macOS)
DF_DO_CURSOR=0 # skip Cursor settings symlinks + extension install
DF_DO_VSCODE=0 # skip VS Code extension install
DF_DO_CMAKE=0 # skip CMake toolchain file deployment
DF_DO_LOCAL_LLM=0 # skip local LLM setup (HuggingFace cache + binary checks)
DF_DO_MEMORY=0 # skip the agent memory stack (cass + qmd + ~/kb)
DF_DO_SKILLS=0 # skip agent skills from agent-skills.txt
DF_DO_BLENDER_MCP=0 # skip Blender MCP addon install
DF_DO_AUTH=1 # run interactive API token setup (default 0)
DF_DO_OVERLAYS=0 # skip all overlay bootstraps (dotfiles-*/bootstrap.sh)
DF_USE_PLAT=1 # opt in to per-PLAT directory isolation (default 0; flat layout)
DF_BREW_UPGRADE=0 # skip Homebrew upgrades (default except in upgrade mode)
DF_STRICT_UPGRADE=0 # report stale tools without failing upgrade
The complete reference lives at Env vars.
Example — dotfiles only, no runtimes:
DF_DO_PACKAGES=0 DF_DO_ZSH=0 DF_DO_NODE=0 \
DF_DO_RUST=0 DF_DO_PYTHON=0 DF_DO_CLAUDE=0 \
~/dotfiles/bootstrap.sh
Debug mode
For verbose output with command timing:
DF_DEBUG=1 ~/dotfiles/bootstrap.sh
Shows [dbug] lines for every command executed by run_logged, including exit codes and elapsed time.
Shared home directories (NFS/GPFS)
If you share $HOME across multiple machines with different CPU architectures, enable PLAT isolation:
DF_USE_PLAT=1 ~/dotfiles/bootstrap.sh
(Or persist it in chezmoi data: chezmoi edit ~/.config/chezmoi/chezmoi.toml and set use_plat = true.)
With PLAT on, each machine installs compiled tools to its own ~/.local/$PLAT/ directory:
| Machine | PLAT | Where tools live |
|---|---|---|
| AVX-512 Linux (e.g. Ice Lake) | plat_Linux_x86-64-v4 | ~/.local/plat_Linux_x86-64-v4/ |
| AVX2 Linux (e.g. Haswell/Zen2) | plat_Linux_x86-64-v3 | ~/.local/plat_Linux_x86-64-v3/ |
| ARM Linux | plat_Linux_aarch64 | ~/.local/plat_Linux_aarch64/ |
| Apple Silicon | plat_Darwin_arm64 | ~/.local/plat_Darwin_arm64/ |
Text configs (dotfiles) are arch-neutral and shared freely across all machines. See PLAT isolation for the deeper explanation, the decommission script, and the failure modes that PLAT exists to prevent.
Scratch space (large quota environments)
If your home directory has a small quota (common on HPC NFS mounts), direct large directories to local scratch storage:
DF_SCRATCH=/scratch/$USER \
DF_NAME="Your Name" DF_EMAIL="you@example.com" \
~/dotfiles/bootstrap.sh
This symlinks large directories to $DF_SCRATCH/.paths/ before any tools are installed, so the multi-GB Homebrew prefix and caches never touch NFS.
Default directories redirected to scratch (controlled by DF_LINKS):
~/.local— PLAT directories, Homebrew prefix, tool binaries~/.cache— ccache, sccache, pip/uv cache~/.vscode/~/.vscode-server— VS Code extensions and data~/.cursor/~/.cursor-server— Cursor IDE data~/.nv— NVIDIA shader and OptiX cache~/.npm— npm cache~/.oh-my-zsh/~/.oh-my-zsh-custom— oh-my-zsh and plugins
Plus the heavy unmanaged entries of the two agent config dirs, which stay real directories themselves because chezmoi manages files inside them:
~/.claude(controlled byDF_CLAUDE_LINKS):projects(history + memory),plugins,file-history~/.codex(controlled byDF_CODEX_LINKS):sessions(transcripts — usually the largest single directory on the machine),cache,plugins,attachments,shell_snapshots,.tmp,tmp, plus the loose*.sqlitedatabases
~/.codex is skipped while any process holds a file there open — see Scratch space.
Auth (API tokens)
See the dedicated Auth page for the full walkthrough. Quick reference:
bash ~/dotfiles/install/auth.sh # walk every service interactively
bash ~/dotfiles/install/auth.sh status # show current state, no prompts
bash ~/dotfiles/install/auth.sh huggingface # set/update one service
bash ~/dotfiles/install/auth.sh gh # `gh auth login` (browser flow)
# Or during bootstrap:
DF_DO_AUTH=1 ~/dotfiles/bootstrap.sh
Covers GitHub, Anthropic, OpenAI, Cloudflare, HuggingFace, plus a separate gh auth login flow for the Claude GitHub MCP. Tokens land in ~/.<service>.env files (chmod 600) and are auto-sourced by install scripts and login shells. Each prompt shows a skip if: hint — most users only set 1–2 of them.
Managing dotfiles
chezmoi manages the files in home/ and applies them to ~/, resolving templates along the way.
Data flow
sequenceDiagram
participant U as User
participant B as bootstrap.sh
participant CZ as chezmoi
participant T as ~/.config/chezmoi/<br/>chezmoi.toml
participant S as home/dot_X.tmpl<br/>(repo source)
participant H as ~/.X<br/>(target)
U->>B: run bootstrap.sh
B->>CZ: chezmoi init (first run only)
CZ->>U: prompt name + email (needs a TTY; skipped if DF_NAME / DF_EMAIL pre-set)
U-->>CZ: "Cade", "brown.cade@..."
CZ->>T: cache values
B->>CZ: chezmoi apply
CZ->>T: read .name, .email, .use_plat
CZ->>S: read template
Note over CZ: render Go template — {{ .name }} expands,<br/>{{ if eq .chezmoi.os "linux" }} branches, etc.
CZ->>H: write rendered file (overwrites!)
Note over H: never edit ~/.X directly —<br/>next apply overwrites it
Templates render at apply time using the values in ~/.config/chezmoi/chezmoi.toml. The prompt only fires if a value is missing — re-runs read from cache.
The quick version
chezmoi edit ~/.zshrc # edit a dotfile (opens in $EDITOR, applies on save)
chezmoi edit ~/.zprofile # zsh login shell config
chezmoi edit ~/.bash_profile # bash login shell config (mirrors .zprofile)
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:
| Source | Target |
|---|---|
home/dot_zshrc.tmpl | ~/.zshrc |
home/dot_zprofile.tmpl | ~/.zprofile (zsh login shell) |
home/dot_bash_profile.tmpl | ~/.bash_profile (bash login shell) |
home/dot_config/git/ignore | ~/.config/git/ignore |
home/dot_ssh/config.tmpl | ~/.ssh/config |
home/dot_claude/CLAUDE.md | ~/.claude/CLAUDE.md |
home/dot_codex/AGENTS.md | ~/.codex/AGENTS.md |
dot_prefix →.in target.tmplsuffix → 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)
{{ .use_plat }} PLAT directory isolation flag (default false; see PLAT page)
{{ .chezmoi.os }} "darwin" or "linux"
{{ .chezmoi.arch }} "amd64" or "arm64" ← do NOT use in shared-NFS templates
{{ .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 # zsh login shell
chezmoi edit ~/.bash_profile # bash login shell
Directly in the repo (then apply manually):
$EDITOR ~/dotfiles/home/dot_zshrc.tmpl
$EDITOR ~/dotfiles/home/dot_zprofile.tmpl
$EDITOR ~/dotfiles/home/dot_bash_profile.tmpl
chezmoi apply
Never edit ~/.zshrc, ~/.zprofile, or ~/.bash_profile directly — chezmoi will overwrite them 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.
Multi-machine sync
chezmoi apply only affects the machine it runs on. Each home is independent — macOS
(/Users/cadeb/) and Linux NFS (/home/cadeb/) don’t share target files.
Normal workflow — commit first, then sync remotes:
# 1. Edit and apply locally
chezmoi edit ~/.ssh/config
chezmoi apply
# 2. Commit and push
cd ~/dotfiles
git add home/dot_ssh/config.tmpl
git commit -m "ssh: describe what changed"
git push
# 3. On each remote — pull and apply
ssh remote-host 'bash -l ~/dotfiles/bootstrap.sh update'
If you applied locally without committing (the wrong order), remotes are stale. Quick workaround while you clean it up:
# Render the template locally and copy the result over
chezmoi cat ~/.ssh/config | ssh remote-host 'cat > ~/.ssh/config'
Then commit and push so the repo catches up.
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 withcreate_prefix so chezmoi writes it once and never overwrites
Codex-specific note:
~/.codex/AGENTS.mdand~/.codex/rules/are intentionally Codex-specific; skills are shared from~/.claude/skillsvia the~/.agents/skillssymlink
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
| Layer | File | Install script | Platform |
|---|---|---|---|
| System packages | packages/Brewfile | install/homebrew.sh / install/linux-packages.sh | macOS (bottles) / Linux (native, no container) |
| Rust tools | packages/cargo.txt | install/rust.sh | All |
| Python packages | packages/pip.txt, packages/pip-full.txt | install/python.sh | All |
| Global npm | packages/npm.txt, packages/npm-allow-scripts.txt | install/node.sh | All |
| Go CLI tools | packages/go.txt | install/go.sh | All (respects # linux-only / # macos-only) |
| Claude plugins | packages/claude-plugins.txt | install/claude.sh | All |
| Agent skills | packages/agent-skills.txt, packages/agent-skills.lock.json | install/skills-sync.sh | All (shared ~/.claude/skills tree) |
| MCP servers (Claude + Codex) | packages/mcp-servers.txt | install/claude.sh, install/codex.sh | All |
| Codex CLI/config | home/dot_codex/ | install/codex.sh | All |
| Cursor extensions | packages/cursor-extensions.txt | install/cursor.sh | All |
| VS Code extensions | packages/vscode-extensions.txt | install/vscode.sh | All |
Adding a package — priority order
Choose the first layer that applies. Native installers first, Homebrew as fallback:
1. cargo — Rust crates
# Add to packages/cargo.txt
fd-find
ripgrep
bat
typst-cli
my-new-tool
Re-run: bash ~/dotfiles/install/rust.sh
install/rust.sh uses cargo-binstall: it tries to
download a pre-built binary from GitHub releases first (fast, no compilation), and falls back to
cargo install (source compilation) if no binary is available.
On Linux, cargo-binstall avoids the manylinux container round-trip entirely. On macOS, it downloads the same pre-built binary that Homebrew bottles provide — same quality, faster install.
On Linux, musl targets are preferred over gnu (--targets <arch>-unknown-linux-musl,...): static
musl builds have no glibc dependency, while gnu prebuilts from modern CI runners (Ubuntu 24.04 =
glibc 2.39) refuse to load on older hosts. After each install the crate’s binaries are smoke-tested;
one that fails with a dynamic-loader error is force-refetched (musl-first) and, if still broken,
rebuilt from source against the host glibc.
macOS note: Source compilation requires running from a normal terminal. The macOS Sequoia linker enforces
com.apple.provenanceon object files and will block compilation in sandboxed contexts (e.g., certain CI environments). This isn’t an issue for day-to-day use.
2. npm — npm-specific tools
# packages/npm.txt
@earendil-works/pi-coding-agent
Re-run: bash ~/dotfiles/install/node.sh
npm-allow-scripts.txt is the reviewed lifecycle-script allowlist for global
tools. The installer passes it per command instead of persisting a policy in
~/.npmrc.
nvm owns Node, npm, and npm’s global prefix under the PLAT-specific $NVM_DIR.
Keep ~/.npmrc for registry/auth and npm behavior only; do not set prefix or
globalconfig. packages/npm.txt is the source of truth for global CLIs, and
install/node.sh reconciles them into the supported default Node LTS tree.
Currently ships pi — a multi-provider coding agent (Claude / OpenAI / Gemini / etc.). The official pi.dev/install.sh ultimately runs npm install -g @earendil-works/pi-coding-agent, so we list it here directly.
Other CLI agents are installed via their native packagers:
claude-code→install/claude.sh(Anthropic GCS binary)codex→ unpinned@openai/codexinpackages/npm.txt; managed config and healthcheck viainstall/codex.shopencode→brew "opencode"(packages/Brewfile)
Codex CLI config, rules, themes, and MCP servers are managed from home/dot_codex/
(skills live in home/dot_claude/skills/, shared via the ~/.agents/skills symlink).
install/codex.sh sync-config preserves runtime trust/plugin sections while refreshing the
managed config. Chezmoi also runs this sync when home/dot_codex/create_private_config.toml changes.
3. pip — Python packages
# packages/pip.txt (core) or packages/pip-full.txt (full profile)
requests
black
numpy
some-macos-tool # macos-only (requires Metal / only available on macOS)
Re-run: bash ~/dotfiles/install/python.sh
Each tool gets its own isolated venv via uv tool install, with entrypoints in $LOCAL_PLAT/bin/.
DF_PROFILE=full is the default and installs both manifests. Use
DF_PROFILE=core for a small bootstrap or CI environment; the core profile
also keeps the Rust toolchain while skipping optional cargo.txt tools.
Comment conventions parsed by install/python.sh:
# macos-only— skipped on Linux (e.g.mlx-lmrequires Apple Metal/MLX framework)# python=X.Y— pins to a specific Python version for that tool (e.g.mlx-openai-serverneeds 3.12 becauseoutlines-corehas no cp313/cp314 wheels)
4. Homebrew — non-language-specific tools and C libraries
# packages/Brewfile
brew "tool-name"
# macOS-only (casks, GUI apps, macOS-specific services)
if OS.mac?
cask "some-app"
brew "macos-only-tool"
end
Re-run: brew bundle --file=~/dotfiles/packages/Brewfile
if OS.mac? blocks are silently skipped on Linux. Everything outside those blocks runs on both platforms.
Prefer Homebrew for tools that aren’t available via cargo/npm/pip, have complex C dependencies, or are macOS-specific (casks, GUI apps).
5. VS Code / Cursor extensions
Both editors have separate extension lists since marketplace availability differs (Cursor uses OpenVSX, which doesn’t carry every Microsoft-restricted extension).
# packages/vscode-extensions.txt (VS Code marketplace)
# packages/cursor-extensions.txt (OpenVSX, Cursor)
ms-python.python
charliermarsh.ruff
myriad-dreamin.tinymist # Typst LSP — works in both
Re-run: bash ~/dotfiles/install/vscode.sh and/or bash ~/dotfiles/install/cursor.sh
To capture newly installed extensions back into the file (union — never removes):
bash ~/dotfiles/install/vscode.sh sync-extensions
bash ~/dotfiles/install/cursor.sh sync-extensions
Note: VS Code
settings.jsonis not tracked (contains embedded credentials in some setups). Cursor’s settings ARE tracked via symlinks underhome/dot_cursor/.
6. Custom install script
Look at an existing install/ script for patterns and follow them. Add a DF_DO_* flag to bootstrap.sh.
Local AI tools
Local LLM inference and coding agents are split across three layers:
| Tool | Layer | Notes |
|---|---|---|
ollama | packages/Brewfile (macOS only) | Inference server; installed as Homebrew formula, managed as a LaunchAgent |
opencode | packages/Brewfile | TUI coding agent by the SST team |
mlx-lm | packages/pip-full.txt | Apple Silicon Metal inference; full profile only |
just | packages/cargo.txt | Command runner / Makefile alternative |
install/local-llm.sh creates the PLAT-isolated HuggingFace cache directory ($LOCAL_PLAT/.cache/huggingface)
and verifies that the expected binaries are present. install/opencode.sh verifies the opencode binary; opencode’s backend config is pure chezmoi (opencode.json.tmpl, MLX primary).
See Local AI coding for usage details.
Research mathematics
Two of these get their own install script because neither has a usable Homebrew path on no-sudo Linux; the rest are ordinary package-list entries.
| Tool | Layer | Notes |
|---|---|---|
Lean 4 + lake | install/lean.sh | elan (Lean’s rustup) → $LOCAL_PLAT/elan. Toolchains are ~1.5 GB and arch-specific, hence PLAT-isolated. Pin lives in the script; override with DF_LEAN_TOOLCHAIN. |
| TeX | install/latex.sh | macOS: cask "mactex". Linux: TinyTeX under $LOCAL_PLAT/tex/.TinyTeX, with tlmgr sys_bin pointed at $ARCH_BIN. |
| PARI/GP, FLINT, z3, minizinc, cadical, kissat | packages/Brewfile | gp collides with the gp='git push' alias — use command gp. |
| Sage, Zotero | packages/Brewfile casks (macOS) | Homebrew core has no Sage formula; per-project passagemath wheels are the uv-native route. |
Julia / juliaup | packages/Brewfile + install/julia.sh | The rolling release channel and depots are PLAT-isolated; OSCAR.jl stays project-local. |
| R | packages/Brewfile | Statistical runtime; project packages stay reproducible through renv. |
leanblueprint, marimo, paper-qa, papis, … | packages/pip-full.txt | Full-profile uv tool install entries. |
rga (ripgrep-all) | packages/cargo.txt | Full-text search across a PDF/EPUB paper library. |
Agent-side wiring (lean-lsp, arxiv, mathlas, asta MCP servers, and the
verification-first norms in math-common.md) is covered in
Agent guidance.
Don’t duplicate across layers
Do not install the same tool in both cargo.txt and Brewfile. $LOCAL_PLAT paths come first on PATH — the Homebrew copy would install but never be used. If a tool is in cargo.txt, it must not be in Brewfile, and vice versa.
Why cargo over Homebrew for Rust tools
Tools like fd, sd, bat, ripgrep, git-delta, difftastic, procs, bottom,
ast-grep, zoxide, and hyperfine live in cargo.txt because:
$CARGO_HOME/bin/is already under$LOCAL_PLAT/— PLAT isolation is freecargo-binstalldownloads pre-built GitHub release binaries — fast, no compilation
Tools that have no pre-built binary and are painful to compile (or only make sense on macOS) go in
Brewfile under if OS.mac?.
Why Homebrew for Linux
Homebrew on Linux installs natively on the host (no container, no sudo). It bundles its own glibc, making binaries fully self-contained regardless of the host’s glibc version.
The glibc keg tracks the formula. Homebrew’s Linux bottles carry the glibc floor of the
CI image that built them, and a builder move comes with a formula bump (Ubuntu 22.04 → 24.04
and glibc 2.35 → 2.39 in July 2026). Since glibc is installed by linux-packages.sh rather
than the Brewfile, nothing else would ever upgrade it — and a keg left behind makes every
formula poured afterwards die with version `GLIBC_2.38' not found. Each run reconciles the
keg first, then checks the kegs installed since the last run against what the keg provides.
The keg is built for the architecture baseline, not the build host’s CPU, so a prefix built on
an AVX-512 node still runs on every other machine sharing the home.
Custom prefix tradeoff: Installing to $LOCAL_PLAT/brew/ instead of the standard
/home/linuxbrew/.linuxbrew enables a rootless flat prefix by default and per-CPU
isolation when PLAT mode is enabled, but bottles
built for the standard prefix can’t always be relocated:
- Relocatable packages (jq, CLI tools with simple dependencies) pour as bottles — patchelf rewrites RPATH and they work fine
- Deep path embedding (Python, Perl, git, vim, ffmpeg, imagemagick) build from source
on first install. Homebrew uses all available CPU cores (auto-detects
nproc), so builds are fast on modern hardware.
Once built, packages are cached. Subsequent runs and upgrades are bottle-only.
Compilers: gcc and llvm are keg-only (Homebrew doesn’t create unversioned gcc/clang
symlinks to avoid shadowing system compilers). linux-packages.sh creates symlinks in
$LOCAL_PLAT/bin/ so gcc → the highest installed GCC and clang → llvm@21/bin/clang.
See Compiler toolchains below for CMake integration.
Python@3.14 patches: On Linux, install/patch-homebrew-python.sh automatically patches
the python@3.14 formula to fix build issues (uuid module detection, test_datetime PGO hangs).
Patches are applied during bootstrap and protected by HOMEBREW_NO_AUTO_UPDATE=1.
The same Brewfile works on macOS and Linux. if OS.mac? blocks are silently skipped on Linux.
Compiler toolchains
CMake compiler selection is handled by toolchain files deployed per-PLAT, not by raw
CC/CXX env vars. install/cmake.sh copies them from install/cmake/toolchains/
to $LOCAL_PLAT/cmake/toolchains/ on every bootstrap run (always overwrites, so they
stay in sync with the repo).
Default: LLVM (Homebrew clang)
Toolchain files are versioned: llvm-21.cmake, llvm-22.cmake, gcc-13.cmake, gcc-15.cmake, plus a shared _brew.cmake helper.
When Homebrew LLVM is present, ~/.profile auto-sets:
export CMAKE_TOOLCHAIN_FILE="$_LOCAL_PLAT/cmake/toolchains/llvm-22.cmake"
# (highest installed LLVM version wins; falls back to llvm-21)
The toolchain configures:
| CMake variable | Value |
|---|---|
CMAKE_C_COMPILER | $_LOCAL_PLAT/brew/opt/llvm@22/bin/clang (or unversioned opt/llvm/) |
CMAKE_CXX_COMPILER | $_LOCAL_PLAT/brew/opt/llvm@22/bin/clang++ |
CMAKE_AR / CMAKE_RANLIB | llvm-ar, llvm-ranlib (LTO needs the matching tool) |
CMAKE_LINKER_TYPE | MOLD > LLD (Linux only; macOS uses Apple’s ld) |
CMAKE_CUDA_COMPILER | $_LOCAL_PLAT/.cuda/bin/nvcc (only if symlink set up) |
CMAKE_CUDA_HOST_COMPILER | clang++ (when CUDA available) |
CMake auto-detects nm/objcopy/objdump/strip from CC, so the toolchain files only override what actually matters.
Switching toolchains
Per-invocation:
CMAKE_TOOLCHAIN_FILE="$_LOCAL_PLAT/cmake/toolchains/gcc-15.cmake" cmake -B build
Per-session via the tc shell function:
tc # show active
tc list # list available
tc gcc-15 # GCC 15
tc gcc-13 # GCC 13
tc llvm-22 # LLVM 22
tc llvm-21 # LLVM 21
Per-project (CMakePresets.json):
{ "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "/absolute/path/to/gcc-15.cmake" } }
The GCC toolchains use versioned binaries (gcc-15, g++-15, etc.) because Homebrew doesn’t create unversioned gcc symlinks on macOS. Linux gets unversioned symlinks via linux-packages.sh, but the versioned files work on both. Linker priority on Linux: mold → lld → gold → system ld.
Disabling the toolchain
unset CMAKE_TOOLCHAIN_FILE # let CMake auto-detect compilers
CUDA
CUDA is not managed by bootstrap — install the toolkit separately (system package, NVIDIA runfile, or a module system on HPC). Then point the per-PLAT symlink at it:
ln -sfn /usr/local/cuda "$_LOCAL_PLAT/.cuda" # system default
ln -sfn /opt/nvidia/cuda/12.6 "$_LOCAL_PLAT/.cuda" # versioned install
~/.profile resolves the symlink at login and exports:
CUDA_PATHandCUDAToolkit_ROOT— picked up by CMake’sfind_package(CUDAToolkit)and most other build systems- Prepends
$CUDA_PATH/bintoPATHsonvccis on the path
Both toolchain files also set CMAKE_CUDA_COMPILER to $LOCAL_PLAT/.cuda/bin/nvcc when the
symlink exists, so enable_language(CUDA) works without any project-level configuration.
Different machines on a shared NFS home can point their $LOCAL_PLAT/.cuda symlinks at
different toolkit versions — no conflicts.
Switching toolchains at runtime
The tc shell function (defined in .zshrc) switches the active toolchain for the current session:
tc # show active toolchain
tc list # list available toolchain files
tc gcc-15 # switch to GCC 15 (sets CC/CXX/AR/RANLIB/NM + CMAKE_TOOLCHAIN_FILE)
tc gcc-13 # switch to GCC 13
tc llvm-22 # switch to LLVM 22 (clears CC/CXX; CMake file owns compiler selection)
tc llvm-21 # switch to LLVM 21
Compiler caching (ccache / sccache)
~/.profile configures ccache and sccache automatically when they’re installed:
| Setting | Value | Why |
|---|---|---|
CCACHE_BASEDIR | scratch root or $HOME | Rewrites absolute paths to relative before hashing — builds in different directories share cache hits |
CCACHE_COMPILERCHECK | content | Hash compiler by content, not mtime — survives brew reinstalls and module swaps |
CCACHE_SLOPPINESS | file_stat_matches,time_macros | Use mtime+size for include checks; cache TUs with __DATE__/__TIME__ |
CCACHE_HARDLINK | 1 | Hardlink cached objects instead of copying — halves I/O on cache hits |
CCACHE_MAXSIZE | 2% of partition, clamped [10G, 100G] | Auto-sized to scratch partition |
RUSTC_WRAPPER | sccache | Rust compiler caching |
SCCACHE_CACHE_SIZE | 2% of partition, clamped [10G, 100G] | Same auto-sizing as ccache |
CMake integration: CMAKE_C_COMPILER_LAUNCHER=ccache and CMAKE_CXX_COMPILER_LAUNCHER=ccache are exported automatically.
openssh from Homebrew
The Brewfile installs openssh cross-platform (not just macOS) to avoid OpenSSL version
mismatches between the system ssh and Homebrew-linked libraries. On Linux, the system
ssh may link against a different OpenSSL than Homebrew’s, causing git push failures
when Homebrew’s git shells out to ssh. Brew’s openssh uses Homebrew’s OpenSSL consistently.
Source files
Toolchain source files live in install/cmake/toolchains/ — edit them there, not in the
deployed copies under $LOCAL_PLAT/. Re-deploy with:
bash ~/dotfiles/install/cmake.sh
Then wipe the CMake cache (rm -rf build/CMakeCache.txt build/CMakeFiles) for the changes
to take effect in an existing build directory.
Updating all packages
~/dotfiles/bootstrap.sh update # pull + refresh (install missing, skip current)
~/dotfiles/bootstrap.sh upgrade # update + brew upgrade + cargo upgrade
update refreshes tools without upgrading existing versions. upgrade additionally enables Homebrew upgrades and forces cargo-binstall to re-check for newer binaries. Both are idempotent — safe to run at any time.
PLAT isolation
PLAT (PLATform) is the per-architecture directory namespacing scheme this repo uses to make a single $HOME work across machines with different CPU architectures. It’s off by default because most users have one machine.
The decision in 30 seconds
Do you share $HOME across machines with different CPUs (NFS, etc.)?
├── No → leave DF_USE_PLAT=0 (default). Done.
└── Yes → set DF_USE_PLAT=1 on every machine that shares the home.
Each machine installs into ~/.local/$PLAT/ instead of ~/.local/.
One home, many machines, no clobbering.
DF_USE_PLAT=0 (default) | DF_USE_PLAT=1 | |
|---|---|---|
| Layout | flat ~/.local/{bin,brew,cargo,nvm,…} | per-PLAT ~/.local/$PLAT/{bin,brew,cargo,nvm,…} |
$LOCAL_PLAT | $HOME/.local | $HOME/.local/$PLAT |
| Capability flags | still applied (CPU-tuned -march, RUSTFLAGS, HOMEBREW_OPTFLAGS) | same |
| PATH entries | ~/.local/bin first | ~/.local/$PLAT/bin first, then ~/.local/bin |
| Disk per machine | one tree (~few GB) | one tree per PLAT (~few GB × N) |
| Right for | single laptop, workstation, VM | NFS-shared $HOME across heterogeneous CPUs (HPC, lab racks) |
Layouts side-by-side
DF_USE_PLAT=0 (default, flat) DF_USE_PLAT=1 (NFS-shared homes)
───────────────────────────── ────────────────────────────────────
~/.local/ ~/.local/
├── bin/ ├── plat_Darwin_arm64/
│ ├── chezmoi │ ├── bin/{chezmoi,uv,claude}
│ ├── uv │ ├── brew/ (Apple Silicon)
│ └── claude │ ├── cargo/bin/ (arm64 binaries)
├── brew/ (one prefix) │ └── nvm/ (arm64 node)
├── cargo/bin/ (host arch) ├── plat_Linux_x86-64-v3/
└── nvm/ │ ├── brew/ (AVX2 glibc)
│ └── ...
$_LOCAL_PLAT = ~/.local └── plat_Linux_x86-64-v4/ (AVX-512)
└── ...
$_LOCAL_PLAT = ~/.local/$_PLAT
(set per-shell from CPU detection)
Even with PLAT off, .plat_env.sh still sources at shell start so the host CPU gets -march=x86-64-v3, RUSTFLAGS=-C target-cpu=apple-m1, etc. Capability detection is independent of directory layout — only LOCAL_PLAT changes.
What PLAT directories look like
PLAT is a string of the form plat_{OS}_{cpu-target}. Examples:
plat_Darwin_arm64 # Apple Silicon
plat_Darwin_x86-64 # Intel Mac
plat_Linux_aarch64 # ARM Linux (Graviton, Ampere)
plat_Linux_x86-64-v4 # AVX-512 (Ice Lake+, Zen 4+)
plat_Linux_x86-64-v3 # AVX2 (Haswell+, Zen 2+)
plat_Linux_x86-64-v2 # SSE4.2 (Nehalem+)
Detection: shell startup scans ~/dotfiles/install/plat/plat_${OS}_*/ (highest level first), runs each spec’s .plat_check.sh, picks the first that exits 0, then sources .plat_env.sh for compiler flags.
Enabling PLAT isolation
Per-machine, persistent (recommended):
# Edit chezmoi data
chezmoi edit ~/.config/chezmoi/chezmoi.toml
# Set:
# use_plat = true
chezmoi apply
exec zsh -l # reload shell so $_LOCAL_PLAT picks up the new path
One-shot via env var:
DF_USE_PLAT=1 ~/dotfiles/bootstrap.sh
The env var is normalized — 1, true, yes, on (case-insensitive) all enable.
Disabling / migrating off PLAT
When you switch a machine from DF_USE_PLAT=1 back to flat, the old ~/.local/$PLAT/ tree becomes orphaned (multi-GB of cargo registry, nvm node versions, uv tools, etc., all stranded). One-shot cleanup:
# 1. Set DF_USE_PLAT=0 (or remove use_plat=true from chezmoi data)
# 2. Reload shell so the running session sees the flat layout
# 3. Run the decommission script:
bash ~/dotfiles/install/plat-decommission.sh
The script is standalone — never invoked by bootstrap.sh (including upgrade mode), to prevent accidental data loss. Safety guarantees:
- Refuses to run if
DF_USE_PLAT=1is currently set in the environment (won’t nuke the active install) - Asks for confirmation before deleting (skip with
DF_FORCE=1) - Idempotent — running with no
~/.local/plat_*/dirs is a no-op - After cleanup, re-run
~/dotfiles/bootstrap.shto repopulate the flat layout
Failure modes PLAT exists to prevent
If you skip PLAT but actually share $HOME across architectures, you get one of these:
- Wrong-arch binary on PATH — Linux machine sees Apple Silicon
~/.local/bin/uv; runs and immediately segfaults withBad CPU typeorcannot execute binary file. - Cargo registry corruption — two machines share
~/.local/cargo/registry/and race-update the index Git repo. Eventually one machine’scargo buildfails with “object file is broken.” - nvm node-version collisions — one machine’s Node 24 binary is x86_64 ELF; another machine sees the same path containing arm64.
node --versionfails. - Brew prefix incompatibility — Brew’s bottle relocation embeds the prefix path in binaries. Running
brew install fooon machine A then trying to usefooon machine B without re-installing fails because the embedded RPATH is for A’s libgcc.
PLAT is the heavy hammer that solves all of these by giving each architecture its own tree. The cost is disk space (a few GB × number of machines) and one extra path segment in $_LOCAL_PLAT.
Why opt-in by default
Most people have one machine. The per-PLAT directory adds a layer of indirection, breaks tools that hard-code their own install location (uv self update was the canonical bug), and makes default tutorials more confusing. The mainstream answer to “what about binaries on shared $HOME?” in the broader ecosystem is don’t share that part of $HOME (move ~/.local to local disk per host). PLAT exists for the cases where that’s not an option — typically HPC NFS where you can’t.
See install/_lib.sh (the ### PLATFORM ### block) for the implementation.
Auth (API tokens)
install/auth.sh is a guided helper for the API tokens this repo’s tools need. It maintains ~/.<service>.env files (chmod 600) — sourced automatically by install/_lib.sh on every install run and by your login shell.
Quick reference
bash ~/dotfiles/install/auth.sh # walk every service interactively
bash ~/dotfiles/install/auth.sh status # current state, no prompts
bash ~/dotfiles/install/auth.sh huggingface # set/update one
bash ~/dotfiles/install/auth.sh gh # `gh auth login` (browser)
bash ~/dotfiles/install/auth.sh help # service list
# Or as part of bootstrap:
DF_DO_AUTH=1 ~/dotfiles/bootstrap.sh
Service registry
| Service | Env var | File | Used for | Skip if |
|---|---|---|---|---|
| github | GITHUB_TOKEN | ~/.github.env | cargo-binstall rate limits, Homebrew rate limits, gh CLI fallback | you don’t bulk-binstall from GitHub releases (or use the gh-derive trick below) |
| anthropic | ANTHROPIC_API_KEY | ~/.anthropic.env | Anthropic SDK, agents using api.anthropic.com directly | you only use Claude via Pro / Claude Code OAuth |
| openai | OPENAI_API_KEY | ~/.openai.env | OpenAI SDK, Codex CLI in API mode | you only use Codex via ChatGPT login |
| cloudflare | CLOUDFLARE_API_TOKEN | ~/.cloudflare.env | OpenTofu in infra/, Cloudflare MCP via API, R2/Pages | you don’t deploy infra/ via OpenTofu (the Cloudflare MCP can use OAuth) |
| huggingface | HF_TOKEN | ~/.huggingface.env | mlx-lm gated models, transformers | you don’t pull gated models or private repos |
Plus gh auth login (browser flow) — required for the GitHub MCP server consumed by both Claude and Codex (auth=gh in mcp-servers.txt). gh stores its token in macOS keychain / Linux secret service, not in an env file.
How tokens get loaded
Walk auth.sh ─writes─► ~/.<service>.env (chmod 600)
│
│ sourced on every install run
▼
install/_lib.sh ◄─sources─ for f in ~/.*.env; do . "$f"; done
│
│ exported into the shell environment
▼
install scripts see GITHUB_TOKEN, HF_TOKEN, etc. as env vars.
Same files are also sourced by your shell profile so interactive
sessions inherit them — no need to `source` manually after setup.
After setting a token, open a new shell (or source ~/.<svc>.env) to use it in your current session.
Per-prompt UX
Each service prompt shows status, create-URL, scope hint, file path, and a “skip if” note. Then either [k]eep / [u]pdate / [d]elete (when set) or “Enter token / Enter to skip” (when empty). Tokens are masked everywhere — only the last 4 characters appear (e.g. ...mqTO). Input is hidden via stty -echo.
github (GITHUB_TOKEN)
GitHub PAT (cargo-binstall, Homebrew rate limits, gh fallback)
create: https://github.com/settings/tokens
scopes: fine-grained no-permission (rate limits only) OR repo (private clones)
skip if: you don't bulk-binstall from GitHub releases — or press G to derive from `gh auth token`
file: /Users/cade/.github.env
status: empty
Enter GITHUB_TOKEN, [G] to derive from `gh auth token`, or Enter to skip:
After a walk, you get a tally:
Summary
set: 2
updated: 0
kept: 1
deleted: 0
skipped: 2
The gh-derive trick (GITHUB_TOKEN)
gh auth login already stores a token in your OS keychain. Rather than maintain a second token, point ~/.github.env at the keychain dynamically:
# ~/.github.env
export GITHUB_TOKEN="$(gh auth token 2>/dev/null)"
Now cargo-binstall etc. always see the current keychain token, and gh auth refresh automatically picks up everywhere.
The auth.sh prompt offers this with [G] when github is empty and gh auth status succeeds. Selecting it writes exactly that one-liner.
Adding a new service
The registry is one constant in install/auth.sh. Add a row with:
name|ENV_VAR|.env_file_basename|short description|create_url|scopes hint|skip-if hint
Example for adding OpenRouter:
"openrouter|OPENROUTER_API_KEY|.openrouter.env|OpenRouter token (openrouter/ models)|https://openrouter.ai/keys|—|you don't use OpenRouter-routed models"
Now bash auth.sh status, bash auth.sh openrouter, and the walk all include it. No code changes needed.
File security
- All env files are chmod 600 (owner-only).
- Tokens are never echoed in plaintext — only masked tails.
- The bash glob
for _envfile in "$HOME"/.*.envin_lib.sherrors silently if no files match (no leakage). - A global pre-push gitleaks hook scans the commits being pushed for accidental token leakage before they reach a remote, across every repo on the machine (via
core.hooksPath). Source:home/dot_config/git/hooks/executable_pre-push→ deployed to~/.config/git/hooks/pre-push. See Troubleshooting → git push blocked by gitleaks if it ever blocks a push.
Scratch space
Some shared filesystems give you a tiny home quota and a much larger “scratch” partition (HPC clusters, lab racks, certain NAS setups). The bootstrap can transparently redirect heavy directories to scratch via symlinks, so the multi-GB Homebrew prefix and tool caches never touch NFS.
You don’t need this if your $HOME quota is fine. Skip the rest of this page.
How it works
install/scratch.sh (run as bootstrap step 0) symlinks selected $HOME directories into $DF_SCRATCH/.paths/. Existing contents are moved over before the symlink replaces the original directory.
$HOME/ $DF_SCRATCH/.paths/
├── .local ──symlink──▶ ├── .local/ ◀── PLAT dirs, brew, cargo
├── .cache ──symlink──▶ ├── .cache/ ◀── ccache, sccache, uv cache
├── .npm ──symlink──▶ ├── .npm/
├── .nv ──symlink──▶ ├── .nv/ ◀── NVIDIA shader cache
├── .vscode ──symlink──▶ ├── .vscode/
├── .vscode-server ─symlink──▶ ├── .vscode-server/
├── .cursor-server ─symlink──▶ ├── .cursor-server/
├── .computelab ──symlink──▶ ├── .computelab/
├── .agent-browser ─symlink──▶ ├── .agent-browser/
├── .gradle ──symlink──▶ ├── .gradle/
├── .oh-my-zsh ──symlink──▶ ├── .oh-my-zsh/
│ ├── .cursor/
├── .cursor/ ◀── real dir │ ├── projects/ ◀── agent history
│ ├── projects ──symlink──▶ │ └── worktrees/
│ ├── worktrees ──symlink──▶ │
│ └── hooks.json ◀── chezmoi │
│ ├── .config/
├── .config/ ◀── real dir │ └── Code/ ◀── VS Code user data
│ └── Code ──symlink──▶ │
│ ├── .claude/
├── .claude/ ◀── real dir │ ├── projects/ ◀── history + memory
│ ├── projects ──symlink──▶ │ ├── plugins/
│ ├── plugins ──symlink──▶ │ └── file-history/
│ ├── file-history ─symlink──▶ │
│ ├── settings.json ◀── chezmoi-managed, stays local
│ └── skills/ ◀── chezmoi-managed, stays local
│ └── .codex/
├── .codex/ ◀── real dir ├── sessions/ ◀── transcripts, the bulk
│ ├── sessions ─symlink──▶ ├── generated_images/
│ ├── generated_images symlink──▶ ├── cache/ plugins/ attachments/
│ ├── cache ─symlink──▶ ├── shell_snapshots/ log/ backups/
│ ├── plugins ─symlink──▶ ├── .tmp/ tmp/
│ ├── *.sqlite ─symlink──▶ └── logs_2.sqlite (+ -wal, -shm)
│ ├── config.toml ◀── chezmoi-managed, stays local
│ └── AGENTS.md ◀── chezmoi-managed, stays local
├── dotfiles/ ◀── real dir, version controlled
└── .config/ ◀── real dir, small files
~/.claude and ~/.codex themselves stay real directories — chezmoi manages files inside them (settings.json, skills/, config.toml, AGENTS.md, hooks, profiles, themes), and a symlink at either path gets clobbered on chezmoi apply. Only the heavy unmanaged entries are redirected, controlled by DF_CLAUDE_LINKS and DF_CODEX_LINKS.
Codex specifics
Codex keeps its loose SQLite state (logs_N.sqlite, state_N.sqlite, …) directly in ~/.codex, and on a busy machine logs_N alone reaches several hundred MB — the largest item after sessions/. Those files are symlinked individually. SQLite canonicalizes a database path before deriving the -wal/-shm sibling names, so linking just the .sqlite file puts the whole write-ahead log on scratch too.
Two consequences worth knowing:
~/.codexmigrates only when nothing holds it open. A cross-filesystem move is copy-then-unlink, so a process with one of these files open would keep writing to the unlinked inode and lose those writes. The check is per-file (/proc/*/fd), not “is Codex running” — Codex leaves anapp-serverdaemon resident for days with every file closed, and refusing on that would mean never migrating. If the script reports the tree is in use, quit Codex (itsapp-servertoo) and rerunbash install/scratch.sh.- The version suffix bumps with Codex’s schema. A new
logs_3.sqliteis born on NFS and migrates on the next run; the same is true after a Codex self-repair replaces a database.
~/.codex/memories/ is deliberately not migrated. It holds small markdown (MEMORY.md, memory_summary.md) that is worth keeping on NFS so it follows you across the fleet; only its SQLite index moves to scratch, matching how the qmd and cass indexes are already treated as per-machine.
Why not CODEX_HOME?
Codex does expose a CODEX_HOME env var, and pointing it at scratch looks tidier than a handful of symlinks. It was rejected for two reasons:
- It relocates the whole tree, including the chezmoi-managed config. chezmoi has no per-entry destination override, so
~/.codexwould have to leave chezmoi’s control entirely and be deployed byinstall/codex.shinstead — on macOS too, where none of this is needed. - Any Codex launched without the variable set — an IDE extension, a cron job, a non-interactive
ssh host codex …— silently starts a second, unconfigured~/.codex. That is the same silent-divergence failure the symlinks exist to prevent.
Subdir symlinks need no env var and hold in every launch context.
Why not symlink the whole dir and .chezmoiignore it?
This does work, and it is the pattern ~/.local already uses (see the non-darwin block in home/.chezmoiignore): once a path is ignored, chezmoi drops it from chezmoi managed and leaves an existing symlink there untouched across applies. A symlink_dot_codex entry is not an alternative — declaring it alongside the dot_codex/ source directory fails with .codex: inconsistent state.
It was still rejected, because ignoring the directory means install/codex.sh has to re-implement the chezmoi attributes that home/dot_codex/ relies on:
create_private_config.toml—create_seeds~/.codex/config.tomlonce and never rewrites it, which is precisely what letscodex.shown the file afterward;private_pins it to 600executable_rtk-rewrite.sh— 755AGENTS.md.tmpl— rendered from the sharedagents-common.md/voice-common.mdpartials
Hand-rolling create-once, mode bits, and template rendering is exactly the kind of thing that drifts from what chezmoi actually does, and chezmoi apply would stop repairing edits to the Codex config. The .chezmoiignore line also becomes a cliff: delete it and chezmoi silently eats the symlink again, which is the original bug.
The payoff for all that is 1.5 MB out of 3.0 GB — the managed config plus skills/, memories/, and models_cache.json. Not worth it. If ~/.codex ever grows something large outside a subdirectory, add it to DF_CODEX_LINKS (directories) or let the *.sqlite glob pick it up, rather than revisiting this.
Configuring
Either set DF_SCRATCH before running bootstrap:
DF_SCRATCH=/scratch/$USER ~/dotfiles/bootstrap.sh
…or pre-create a ~/scratch symlink and let bootstrap auto-detect it:
ln -s /local/disk/$USER ~/scratch
~/dotfiles/bootstrap.sh
| Env var | Default | What it does |
|---|---|---|
DF_SCRATCH | (unset) | Path to scratch root. Setting this enables scratch mode. |
DF_SCRATCH_LINK | ~/scratch | Symlink in $HOME pointing at scratch. Bootstrap creates this if DF_SCRATCH is set. |
DF_LINKS | ~/.local:~/.cache:~/.cass:~/.vscode:~/.vscode-server:~/.cursor-server:~/.nv:~/.npm:~/.oh-my-zsh:~/.oh-my-zsh-custom:~/kb:~/.computelab:~/.agent-browser:~/.gradle | Colon-separated list of top-level dirs to symlink to scratch. TinyTeX is already below $LOCAL_PLAT; ~/.cursor is chezmoi-owned. |
DF_CONFIG_LINKS | Code | Colon-separated ~/.config subdir names to redirect to scratch (never ~/.config itself — chezmoi owns it). |
DF_CURSOR_LINKS | projects:worktrees | Colon-separated ~/.cursor subdir names to redirect to scratch (never ~/.cursor itself). |
DF_CLAUDE_LINKS | projects:plugins:file-history | Colon-separated ~/.claude subdir names to redirect to scratch (never ~/.claude itself — chezmoi owns it). Drop projects to keep conversation history + memory on NFS. |
DF_CODEX_LINKS | sessions:generated_images:cache:plugins:attachments:shell_snapshots:log:backups:.tmp:tmp | Colon-separated ~/.codex subdir names to redirect to scratch (never ~/.codex itself). Set empty to leave ~/.codex alone entirely, including its SQLite files. |
DF_DO_SCRATCH | 1 (install mode), 0 (update/upgrade) | Skip scratch setup entirely. |
Setting any of DF_LINKS, DF_CLAUDE_LINKS, or DF_CODEX_LINKS to the empty string means “migrate nothing here” — unsetting it restores the default.
What NOT to symlink
These look tempting but are traps:
~/.claude/and~/.codex/themselves — chezmoi manages files in both. If either directory is symlinked,chezmoi applyreplaces the symlink with a real directory containing only managed files, orphaning all your conversation history, sessions, and transcripts on scratch — silently, with no error. Neither is ever inDF_LINKS. The heavy unmanaged entries are redirected one level down viaDF_CLAUDE_LINKS/DF_CODEX_LINKS, which chezmoi leaves alone — that’s the supported way to get these off the quota.~/.config/— small, fast, and chezmoi-managed. Many tools assumeXDG_CONFIG_HOMEis local-disk-fast (e.g. shell startup reads it constantly).~/dotfiles/— the repo itself. Cloned to$HOMEdirectly so editor “open file” dialogs and IDE indexing work normally.~/.ssh/— security boundary. Local disk only.
Filesystem caveats
- tmpfs scratch is detected and warned about — contents are lost on reboot. Fine for ephemeral state, fatal for the Homebrew prefix.
- Cross-filesystem moves can be slow on first bootstrap (existing
~/.localmay be tens of GB). Subsequent runs are no-ops. - NFS open-file locks sometimes leave
.nfs*silly-rename files behind during the move; the script logs a warning but doesn’t fail.
Re-running
scratch.sh is idempotent. If a path is already a symlink to the right target, it’s left alone. If it’s a real directory with new content, the script moves the new content and re-symlinks. If it’s a symlink pointing somewhere unexpected, the script logs a warning and skips (won’t silently overwrite an admin-set link).
To opt out without unwinding the symlinks (just stop redirecting new dirs):
DF_DO_SCRATCH=0 ~/dotfiles/bootstrap.sh
To fully unwind (move data back to real $HOME), do it manually — the script doesn’t ship a “decommission scratch” mode.
Overlays
An overlay is a separate repo (typically private) that extends this base dotfiles without forking. Overlays live next to the base in $DF_ROOT/dotfiles-*/ and get discovered automatically — their package lists, install scripts, claude skills, and Codex skills compose with the base.
Use overlays for:
- Personal/private content that shouldn’t ship in the public repo (
dotfiles-personal/) - Org-specific setup (
dotfiles-acme/,dotfiles-lab/) - Hardware-specific extras (
dotfiles-nvidia/for CUDA toolkits, MCPs, kernels)
Discovery model
The base _lib.sh defines DF_OVERLAYS (an array of paths to overlay roots) and overlay_package_files() (a helper that returns base-first-then-overlays paths for any package list filename).
~/dotfiles/ ← base, public
└── packages/mcp-servers.txt (5 entries: cloudflare, github, openaiDeveloperDocs, context7, blender)
~/dotfiles-nvidia/ ← overlay, private
└── packages/mcp-servers.txt (NVIDIA-internal MaaS entries)
│
│ install/claude.sh + install/codex.sh:
│ while IFS= read -r f; do
│ _register_mcps_from "$f" # claude.sh
│ _emit_mcp_blocks_to ... # codex.sh
│ done < <(overlay_package_files "mcp-servers.txt")
│
▼
Effective merged list (base first, then each overlay sorted) — same list
consumed by both Claude (`claude mcp add`) and Codex (`[mcp_servers.*]`).
The merge is append-only — overlays add to the base, they don’t replace it. Order is base, then overlays in lexicographic path order.
What an overlay can provide
| Path in overlay | Effect |
|---|---|
packages/cargo.txt | additional Rust crates installed by install/rust.sh |
packages/mcp-servers.txt | additional MCP servers registered by install/claude.sh and install/codex.sh |
packages/claude-plugins.txt | additional Claude plugins installed |
packages/<other>.txt | discovered via overlay_package_files() — pattern works for any list-style file |
home/dot_claude/CLAUDE.md | appended to ~/.claude/CLAUDE.md via the chezmoi template |
home/dot_claude/skills/<name>/SKILL.md | deployed to ~/.claude/skills/<name>/ by install/claude.sh |
install/auth.sh | runs alongside the base auth walk during step 7.5 (post-base auth) |
install/<other>.sh | source _lib.sh and use the same conventions; invoked from the overlay’s bootstrap |
bootstrap.sh | runs as the base bootstrap step 8 (after everything else) |
The base intentionally has no built-in awareness of any specific overlay — discovery is purely by directory glob (dotfiles-*/).
Creating an overlay
# 1. Create the repo somewhere accessible (or just a local dir):
mkdir -p ~/dotfiles-mine
cd ~/dotfiles-mine
git init
# 2. Add a package file or two:
mkdir -p packages
cat > packages/cargo.txt <<'EOF'
# my private cargo additions
hyperfine
flamegraph
EOF
# 3. Optionally, a bootstrap to do per-overlay setup:
cat > bootstrap.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source "$DF_ROOT/install/_lib.sh" # base helpers (log_info, etc.)
log_section "dotfiles-mine"
# ... your custom logic ...
EOF
chmod +x bootstrap.sh
# 4. Symlink (or clone) it next to the base:
ln -s ~/dotfiles-mine ~/dotfiles/dotfiles-mine
# 5. Re-run the base bootstrap. Step 8 picks up your overlay automatically.
~/dotfiles/bootstrap.sh
The directory name must start with dotfiles- for the glob to find it. Common names: dotfiles-personal, dotfiles-work, dotfiles-{laptop,desktop,server}, dotfiles-{nvidia,amd,intel}.
chezmoi integration
Overlays don’t usually own their own chezmoi root — instead, the base home/ template references overlay files via glob:
{{ glob (joinPath .chezmoi.workingTree "dotfiles-*/packages/mcp-servers.txt") }}
This pattern is used by home/run_onchange_*.sh.tmpl scripts, so chezmoi notices when any overlay’s package file changes (not just the base) and re-fires the install script.
For chezmoi-managed content (skills, claude/codex configs), the base’s chezmoi templates have {{ if (stat ...) }} guards that pull the overlay file’s contents in if present.
Why overlays vs forks
A fork makes you carry every base change into your private tree forever. An overlay lets you git pull the base independently and keep your private stuff strictly additive. Conflicts only happen if the base removes something your overlay depended on (rare; the discovery contract is stable).
For one-off per-machine tweaks that aren’t worth a whole overlay, see Managing dotfiles → Customizing per-machine. Overlays are the right answer when the tweak is a coherent set of files you’d commit together.
Day-to-day workflow
Update and upgrade
~/dotfiles/bootstrap.sh update # pull latest + refresh tools (no brew upgrade)
~/dotfiles/bootstrap.sh upgrade # update + brew upgrade + cargo upgrade
~/dotfiles/bootstrap.sh # full install (same as first run, idempotent)
update pulls the repo, applies chezmoi, refreshes zsh plugins, and reconciles missing tools while holding existing Homebrew and Go packages. upgrade also refreshes Homebrew, Go, Rust/Cargo, Node/npm, uv tools, Julia, TeX, and extensions, then runs the strict JSON toolchain audit.
Run the read-only audit independently with:
bash ~/dotfiles/install/audit-versions.sh | jq .
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 core → packages/pip.txt; optional full tools → packages/pip-full.txt
bash ~/dotfiles/install/python.sh
Edit a dotfile
chezmoi edit ~/.zshrc # opens in $EDITOR, applies on save
chezmoi edit ~/.zprofile # zsh login shell
chezmoi edit ~/.bash_profile # bash login shell
chezmoi edit ~/.gitconfig
Or edit the source directly and apply:
$EDITOR ~/dotfiles/home/dot_zshrc.tmpl
$EDITOR ~/dotfiles/home/dot_zprofile.tmpl
$EDITOR ~/dotfiles/home/dot_bash_profile.tmpl
chezmoi apply
Preview before applying: chezmoi diff
Sync dotfiles from the repo
chezmoi update # git pull + chezmoi apply
AeroSpace config (v2)
Window-management docs are now in AeroSpace window management.
Update AI agent instructions
Claude and Codex now diverge intentionally:
chezmoi edit ~/.claude/CLAUDE.md
chezmoi edit ~/.codex/AGENTS.md
Use ~/.claude/CLAUDE.md for Claude-specific memory and ~/.codex/AGENTS.md for Codex-specific guidance. Keep only genuinely shared preferences aligned.
Claude Code’s status line is a custom bash script at home/dot_claude/executable_statusline.sh (no npm dependency). Edit it with chezmoi edit ~/.claude/statusline.sh. The header comment documents the shape; DEBUG=1 env var dumps parsed input + intermediate values to stderr.
Codex also has global skills and rules (edit source-of-truth in the repo):
$EDITOR ~/dotfiles/home/dot_codex/create_private_config.toml
$EDITOR ~/dotfiles/home/dot_codex/rules/dotfiles.rules
chezmoi apply
~/dotfiles/install/codex.sh sync-config
Codex binary/config health commands:
~/dotfiles/install/codex.sh upgrade # install latest binary + sync config + healthcheck
~/dotfiles/install/codex.sh sync-config # sync managed config; preserve runtime trust sections
~/dotfiles/install/codex.sh check # verify binary, profiles, and rules
Skills live under home/dot_claude/skills/ in the repo, apply to ~/.claude/skills/,
and reach Codex/opencode/pi through the ~/.agents/skills symlink.
Custom domain skills included:
web-shippingsimulation-labcompiler-workbenchgame-systems
Custom Codex themes live under home/dot_codex/themes/ and sync to ~/.codex/themes/:
neon-noirsunburst-candyminty-terminal
Useful Codex commands after updating:
codex --profile fast
codex --profile review
codex --profile deep
codex # default: Sol/high, unrestricted host access, no prompts
codex -c 'tui.theme="neon-noir"'
codex -c 'tui.theme="sunburst-candy"'
codex -c 'tui.theme="minty-terminal"'
codex mcp list
codex execpolicy check --pretty --rules ~/.codex/rules/dotfiles.rules -- git status
codex '$env-reconciler Map this repository and propose the first validation step.'
codex '$simulation-lab Define state variables and a minimal validation case for this model.'
Codex schema note: profiles are delta-only overlay files at ~/.codex/<name>.config.toml
with top-level keys (Codex 0.134+); the old [profiles.*] tables in config.toml are
ignored. Managed sources: home/dot_codex/{deep,review,fast}.config.toml.
Default Codex mode uses the built-in :danger-full-access permission profile and
approval_policy = "never". MCP and connector tools are also configured for
prompt-free execution. Use -p deep for extra-high reasoning, -p fast for
Luna/low, or -p review for deliberately read-only work.
Add an env var or PATH entry
Edit both home/dot_zprofile.tmpl and home/dot_bash_profile.tmpl (they should stay identical). 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.
Git worktrees
gwt creates branch-aware Git worktrees in a self-contained repository
directory. It preserves the complete branch hierarchy instead of flattening
slashes.
~/dev/project/
├── .bare/
├── main/
└── cadeb/
└── perf/
└── fft/
In this example the last worktree checks out cadeb/perf/fft. Git stores the
shared repository data in .bare; each leaf directory is an ordinary working
tree.
The implementation is a Bash script installed as git-wt in the active
~/.local or PLAT-specific bin directory. Git discovers it as the external
subcommand git wt. The shell alias gwt invokes the same command.
After installing or updating the dotfiles, open a new shell so gwt and the
interactive gwtize wrapper are loaded. git wt itself works as soon as the
script is installed.
The links between worktrees and .bare remain absolute. Relative worktree
metadata would make the container relocatable, but it creates a repository
extension that the system Git 2.43 on current Linux hosts cannot read.
Convert a clone
Start at the root of a normal clone:
cd ~/dev/project
gwtize
gwtize is the interactive wrapper for git wt init. It converts .git to
.bare, creates a worktree whose path matches the current branch, preserves
staged, unstaged, and untracked files, and enters the new worktree.
Use an explicit primary path or add existing branches during conversion when needed:
git wt init --path work --add release/13.5
--path changes only the primary directory name; it does not rename the
checked-out branch. The default path mirrors the branch and is preferred.
Conversion stops before changing the repository when it finds an active merge,
rebase, cherry-pick, revert, or bisect; sparse checkout; initialized submodules;
split index; existing linked worktrees; overlapping worktree paths; or a
filesystem path that conflicts with the primary worktree. Resolve that state
and rerun the command. Disable split index with
git update-index --no-split-index.
Create a personal branch
gwt new perf/fft develop
new selects the configured username from the push remote’s host, creates the
branch from the optional start point, and uses the complete branch name as the
path. The same logical command produces:
remote branch path
GitHub cadebrown/perf/fft ~/dev/project/cadebrown/perf/fft
NVIDIA cadeb/perf/fft ~/dev/project/cadeb/perf/fft
start point develop
The start point defaults to HEAD:
gwt new docs/worktrees
Passing an already qualified name does not duplicate the prefix:
gwt new cadeb/perf/fft
Add an existing branch
add never changes a branch name. Use it for shared branches, base branches,
or a branch that already has the correct namespace:
gwt add main
gwt add release/13.5
gwt add cadeb/perf/fft
The branch must already exist locally. Use gwt new when creating a personal
branch.
Forge usernames
The remote repository owner is not necessarily your forge identity, so gwt
uses the push remote only to select a host. The Git config maps that host to an
explicit branch namespace:
[gwt "github.com"]
user = cadebrown
[gwt "gitlab-master.nvidia.com"]
user = cadeb
Add another forge without changing the script:
git config --global gwt.example.com.user my-username
If the selected host has no mapping, gwt new stops without creating a branch
or directory and prints the corresponding git config --global command.
For a checked-out branch, remote selection follows branch.<name>.pushRemote,
remote.pushDefault, and branch.<name>.remote, then falls back to origin or
the repository’s only remote.
Other worktree operations
The remaining commands delegate to Git and keep their native arguments:
gwt list
gwt lock ../offline-worktree
gwt move ../old-path ../new-path
gwt remove ../finished-worktree
gwt repair
gwt prune --dry-run
Use git worktree directly for an operation that intentionally bypasses the
branch and path policy.
Help
gwt help
gwt help new
gwt help add
gwt help init
The equivalent forms gwt --help, git-wt --help, and
git wt <command> --help work in any shell. Git reserves git wt --help for
manual-page lookup, so use git wt help for the top-level menu.
AeroSpace (v2)
This is the canonical reference for macOS window management in this dotfiles repo.
Source of truth
$EDITOR ~/dotfiles/home/dot_aerospace.toml
chezmoi apply ~/.aerospace.toml
aerospace reload-config
Design principles
- Direct hotkeys for primary actions (no leader-mode dependency)
- No hardcoded workspace-to-monitor assignment
- No automatic app-to-workspace routing
- Tight grid (zero gaps) with predictable normalization
Main keymap
alt + ←/↓/↑/→: focus windowalt + shift + ←/↓/↑/→: move windowcmd + alt + ←/↓/↑/→: join-with directionalt + -/alt + =: resize smart-50/+50alt + /: cyclelayout tiles horizontal verticalalt + ,: cyclelayout accordion horizontal verticalalt + f: AeroSpace fullscreenalt + shift + f: macOS native fullscreenalt + tab: workspace back-and-forthalt + 1..9: switch workspacealt + shift + 1..9: move node to workspace and followcmd + alt + 1..9: move node to workspace without followingalt + pageUp/pageDown: focus monitor next/prev (wrap)alt + shift + pageUp/pageDown: move workspace to monitor next/prev (wrap)
Service mode
- Enter:
alt + shift + ; esc: reload config + return to mainr: flatten workspace tree + return to mainf: toggle floating/tiling + return to mainbackspace: close all windows but current + return to main
Local AI coding
Local LLM inference on macOS Apple Silicon (M-series) — no API keys, no rate limits, no cloud — used as the default backend for opencode and pi (and as a generic OpenAI-compatible endpoint for anything else).
Overview
| Layer | Tool | Where it lives |
|---|---|---|
| Server | mlxserve (mlx-openai-server) | LaunchAgent dev.cade.mlxserve (KeepAlive, auto-start off by default — start with the mlxserve shell function); port 8080, OpenAI-compat + tool calling |
| Server (fallback) | Ollama | LaunchAgent (auto-start off by default), port 11434, OpenAI-compat |
| Client | opencode, pi | Both point at localhost:8080/v1 by default on macOS |
| Cloud | Anthropic, OpenAI | Available everywhere via ANTHROPIC_API_KEY / OPENAI_API_KEY |
MLX is the primary backend because it’s roughly 2-3× faster than Ollama
(llama.cpp) on the M3 Max for the same quants, and mlx-openai-server adds
OpenAI tool-call parsing on top — which mlx_lm.server upstream still lacks.
Ollama remains installed as a plain fallback.
Quick start
# LaunchAgent (preferred — survives terminal close, KeepAlive):
mlxstart # launchctl enable + bootstrap dev.cade.mlxserve
mlxstatus # is it running?
mlxstop # bootout + disable (stays off across logins)
# Or foreground in a terminal:
mlxserve # default: Qwen3.6-27B 8-bit (served as "qwen3.6-27b")
mlxserve qwen3.6-35b-a3b # MoE alternative — fast tokens (3B active)
mlxserve coder-next # Qwen3-Coder-Next 80B/3B MoE (no thinking)
# Then launch any client:
opencode # TUI agent, full tool-calling loop
pi # TUI agent, full tool-calling loop
All requests use the served-model-name qwen3.6-27b regardless of which
physical model is loaded — client configs stay stable when you swap models.
mlxserve and mlx-openai-server
mlxserve is a shell function (defined in both .zshrc and .bashrc) that
starts mlx-openai-server with the right parsers for the chosen model:
mlx-openai-server launch \
--model-type lm \
--model-path unsloth/Qwen3.6-27B-MLX-8bit \
--served-model-name qwen3.6-27b \
--tool-call-parser qwen3_coder \
--enable-auto-tool-choice \
--reasoning-parser qwen3_5 \
--kv-bits 8 --kv-group-size 64 \
--host 127.0.0.1 --port 8080
The parser flags are critical: opencode and pi are tool-call-heavy, and the
upstream mlx_lm.server does not emit tool_calls[] in OpenAI format
(ml-explore/mlx-lm#1096).
mlx-openai-server adds parser layers that translate model output into the
standard format. Qwen3.6 emits Qwen3-Coder’s XML tool-call wire format, so
the tool parser is qwen3_coder even on non-Coder variants; the reasoning
parser (qwen3_5) strips <think> blocks before clients see the output.
Override the port with MLX_PORT=9000 mlxserve.
Pre-pulled models
Models live in packages/mlx-models.txt:
unsloth/Qwen3.6-27B-MLX-8bit # primary (~35 GB, 256K ctx, reasoning-tuned)
# mlx-community/Qwen3.6-35B-A3B-8bit # MoE alternative — pull on demand
# mlx-community/Qwen3-Coder-Next-8bit# max tool-call throughput (~85 GB)
Pre-pull the default set in one shot:
bash ~/dotfiles/install/local-llm.sh pull-models
This is opt-in (the default local-llm.sh run only verifies binaries —
pulling ~35 GB of models on every bootstrap would be unfriendly). The
commented entries are one mlxpull <alias> away.
HF_HOME is set by .zprofile to $_LOCAL_PLAT/.cache/huggingface, so
weights live on scratch when scratch is configured.
Per-tool config
Both coding agents are configured to use localhost:8080/v1 as their
default backend on macOS. Each one lives under chezmoi:
| Tool | Default config | AGENTS file |
|---|---|---|
| opencode | ~/.config/opencode/opencode.json (+ plugin/git-context.ts) | ~/.config/opencode/AGENTS.md |
| pi | ~/.pi/agent/{settings,models}.json (+ themes/dotfiles.json) | ~/.pi/agent/AGENTS.md |
Both AGENTS files (plus Claude’s CLAUDE.md and Codex’s AGENTS.md)
include a shared partial — see Agent guidance. Cloud model pins
are single-sourced in home/.chezmoidata.toml ({{ .models.opus }} etc.).
Switching to cloud
# opencode — switch agent or model in the TUI
/agent plan # plan agent runs Fable
/model anthropic/claude-sonnet-5
# pi — Ctrl+L (or /model)
/model anthropic/claude-sonnet-5
API keys come from ~/.<service>.env files (written by bash auth.sh),
sourced into the shell by ~/.zprofile.
Ollama (fallback)
Installed via Homebrew (brew "ollama"). Has a LaunchAgent on macOS but
auto-start is off by default (DF_START_LOCAL_SERVICES=1 to opt in, or run
ollama serve); when running it serves http://127.0.0.1:11434. No model fleet is maintained
for it; an ad-hoc pull (ollama pull qwen3-coder:30b) is one command away.
(The old context-boosted alias machinery was removed — nothing consumed it.)
run_onchange hooks
| Trigger file | Script re-run |
|---|---|
packages/pip-full.txt | install/local-llm.sh (verifies binaries) |
home/dot_config/opencode/opencode.json.tmpl | install/opencode.sh (binary check) |
chezmoi update after pulling dotfile changes re-verifies the setup.
Game development stack
Gamedev tooling on macOS Apple Silicon, chosen (August 2026) for how well AI
agents can drive it — engines with diffable text formats and headless CLIs,
plus MCP servers that let Claude Code manipulate scenes, run tests, and
generate assets. Full research + phased roadmap:
GAMEDEV_PLAN_CLAUDE.md
at the repo root.
Overview
| Layer | Tool | Status |
|---|---|---|
| Asset hub | Blender 5.2 LTS (cask "blender") + blender-mcp (packages/mcp-servers.txt) | Installed |
| Engine — early-adopter | Unity via cask "unity-hub" — prerelease stream + first-party MCP | Installed; manual steps below |
| Engine — planned | Godot 4.7 + Bevy 0.19 (plan Phase 1, not yet applied) | Planned |
| Engine — wanted | UE6 (native MCP, Verse) — Early Access ~late 2027 | Watchlist |
| Skills | router (awesome-gamedev-agent-skills) in packages/agent-skills.txt | Installed |
The 2026 agent-friendliness ranking that drove the choices: Godot first
(.tscn/.tres/.gd are plain diffable text, first-class --headless),
Bevy first for code-first work (pure Rust — maximally LLM-legible),
Unity second (best MCP tooling, held back by GUID-heavy YAML scenes),
Unreal last (binary .uasset, opaque Blueprints — agents can’t diff
content; UE6 is the fix).
Blender as the asset hub
ahujasid/blender-mcp (registered
as uvx blender-mcp; requires its addon installed inside Blender) is the most
mature MCP in the gamedev space: arbitrary Python in Blender, viewport
screenshots for feedback loops, and generation hooks for Poly Haven (CC0
stock), Sketchfab, Hyper3D Rodin, and Hunyuan3D. Engine-agnostic — it feeds
Unity, Godot, or Bevy via glTF/FBX export.
Unity early-adopter track
Unity’s alpha/beta streams are open to everyone (no signup) and it is the only
major engine shipping a first-party MCP server. After brew bundle:
- Unity Hub → Installs → Pre-releases — install the current beta. The milestone build is the 6.8 alpha (~end of 2026): full-CoreCLR editor, Mono gone, .NET 10 + C# 14.
- First-party MCP (per-project, not in
mcp-servers.txtby design): add thecom.unity.ai.assistantpre-release package, then follow its unity-mcp-get-started page — the editor auto-launches an MCP bridge and Claude Code spawns a relay from~/.unity/relay/over stdio. Editor must be running. - Fallback if the pre-release MCP disappoints:
CoplayDev/unity-mcp— 47 tools including play-mode tests, profiling, and builds.
Licensing: Personal tier is free under $200k revenue; the 2023 runtime fee was cancelled in 2024. In-editor Unity AI (Assistant/Generators) is metered via AI-gateway points — the MCP path is the agent surface, not that.
UE6 (wanted ASAP — nothing installable yet)
UE6 was announced June 2026: UE5+UEFN unification, gameplay in Verse, native MCP integration. Early Access lands ~late 2027, final ~mid-2029. Until then:
- Link an Epic account for GitHub source access; watch
ue5-mainand Lore (Epic’s open-sourced Rust VCS). - Verse runs today only inside UEFN, which is Windows-only — on macOS, learn the language from Epic’s docs and wait.
- Day one of EA: install via
cask "epic-games", wire the native MCP, re-run the engine bake-off against Godot/Bevy.
Roadmap (plan Phases 1-2, not yet applied)
From GAMEDEV_PLAN_CLAUDE.md: casks godot, krita, affinity,
material-maker, reaper; godot-mcp registration; Aseprite (paid, no cask
possible). On-demand: Plasticity, Cascadeur, Houdini Apprentice, Tripo/Meshy
credits, ElevenLabs SFX. Avoid: Suno/Udio for shipped-game music
(mid-litigation), Luma Genie and Quixel Mixer (dead).
Research mathematics stack
Verification-first mathematics tooling, implemented August 2026 across all
four agent harnesses (Claude Code, Codex, opencode, pi). The organizing rule:
a claim is proved only when the exact intended statement compiles in Lean with
no sorry — everything else (CAS output, notebooks, numerics) is evidence.
Overview
| Layer | What | Where |
|---|---|---|
| Proof | Lean 4 via elan (ELAN_HOME=$LOCAL_PLAT/elan), default toolchain pinned by DF_LEAN_TOOLCHAIN | install/lean.sh (DF_DO_LEAN) |
| Agent norms | Proof gate + tool routing shared by all harnesses | home/.chezmoitemplates/math-common.md |
| MCPs | lean-lsp (pinned), lean-explore (API backend), asta, arxiv, mathlas, wolfram (AgentTools paclet via wolfram-mcp wrapper) | packages/mcp-servers.txt |
| Skills | math-lookup (OEIS/LMFDB/zbMATH/PSLQ recipes), doc-coauthoring, shared lean4 skill for non-Claude harnesses | home/dot_claude/skills/, packages/agent-skills.txt |
| CAS / compute | PARI, FLINT, juliaup (depot under $LOCAL_PLAT), minizinc, cadical/kissat; heavy Python deps per-project | packages/Brewfile, project repos |
| Writing | MacTeX (macOS) / TinyTeX (install/latex.sh, DF_DO_LATEX), Typst, Quarto, texlab + harper, Zotero + Better BibTeX | packages/Brewfile, packages/pip-full.txt |
| Prover APIs | Asta (free), Aristotle, Aleph — bash install/auth.sh <service> | install/auth.sh |
The proof gate
For anything exported or AI-generated:
informal claim
-> precise Lean statement (unit-test it against known examples —
misformalization is the classic failure)
-> no `sorry`
-> lake build
-> axiom audit (lean_verify / #print axioms)
-> lean4checker --fresh
Projects pin their toolchain (lean-toolchain, lakefile, lake-manifest.json
always committed); Mathlib is cache-first (lake exe cache get) with source
build as a supported fallback (16 GB+ RAM).
Workspaces
- ~/dev/math-lab — heavy uv project: sympy, python-flint, mpmath, networkx, fpylll, cypari2, jax, passagemath, z3/cvc5, prover clients.
- Julia/OSCAR:
juliaup add release; OSCAR and friends per-project. - Template repos still to create: Lean research template (LeanProject + blueprint + doc-gen4 + CI + Pages deploy) and video-lab (remotion + canvas-commons + three).
Gotchas
gp(PARI) is shadowed interactively by thegp='git push'alias — humans needcommand gp; scripts get the real binary.- passagemath on macOS needs signal handlers reset before import — see
sagefix.pyin math-lab and the troubleshooting entry. lean-lsp-mcpis version-pinned inpackages/mcp-servers.txt; bump deliberately, not via ambient uvx resolution.- ltex-plus is not on Open VSX (VS Code only); Cursor uses harper instead.
- DaVinci Resolve has no Homebrew cask — manual install.
Scientific review
scientific-review is the shared-skill workflow for an auditable literature
review, manuscript check, or public peer-review analysis. Its source is
home/dot_claude/skills/scientific-review/;
chezmoi applies it to the shared agent-skill tree.
Use it when the answer needs an explicit claim-evidence matrix, source records,
reproduction status, or the distinction between a formal proof and weaker
evidence. It complements research for web-grounded investigation and
math-lookup for exact mathematical databases.
Research record
Each conclusion-changing source records a canonical identifier, query,
retrieval date, version, license/access status, and either the raw response or
its checksum. Use DOI/arXiv/PMID/PMCID/OpenReview/dataset DOI identifiers rather
than URLs alone. references/evidence-record.md in the skill defines the review
matrix.
Tool boundaries
- Asta and arXiv handle discovery and source retrieval; Crossref, OpenAlex, and DataCite resolve authoritative metadata; OpenReview v2 exposes venue records; Zotero’s local API stays on the machine.
- Lean establishes an exact formal statement only after the project proof gate. Wolfram or other CAS output remains recorded computation, not proof.
- Public review records can still carry anonymity and venue-policy obligations. Never infer concealed identities or send private manuscripts to external services.
Scite is an opt-in remote profile because queries and account-scoped library
context leave the machine. Enable it during agent configuration with
DF_MCP_PROFILES=research-scite ~/dotfiles/bootstrap.sh update; omit the
profile to keep the default Asta/arXiv research stack local/keyless where
possible. biomed and publish are reserved opt-in profiles; publication
tools still require explicit confirmation for writes.
Reproducibility and publication
Record the command, inputs, seed, environment/toolchain, output, and checksums for a rerun. DOI/repository operations remain project-scoped: inspect or create a draft, validate metadata and checksums, then require an explicit instruction before publishing. The skill neither stores secrets nor automates manuscript uploads.
Agent guidance
Four different AI coding tools (Claude Code, Codex, opencode, pi) each expect their own AGENTS.md / CLAUDE.md file. Most of the content is the same — user background, communication style, engineering principles, tool preferences. The differences are the per-tool addenda (skill systems, MCP usage, tool-call quirks, etc.).
The shared partial
home/.chezmoitemplates/agents-common.md holds the common content. Each
tool’s .tmpl file pulls it in with one line:
{{ template "agents-common.md" . }}
A typical wrapper looks like:
# AGENTS.md
This is the global memory for <tool>. Common guidance lives in the shared
partial; <tool>-specific notes follow.
{{ template "agents-common.md" . }}
## <Tool>-specific
- ...tool quirks, MCP setup, edit modes, etc...
voice-common.md
home/.chezmoitemplates/voice-common.md holds tone/communication and
estimate conventions — deliberately split out of agents-common.md so it can
load at different levels per tool: Claude gets it via the cade output style
(system-prompt level), while the Codex/opencode/pi wrappers include it
directly next to agents-common.md. Keeping it out of agents-common.md
means Claude never loads the voice guidance twice.
math-common.md
home/.chezmoitemplates/math-common.md holds the research-mathematics norms,
included by all four guidance files. Three things it fixes in place:
- The proof gate. A claim counts as proved only when the exact intended
statement compiles in Lean with no
sorry, surviveslake build, passes an axiom audit, and certifies underlean4checker --fresh. Misformalization — proving the wrong statement — is the classic failure, not bad tactics, so formalized statements get unit-tested against known examples first. - Evidence tiers. CAS output, notebook experiments, and numerical sweeps are evidence, never proof, and have to be labeled as such. Literature claims carry a source.
- Tool routing, so agents reach for the verifying tool instead of guessing:
Lean state and search → the
lean-lspMCP; literature →astaandarxiv; CAS checks →wolframscript; sequences → OEIS; constants → PSLQ viamathlas. Registered for every harness frompackages/mcp-servers.txt.
Where each file lives
| Tool | Source (chezmoi) | Deployed to |
|---|---|---|
| Claude Code | home/dot_claude/CLAUDE.md.tmpl | ~/.claude/CLAUDE.md |
| Codex | home/dot_codex/AGENTS.md.tmpl | ~/.codex/AGENTS.md |
| opencode | home/dot_config/opencode/AGENTS.md.tmpl | ~/.config/opencode/AGENTS.md |
| pi | home/dot_pi/agent/AGENTS.md.tmpl | ~/.pi/agent/AGENTS.md |
All four render through the same partial — edit agents-common.md once and
chezmoi apply propagates everywhere.
Adding a new tool
- Drop
home/<tool-config-path>/AGENTS.md.tmpl(or whatever the tool calls it) with the wrapper shown above. - Add a
## <Tool>-specificsection at the bottom for anything the partial doesn’t cover. chezmoi applydeploys it.
No bootstrap.sh changes needed — chezmoi apply is step 2 of every bootstrap.
Editing the shared content
Edit home/.chezmoitemplates/agents-common.md directly. The change takes
effect on every tool the next time they read their config (most pick up
file changes on session start; some are eager).
Project-level overrides
Most of these tools also walk up from the current working directory looking for a project-local AGENTS.md / CLAUDE.md. Those override or augment the global file — write project-specific guidance there, not in the partial.
Skills (shared across tools)
Skills live in one place: home/dot_claude/skills/ → deployed to
~/.claude/skills. A chezmoi-managed symlink ~/.agents/skills →
~/.claude/skills exposes the same tree to Codex, opencode, and pi (all
three scan ~/.agents/skills; opencode also reads ~/.claude/skills
directly). One SKILL.md edit propagates to every tool on chezmoi apply.
Installer-managed skills are declared in packages/agent-skills.txt; Codex
plugins are declared separately in packages/codex-plugins.txt. Run
bash install/skills-sync.sh check for a read-only drift check. Do not use
npx skills check as an audit: current versions update installed skills.
Codex and Claude each have researcher and reviewer specialists under their
managed agents/ directories. Global instructions authorize bounded parallel
research, log analysis, tests, and final review while keeping overlapping edits
in one agent. Codex is capped at six direct children and one level of nesting.
df-agent-doctor checks the declared tool surface, skill registry, Codex
plugins/config, qmd, cass, and LaunchAgents.
Model and safety defaults
- Codex defaults to GPT-5.6 Sol at high reasoning.
deepraises reasoning to extra-high,fastuses GPT-5.6 Luna at low reasoning, andreviewis read-only. - Codex defaults to the built-in
:danger-full-accessprofile with approval policynever. All MCP and connector tools, including destructive and open-world tools, run without prompts. - Claude Code defaults to Claude Fable 5 with extra-high effort,
bypassPermissions, and its OS sandbox disabled. - OpenCode uses Fable for planning, local Qwen3.6 for builds on macOS, and a
read-only Sonnet 5 review subagent. Plan/build agents and all MCP tools use the
global
allowpolicy; its shell wrapper also passes--auto. Review rejects unmatched shell commands without asking. - Cursor CLI permits every shell command, Cursor’s Claude extension starts in bypass mode, Claude Desktop permits all browser actions, and Codex Desktop skips its full-access confirmation.
The chezmoi source guard still blocks edits to rendered targets when an authoritative
source exists under home/. That is a correctness invariant, not an approval gate.
Memory layers
Three layers, set up by install/memory.sh (bootstrap step 6.6, DF_DO_MEMORY):
| Layer | Store | Search | Synced? |
|---|---|---|---|
| L1 auto-memory | ~/.claude/projects/<proj>/memory/ (markdown) | loaded each session; also indexed by qmd | no (per-machine) |
| L2 knowledge base | ~/kb git repo (markdown) | qmd — hybrid BM25 + local GGUF embeddings + rerank, MCP daemon on localhost:8181 | yes (git remote) |
| L3 session history | every agent’s transcripts (Claude Code, Codex, opencode, pi) | cass — hybrid BM25 + native MiniLM embeddings, CLI/history-search skill | no (per-machine) |
Both stores are local (~/.cache/qmd, ~/.cass — on scratch when configured);
only ~/kb and the dotfiles repo sync across machines. qmd’s index is fully
rebuildable from ~/kb; cass is not — it keeps transcripts the harnesses
later rotate away, so for those conversations it is the only remaining copy.
That is why it lives at ~/.cass rather than under ~/.cache. qmd keeps a
persistent MCP daemon, but cass indexing is manual on every platform so a large
session archive never blocks bootstrap or consumes resources on a schedule.
Run bash install/memory.sh index for a lexical refresh. Run
bash install/memory.sh semantic for one resumable 64-conversation semantic
batch; repeat it when you want more history embedded. After bulk changes,
bash install/memory.sh reindex forces the qmd embedding and cass lexical
indexes to rebuild. Agent-facing usage rules live in the ## Memory layers
section of agents-common.md.
Remote clipboard
Ghostty copies selections to the local clipboard and permits remote OSC 52
writes. Its shell integration propagates environment and terminfo over SSH.
The managed tmux config enables clipboard escape passthrough, and Neovim forces
its OSC 52 provider whenever SSH_TTY or SSH_CONNECTION is set. Paste remains
local terminal input; remote clipboard reads still require Ghostty approval.
Troubleshooting
Quick reference for when things go wrong. Check here before digging into scripts.
Tool not found after bootstrap
echo "$_PLAT" "$_LOCAL_PLAT" # capability + install root
ls "$_LOCAL_PLAT/bin/" # chezmoi, uv, claude should be here
ls "$_LOCAL_PLAT/cargo/bin/" # fd, sd, zoxide, etc.
which fd # should point under $_LOCAL_PLAT
$_LOCAL_PLAT is $HOME/.local by default (flat layout) or $HOME/.local/$_PLAT when PLAT isolation is enabled. If $_PLAT or $_LOCAL_PLAT is empty, .zprofile wasn’t sourced. Open a new login shell (zsh -l) or source it:
source ~/.zprofile
Codex install fails with marketplace unavailable: openai-bundled
Symptom: install/codex.sh reports that openai-bundled is unavailable even
though codex login status says the user is authenticated.
Root cause: openai-bundled is a local marketplace owned and registered by
Codex Desktop. Authentication does not expose it to the standalone CLI, whose
built-in marketplace is openai-curated. The CLI-managed
packages/codex-plugins.txt must therefore contain only plugins from
marketplaces reported by codex plugin marketplace list.
Confirm:
codex login status
codex plugin marketplace list --json
Fix: update the dotfiles checkout and rerun bootstrap.sh. Bundled plugins
remain owned by Codex Desktop; do not register the app’s internal plugin path
manually because that can conflict with the app’s marketplace reconciliation.
Codex plugin fails: plugin X was not found in marketplace openai-curated
Symptom: install/codex.sh (Codex Plugins step) logs a [warn] like
Error: plugin openai-developers was not found in marketplace openai-curated,
and on an older checkout the healthcheck then died with
Missing or disabled Codex plugin: <plugin>@openai-curated.
Root cause: openai-curated is a snapshot bundled with codex-cli, and codex
is unpinned (packages/npm.txt), so its curated plugin set changes across
versions. A selector in packages/codex-plugins.txt that a newer codex-cli no
longer ships can’t install — the entry is stale. (openai-developers and
build-web-data-visualization were temporarily absent in codex-cli 0.144.6 and
returned in 0.147.0.)
Confirm — list what the installed codex actually offers:
codex plugin list --json | jq -r '.available[].pluginId'
Fix: prune (or re-point) the missing selectors in
packages/codex-plugins.txt to match that list, then rerun bootstrap.sh. The
healthcheck now warns (dropped upstream: … — prune packages/codex-plugins.txt)
instead of failing when a declared plugin is gone from the snapshot, so this no
longer blocks bootstrap — the warning is your cue to prune. A plugin still
offered by the snapshot but not installed/enabled stays a hard failure.
The WARNING: failed to clean up stale arg0 temp dirs: Directory not empty line
from codex-cli is unrelated NFS noise (.nfs* files in its temp dir) — harmless.
Claude plugin fails: Plugin "X" not found in any configured marketplace
Symptom: install/claude.sh logs [warn] fail <plugin>: … ✘ Failed to install plugin "<plugin>": Plugin "<plugin>" not found in any configured marketplace, but the plugin visibly exists in the marketplace’s GitHub repo.
Root cause: plugin installs resolve against the local marketplace clones
under ~/.claude/plugins/marketplaces/, and with DISABLE_AUTOUPDATER=1 those
never refresh themselves. A plugin added upstream after the clone date is
invisible (the claude-plugins-official clone once sat 4 months stale while
math-olympiad existed upstream). claude.sh used to refresh catalogs only in
upgrade mode — and even that call was broken, passing a nonexistent --all flag
whose error was silenced by >/dev/null || true, so no mode ever refreshed.
It now refreshes (with the correct no-name form) in every mode.
Confirm — compare the clone date against upstream:
git -C ~/.claude/plugins/marketplaces/<marketplace> log -1 --format=%cd
jq -r '.plugins[].name' \
~/.claude/plugins/marketplaces/<marketplace>/.claude-plugin/marketplace.json | grep <plugin>
Fix: update the checkout and rerun bootstrap.sh (or install/claude.sh).
Manual one-off:
claude plugin marketplace update <marketplace>
claude plugin install <plugin>@<marketplace>
The same VS Code / Cursor extensions report fail on every upgrade run
Symptom: bootstrap.sh upgrade logs fail <ext-id> for a fixed set of
extensions, run after run — yet the extensions are installed and working in the
editor. Install mode never reports them.
Root cause: upgrade mode used to reinstall each declared extension with
--install-extension <id> --force, which re-resolves the ID against the
editor’s marketplace. Two categories can never satisfy that:
- Extensions the editor now bundles. VS Code ships
github.copilot-chatbuilt in (0.59.0); the marketplace copy is older (0.48.1) and the CLI refuses the downgrade outright:is a built-in extension … and cannot be downgraded. - IDs the marketplace doesn’t carry. Cursor resolves against Open VSX, so
Microsoft-proprietary IDs fail with
Extension '<id>' not foundeven when the extension is installed — Cursor imported it from VS Code on first run, a path the CLI can’t reproduce.nvidia.nsight-vscode-editionis refused explicitly:not available in Cursor for the Mac Silicon.
Confirm — run the install by hand to see the real error the scripts swallow:
code --install-extension <id> --force # or: cursor --install-extension …
Fix: update the checkout and rerun. vscode.sh / cursor.sh now upgrade
with a single --update-extensions bulk pass instead of per-extension
--force, which only touches what the editor can actually resolve. Drop
bundled extensions from packages/vscode-extensions.txt, and keep IDs Open VSX
can’t serve out of packages/cursor-extensions.txt (that file’s header lists
the known-unavailable set and the Anysphere forks to use instead).
Cursor reports ENOENT for User/settings.json and ignores user settings
Symptom: Cursor logs or displays an error such as:
ENOENT: no such file or directory, open '.../Cursor/User/settings.json'
The native file is still a symlink, but its managed target is missing or empty:
ls -l "$HOME/Library/Application Support/Cursor/User/settings.json"
jq -e 'type == "object"' ~/.config/cursor/settings.json
git diff -- home/dot_config/cursor/settings.json
Root cause: Cursor writes the symlinked settings file non-atomically. The Cursor
agent hook could run after the file was truncated but before the replacement
contents arrived, and chezmoi add then copied the empty file into the repo.
The hook’s jq cleanup also accepted empty input as success, preserving the
damage. A later failed rewrite can leave the native symlink dangling.
Fix: update the checkout. The hook now accepts only a complete settings object or keybindings array, then validates the chezmoi source after import and restores its previous contents if the file changed during the copy. To recover an already-empty source after confirming the diff contains no wanted edits:
git restore --source=HEAD -- home/dot_config/cursor/settings.json
chezmoi apply ~/.config/cursor/settings.json ~/.cursor/hooks/sync-dotfiles-cursor.sh
If an open window still shows defaults after the native symlink resolves, run
Developer: Reload Window from Cursor’s command palette.
Brew bundle fails: No available formula … This command requires the tap
Symptom: brew bundle errors with No available formula with the name "owner/tap/formula". This command requires the tap owner/tap. If you trust this tap, tap it explicitly and then try again: brew tap owner/tap — even though the
Brewfile has the tap "owner/tap" line and the tap is already trusted.
Root cause: two separate Homebrew gates protect third-party taps — trust
(HOMEBREW_REQUIRE_TAP_TRUST) and the tap actually being cloned. Homebrew no
longer auto-taps from a fully-qualified formula name, and brew bundle can hit
formula resolution before executing the Brewfile’s own tap directive — in
particular the upgrade check for a formula already installed under the same name
from homebrew/core (seen with rtk: core keg installed, rtk-ai/tap/rtk in the
Brewfile, tap trusted but never tapped → resolution error every run).
Confirm:
brew tap # tap missing from the list
jq . ~/.homebrew/trust.json # …while already trusted here
Fix: update the checkout and rerun — ensure_brewfile_taps() (_lib.sh)
now trusts and taps every tap referenced by the Brewfile before the bundle.
Manual one-off: brew tap owner/tap, then rerun install/homebrew.sh.
Brew bundle reports Upgrading X has failed! after installing X
Symptom: bootstrap.sh upgrade pours and links a formula successfully, then
reports Upgrading <formula> has failed!. Nearby errors name a vanished file in
~/.cache/Homebrew/downloads/, such as No such file or directory @ dir_s_rmdir - ...bottle_manifest.json. Several unrelated formulae can fail this
way in one run.
Root cause: Homebrew Bundle defaults to as many as four package workers. Those
workers launch separate brew install or brew upgrade processes that share
one download cache and run install cleanup against it. One worker can remove a
cache entry after another has inspected it, turning successful installs into
nonzero exits. Homebrew tracks the broader parallel-worker race as
Homebrew/brew#23328; the
Homebrew manpage documents the auto job
default and the sequential override.
Confirm after the original bootstrap process has exited:
formula=tree
brew list --versions "$formula"
brew outdated --formula "$formula"
brew linkage --test "$formula"
If the new version is listed, brew outdated prints nothing, and linkage
passes, the install succeeded and only its cleanup path failed.
Graphviz has a second version of this symptom. Netpbm fetches its source and
manual from Subversion. Bundle can queue those SVN fetches before it finishes
installing Graphviz’s Subversion dependency, record You must: brew install svn, then install Subversion, retry both checkouts, and successfully build
Netpbm and Graphviz. The early fetch result still makes Bundle print Upgrading graphviz has failed! after the successful install.
Fix: update the checkout and rerun bootstrap.sh upgrade. The shared
installer environment now disables Bundle package jobs; downloads and each
source build can still run concurrently, but package installs and cleanup are
serialized. On Linux it also installs a working Subversion before a Brewfile
containing Graphviz, then runs brew bundle check after any nonzero Bundle exit.
If that check passes, it retries Bundle once and reports recovery only when the
clean retry exits zero. Manual one-off:
brew bundle install --jobs=1 --file="$HOME/dotfiles/packages/Brewfile"
Do not start the retry while the first bootstrap is still running.
Brew cleanup fails with Device or resource busy .../.nfs...
Symptom: an upgrade prints a formula’s beer-mug success line, then fails while cleanup removes an unrelated old keg:
Error: Device or resource busy @ apply2files - .../Cellar/expat/<version>/lib/.nfs...
Root cause: NFS renames an unlinked-but-open file to .nfs* and keeps it until
the last process closes it. Any Homebrew executable or shared library can be a
holder: this first appeared with the Bash running bootstrap, then with dozens of
long-lived dbus-daemon processes mapping an old libexpat.so. Homebrew runs
formula cleanup after installs and, every 30 days, a full cleanup. That cleanup
exception changes the command’s exit status after the package succeeds, so
Bundle misleadingly reports Upgrading <formula> has failed! for each later
package too.
Confirm which processes still hold the file:
lsof /path/from/the/error/.nfs...
Fix: wait for the original bootstrap to exit, update the checkout, and retry.
On an NFS Homebrew prefix, linux-packages.sh sets the documented
HOMEBREW_NO_INSTALL_CLEANUP
switch for the run. Installs and upgrades still happen, but cleanup cannot turn
their success into failure. The switch does not disable an explicit cleanup;
after every process shown by lsof has exited, reclaim the retired keg with:
brew cleanup expat
Old kegs consume some disk until that maintenance succeeds. Do not delete the
.nfs* file manually or terminate unrelated holders just to make cleanup pass.
Ruby upgrade writes outside its new keg during make install
Symptom: upgrading vim, ccache, or another Ruby dependent builds Ruby and
then fails under the global Homebrew Ruby directory:
Dir.mkdir: Permission denied @ dir_s_mkdir - .../brew/lib/ruby
The same contamination can later surface as Errno::ENOENT under
.../brew/lib/ruby/gems/... during RubyGems setup.
Root cause: the formula adds the versioned ruby@X.Y path as a compatibility
fallback. runruby supplies the build directory through LD_LIBRARY_PATH, but
Homebrew’s GCC emits DT_RPATH, which the dynamic loader searches first. During
make install, the new Cellar lib directory is not populated yet, so the build
executable falls through to the previous keg’s libruby. The source RUBYLIB
also lacks RubyGems’ optional defaults/operating_system.rb; its require can
therefore find the previous keg’s file. Those old Homebrew defaults redirect
Gem.default_dir and Gem.ruby outside the new keg. The Linux filesystem
sandbox correctly rejects that write; the prefix permissions are not broken.
Fix: install/patch-homebrew-ruby.sh patches the local formula before Brew
Bundle runs. It enables new ELF dtags so DT_RUNPATH yields to the build-tree
library path, retains the new keg before the versioned fallback after install,
and adds a build-local empty RubyGems packager-default file so the previous
keg’s override cannot leak in. The formula replaces that empty file with the
current Homebrew configuration after installation.
This formula-local DT_RUNPATH is a deliberate exception to the prefix’s usual
DT_RPATH policy: the build runner must let its temporary LD_LIBRARY_PATH
select the new build-tree libruby.
Do not chmod the prefix or disable Homebrew’s Linux sandbox. Wait for any
active Homebrew process to exit, then rerun ~/dotfiles/bootstrap.sh upgrade so
the formula refresh and patch happen in the intended order.
apache-serf cannot find asm/socket.h
Symptom: a source build of apache-serf invokes a brewed GCC directly and
fails through Homebrew’s glibc headers:
glibc/include/bits/socket.h: fatal error: asm/socket.h: No such file or directory
Root cause: Homebrew’s standard build environment already puts the installed
linux-headers@6.8 include directory in CPATH. Serf’s SConstruct creates a
new SCons child environment that does not inherit that variable, so the direct
GCC command loses the kernel-header path. Adding the path to superenv or CPATH
again does not cross this second environment boundary.
Fix: install/patch-homebrew-apache-serf.sh adds a direct Linux dependency
on linux-headers@6.8 and passes its stable opt_include path through Serf’s
supported CPPFLAGS SCons variable. The patch fails closed if the formula
structure changes instead of silently starting another known-broken source
build.
A trailing Clang warning about which GCC installation it may prefer is separate
from this GCC compile failure. Wait for any active Homebrew process to exit,
then rerun ~/dotfiles/bootstrap.sh upgrade so the refreshed formula is patched
before Bundle starts.
Gecode patch reports that its configure target moved
Symptom: bootstrap records this degradation even though MiniZinc may still finish installing:
gecode configure patch target not found — formula may have changed
Root cause: Homebrew changed the Gecode formula from Autotools flags such as
--enable-qt to CMake settings such as GECODE_ENABLE_GIST. The dependency
guard could still apply while the old configure anchor no longer existed,
leaving a partial formula edit and a misleading successful patch status.
Fix: install/patch-homebrew-gecode.sh recognizes both formula shapes,
forbids the upstream bottle on Linux, and sets Gist and Qt off while retaining
the macOS bottle and GUI. The bottle gate matters because build flags cannot
change a bottle that already contains libgecodegist and Qt dependencies. The
installer rebuilds an existing Gist-bearing keg from source and requires both
brew linkage --test gecode and the absence of libgecodegist.so; a moved
anchor stops source builds instead of leaving a partial formula edit.
Clang cannot load libz3 during an in-progress upgrade
Symptom: a formula failure ends with a separate loader error such as:
clang: error while loading shared libraries: libz3.so.4.15: cannot open shared object file
Root cause: Bundle upgraded Z3 before unversioned LLVM. The installed LLVM still
needs Z3’s previous major SONAME, while opt/z3 already selects the new keg.
This is independent of a formula that was compiled with GCC and usually repairs
itself when the same Bundle run reaches LLVM.
The Linux installer reconciles this pair before Bundle: it upgrades an outdated
LLVM in upgrade mode, reinstalls a current keg whose linkage is broken, and
checks both brew linkage and the unversioned Clang executable again after
Bundle.
Do not point the old SONAME at the new major library or repoint opt/z3 during
the active transaction. After all Homebrew processes exit, rerun
~/dotfiles/bootstrap.sh upgrade. For a manual check, run:
"$(brew --prefix)/bin/clang" --version
If it still reports the old Z3 SONAME, upgrade an outdated LLVM or reinstall a current but broken keg, then verify its linkage:
if [[ -n "$(brew outdated --formula llvm)" ]]; then
brew upgrade llvm
else
brew reinstall llvm
fi
brew linkage --test llvm
"$(brew --prefix)/bin/clang" --version
OpenSSH upgrade fails with inreplace failed ... sshd_config
Symptom: OpenSSH finishes make install, then Homebrew aborts while replacing
its Cellar prefix in the persistent configuration:
Error: inreplace failed
.../brew/etc/ssh/sshd_config:
expected replacement of ".../Cellar/openssh/<version>" with ".../opt/openssh"
Root cause: Homebrew preserves files under etc across upgrades. After the
first install, sshd_config already contains opt/openssh; a later install has
no Cellar path left to replace, but the formula treats that valid no-op as an
error.
Fix: install/patch-homebrew-openssh.sh guards the replacement with a
content check. It leaves an already-normalized configuration untouched, while
the formula’s test still rejects any Cellar path that remains.
Brew has the current keg but still uses an older version
Symptom: brew list --versions glib or another formula lists the current
version, and brew outdated prints nothing, but opt/<formula>, bin/<tool>,
or pkg-config still resolves an older keg. This can follow an interrupted or
failed upgrade.
Inspect both the selected keg and the current keg’s receipt:
formula=glib
prefix=$(brew --prefix)
brew info --json=v2 "$formula" | jq '.formulae[0] | {linked_keg, installed}'
readlink -f "$prefix/opt/$formula"
ls "$prefix/Cellar/$formula"/*/INSTALL_RECEIPT.json
If the current keg has an install receipt, its direct executable works, and
brew linkage --test "$formula" passes, preview and repair only the links:
brew link --overwrite --dry-run "$formula"
brew link --overwrite "$formula"
If its receipt is absent or brew info --json=v2 reports a null install time,
the keg is incomplete. Do not force-link it; rebuild it:
HOMEBREW_NO_INSTALL_CLEANUP=1 brew reinstall --build-from-source "$formula"
This run found both forms: Fish 4.8.1 was complete but its bin/fish symlink
still named 4.5.0, while GLib 2.88.3 lacked a receipt and had to be rebuilt.
Brew Bundle reports a circular libtiff, webp dependency
Symptom: brew bundle check refuses to sort its graph even though both current
formulae are installed:
Formulae dependency graph sorting found a circular dependency:
libtiff, webp
Root cause: the installed WebP receipt can retain an old libtiff dependency,
while the current libtiff formula depends on WebP. Generated keg receipts are
installation records; do not hand-edit them. Reinstalling WebP regenerates its
receipt from the current one-way dependency graph:
HOMEBREW_NO_INSTALL_CLEANUP=1 brew reinstall --build-from-source webp
brew bundle check --file="$HOME/dotfiles/packages/Brewfile"
Rust fails after Homebrew says its packages are satisfied
Symptom: install/rust.sh reports Homebrew rustup not found even though
brew list rustup and brew --prefix rustup succeed.
Root cause: Homebrew’s keg-only rustup formula removed rustup-init. Older
bootstrap runs left ~/.local/cargo/bin/rustup pointing at the removed
/opt/homebrew/bin/rustup-init, and rust.sh incorrectly required that removed
binary before accepting the installed formula.
Confirm:
brew info rustup | grep -E 'keg-only|no longer provides'
readlink ~/.local/cargo/bin/rustup
ls "$(brew --prefix rustup)/bin/rustup"
Fix: update the checkout and run bash ~/dotfiles/install/rust.sh. The
installer now links Homebrew’s individual keg wrappers into the managed Cargo
bin directory and initializes stable with rustup toolchain install; it does
not depend on rustup-init.
Cask upgrade fails: It seems there is already an App at '/Applications/X.app'
Symptom: bootstrap.sh upgrade reports Some greedy cask upgrades failed, and
brew upgrade --cask --greedy ends with Error: Problems with multiple casks:
naming an app (or a binary, e.g. already a Binary at '/opt/homebrew/bin/dnx').
Homebrew reverts the upgrade, so the same failure repeats every run.
Root cause: Homebrew refuses to overwrite an artifact it doesn’t have a receipt for. Two ways an auto-updating cask gets there:
- Upstream renames the app bundle. The cask’s
appstanza changes name, the self-updater has already written the new bundle, and brew’s receipt still points at the old one — so brew tries to create a file that exists. This is whatcodex-appdid: OpenAI folded the Codex desktop app into ChatGPT and renamedCodex.app→ChatGPT.app(samecom.openai.codexbundle ID). Thecodex-appcask is deprecated withchatgptas its replacement, and the auto-migration leaves an orphanCaskroom/codex-app/directory behind, sobrew list --caskstill shows it whilebrew infosays “Not installed”. - A leftover symlink from a previous install.
dnxpointing into/usr/local/share/dotnet/blocked everydotnet-sdkupgrade.
Confirm:
brew outdated --cask --greedy # which casks are stuck
ls /opt/homebrew/Caskroom/<cask> # receipt version vs the running app
/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" /Applications/X.app
Two apps printing the same bundle ID means one is a stale copy under the old name, not a second product.
Fix: brew install --cask <name> --force — it overwrites the unmanaged
artifact, removes the old-named bundle, and re-establishes the receipt at the
current version. Delete any orphan Caskroom/<old-cask>/ directory (it holds
only a symlink and metadata; rm -rf on it does not follow the symlink) and
remove stray binaries before retrying. For a renamed cask, also update
packages/Brewfile to the replacement name.
A Homebrew package is months behind upstream (Linux)
Symptom: a formula installs at a version far older than upstream stable, and
re-running bootstrap never moves it. Seen with glab, which stuck at 1.89.0
while upstream was 1.109.0 — old enough that glab skills install --global
failed with Unknown command "skills", so install/skills-sync.sh reported
fail glab / missing declared skill: glab on every run.
Root cause: install/linux-packages.sh sets HOMEBREW_NO_AUTO_UPDATE=1 (so an
implicit refresh can’t revert the in-place formula patches) and, until now, never
ran an explicit brew update. The homebrew-core clone therefore froze at
whatever date it was first cloned, and HOMEBREW_NO_INSTALL_FROM_API=1 forces
every lookup through that frozen clone. One machine sat 4.5 months stale with
193 of 336 installed formulae behind upstream.
Confirm:
git -C "$(brew --repo homebrew/core)" log -1 --format=%ci # tap's age
brew info <formula> | head -1 # frozen version
Fix: rerun install/linux-packages.sh — it now discards the formula patches,
runs brew update, and re-applies each patch against the fresh formula. This
refreshes definitions only; DF_BREW_UPGRADE still governs whether installed
kegs move, so a plain run leaves working binaries alone.
Upgrade a single package without touching the tap:
env -u HOMEBREW_NO_INSTALL_FROM_API -u HOMEBREW_NO_AUTO_UPDATE brew upgrade <formula>
Expect patch anchors to rot across a long refresh — see the patch-anchor gotcha
in .claude/rules/homebrew.md.
GLIBC_x.y not found from a Homebrew binary (Linux)
Symptom: a brew-installed binary refuses to start, blaming Homebrew’s own libc:
.../opt/binutils/bin/as: .../opt/glibc/lib/libc.so.6: version `GLIBC_2.38' not found
Only some binaries are affected — the recently installed ones — and the error
names whichever binary you happened to run, so it reads like a problem with that
package. ldd disagrees and shows the system libc, because it resolves through
the system loader while the binary itself runs under brew/lib/ld.so.
Root cause: the glibc keg is older than the bottles. Homebrew’s Linux bottles
carry the glibc floor of the CI image that built them, and when homebrew-core
moves that image it bumps the glibc formula in the same breath (Ubuntu 22.04 →
24.04, glibc 2.35 → 2.39, July 2026). Nothing upgrades an installed glibc keg on
its own — it isn’t in the Brewfile — so every formula poured after the move lands
with a floor the keg can’t meet. Aug 2026: seven kegs (binutils, gcc@15,
texlab, tinymist, cadical, harper, juliaup) broke at once, all poured
by one brew bundle run days after the builder moved.
Confirm:
brew outdated --formula --verbose glibc # glibc (2.35_2) < 2.39_1
ldd --version | head -1 # host glibc
jq -r '.built_on.os_version' "$(brew --cellar)"/<formula>/*/INSTALL_RECEIPT.json
Fix: rerun install/linux-packages.sh. It reconciles the keg against the
formula before the bundle, and checks every keg installed since the last run
against what the keg provides. The broken kegs need no reinstall — they were
fine all along; only the loader under them was too old.
Upgrading by hand needs one guard:
HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew upgrade glibc
A bare brew upgrade glibc moves the keg in ~3 minutes and then spends hours
source-rebuilding every dependent whose linkage it considers stale, holding a
formula lock the whole time so every other brew command fails with “has already
locked”. The rebuild buys nothing: glibc is backward compatible, and the kegs
built against the old one keep running.
Homebrew refuses to build a glibc newer than the host’s, so a host older than the formula can’t be fixed this way. That combination has no in-place remedy: either the host glibc moves, or the keg goes and every formula is reinstalled against the host loader. The script warns rather than pretending.
Related: brew reinstall glibc fails with Errno::ENOENT ... gcc-13. Reinstall
unlinks the keg before building, which leaves brew/lib/ld.so dangling, and no
brew binary — including the compiler — can start. Upgrades build the new keg
first and are safe; a keg can only be rebuilt at the same version by rebuilding
the prefix.
NODE_MODULE_VERSION mismatch / ERR_DLOPEN_FAILED from an npm tool
Symptom: an npm-installed CLI dies loading a native addon, usually mid-script:
Error: The module '.../node_modules/better-sqlite3/build/Release/better_sqlite3.node'
was compiled against a different Node.js version using NODE_MODULE_VERSION 141.
This version of Node.js requires NODE_MODULE_VERSION 147.
Root cause: the package was installed under one Node major and is being run by
another. Native addons are ABI-locked per major, and the bin shebang is
#!/usr/bin/env node — so whichever Node is first on PATH wins. Two ways in:
- Two Node layers. Globals installed under Homebrew’s node keg and under nvm.
List them:
npm ls -g --depth=0 --prefix "$(brew --prefix)"should show onlynpm. Remove strays withnpm uninstall -g --prefix "$(brew --prefix)" <pkg>— nvm is the layer that owns npm globals. - PATH order.
brew shellenvputs brew’s bin ahead of nvm’s;install/_lib.shrestores nvm-first for install scripts, but an ad-hoc shell can still invert it.
It hides well: only subcommands that actually load the addon fail. qmd collection show works while qmd update aborts, which reads like a broken index rather than a
broken interpreter.
Fix: run it under the Node that installed it (command -v node should be under
$NVM_DIR), or reinstall the package under the current Node (npm install -g <pkg>).
nvm rejects prefix or globalconfig in ~/.npmrc
Symptom: install/node.sh stops during nvm use or after installing a Node
version:
Your user’s .npmrc file (${HOME}/.npmrc)
has a `globalconfig` and/or a `prefix` setting, which are incompatible with nvm.
Root cause: a legacy prefix=~/.npm sends every global package to one shared
tree. nvm instead gives each Node version its own global prefix under $NVM_DIR;
mixing the two makes package binaries and native addons run under the wrong Node
ABI. globalconfig can redirect npm to another file containing the same
conflict.
Fix: rerun install/node.sh. Before loading nvm it atomically removes an
active prefix from the user .npmrc, preserves the remaining npm policy, and
keeps the file private. If .npmrc sets globalconfig, the installer leaves it
unchanged and stops: copy any needed registry/auth/policy from the referenced
file into ~/.npmrc, remove the globalconfig line, then rerun. nvm owns the
Node/npm prefix; packages/npm.txt owns the global CLI set. Do not export
NPM_CONFIG_PREFIX or add another npm prefix for this setup.
Verify the selected runtime and global tree agree:
source "$NVM_DIR/nvm.sh"
nvm use default --silent
command -v node
npm prefix -g
dirname "$(dirname "$(nvm which default)")"
The last two paths must match.
A Homebrew binary can’t find libX.so.N after an upgrade
Symptom: one program stops starting, naming a library version that used to exist:
node: error while loading shared libraries: libllhttp.so.9.3: cannot open shared object file
Root cause: brew upgrade <formula> doesn’t stop at the formula — it then rebuilds every
dependent whose linkage the upgrade invalidated. Interrupt it in between (Ctrl-C, a
killed terminal, a timeout) and you’re left with the new dependency and the old
dependent: the dependent’s RPATH points at opt/<dep>/lib, which now holds only the new
soname. Both kegs are usually still in the Cellar, so brew list looks healthy.
Confirm:
brew list --versions <dep> # e.g. llhttp 9.3.1 9.4.3
readelf -d "$(brew --prefix)/opt/<formula>/bin/<prog>" | grep RPATH
Fix: rebuild the dependent — brew upgrade <formula> (or reinstall). Don’t relink
the old dependency keg; that just moves the breakage to whatever wanted the new one.
Prevention: pass HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 when you upgrade something
with many dependents, so nothing half-finishes in the first place.
nvm or node not available in a script
nvm.sh is lazy-loaded in interactive shells only. Login profiles put the bin
directory selected by nvm’s default alias on PATH. A standalone
non-interactive script should activate that same alias explicitly:
source "$NVM_DIR/nvm.sh"
nvm use default --silent
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:
DF_NAME="Your Name" DF_EMAIL="you@example.com" chezmoi init
chezmoi diff shows unexpected changes
Another program modified a managed file. Common culprits:
uvauto-adds source lines to.zshrc/.bashrcfor itsbin/envfiles- Claude Code updates
~/.claude/settings.jsonwhen plugins are installed - Other tools may modify shell configs without asking
Options:
chezmoi diff # see what changed
chezmoi apply --force # overwrite with repo version (safe for shell configs)
chezmoi add ~/.claude/settings.json # pull the live version into the repo (for config files)
For shell configs (.zshrc, .zprofile, .bash_profile), always use chezmoi apply --force to restore the clean template. These files should never be manually edited.
PATH order is wrong — wrong binary is resolving
Expected priority (highest to lowest). $_LOCAL_PLAT collapses to $HOME/.local in flat-mode (default).
$_LOCAL_PLAT/cargo/bin Rust tools (fd, sd, zoxide, bat, rg, etc.)
$_LOCAL_PLAT/nvm/.../bin Node.js (highest installed version)
$_LOCAL_PLAT/bin chezmoi, uv, claude, codex, uv-tool entrypoints
~/.local/bin arch-neutral scripts (collapses to $_LOCAL_PLAT/bin in flat mode — deduped via typeset -U)
/opt/homebrew/bin Homebrew (macOS) — also where rustup lives
/opt/homebrew/sbin Homebrew sbin
/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.
The other classic shadowing footgun: legacy binaries at ~/.local/bin/<tool> from before a layout migration. The [[ -x "$ARCH_BIN/<tool>" ]] install checks in current scripts catch most of these, but if <tool> --version shows an unexpectedly old version, check ls ~/.local/bin/<tool>* for backups (*.preplat-bak.* or stale binaries) and delete them.
nsys / ncu resolve to the CUDA toolkit copy, not the standalone install
Symptom. You added a newer Nsight Systems / Nsight Compute to
dotfiles-nvidia/packages/{nsys,ncu}-versions.txt, the installer reports ok,
nsys_list / ncu_list show it — but nsys --version still prints the older
version, and which nsys points at $_LOCAL_PLAT/.cuda/bin/nsys.
Root cause chain. The CUDA toolkit bundles its own nsys and ncu. In the
shell profiles the ### CUDA ### block runs before the Nsight blocks, and
cuda_use prepends $CUDA_HOME/bin to PATH. The Nsight auto-activation used to
be guarded on ! command -v nsys (“activate only if not already in PATH”) — by
that point the toolkit had always put one there, so the guard never fired and
the standalone install was permanently shadowed.
A second, independent trap: Nsight Compute ships ncu at the root of its
tree, not under bin/ (Nsight Systems does use bin/). So even when ncu_use
did run, prepending $NCU_HOME/bin added a nonexistent directory.
Confirm.
which nsys ncu # .cuda/bin/... means it's shadowed
ls "$_LOCAL_PLAT/.ncu" # ncu at top level, no bin/
readlink "$_LOCAL_PLAT/.nsys" # which standalone version is active
Fix. Both are fixed in the profile templates: activation is now
unconditional whenever $_LOCAL_PLAT/.nsys / .ncu exists (the standalone
prepends after cuda_use, so it wins), and ncu_use falls back to
$NCU_HOME when there is no bin/. Run chezmoi apply and start a new login
shell. If it still resolves wrong, the version symlink is the likely culprit —
cuda.sh/nsys.sh/ncu.sh deliberately never overwrite an existing .nsys /
.ncu / .cuda, so a new install does not become active on its own:
nsys_switch tarball_nsys_2026.1.3.425
ncu_switch tarball_ncu_2026.2.1.5
Cloudflare Pages build failing
Check the build log via the API:
ACCOUNT="YOUR_CLOUDFLARE_ACCOUNT_ID"
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/binnot on PATH; checkinfra/cloudflare/build.shmdbook: command not found— binstall failed; check network or fall back tocargo install mdbook --locked- Build output not found — confirm
destination_dir = "docs/book"ininfra/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.
A symlinked ~/.codex or ~/.claude turned back into a real directory
Symptom: you hand-symlinked ~/.codex (or ~/.claude) to scratch, and some time
later it is a plain directory again holding only the managed config — while the
scratch copy sits frozen at the date the link died. Nothing logged an error.
Root cause: chezmoi manages files inside both directories (config.toml,
AGENTS.md, hooks.json, profiles, rules/, themes/, agents/ for Codex;
settings.json, skills/, hook scripts for Claude). A source directory means
chezmoi’s target state for that path is “directory” — so on the next chezmoi apply
it removes whatever is there, symlink included, and recreates a real directory with
just the managed files. Everything else is orphaned wherever the link pointed.
Confirm — a stale target next to a fresh $HOME copy is the tell:
chezmoi managed | grep -x '.codex' # non-empty ⇒ chezmoi owns this path
stat -c '%n %y' ~/.codex ~/scratch/.codex # scratch frozen, home current
Fix: don’t symlink the directory. Run bash install/scratch.sh, which redirects
the heavy unmanaged entries one level down (sessions, cache, plugins,
*.sqlite, …) where chezmoi never looks. See
Scratch space.
Then reconcile the orphaned copy by hand — the script won’t touch it, because it cannot tell your stale data from a deliberate second install:
du -sh ~/scratch/.codex # what was orphaned
rm -rf ~/scratch/.codex # once you've confirmed nothing is wanted
Setting CODEX_HOME instead does not help here, and makes things worse — see
Why not CODEX_HOME?.
Duplicate PLAT paths in PATH (both v3 and v4 showing up)
Only relevant with DF_USE_PLAT=1. Fixed in current versions — .zprofile/.bash_profile resolve ~/.local symlinks before setting _LOCAL_PLAT so PATH entries use the same physical path.
If you upgraded from before that fix:
chezmoi apply ~/.zprofile ~/.bash_profile
exec zsh -l # or: exec bash -l
echo "$PATH" | tr ':' '\n' | grep plat # all entries should share the same PLAT prefix
In flat mode (DF_USE_PLAT=0, the default), this failure mode doesn’t apply — there’s no $PLAT segment in $_LOCAL_PLAT.
Lost shell history
Zsh history lives at ~/.zsh_history (the conventional default; survives any ~/.local cleanup). Bash history at ~/.bash_history. The bash sidecar command log (richer: timestamps, exit codes, cwd) at ~/.bash_log — search via bash_log_search <pattern>.
If you have history under the old location (~/.local/state/{zsh,bash}/), one-time migrate:
[ -f ~/.local/state/zsh/history ] && mv ~/.local/state/zsh/history ~/.zsh_history
[ -f ~/.local/state/bash/history ] && mv ~/.local/state/bash/history ~/.bash_history
[ -f ~/.local/state/bash/log ] && mv ~/.local/state/bash/log ~/.bash_log
Migrating off PLAT isolation
If you set up with DF_USE_PLAT=1 and want to switch to flat (or vice-versa), the layout in ~/.local/ is stable as long as one mode is active — but switching strands GBs in the unused tree. Decommission tool:
# After setting DF_USE_PLAT=0 (or removing use_plat=true from chezmoi data):
bash ~/dotfiles/install/plat-decommission.sh
Refuses to run if DF_USE_PLAT=1 is currently set (won’t nuke the active install). See PLAT isolation for the full migration story.
Brew zsh tab completion leaves remnant characters (Linux)
Symptom: after pressing Tab, stale characters remain on the line instead of being erased.
Root cause chain:
- Brew zsh’s RUNPATH loads Homebrew’s own glibc (
brew/opt/glibc/lib/libc.so.6) - Homebrew’s glibc ships no
lib/locale/data →setlocale()silently falls back toC/ASCII - In the C locale,
wcwidth()returns byte counts instead of display columns - Every cursor-position calculation in ZLE/completion is off → artifacts
Confirm by checking the codeset inside brew zsh:
zsh --no-rcs -c 'zmodload zsh/langinfo; echo $langinfo[CODESET]'
# broken: ANSI_X3.4-1968
# working: UTF-8
Fix: linux-packages.sh generates en_US.UTF-8 locale data for brew’s glibc into
$LOCAL_PLAT/locale/ using brew’s own localedef. The shell profiles export LOCPATH
pointing there so brew zsh picks it up at startup.
If you installed before this fix:
# Regenerate locale data
bash ~/dotfiles/install/linux-packages.sh
# Apply updated shell profiles (adds LOCPATH export)
chezmoi apply ~/.zprofile ~/.bash_profile
# Open a new login shell and verify
exec zsh -l
zsh --no-rcs -c 'zmodload zsh/langinfo; echo $langinfo[CODESET]' # UTF-8
Test suite: bash ~/dotfiles/tests/test-locale.sh
Copy/paste from a remote SSH session pastes as mojibake (’, Â, é)
Symptom: text copied out of a remote Linux session pastes with latin-1 garbage
where punctuation, accents, or spaces should be: ’ becomes ’, é becomes
é, non-breaking spaces surface as  . Two common shapes:
- plain ssh + tmux — the display itself is garbled, and copies carry it
- VS Code/Cursor Remote-SSH embedded terminal — display may look fine, but
copying agent TUI output (Claude Code renders with padding/NBSP characters)
pastes with stray
Âaccent characters; no tmux involved
The terminal emulator (iTerm2, xterm.js) is innocent: the bytes are already mangled before they reach it.
Root cause chain:
- On macOS,
.zprofileexportsLC_ALL=en_US.UTF-8 - macOS ships
SendEnv LANG LC_*in/etc/ssh/ssh_config, and Linux sshd acceptsLC_*by default — the Mac’sLC_ALLlands in the remote environment. This covers Remote-SSH too: the VS Code/Cursor server is started over that same ssh connection, and every embedded terminal inherits its environment LC_ALLoverridesLANG, defeating the deliberate LANG-only locale setup in the Linux shell profiles (see the entry above). Embedded terminals are hit hardest: they spawn non-login shells, so a profile-only guard never even runs there- On hosts whose system glibc has no
en_US.UTF-8compiled (minimal server images — the brew-glibcLOCPATHdata doesn’t help system binaries),setlocale()falls back to C/ASCII - Anything in that C locale that re-encodes the byte stream (system tmux is the classic offender) treats each UTF-8 byte as a separate latin-1 character — the display, and therefore anything selected and copied from it, is mojibake
Confirm on the remote, inside the garbling session:
locale; echo "LC_ALL=$LC_ALL"; locale -a 2>/dev/null | grep -iE 'en_US|utf'
printf 'caf\xc3\xa9 \xe2\x80\x94 \xe2\x80\x9cok\xe2\x80\x9d\n' # should render: café — “ok”
Broken looks like: a “cannot change locale” warning or LC_CTYPE="C" in the
locale output, and the printf line rendering as café — “okâ€.
Fix: the locale guard (unset LC_ALL before exporting LANG, from the
locale-env.sh shared partial) runs in the Linux shell profiles AND the
interactive rc files — the rc copy is what protects non-login embedded
terminals. Then:
chezmoi apply ~/.zprofile ~/.bash_profile ~/.zshrc ~/.bashrc
tmux kill-server # the tmux server caches the locale it started with
exec zsh -l # or reconnect / open a fresh embedded terminal
Note the fix cleans the encoding; agent TUIs like Claude Code still put
invisible layout characters (padding spaces, hard wraps) into the scrollback,
so terminal-selection copies of long output stay imperfect. For clean text use
/export or copy from the paired web/mobile session instead.
If it’s still garbled, the host has no UTF-8 locale usable by system binaries at
all — check locale -a; export LANG=C.UTF-8 (built into every modern glibc)
is the fallback.
Note the tempting client-side fix does NOT work: SendEnv -LC_* in
~/.ssh/config is a no-op here, because ssh reads the user config before
/etc/ssh/ssh_config and -pattern removals apply at parse time — the system
default adds the patterns after your removal runs.
Python@3.14 build fails on Linux (uuid or test_datetime errors)
Python 3.14 from Homebrew has build issues on some Linux systems:
- UUID module detection failure - configure detects libuuid but the build fails
- test_datetime hangs during PGO - Profile-guided optimization runs the test suite, but
test_datetimehangs on some CPUs (timezone-related)
Fix: Patches are applied automatically by install/patch-homebrew-python.sh during bootstrap. If you need to re-apply manually:
bash ~/dotfiles/install/patch-homebrew-python.sh
brew reinstall --build-from-source python@3.14
The patches:
- Set
py_cv_module__uuid=n/ato disable the uuid module - Patch Makefile’s
PROFILE_TASKto skiptest_datetimeduring PGO
Environment variables in .zprofile/.bash_profile prevent Homebrew from auto-updating and overwriting these patches:
HOMEBREW_NO_AUTO_UPDATE=1- prevents tap updatesHOMEBREW_NO_INSTALL_FROM_API=1- forces local formula usage
cass source build fails with rustc 1.94.0 is not supported or E0554
On a host with glibc < 2.38 (e.g. Ubuntu 22.04) cass has no usable prebuilt, so
memory.sh builds it from source — and you see one of:
rustc 1.94.0 is not supported by the following packages: sysinfo@0.39.5 requires rustc 1.95 …
# or, on a newer stable:
error[E0554]: `#![feature]` may not be used on the stable release channel
Two root causes stacked:
- cass requires nightly. A dependency gates
#![feature(try_trait_v2)]and the repo pinschannel = "nightly". Stable can’t build it — an old stable fails the MSRV check, a new stable failsE0554. - A stray Homebrew
rustshadows rustup. Arustformula (a lingering build dependency — not in the Brewfile, nothing depends on it) putscargo/rustcinbrew/binat an old version. In bootstrap’s PATH that shadows rustup, socargoresolved to brew’s 1.94.0 even afterrust.shupdated rustup’s stable to 1.97.1.
Confirm:
which -a cargo # a brew/bin/cargo at an old version is the smoking gun
rustup toolchain list # is `nightly` installed?
Fix (already baked into current memory.sh — this is for older checkouts or
manual recovery):
brew uninstall rust # remove the orphan shadow (safe: nothing depends on it)
rustup toolchain install nightly --profile minimal
$CARGO_HOME/bin/cargo +nightly install --git \
https://github.com/Dicklesworthstone/coding_agent_session_search \
coding-agent-search --bin cass --locked --root "$LOCAL_PLAT"
_cass_build_from_source now installs nightly on demand and calls
$CARGO_HOME/bin/cargo +nightly explicitly, so it no longer depends on PATH
resolution or the default toolchain.
cass search misses sessions you know happened
Symptom: history you remember from Codex or Cursor never surfaces, even on exact phrases, while Claude Code sessions from the same week come back fine.
Root cause: cass ingest is append-only per conversation. Once a conversation is
in the canonical DB it is never re-read, so every parser improvement since it was
first indexed only reaches new sessions. Old ones keep whatever subset the parser
of the day extracted. cass index --full does not fix this — it forces a full
scan and a lexical rebuild, then logs skipping historical salvage because canonical database is already populated.
Confirm — compare the DB against a fresh parse of the same files:
sqlite3 -readonly ~/.cass/agent_search.db \
"select a.name, count(m.id) from messages m
join conversations c on c.id=m.conversation_id
join agents a on a.id=c.agent_id group by 1 order by 2 desc;"
wc -l < ~/.codex/sessions/2026/*/*/rollout-*.jsonl # rough upper bound per file
Measured 2026-08-01: codex held 15,037 messages where the same 88 rollouts parse to 85,488 today, and cursor 3,384 vs 6,949 — 82% and 51% of that history unsearchable.
Fix, one connector at a time. Every step matters:
# 0. verify each source file still exists — forget is only safe if it can come back
sqlite3 -readonly ~/.cass/agent_search.db \
"select c.source_path from conversations c join agents a on a.id=c.agent_id
where a.name='codex';" | while read -r p; do [ -f "$p" ] || echo "MISSING: $p"; done
# 1. back up via sqlite, NOT cp — a plain copy can tear a live WAL
sqlite3 ~/.cass/agent_search.db ".backup '$HOME/.cass/agent_search.db.bak'"
# 2. drop the stale rows (dry-run first: omit --apply)
cass forget --source-glob "$HOME/.codex/sessions/**" --apply
# 3. reset the connector watermark — --full still honours it, and rollouts dated
# months ago never beat a watermark stamped today, so the scan finds nothing
sqlite3 ~/.cass/agent_search.db \
"update meta set value='0' where key='last_scan_ts:connector:codex';"
# 4. re-ingest, then clean up after a cass bug: forget leaks tail-state rows keyed
# by the deleted conversation_id, and that column is a plain rowid — SQLite
# reuses freed ids, so a future conversation would inherit a stale
# "ingested through idx N" marker and be silently truncated
cass index --full
sqlite3 ~/.cass/agent_search.db \
"delete from conversation_tail_state
where conversation_id not in (select id from conversations);"
# 5. rebuild vectors in bounded batches; repeat until the backlog is empty
bash ~/dotfiles/install/memory.sh semantic
Skipping step 3 is the usual failure — the run exits 0, having ingested nothing.
cass index fails with graph topology attestation failed
build HNSW index failed: hnsw error: graph topology attestation failed:
parallel construction failed (search entry origin 6219 reaches only 92908/97513
points at the base layer); serial rebuild also failed
Transcripts contain thousands of byte-identical tool stubs — [Tool: apply_patch]
alone repeats 4,383 times — and identical text embeds to identical vectors. Under
DistDot those form zero-distance cliques larger than the layer-0 fanout
(max_nb_connection 16 → ~32 links), so a clique fills every member’s neighbour
list with its own duplicates and nothing outside ever links in. HNSW reachability
is directional, so the whole group is unreachable from the entry point and cass’s
attestation rejects the graph.
It is deterministic: retrying fails identically, which is why the serial rebuild
also failed and why retryable=true in the error is misleading.
Fix: drop --build-hnsw (the manual memory.sh semantic mode does not use
it). HNSW only backs --approximate; exact search over ~100k vectors is
fast enough, and the flag has never once succeeded on this archive — every
semantic_manifest.json here records "hnsw": null. Restore it if cass starts
deduping identical vectors before insert.
Every cass command fails: unable to open database file (but sqlite3 opens it fine)
opening frankensqlite db readonly at /Users/cade/.cass/agent_search.db:
unable to open database file: '/Users/cade/.cass/agent_search.db'
cass doctor reports archive-db-unreadable, cass index --full refuses to
run (“index refused to modify an unhealthy canonical archive”), and the
older cass-watch/cass-semantic LaunchAgents may crash-loop with exit 5 — yet
sqlite3 -readonly ~/.cass/agent_search.db "pragma quick_check;" says ok.
frankensqlite pins the database’s file identity — device id + inode — in the
agent_search.db-fsqlite-ns-use sidecar (record: 8-byte FSQLNS01 magic,
1-byte version, then tag/dev/ino big-endian). On macOS, APFS volume device ids
are assigned at mount time and can change across reboots. After the id shifts,
every read-only open compares recorded vs live identity and fails closed with
SQLITE_CANTOPEN. A read-write open would rewrite the record and self-heal,
but cass health-gates every mutating command behind a read-only open first, so
nothing ever reaches the heal path.
Confirm.
stat -f "dev=%d ino=%i" ~/.cass/agent_search.db # live identity
hexyl -n 40 ~/.cass/agent_search.db-fsqlite-ns-use
# bytes 10..18 = recorded dev (BE), bytes 18..26 = recorded ino (BE)
Inode matches, device id doesn’t → this bug. If the inode differs, the db file was actually replaced — stop and investigate before touching anything.
Fix. Stop all cass processes, then patch the recorded dev to the live value
(here only the last byte differed, 0x10 → 0x0d at offset 17):
printf '\x0d' | dd of="$HOME/.cass/agent_search.db-fsqlite-ns-use" \
bs=1 seek=17 count=1 conv=notrunc
cass doctor # database failure should be gone
bash ~/dotfiles/install/memory.sh index
bash ~/dotfiles/install/memory.sh semantic # one bounded vector batch
Recurs whenever the Data volume mounts with a different device id. Do not
run cass doctor --fix for this: on 0.6.23 it enters unbounded recursion in
the reconstruct path (observed: 1.6 h at 100% CPU, ~50 GB RSS, no output) —
kill it if started; it only touches lock files before hanging.
macOS keeps asking: “cass would like to access data from other apps”
The prompt returns every few minutes, and “Allow” doesn’t make it stop.
Older dotfiles deployed a dev.cade.cass-watch LaunchAgent that ran cass index
every 300 s, and the aider
connector crawls $HOME. Aider histories are project-local
(.aider.chat.history.md in each repo), so discovery walks its root — and that root
defaults to $HOME. The walk enters ~/Pictures, ~/Music, ~/Documents,
~/Desktop, ~/Downloads, and ~/Library, so it asks for Photos, MediaLibrary,
AddressBook, Calendar, AppData — and AllFiles.
It is tempting to blame the connectors that read ~/Library/Application Support
(cursor, chatgpt, copilot) — don’t. Measured 2026-08-05: a scan that opens 13
Cursor state.vscdb files raises zero TCC requests, because Cursor is a
non-sandboxed Electron app with no registered container, so its app-support dir isn’t
protected. Excluding those connectors costs you cross-harness session coverage and
fixes nothing.
“Allow” doesn’t make it stop because cass is ad-hoc signed (codesign -dv →
Signature=adhoc, no Team ID), so a grant is pinned to the binary’s cdhash and is
voided at the next cass upgrade. There is also no System Settings pane for App Data
grants, so a stale one can’t be repaired from the UI.
See exactly what cass is asking for — this is the diagnostic that matters, since the cass logs only record paths it opens deliberately, not what a directory walk touches:
/usr/bin/log show --last 30m --predicate 'process == "tccd"' --info \
| rg 'Sub:\{.*/\.local/bin/cass\}' | rg -o 'kTCCService[A-Za-z]+' | sort | uniq -c
(/usr/bin/log explicitly — log is a shell function in this repo’s profiles.)
Fix — bound the crawl. That’s the whole fix; leave every connector enabled:
export CASS_AIDER_DATA_ROOT="$HOME/dev" # aider discovery root, not $HOME
Applied on macOS by the manual install/memory.sh index modes. Current dotfiles
remove the old scheduled LaunchAgents. Aider still indexes normally — just only
under the given root, so aider projects elsewhere go unindexed.
CASS_AIDER_DATA_ROOT takes a single path, so $HOME is the only “covers everything”
value and it is what causes the problem.
Verify with the log show command above: a scan should now produce no cass entries at
all. Check a scan really ran, or the empty result proves nothing —
rg 'skipping disabled connectors' ~/.local/share/cass/stderr.log | tail -1.
The alternative to all of this is granting ~/.local/bin/cass Full Disk Access
(kTCCServiceSystemPolicyAllFiles is one of the things it asks for), which covers every
service at once — but it must be re-added after every cass upgrade, and it hands a
self-updating ad-hoc-signed binary read access to Mail, Messages, and Safari history.
git push blocked by gitleaks (“secrets detected”)
A global pre-push hook scans the commits being pushed for secrets with gitleaks and refuses the push if it finds any. This is the safety net that keeps tokens and private keys out of remote history — see Authentication → File security.
How it’s wired:
brew "gitleaks"(inpackages/Brewfile) installs the scanner.- The hook lives at
home/dot_config/git/hooks/executable_pre-push, deployed by chezmoi to~/.config/git/hooks/pre-push. ~/.gitconfigsetscore.hooksPath = ~/.config/git/hooks, so it applies to every repo on the machine, not just dotfiles.- It scans only the commits being pushed (a new branch is scanned against
--remotes), not the full history, so it stays fast. - If gitleaks isn’t installed yet, the hook prints a warning and exits cleanly rather than blocking you.
When a push is blocked, the hook prints the exact --log-opts range it flagged.
Review the finding:
# Re-run the scan the hook ran (range is printed in the failure message)
gitleaks git --log-opts="<remote_sha>..<local_sha>"
# Or scan the entire repo history
gitleaks git --no-banner
If it’s a real secret: rotate it, then rewrite the offending commit(s) to remove
it before pushing (a --no-verify push would leak it to the remote). If it’s a
confirmed false positive, add a gitleaks allowlist entry rather than
disabling the hook.
Emergency bypass (use only when you’re certain there’s no secret):
git push --no-verify
Don’t disable the hook permanently — core.hooksPath is global precisely so the
protection can’t be forgotten on a per-repo basis.
npm install -g fails with EBUSY … unlink '.nfsXXXX' (qmd upgrade)
bootstrap.sh upgrade (or install/node.sh) dies upgrading a global npm
package — almost always @tobilu/qmd:
npm error code EBUSY
npm error EBUSY: resource busy or locked, unlink
'.../@tobilu/qmd/node_modules/sqlite-vec-linux-x64/.nfs000000001f79d0f000015a88'
[fail] node.sh failed
Root cause: NFS “silly-rename”. The qmd MCP daemon
(qmd mcp --http --port 8181) keeps native addons (sqlite-vec,
node-llama-cpp, better-sqlite3) mmap’d. When npm deletes the old package
tree to swap in the new one, NFS can’t remove a file the daemon still has open,
so it renames it to .nfsXXXX and keeps it until that fd closes. npm then can’t
unlink the .nfs* file and aborts with EBUSY. Only happens on NFS homes
(the Linux clusters) — macOS local disks unlink open files fine, so this is
gated to Linux.
node.sh now stops the daemon around the qmd upgrade and restarts it (via the
qmd_daemon_* helpers in _lib.sh), so a normal upgrade no longer trips on it.
To recover a checkout that predates the fix, or if you hit it by hand:
pkill -f "qmd[^ ]* mcp --http" # 1. stop the daemon → NFS reaps .nfs* files
npm install -g @tobilu/qmd@latest # 2. re-run the upgrade (or: bash install/node.sh)
qmd mcp --http --daemon & # 3. restart (a new shell also lazy-starts it)
A failed swap can also leave a broken husk — a qmd/ dir with only an empty
node_modules/ plus a dangling bin/qmd symlink — in a different npm prefix
than the one which qmd resolves to (nvm’s). Delete the husk; the live copy is
the one on PATH.
import sage.all / cysignals dies with TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object
Symptom. Importing cysignals.pysignals (directly, or transitively via
passagemath’s sage.all) raises the TypeError above. Plain import cysignals.signals (and cypari2) works fine.
Root cause. This macOS release (Darwin 25.x) pre-installs C-level fault
handlers (SIGILL, SIGABRT, SIGFPE, SIGBUS, SIGSEGV) in every process.
signal.getsignal() reports a handler Python didn’t install as None, and
cysignals’ pysignals init saves + re-installs existing handlers — re-setting
None is rejected by CPython. Not sandbox-, uv-, or Python-version-specific:
reproduced on uv’s python-build-standalone 3.12/3.13 and Homebrew 3.14.
Confirm.
python3 -c "import signal; print(signal.getsignal(signal.SIGSEGV))" # → None
Fix. Reset the fault handlers from Python before anything imports
cysignals.pysignals (~/dev/math-lab/sagefix.py does exactly this):
import signal
for s in (signal.SIGILL, signal.SIGABRT, signal.SIGFPE, signal.SIGBUS, signal.SIGSEGV):
signal.signal(s, signal.SIG_DFL)
Related trap: pinning cysignals older than what passagemath wheels were
built against fails later with cysignals.signals does not export expected C function _do_raise_exception — keep the resolver’s cysignals (1.12.x), fix
the handlers instead.
pdflatex: command not found on macOS with MacTeX installed
Symptom. brew list --cask shows mactex and
/Library/TeX/texbin/pdflatex exists and is executable, but pdflatex,
latexmk, chktex, and texcount all report “command not found”.
Root cause. MacTeX installs into /Library/TeX/texbin, which is on no
default PATH. Its installer drops a /etc/paths.d/TeX entry, but that only
reaches path_helper-processed shells, and these profiles rebuild PATH
themselves. install/latex.sh verified the binary by absolute path, so the
step reported [okay] while nothing was actually reachable.
Confirm.
ls /Library/TeX/texbin/pdflatex # exists
command -v pdflatex # nothing
Fix. Handled by both shell profiles:
[ -d /Library/TeX/texbin ] && path=($path /Library/TeX/texbin) # zprofile
Appended, not prepended, so Linux’s TinyTeX binaries (symlinked into
$ARCH_BIN by latex.sh) keep priority on a machine with both. Run
chezmoi apply ~/.zprofile ~/.bash_profile and start a new shell.
brew bundle installs nothing new and exits 0
Symptom. brew bundle install prints only Using <formula> lines and
succeeds. Packages just added to the Brewfile never appear, and no error names
them.
Root cause. A cask-only package declared as brew "..." instead of
cask "...". Homebrew resolves the whole dependency graph before installing
anything, so one unsatisfiable entry aborts the entire run — every other new
package is collateral, which is what makes this read as a no-op rather than a
failure. Hit Aug 2026 with brew "quarto": homebrew-core has no quarto formula
at all, only a cask.
Confirm.
brew bundle check --file=packages/Brewfile --verbose
# → Formula quarto needs to be installed or updated.
brew info --formula quarto
# → Error: No available formula ... Found a cask named "quarto" instead.
Fix. Move it into the if OS.mac? block as cask "quarto". Casks are
macOS-only, so a cask-only tool has no Homebrew route on Linux — install it
another way there rather than leaving a brew line that breaks every bundle
run. Check a new entry with brew info --formula <name> before committing.
A GUI app’s config is permanently dirty in chezmoi status
Symptom. chezmoi status shows MM on an app’s config file every time you
look, even when you changed nothing. Running chezmoi apply “fixes” it, then it
comes back after the app runs. Worse, bootstrap (which runs chezmoi apply --force) silently reverts real in-app settings changes along the way.
Root cause. Two writers on one file. The app owns and rewrites its config,
and a statically chezmoi-managed copy fights it. LinearMouse is the sharpest
case: it stamps "$schema": "https://schema.linearmouse.app/<app version>" into
the file, so the file goes dirty on a timer — every app update produces a
diff with no setting change behind it. That trains you to ignore the dirty
status, which is exactly when a real reverted setting slips past.
Confirm.
chezmoi diff ~/.config/linearmouse/linearmouse.json
# - "$schema" : ".../0.11.3" <- what the app wrote
# + "$schema" : ".../0.11.2" <- what apply would force back
Fix. Don’t let chezmoi manage app-owned configs. Use the apply/sync split
(install/linearmouse.sh, install/claude-desktop.sh,
install/codex-desktop.sh): the tracked source lives under install/<app>/,
apply merges it into the live file live-first so app-owned keys survive, and
sync captures in-app changes back. For LinearMouse specifically the tracked
source omits $schema entirely, so only genuine setting changes ever diff.
bash install/linearmouse.sh sync # capture in-app changes → repo
bash install/linearmouse.sh # push repo settings → app (default: apply)
Adding a new app to this pattern means deleting its home/ chezmoi source
(chezmoi then leaves the live file alone), adding the script, and wiring a
DF_DO_* flag in bootstrap.sh.
Codex MCP OAuth fails: “Authorization server response missing required issuer”
Symptom. codex mcp login <server> (or first use of an OAuth MCP server in
Codex) opens the browser, auth succeeds there, then the CLI dies with
failed to handle OAuth callback … Authorization server response missing required issuer: expected <server url>. The same server connects fine from
Claude Code.
Root cause. A Codex regression, not a server or config problem. Codex
0.143.0+ looks for an iss field in the token endpoint’s JSON response body —
where RFC 6749 doesn’t put one — instead of using the RFC 9207 iss callback
parameter it already validated. Spec-compliant authorization servers
(Cloudflare’s among them) fail the check. Tracked in
openai/codex#31573; introduced
via a modelcontextprotocol/rust-sdk change.
Confirm. codex --version ≥ 0.143.0, the issue above still open, and the
server works from another harness. For Cloudflare specifically, prove the
server itself is healthy with a direct handshake:
curl -s -X POST https://mcp.cloudflare.com/mcp \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.0"}}}'
# → {"result":{…"serverInfo":{"name":"cloudflare-api"…
Fix. Sidestep OAuth in Codex with a static bearer: the
--codex-bearer <ENV_VAR> annotation in packages/mcp-servers.txt makes
install/codex.sh emit bearer_token_env_var = "<ENV_VAR>" for that server
while every other harness keeps OAuth. Cloudflare rides
--codex-bearer CLOUDFLARE_API_TOKEN (from ~/.cloudflare.env,
bash install/auth.sh cloudflare); mcp.cloudflare.com accepts API-token
bearers directly, verified with the handshake above. Caveat: the token’s
scopes bound what the tools can do — OAuth carried your full user grant, so
mint a broader token if a tool call 403s. Remove the annotation once the
upstream fix ships.
Environment variables
Complete reference for DF_* variables and the tool-standard ones this repo cares about. All DF_* flags are read in install/_lib.sh or bootstrap.sh. Set in your shell, prepend to a single command, or persist via chezmoi data.
Configuration
| Var | Default | What it does |
|---|---|---|
DF_NAME | (prompts) | Display name. Pre-seed to skip the chezmoi prompt on first run. |
DF_EMAIL | (prompts) | Email. Pre-seed to skip the chezmoi prompt on first run. |
DF_REPO | cadebrown/dotfiles | GitHub owner/repo slug used by curl-bootstrap. Override to fork. |
DF_PATH | (auto-detect) | Where the repo lives. Local runs use the script directory; piped runs clone to $HOME/dotfiles. |
DF_LINK | $HOME/dotfiles | Symlink in $HOME that points at DF_PATH. |
DF_DIRS | dev:bones:misc | Colon-separated list of subdirs created in $HOME by install/dirs.sh. |
Behavior toggles
| Var | Default | What it does |
|---|---|---|
DF_USE_PLAT | 0 | Per-PLAT directory isolation. 1 enables $LOCAL_PLAT=$HOME/.local/$PLAT; 0 collapses to $HOME/.local. Accepts 1|true|yes|on (case-insensitive). See PLAT isolation. |
DF_BREW_UPGRADE | 0 | Whether to upgrade existing formulae/casks. Auto-set to 1 in upgrade mode on both platforms. |
DF_BREW_DOWNLOAD_CONCURRENCY | 4 | Maximum simultaneous Homebrew bottle/cask downloads. |
DF_BREW_UPGRADE_CASKS | auto | Upgrade greedy casks only when sudo is already cached; set 0 to skip or run sudo -v/set 1 to permit prompts. |
DF_STRICT_UPGRADE | 1 | Run install/audit-versions.sh --strict after bootstrap.sh upgrade; set 0 for a report-only audit. |
DF_MCP_PROFILES | (unset) | Colon/comma/space-separated opt-in MCP profiles such as research-scite, biomed, or publish. Core servers are always rendered. |
DF_DEBUG | 0 | Set to 1 for verbose [dbug] output with timing info on every run_logged command. |
DF_FORCE | 0 | Used by install/plat-decommission.sh to skip the deletion confirmation prompt. |
DF_CARGO_STRATEGIES | (unset) | Override cargo binstall --strategies. E.g. compile to skip GitHub release fetchers (useful behind a VPN). |
Scratch space
| Var | Default | What it does |
|---|---|---|
DF_SCRATCH | (unset) | Path to scratch root. Setting this enables scratch mode (symlinks heavy $HOME dirs). |
DF_SCRATCH_LINK | $HOME/scratch | The $HOME symlink that points at scratch. Bootstrap creates this if DF_SCRATCH is set. |
DF_LINKS | ~/.local:~/.cache:~/.cass:~/.vscode:~/.vscode-server:~/.cursor-server:~/.nv:~/.npm:~/.oh-my-zsh:~/.oh-my-zsh-custom:~/kb:~/.computelab:~/.agent-browser:~/.gradle | Colon-separated top-level dirs to redirect to scratch. TinyTeX lives below $LOCAL_PLAT; ~/.cursor is chezmoi-owned. |
DF_CONFIG_LINKS | Code | Colon-separated ~/.config subdir names to redirect to scratch (never ~/.config itself — chezmoi owns it). |
DF_CURSOR_LINKS | projects:worktrees | Colon-separated ~/.cursor subdir names to redirect to scratch (never ~/.cursor itself — chezmoi owns it). |
DF_CLAUDE_LINKS | projects:plugins:file-history | Colon-separated ~/.claude subdir names to redirect to scratch (never ~/.claude itself — chezmoi owns it). Drop projects to keep history + memory on NFS. |
DF_CODEX_LINKS | sessions:generated_images:cache:plugins:attachments:shell_snapshots:log:backups:.tmp:tmp | Colon-separated ~/.codex subdir names to redirect to scratch (never ~/.codex itself). Top-level *.sqlite files ride along. Set empty to skip ~/.codex entirely. |
The five *_LINKS vars treat a set-but-empty value as “migrate nothing here”; unset restores the default.
See Scratch space.
Skip flags
Each DF_DO_* flag defaults to 1 (run). Set to 0 to skip.
| Var | Step | Skips |
|---|---|---|
DF_DO_SCRATCH | 0 | Scratch space symlink setup (auto-0 in update/upgrade modes) |
DF_DO_DIRS | 0.1 | ~/dev, ~/bones, ~/misc creation |
DF_DO_PACKAGES | 4 | Homebrew + brew bundle |
DF_DO_MACOS_SERVICES | 5 | Colima service registration (macOS) |
DF_DO_MACOS_SETTINGS | 5.5 | Dock/Finder/keyboard/etc. defaults (macOS) |
DF_DO_MACOS_QUICK_ACTIONS | 5.6 | Finder Quick Actions install (macOS) |
DF_DO_ZSH | 3 | oh-my-zsh + plugins |
DF_DO_PYTHON | 6 | uv + per-tool isolated venvs |
DF_DO_NODE | 6 | nvm + Node.js + global npm packages |
DF_DO_RUST | 6 | rustup + cargo tools |
DF_DO_GO | 6 | Go CLI tools from go.txt |
DF_DO_JULIA | 6 | Juliaup release channel and PLAT-isolated depots |
DF_DO_LEAN | 6 | Lean 4 toolchain (elan + the pinned default toolchain) |
DF_DO_LATEX | 6 | TeX distribution (MacTeX verify on macOS, TinyTeX on Linux) |
DF_DO_QUARTO | 4 | Quarto cask verification on macOS or rootless release install on Linux |
DF_DO_CLAUDE | 6 | Claude Code binary + plugins + MCP servers + overlay skills |
DF_DO_CODEX | 6 | Codex CLI binary + managed config + hooks |
DF_DO_CLAUDE_DESKTOP | 6 | Claude Desktop tracked preferences (macOS) |
DF_DO_CODEX_DESKTOP | 6 | Codex desktop app tracked preferences (macOS) |
DF_DO_LINEARMOUSE | 6 | LinearMouse tracked settings (macOS) |
DF_DO_CURSOR | 6 | Cursor settings symlinks + extensions |
DF_DO_VSCODE | 6 | VS Code extensions |
DF_DO_CMAKE | 6 | CMake toolchain file deployment |
DF_DO_LOCAL_LLM | 6.5 | Local LLM tooling (HuggingFace cache + binary checks) |
DF_DO_MEMORY | 6.6 | Agent memory stack (cass + qmd + ~/kb + daemons) |
DF_DO_SKILLS | 6.65 | Agent skills from agent-skills.txt |
DF_DO_BLENDER_MCP | 6.7 | Blender MCP addon install |
DF_DO_AUTH | 7 | Default 0. Set to 1 to run interactive token setup. |
DF_DO_OVERLAYS | 8 | Skip all overlay bootstrap scripts |
Internal (set by _lib.sh, not user-facing)
These are exported by _lib.sh for install scripts to consume — don’t override unless you know why.
| Var | Source | Value |
|---|---|---|
OS | _lib.sh | darwin or linux |
ARCH | _lib.sh | x86_64 or aarch64 (normalized) |
PLAT | _lib.sh | Detected platform name (e.g. plat_Darwin_arm64); empty if no spec matches |
LOCAL_PLAT | _lib.sh | Install root: $HOME/.local (flat) or $HOME/.local/$PLAT (PLAT-on) |
ARCH_BIN | _lib.sh | $LOCAL_PLAT/bin |
RUSTUP_HOME | _lib.sh | $LOCAL_PLAT/rustup |
CARGO_HOME | _lib.sh | $LOCAL_PLAT/cargo |
CARGO_TARGET_DIR | _lib.sh | $LOCAL_PLAT/cargo-build (workaround for macOS Sequoia ar/ld in /var/folders/) |
NVM_DIR | _lib.sh | $LOCAL_PLAT/nvm |
ELAN_HOME | _lib.sh | $LOCAL_PLAT/elan (Lean toolchains — arch-specific, ~1.5 GB each) |
JULIAUP_DEPOT_PATH | _lib.sh | $LOCAL_PLAT/julia/juliaup |
JULIA_DEPOT_PATH | _lib.sh | $LOCAL_PLAT/julia/depot (compiled per-arch artifacts) |
UV_TOOL_BIN_DIR | _lib.sh | $ARCH_BIN (where uv tool entrypoints land) |
UV_TOOL_DIR | _lib.sh | $LOCAL_PLAT/uv/tools (per-tool venvs) |
UV_PYTHON_INSTALL_DIR | _lib.sh | $LOCAL_PLAT/uv/python (uv-managed Python) |
CONAN_HOME | _lib.sh | $LOCAL_PLAT/conan2 |
DF_ROOT | _lib.sh | The dotfiles repo root (parent of install/) |
DF_PACKAGES | _lib.sh | $DF_ROOT/packages |
DF_OVERLAYS | _lib.sh | Bash array of discovered dotfiles-*/ overlay paths |
DF_INSTALL_DIR | bootstrap.sh | $DF_ROOT/install |
DF_MODE | bootstrap.sh | install, update, or upgrade |
GIT_CONFIG_GLOBAL | _lib.sh | Forced to /dev/null so install scripts aren’t affected by SSH-rewriting gitconfig |
Pre-seeding chezmoi
These get cached in ~/.config/chezmoi/chezmoi.toml on first init and don’t re-prompt:
| chezmoi data key | Source | Notes |
|---|---|---|
name | DF_NAME env or interactive prompt | Used in templates as {{ .name }} |
email | DF_EMAIL env or interactive prompt | Used in templates as {{ .email }} |
use_plat | DF_USE_PLAT env or false default | Used in templates as {{ .use_plat }} to gate PLAT-isolated paths |
Edit ~/.config/chezmoi/chezmoi.toml directly to change these without re-running chezmoi init.
Bootstrap flow
Step-by-step diagram of what bootstrap.sh actually does, with the DF_DO_* skip flag for each phase. Steps run in order — failures in any phase abort the rest (except VS Code/Cursor extension installs and a few other clearly-flagged log-warn-but-continue cases).
flowchart TD
A[curl bootstrap.sh] --> S0["0 scratch links<br/>DF_DO_SCRATCH"]
S0 --> S01["0.1 ~/dev ~/bones ~/misc<br/>DF_DO_DIRS"]
S01 --> S05["0.5 clone repo to ~/dotfiles"]
S05 --> S03["0.6 source real repo + detect PLAT<br/>(always; tunes compiler flags)"]
S03 --> S1["1 install chezmoi binary<br/>(idempotent)"]
S1 --> S2["2 chezmoi init --apply --force<br/>(renders home/*.tmpl into ~/)"]
S2 --> S27["2.7 PATH sanity check<br/>(verifies ARCH_BIN writable, no broken symlinks)"]
S27 --> S3["3 oh-my-zsh + plugins<br/>DF_DO_ZSH"]
S3 --> S4["4 Homebrew + Brewfile<br/>DF_DO_PACKAGES"]
S4 --> Q["Quarto<br/>DF_DO_QUARTO"]
Q -.macOS.-> S5["5 Colima service<br/>DF_DO_MACOS_SERVICES"]
S5 -.macOS.-> S55["5.5 defaults write<br/>DF_DO_MACOS_SETTINGS"]
S55 -.macOS.-> S56["5.6 Quick Actions<br/>DF_DO_MACOS_QUICK_ACTIONS"]
Q --> S6
S56 --> S6
subgraph S6["6 language runtimes (each independent)"]
P["python.sh<br/>DF_DO_PYTHON"]
N["node.sh<br/>DF_DO_NODE"]
R["rust.sh<br/>DF_DO_RUST"]
J["julia.sh<br/>DF_DO_JULIA"]
L["lean/latex.sh<br/>DF_DO_LEAN / DF_DO_LATEX"]
C["claude.sh<br/>DF_DO_CLAUDE"]
X["codex.sh<br/>DF_DO_CODEX"]
V["cursor/vscode.sh<br/>DF_DO_CURSOR / DF_DO_VSCODE"]
K["cmake.sh<br/>DF_DO_CMAKE"]
end
S6 --> S65["6.5 local LLM<br/>DF_DO_LOCAL_LLM"]
S65 --> S66["6.6 agent memory stack<br/>DF_DO_MEMORY"]
S66 --> S67["6.7 blender-mcp addon<br/>DF_DO_BLENDER_MCP"]
S66 --> S7["7 auth.sh walk<br/>DF_DO_AUTH (default 0)"]
S7 --> S8["8 overlay bootstraps<br/>DF_DO_OVERLAYS"]
Step details
| Step | Script | What | Idempotent? |
|---|---|---|---|
| 0 | install/scratch.sh | Symlink heavy $HOME dirs to $DF_SCRATCH/.paths/. No-op if DF_SCRATCH unset. | Yes |
| 0.1 | install/dirs.sh | Create ~/dev, ~/bones, ~/misc (or $DF_DIRS). | Yes |
| 0.5 | inline | git clone if first run; git pull --ff-only in update/upgrade modes. | Yes |
| 0.6 | inline | Re-source the cloned repo’s _lib.sh, rebinding repo, overlay, PLAT, and platform-local paths authoritatively. | Yes |
| 1 | install/chezmoi.sh | Download chezmoi to $ARCH_BIN/chezmoi. Skipped if file already executable. | Yes |
| 2 | (inline) | chezmoi init --apply --force --exclude=scripts. Renders home/*.tmpl into ~/. --exclude=scripts skips run_onchange_*.sh.tmpl (bootstrap calls install scripts directly). | Yes |
| 2.7 | inline | Sanity-check that $ARCH_BIN, $CARGO_HOME, $RUSTUP_HOME, $NVM_DIR parents exist and aren’t broken symlinks. Aborts if anything’s wrong. | Yes |
| 3 | install/zsh.sh | Clone or update oh-my-zsh + plugins. | Yes |
| 4 | install/homebrew.sh (macOS) or install/linux-packages.sh | Install Homebrew, run brew bundle install --file=Brewfile, optionally brew upgrade and brew upgrade --cask --greedy. | Yes |
| 4.5 | install/quarto.sh | Verify the macOS cask or install a checksum-verified rootless Linux release under $LOCAL_PLAT. | Yes |
| 5 | install/macos-services.sh | Register Colima as a launchd service; symlink Docker plugins. macOS only. | Yes |
| 5.5 | install/macos-settings.sh | defaults write for Dock, Finder, keyboard, trackpad, Safari, iTerm2, screen lock. Sudo-gated extras (skipped if sudo unavailable): power management, Touch ID for sudo (/etc/pam.d/sudo_local, with pam_reattach so it works in tmux), and a global 60-min sudo ticket (/etc/sudoers.d/df-ticket). | Yes |
| 5.6 | install/macos-quick-actions.sh | Deploy *.workflow bundles to ~/Library/Services/; flush pbs. | Yes |
| 6 | various | See language-runtime table below. Each script is independent; failures cascade only via die (not log_warn). | Yes |
| 6.5 | install/local-llm.sh + install/opencode.sh | Create $LOCAL_PLAT/.cache/huggingface; verify ollama/mlx-lm/mlx-openai-server/opencode binaries. | Yes |
| 6.6 | install/memory.sh | Agent memory stack: cass binary/archive setup (indexing is manual), ~/kb knowledge repo, qmd collections/embeddings, qmd daemon. | Yes |
| 6.65 | install/skills-sync.sh | Install agent skills from agent-skills.txt into the shared ~/.claude/skills tree. | Yes |
| 6.7 | install/blender-mcp.sh | Download addon.py into Blender’s user addons; enable headlessly. Skipped if Blender not installed. | Yes |
| 7 | install/auth.sh | Walk every service, prompt [k] keep / [u] update / [d] delete per service. Default off — set DF_DO_AUTH=1 to enable. | Yes |
| 8 | overlay scripts | Run bash $DF_ROOT/dotfiles-*/bootstrap.sh "$DF_MODE" for each overlay. | Per overlay |
Step 6 in detail
| Sub-step | Script | What | Notes |
|---|---|---|---|
| 6a | install/python.sh | Install uv to $ARCH_BIN; install pip.txt plus pip-full.txt when DF_PROFILE=full (each tool gets an isolated venv). | Runs before Node so node-gyp can use uv’s Python. |
| 6b | install/node.sh | Install pinned nvm; install/upgrade Node 24 LTS; install npm.txt packages globally. | The parent bootstrap activates nvm before later agent/skill steps. |
| 6c | install/rust.sh | Install rustup and rust-analyzer; in the full profile, install the rust-docs MCP nightly and every entry in cargo.txt. | Prebuilt first, host-target source fallback; self-update only in upgrade mode. |
| 6d | install/go.sh | Install CLI tools from go.txt into $ARCH_BIN. | Go itself is owned by the Brewfile. |
| 6e | install/julia.sh | Install/default Juliaup’s release channel in PLAT-isolated depots. | Upgrade mode runs juliaup update release. |
| 6f | install/lean.sh | Install elan to $ELAN_HOME; install and default the pinned Lean toolchain. | Pin moves only alongside Mathlib; upgrade updates elan, not exact project pins. |
| 6g | install/latex.sh | macOS: verify MacTeX. Linux: install TinyTeX below $LOCAL_PLAT, route sys_bin into $ARCH_BIN, and install baseline packages. | Upgrade mode runs tlmgr update --self --all. |
| 6h | install/claude.sh | Download Claude Code; install plugins; register MCP servers; deploy overlay skills. | Atomic binary replacement. |
| 6i | install/codex.sh | Sync private config, hooks, guards, risk-scoped MCP servers, and run the healthcheck. | The healthcheck parses every profile and hook trust entry. |
| 6j | desktop scripts | Merge tracked Claude/Codex Desktop and LinearMouse settings on macOS. | Preserve app-owned state. |
| 6k | install/cursor.sh / install/vscode.sh | Sync Cursor MCP/settings and editor extensions. | Extension failures are warnings. |
| 6l | install/cmake.sh | Copy CMake toolchain files into $LOCAL_PLAT/cmake/toolchains/. | Always overwrites deployed copies. |
Modes
| Mode | What changes |
|---|---|
install (default) | Full idempotent setup. DF_DO_SCRATCH=1 (run scratch step). |
update | Same steps, but: git pull --ff-only in step 0.5, DF_DO_SCRATCH=0 (assume scratch is already set up), tools self-update where they support it. |
upgrade | Same as update, plus Homebrew, rolling Rust channels/Cargo tools, Go @latest tools, Node 24/npm 12 globals, uv tools, Julia release, TeX, and editor refreshes. It ends with audit-versions.sh --strict. |
Reading the source
The canonical source is bootstrap.sh itself — header comment block has the full flag table, then numbered ### N. ### step markers. To trace what a single step actually does, jump to install/<step>.sh. Each install script sources _lib.sh for path variables and logging helpers.
Docs and hosting
The documentation site at dotfiles.cade.io is built with mdBook and deployed automatically on every push to main.
How it works
push to main
→ Cloudflare Pages detects the push
→ runs infra/cloudflare/build.sh
→ downloads pinned mdbook + mdbook-mermaid binaries
→ runs `mdbook build docs`
→ deploys docs/book/ to dotfiles.cade.io
The entire pipeline is defined in two files:
infra/cloudflare/main.tf– OpenTofu config that creates the Cloudflare Pages project, binds the custom domain (dotfiles.cade.io), and sets up the CNAME DNS recordinfra/cloudflare/build.sh– build script that downloads pinned prebuilt binaries directly from GitHub Releases, then builds
Local development
mdbook serve docs/ --open # live reload at localhost:3000
Changes to any .md file under docs/ are reflected instantly in the browser.
Doc structure
docs/
├── book.toml # mdBook config (title, theme, repo link)
├── SUMMARY.md # Table of contents / sidebar nav
├── intro.md # Homepage
├── setup/
│ ├── bootstrap.md # Bootstrap instructions per platform
│ ├── chezmoi.md # Dotfile management with chezmoi
│ └── packages.md # Package layers (cargo, npm, pip, brew)
├── usage/
│ ├── updates.md # Day-to-day workflow
│ └── troubleshooting.md
└── infra/
└── docs-and-hosting.md # This page
Infrastructure management
The Cloudflare Pages project is managed with OpenTofu (open-source Terraform):
cd infra/cloudflare
export CLOUDFLARE_API_TOKEN=...
tofu plan -out=tfplan # write a reviewable plan
tofu show tfplan # inspect the exact saved plan
tofu apply tfplan # apply only that reviewed plan
terraform.tfvars holds account_id and github_owner – gitignored, copy from terraform.tfvars.example on each machine.
What OpenTofu creates
| Resource | Purpose |
|---|---|
cloudflare_pages_project | Pages project linked to GitHub, runs build.sh on push |
cloudflare_pages_domain | Binds dotfiles.cade.io to the project |
cloudflare_dns_record | CNAME dotfiles.cade.io → <project>.pages.dev (proxied) |
Cloudflare provider v5 migration
Commit f8a35b6 is the latest-v4 checkpoint required by Cloudflare’s v5 migration path. Before the first v5 plan against an existing deployment, back up the remote state, check out that commit, run tofu init -upgrade and a refresh-only plan, then return to the v5 configuration and review a saved plan. CI validates configuration only; it never plans or applies Cloudflare changes.
This same pattern (OpenTofu + Cloudflare Pages + mdBook) is used across other projects at cade.io.