Dotfiles, Revisited
Last December I published a setup guide for configuring a fresh MacBook, built around the first version of my dotfiles. That guide ended with: “this repo will probably change often.” Three months and twelve tagged versions later, that prediction held up. The setup scripts from the original post no longer exist, most functions have been renamed, and the toolchain looks quite different. This is an overview of what changed and why.
Table of Contents 1
One Bootstrap to Rule Them All
The original dotfiles had three separate setup scripts: install-zsh.sh for the shell environment, install-tools.sh for developer tools, and install-apps.sh plus link-apps.sh for GUI applications and symlinks. Each had its own Brewfile, its own argument parsing, its own logging helpers. It worked, but it meant maintaining three parallel flows that all did fundamentally the same thing: install packages and create symlinks.
v2 replaced all of that with a single setup/bootstrap.sh. One script, one Brewfile, one entry point.
./setup/bootstrap.sh # full setup
./setup/bootstrap.sh --dry-run # preview without changes
./setup/bootstrap.sh --no-brew # skip Homebrew, just symlink
./setup/bootstrap.sh --reset # remove and recreate all symlinks The root Brewfile became the canonical source of truth for everything Homebrew manages: CLI tools, language runtimes, fonts, GUI apps. A brewsync alias keeps the system in sync:
alias brewsync='brew bundle --file="$DOTFILES_DIR/Brewfile" --cleanup' The --cleanup flag removes anything installed by Homebrew that isn’t in the Brewfile. One command, one source of truth, no drift.
For the handful of tools that can’t go through Homebrew (1Password desktop, Xcode CLI tools, Docker Desktop), there’s a setup/manual-installs.txt file that bootstrap prints at the end as a checklist.21Password’s desktop app needs its own installer for biometrics and browser integration to work correctly. The CLI (1password-cli) installs fine from Homebrew, but the GUI app is an exception.
Terminal-Aware Configuration
One of the more interesting changes was making the shell aware of which terminal it’s running in. The problem: I use Ghostty on my MacBook, but occasionally SSH in from my phone via Termius and mosh. Mosh miscalculates the width of Nerd Font icons, which breaks the Starship prompt layout.
The fix was a TERM_PROGRAM switch in .zshenv:
case "$TERM_PROGRAM" in
vscode)
export STARSHIP_CONFIG="$XDG_CONFIG_HOME/starship.toml"
export EDITOR="cursor --wait"
;;
ghostty)
export STARSHIP_CONFIG="$XDG_CONFIG_HOME/starship.toml"
export EDITOR="fresh"
;;
*)
export STARSHIP_CONFIG="$CONFIG_DIR/starship-mobile.toml"
export EDITOR="micro"
;;
esac Desktop terminals get the full Starship prompt with Nerd Font icons and language detection. Everything else gets a stripped-down variant: just the directory, git branch, and a minimal > character. No icons, no language segments, no clock. It renders differently, but it works everywhere.
The same switch routes $EDITOR. Cursor when inside VS Code’s integrated terminal, Fresh (a lightweight terminal editor) in Ghostty, and micro as the universal fallback.3tmux complicates this because it overrides TERM_PROGRAM. The workaround: tmux.conf sets an OUTER_TERM environment variable to the terminal that launched the tmux server, and .zshenv checks that variable when running inside tmux.
A New Naming Convention
At some point I realized my aliases and functions followed no consistent pattern. edit_zsh, load_key, clean_pycache. Different styles, different verb positions, inconsistent separators. I renamed everything to follow an action-prefix convention, mashed together without underscores:
| Before | After | Pattern |
|---|---|---|
edit_zsh | ezsh | e + target |
edit_dotfiles | edots | e + target |
load_key | key | just the noun |
zsh-doctor | zshdoctor | action + target |
clean_pycache | pyclean | action + target |
pass | mkpass | mk + thing |
The e prefix opens things in the terminal-aware _eopen helper. cd-prefixed versions navigate instead:
ezsh() { _eopen "$ZSH_DIR"; }
edots() { _eopen "$DOTFILES_DIR"; }
cdzsh() { builtin cd "$ZSH_DIR"; }
cddots() { builtin cd "$DOTFILES_DIR"; } Navigation also got simpler: .., ..., ...., and ..... for going up one to four directories.
And suffix aliases, which might be my favorite small addition: type the name of a .md or .json file and it opens in your editor. Type a .pdf or .png and it opens in the default viewer. No need to prefix with a command.
# Type "README.md" → opens in terminal-aware editor
alias -s md='_eopen'
alias -s json='_eopen'
# Type "document.pdf" → opens in Preview
alias -s pdf='open'
alias -s png='open' New Tools in the Toolchain
Several new tools earned a permanent spot in the Brewfile since v1:
Atuin replaced the built-in zsh history search. It stores shell history in a SQLite database with full-text search, timestamps, and directory context. Ctrl-R now opens an interactive search that’s significantly better than the default reverse-i-search. I also configured proper zsh history options (EXTENDED_HISTORY, HIST_IGNORE_ALL_DUPS, SHARE_HISTORY) that should have been there from the start.
Zoxide replaced manual cd for frequently visited directories. It tracks usage with a frecency algorithm and lets you jump with partial matches: z dot takes me to ~/dotfiles, z web to the website repo. The zi variant opens an interactive fzf picker.
Yazi was added as a terminal file manager. It renders a three-pane directory view with file previews and supports the gruvbox-material theme that matches the rest of my terminal. I use it for quick visual exploration when I need a birds-eye view of a directory structure.
Prek replaced pre-commit. It’s a Rust-based drop-in replacement that reads the same .pre-commit-config.yaml but runs noticeably faster.
Biome was added as a global fallback formatter and linter. Project-local installs take priority, but having it globally means I can format a scratch file without setting up a project first.
I also added a getmd shell function that fetches clean markdown from any URL via the defuddle.md API and copies it to the clipboard:
getmd https://some-documentation-page.com
# ✓ Copied 1482 words to clipboard Useful for pulling documentation into notes or LLM context windows.
Defining Boundaries
One of the less obvious but more important changes was deciding what doesn’t belong in dotfiles.
The v1 repo contained Claude Code agent definitions, custom commands, and instruction files alongside the configuration. It had a Claude Desktop config template. It had Codex’s instructions.md. All of these are agent resources, not configuration, and they evolve on a completely different cadence than shell aliases or editor settings.
In v2.5.0 I drew a clear line: this repo manages classic config files only. Agent resources (skills, instructions, commands, rules) belong in the projects they serve, not in a global dotfiles repo. Claude Code’s directory went from containing settings, agents, commands, and a README to just settings.json. Codex went from config, instructions, and README to just config.toml. The Claude Desktop example config was removed entirely; the app manages its own config at the default location.
The boundary made the repo easier to reason about. If something lives in config/, it’s a config file that gets symlinked into place.
Quality Gates
v2.1.0 introduced two git hooks that enforce discipline on the repo itself.
A commit-msg hook requires conventional commit format. Every commit starts with a type prefix: feat:, fix:, refactor:, docs:, chore:. This keeps the git log scannable and makes changelog writing straightforward.
A pre-push guard blocks pushes to main unless a version tag is set on HEAD and the CHANGELOG.md contains a matching entry. This forces me to tag a version and write a changelog entry before publishing. It’s a small amount of friction that prevents sloppy releases.
Things That Came and Went
Not everything stuck.
The cw command suite was one of the bigger additions in v2.0.0: it launched Claude Code sessions in isolated git worktrees inside tmux, with fzf session picking, merge-aware pruning, and Apple Notes sync for mobile reconnect. It was removed entirely in v2.6.0. Claude Code’s built-in worktree support made most of it redundant, and the Apple Notes sync was too fragile to rely on.
Similarly, tproject (a tmux function that created a predefined three-pane layout for coding sessions) was removed. I found I never used it the same way twice. Manual pane splitting was simpler.
The secrets.zsh mechanism was removed in v2.0.1. It was a file-based approach to loading API keys, requiring a secrets.zsh file outside of version control. The key function (originally load_key) already handled this better by pulling secrets directly from 1Password at runtime via op read. No files to manage, no sync to worry about.
tmux Overhaul
The tmux configuration was significantly reworked. The prefix changed from Ctrl-b to Ctrl-Space, which avoids conflicts with Ghostty shortcuts, shell bindings, and Raycast hyperkeys.4Ctrl-b conflicts with the “backward one character” shell binding. Ctrl-a is even worse because it conflicts with “beginning of line.” Ctrl-Space has no default binding in most shells or terminal apps, making it a clean choice.
Copy mode now uses vi keybindings, with v for selection and y to yank to the system clipboard via pbcopy. The config sets OUTER_TERM as an environment variable so new shells inside tmux know which terminal launched the server. Windows renumber automatically when one is closed, and prefix + r reloads the config in place.
The biggest functional addition was ccbot, which manages persistent Claude Code sessions connected to Telegram bots via tmux. Each project gets a detached session, its own bot token pulled from 1Password, and zsh tab completion for project names and running sessions. I wrote about this separately in Editing a Website from Telegram.
What Stayed the Same
For everything that changed, the foundation from the original guide held up. Homebrew manages all packages. 1Password is the SSH agent and the secret store. Zsh with Starship is the shell. Ghostty is the terminal. The .ssh/config still routes everything through 1Password’s agent socket.
The philosophy held too: version-control your configuration, symlink everything into place, make setup reproducible from a single command. What changed was the execution: better structure, clearer boundaries, and a few months of actually living with the choices. Dotfiles are never really done, but the current version feels like a solid foundation to iterate on. The repo is at github.com/jwa91/dotfiles, currently at v2.12.0.
- Jan Willem
Related articles
- 8m
New Macbook Guide
My personal migration guide for setting up a fresh MacBook with 1Password, Homebrew, Zsh, and a developer-focused toolchain. Read → - 5m
Editing a Website from Telegram
What started as a quick Telegram + Tailscale experiment turned into a persistent tmux-based setup for running Claude Code sessions from my phone. Read →