In January 2012, while still in high school, I wrote a post called Unix ZSHell Reloaded about switching from bash to zsh and discovering oh-my-zsh. I still get the occasional email about it. Six years later I followed up with a post on the XDG Base Directory Specification.
Then I barely touched the repo for the better part of a decade.
New machine?
git clone --recursive.
Strange warning at shell start?
Ignore it for another year.
At the beginning of May, I finally sat down and did a new pass. The same dotfiles now power my personal Mac and my work machine at remberg, so the shell is where I spend most of my day. The plan was less to rewrite everything from scratch and more to delete what I was no longer using and replace the tools that had aged out.
What I kept#
I kept the boring infrastructure that aged well:
- dotbot as the bootstrap, built by my friend Anish who I went to high school with. Symlinks from a YAML manifest. No magic.
- The
shell/subdirectory layout from a 2018 cleanup. - The
gitandmacosplugins from oh-my-zsh — for muscle memory more than anything. I have been typinggstandgcofor fourteen years; I am not retraining now. - The public/private split (more on this below).
That is most of it. Everything else got swapped or deleted.
The framework swap: oh-my-zsh → sheldon + starship#
The biggest move was retiring oh-my-zsh as a framework after fourteen years. oh-my-zsh wraps a lot of things I didn’t need anymore: an auto-update mechanism, a theme system, and the umbrella oh-my-zsh.sh that sources everything in a fixed order.
I wanted something smaller and more declarative.
I split that into two tools:
- sheldon as the plugin manager. TOML, declarative, locks plugin versions. It can also pull and source individual files out of the upstream
ohmyzsh/ohmyzshrepo, so I keep thegitandmacosplugins without keeping the framework. - starship as the prompt. A single binary, configured by a single TOML file. No theme DSL, no
$fg_bold[red]strings.
My entire shell/sheldon/plugins.toml is just a few lines:
shell = "zsh"
[plugins.ohmyzsh]
github = "ohmyzsh/ohmyzsh"
use = [
"lib/{git,functions,misc,clipboard,theme-and-appearance}.zsh",
"plugins/git/git.plugin.zsh",
"plugins/macos/macos.plugin.zsh",
]That is it.
Once parity was confirmed I deleted the oh-my-zsh git submodule, which had drifted two years behind upstream.
The new zshrc loader has a deliberate order:
# brew completions on fpath, then init.
# compinit MUST run before sheldon sources oh-my-zsh plugins so that
# `compdef` is defined when those plugins try to register completions.
fpath=(/opt/homebrew/share/zsh-completions $fpath)
autoload -Uz compinit && compinit
# sheldon — plugin manager (oh-my-zsh git/macos)
command -v sheldon > /dev/null && eval "$(sheldon source)"
# mise — version manager auto-switching
command -v mise > /dev/null && eval "$(mise activate zsh)"
# zoxide — smarter cd. Provides `z` and `zi`.
command -v zoxide > /dev/null && eval "$(zoxide init zsh)"
# fzf shell integration
FZF_SHELL="${HOMEBREW_PREFIX:-/opt/homebrew}/opt/fzf/shell"
[[ -f $FZF_SHELL/key-bindings.zsh ]] && source $FZF_SHELL/key-bindings.zsh
[[ -f $FZF_SHELL/completion.zsh ]] && source $FZF_SHELL/completion.zsh
# Starship prompt
command -v starship > /dev/null && eval "$(starship init zsh)"
# Custom config + aliases
source $DOTFILES/shell/config.zsh
source $DOTFILES/shell/functions.zsh
# (alias-file glob omitted for brevity)
# autosuggestions and syntax-highlighting must be sourced LAST
# so they wrap all previously-defined widgets correctly.
source $ZSH_BREW_SHARE/zsh-autosuggestions/zsh-autosuggestions.zsh
source $ZSH_BREW_SHARE/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
# atuin — SQLite-backed shell history with fuzzy Ctrl-R search.
command -v atuin > /dev/null && eval "$(atuin init zsh --disable-up-arrow)"
# ~/.zshlocalrc for machine-local and work-related settings (untracked)
[[ -a ~/.zshlocalrc ]] && source ~/.zshlocalrcThe order matters more than it looks. The Quirks and insights section below explains the ordering constraints that bit me. Get them wrong and zsh fails silently in unhelpful ways.
What I replaced#
The modern-CLI swaps. Each one is a one-line install and a one-line alias. The wins are small individually and add up because I hit these commands constantly.
ls→eza— sane defaults, git-aware columns, tree mode. Aliased.cat→bat— syntax-highlighted, paging-aware.--paging=neverso it stays a drop-in.du→dust— actually readable disk-usage output.z→zoxide— smartercdbased on frecency.git diff/git logpager →delta— wired up via gitconfig, side-by-side diffs.- shell history →
atuin— SQLite-backed, fuzzyCtrl-R, optionally syncs across machines. I bind it with--disable-up-arrowso plain↑still does what muscle memory expects. pyenv/nodenv/rbenv→mise— one tool, one config file, one shim directory.idiomatic_version_file_enable_toolskeeps it reading legacy.node-version/.python-version/.ruby-versionfiles in projects, which mattered more than I expected.pipenvandpipx→uv— the speed difference alone is worth the swap.- classic vim → Neovim + LazyVim — but I kept the old vim config alongside on purpose. Sometimes I still just want to use the simple vim without anything else.
- tmux — modernized to post-2.9 syntax, TPM as a submodule, session persistence via
tmux-resurrect+continuum.
One bonus that surprised me: I now track my iTerm2 preferences in the dotfiles repo.
The dotbot install hook points iTerm2 at the dotfiles folder via defaults write com.googlecode.iterm2 PrefsCustomFolder and an alias keeps the plist as XML so diffs are readable.
Terminal config no longer drifts per-machine.
I should have done this in 2018.
What I deleted#
The two-day pass deleted more than it added, which is the half I don’t see written about often.
- The Atom config — Atom is a museum exhibit at this point. Its creators went on to build Zed, which I have not had the chance to try yet.
- Intel-only env vars in
path.zsh. The first M1 Macs shipped more than five years ago, and I have since replaced every Intel device I was running. - A broken
tmuxbattery segment that had been silently failing for who-knows-how-long. - The oh-my-zsh git submodule itself.
pipenv,pipx, and a handful of one-offlib/shell helpers nobody (including me) was sourcing.
When I caught myself wondering “should I keep this just in case?” the answer was almost always no.
The live config doesn’t have to remember everything.
That is what git log is for.
I can always add things back later.
The public / private split#
One repo runs both a personal Mac and a work machine. Anything machine-specific, work-related, or sensitive lives in two untracked files:
~/.zshlocalrc— sourced last byzshrc, so it can override anything earlier. Work aliases, secret env vars, machine-specific paths.~/.Brewfile.local— local Homebrew packages. Run separately from the publicBrewfile.
Those two live in the home directory, so they are never in the repo to begin with.
For files inside the repo tree there is a convention instead: any filename ending in .local.zsh is gitignored.
The split lets me git push the public repo without thinking and keeps my remberg-specific tooling (internal CLIs, kube contexts, anything sensitive) out of sight.
The work boundary is enforced by a single line in the loader rather than by remembering to be careful.
Quirks and insights#
The most useful thing I can pass along from these two days is the list of bugs that bit me. They are all the kind of thing you only spot once you have hit them.
compinit must run before sheldon#
The first thing that broke. sheldon sources the OMZ git and macos plugins, which call compdef to register completions.
If compinit hasn’t run yet, you get “command not found: compdef” on every plugin load.
The fix is one line in the right place:
fpath=(/opt/homebrew/share/zsh-completions $fpath)
autoload -Uz compinit && compinit
# THEN
command -v sheldon > /dev/null && eval "$(sheldon source)"I had also added zsh-completions as a sheldon plugin initially.
Turns out Homebrew already drops them into /opt/homebrew/share/zsh-completions, which is on fpath before compinit.
The sheldon entry was a redundant clone.
Deleted.
Insecure compinit directory warning#
The classic startup warning: zsh compinit: insecure directories….
On some Brew installs the zsh share dirs end up group/world-writable, which compinit refuses to trust.
I added a one-line fix to the dotbot install hook so a fresh machine never sees it:
chmod -R go-w /opt/homebrew/share/zsh /opt/homebrew/share/zsh-completionsThat is the kind of fix that belongs in your installer once, not in a startup warning you can keep ignoring for a long time.
autosuggestions and syntax-highlighting must be last#
Both plugins work by wrapping every zsh widget that exists at the moment they load. If you source them too early, you silently lose suggestions on widgets defined later by mise, zoxide, fzf, or starship. The annoying part is that it breaks silently. You just stop seeing autosuggestions on the new widgets and assume the tools never had them.
atuin is the one deliberate exception in my loader: it initializes even later, but all it does is rebind Ctrl-R to its own full-screen TUI.
There is no inline editing for the two plugins to decorate, so it doesn’t need to be wrapped.
Powerline glyphs: U+2B80 vs U+E0B0#
My first prompt iteration used the Unicode arrow at U+2B80 (⮀).
It looked roughly right, but every segment had a one-pixel gap before the next.
Turns out that codepoint is just a generic geometric triangle, and most fonts render it with default character metrics rather than the zero-width advance you actually want for a Powerline-style arrow.
I switched to U+E0B0, the canonical Powerline private-use codepoint that Nerd Fonts ship with the exact width and baseline to butt up against the next character.
No gap.
So “use a Nerd Font” is not only an aesthetic choice.
There are entire codepoints in there whose only job is to be the closing arrow of a colored background segment, which is what I needed to replicate the prompt I was used to.
A git status --porcelain that timed out#
starship’s default command_timeout is 500 ms.
My first dirty-detection module ran git status --porcelain to decide whether the working tree was clean or dirty.
On a sizable older remberg repo that command blew past 500 ms, starship gave up on it, the git module rendered nothing, and the prompt chain visually broke.
Two small fixes:
# Was:
git status --porcelain # enumerates every untracked file. Slow on large repos.
# Now:
git diff-index --quiet HEAD -- # skips untracked enumeration. <50ms even on big repos.And then I bumped command_timeout to 2000 ms in starship.toml as a safety net for any other slow custom modules.
A prompt status that occasionally fails to render is a prompt you stop trusting, which is worse than no prompt statuses at all.
Continuous prompt chain (and the journey to native inheritance)#
Powerline aesthetics need each segment’s leading arrow to match the foreground color of the previous segment’s background. My first version hardcoded those transitions per-segment, which broke whenever a module was hidden (kctx outside a k8s repo, for example) — you would get an orphan triangle in the wrong color.
I went down a rabbit hole and even delegated the whole chain to a tiny shell script for dynamic transitions.
In the end it was just my starship version.
prev_bg and prev_fg have been native color aliases since 1.20, and a fix in 1.24.2 made them work with conditionally hidden segments, the exact case that kept breaking for me.
I upgraded, and the chain now inherits without any external scripts:
[directory]
format = '[](fg:prev_bg bg:dir_bg)[ $path ](fg:black bg:dir_bg)'Each segment owns its own leading arrow, painted in fg:prev_bg.
Hide a segment and the next one just inherits the color of whatever was actually rendered.
So I removed the shell script.
kubectl context visibility#
The last fiddly one.
My prompt tended to become quite long with all statuses included.
A k8s segment that is always on is noise, but one that flickers as you cd between repos is worse.
What I actually want from a prompt is a signal about what is going to matter for the next command, not a list of everything in my environment.
So the kctx segment now hides outside k8s-related directories — it walks up looking for Chart.yaml, kustomization.yaml, a k8s/ folder, and so on.
But it also shows for one hour after the last kubectl invocation in the current session, so you don’t lose context immediately after a deploy.
I track kubectl calls with a zsh preexec hook that exports a timestamp:
autoload -Uz add-zsh-hook
_track_kubectl() {
[[ "$1" == kubectl* ]] && export KUBECTL_LAST_TS=$(date +%s)
}
add-zsh-hook preexec _track_kubectlThe starship custom module reads KUBECTL_LAST_TS and shows the segment if it is less than an hour old.
Scoped to the session by design: a fresh terminal shouldn’t inherit the “just deployed” feeling from another window.
Long contexts also get aliased to short labels (anything matching *prod* becomes ⚠ prod, *dev* becomes ⎈ dev), mostly so I never kubectl apply against the wrong cluster at midnight.
A few days after writing all this I realized the same gating wanted to drive an AWS segment too.
The directory-walk logic moved into shell/starship/infra.sh, and both kubernetes.sh and aws.sh now call into it.
The AWS segment has one extra condition — it only renders if $AWS_PROFILE is set explicitly, since an implicit [default] is a weak signal not worth the visual noise.
reload! is now exec zsh#
Old habit: alias reload!='. ~/.zshrc'.
Used to work fine.
Once sheldon caches and starship init are in play, re-sourcing zshrc doesn’t give you a clean state — variables stick around, hooks compound, the cache misbehaves.
Switched to:
alias reload!='exec zsh'exec zsh replaces the current shell process with a fresh one.
Same window, same scrollback, but a true cold start.
Telemetry off by default#
Several modern CLIs phone home unless told not to.
I disabled them all in shell/zshenv so it propagates to every new machine and I don’t have to actively think about it anymore:
export DO_NOT_TRACK=1
export HOMEBREW_NO_ANALYTICS=1
export NEXT_TELEMETRY_DISABLED=1
export NUXT_TELEMETRY_DISABLED=1
export GATSBY_TELEMETRY_DISABLED=1DO_NOT_TRACK=1 is the emerging cross-tool standard; the tool-specific variables cover the holdouts that have not adopted it yet. Setting it all once in dotfiles is much nicer than tracking each opt-out down on every new machine.
Final words#
In 2012 I closed the zsh post with “Change now! Because ZSH will make your life a lot easier.” The tools have changed since then, but apparently the urge to write a post about them has not.
If you have a dotfiles repo you have not touched in a few years, take an afternoon. Open those files and be honest about what you are still using. The tooling out there now is way ahead of what existed a decade ago. Your repo will likely end up smaller, and you will trust it more.
One thing this post leaves out on purpose: AI tooling has grown its own config sprawl (skills, agent setups, model settings), and I have started folding that into the same repo. That is a post of its own.
The full repo is at github.com/cwoebker/dotfiles — public so it can be referenced, not designed for fork-and-use.
The README has a fork warning for a reason: there is also a scripts/macos.sh in there that flips dozens of defaults (locale, keyboard repeat, hot corners, Dock orientation), and running it blindly will make your machine look like mine, which is unlikely to be exactly what you want.
Let me know what you swapped, and what you finally deleted!

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.