# Zsh Configuration
> [!warning] Raw notes
> These are living, personal config notes — raw, frequently out-of-date, and sometimes half-baked. They reflect my setup at some point in time, not a maintained tutorial. Verify against upstream docs before copying. Reach out if something looks broken.
Personal Zsh setup on macOS. [[Antidote]] for plugin management, zoxide for directory jumping, a deliberately plain built-in prompt (Starship was benched for latency reasons), and API secrets loaded lazily from [[1Password]] per-command.
Config entrypoint: `~/.config/zsh/base.zsh`
## Layered Loading
The setup uses a two-layer shim pattern inherited from how the dotfiles repo is structured:
```
~/.zshrc # compat shim — sources base.zsh
~/.zshenv # PATH bootstrap, dogbrew env
~/.config/zsh/base.zsh # the real config
~/.config/zsh/layer.zsh # environment-specific additions (optional)
~/.zshrc.local # machine-local overrides (not committed)
```
`~/.zshenv` is loaded for every shell (interactive or not) so it contains only PATH and env vars that scripts need. `base.zsh` bails out immediately for non-interactive shells so plugins and aliases never load in scripts.
```zsh
# base.zsh — skip for non-interactive shells
[[ -o interactive ]] || return
```
## Platform Detection
```zsh
case "$OSTYPE" in
darwin*) export ZSH_PLATFORM=macos ;;
linux*)
. /etc/os-release
export ZSH_PLATFORM="${ID:-linux}"
;;
esac
```
Platform-specific config lives in `~/.config/zsh-platform/platform.zsh` and is sourced after detection. That file is not in the shared dotfiles — it's machine-local (generated by Ansible or written by hand).
## History
```zsh
HISTFILE=~/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt HIST_EXPIRE_DUPS_FIRST
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_FIND_NO_DUPS
setopt HIST_SAVE_NO_DUPS
setopt SHARE_HISTORY
setopt APPEND_HISTORY
setopt INC_APPEND_HISTORY
```
- `SHARE_HISTORY` + `INC_APPEND_HISTORY` — all open terminal windows share the same history in real time, not just at shell exit
- `HIST_IGNORE_SPACE` — prefix a command with a space to skip writing it to history (useful for secrets entered directly)
- `HIST_IGNORE_ALL_DUPS` + `HIST_EXPIRE_DUPS_FIRST` — duplicates are gone; the most recent execution survives
## Navigation Options
```zsh
setopt AUTO_CD # type a directory name to cd into it
setopt AUTO_PUSHD # cd pushes to the directory stack automatically
setopt PUSHD_IGNORE_DUPS
setopt PUSHD_SILENT
setopt INTERACTIVE_COMMENTS # allow # comments at the prompt
setopt NO_BEEP
bindkey -e # Emacs key bindings (Ctrl+A, Ctrl+E, etc.)
```
`AUTO_PUSHD` + the number aliases (`1`–`9`) from `aliases/common.zsh` let you jump back to recent directories with `cd -1`, `cd -2`, etc.
## Environment Variables
```zsh
export EDITOR='nvim' # vim on SSH connections
export GOPATH="$HOME/go"
export KREW_ROOT="${KREW_ROOT:-$HOME/.krew}"
path=(
"$HOME/.local/bin"
"$GOPATH/bin"
"$KREW_ROOT/bin"
$path
)
```
Extra PATH entries are injected via `ZSH_EXTRA_PATH_PRE` / `ZSH_EXTRA_PATH_POST` arrays — the platform layer or layer.zsh appends to those arrays before `base.zsh` builds the final PATH, so ordering is correct without hard-coding paths in the shared config.
## Lazy API Secret Loading
AI tool API keys (Gemini, etc.) are loaded from [[1Password]] only when the relevant command is first run in a session — not on shell startup:
```zsh
typeset -A _op_refs=(
GEMINI_API_KEY "op://Private/Gemini API Key/credential"
)
typeset -A _op_cmd_keys=(
aider "OPENAI_API_KEY GEMINI_API_KEY"
gemini "GEMINI_API_KEY"
)
_maybe_load_op_secrets() {
local cmd="${1%% *}"
local keys="${_op_cmd_keys[$cmd]}"
[[ -z "$keys" ]] && return
for key in ${=keys}; do
[[ -n "${(P)key}" ]] && continue
export "$key=$(op read "${_op_refs[$key]}" --account=my.1password.com --no-newline 2>/dev/null)"
done
}
preexec_functions+=(_maybe_load_op_secrets)
```
`preexec_functions` is a zsh hook array — every entry is called before each command runs. The function bails immediately if the command isn't in `_op_cmd_keys` or the key is already set, so overhead for normal commands is negligible.
`load-secrets` is a manual function to preload all keys at once (useful when you know you're about to run multiple AI tools).
## Completions
```zsh
autoload -Uz compinit
compinit -i -C
```
`-C` skips the security check on the dump file (the check runs `stat` on every `fpath` entry — slow). `-i` silently ignores insecure directories instead of erroring.
Completion paths are built from a list rather than hardcoded:
```zsh
_completion_dirs=(
/opt/homebrew/share/zsh/site-functions
"$HOME/.docker/completions"
"$HOME/.config/zsh/completions"
# ...
)
for dir in "${_completion_dirs[@]}"; do
[[ -d "$dir" ]] && fpath=("$dir" $fpath)
done
```
`compinit` runs before plugins load so `compdef` is available when plugin setup code calls it.
## Plugin Management (Antidote)
[[Antidote]] manages plugins via a static bundle. The plugin list lives in `~/.config/zsh/plugins.txt`; Antidote compiles it into `plugins.zsh` and `zcompile`s it. The bundle is only rebuilt when `plugins.txt` is newer than the bundle or a sourced file is missing.
Active plugins (abbreviated):
```
# Fish-like completions
zsh-users/zsh-completions kind:fpath path:src
# Core workflow
ohmyzsh/ohmyzsh path:plugins/colored-man-pages
ohmyzsh/ohmyzsh path:plugins/sudo
```
`kind:defer` plugins (most of them) are queued to load after the first prompt is drawn using `zsh-defer`, shaving startup time. `kind:fpath` only adds to `fpath` without sourcing, so zsh-completions is free.
## Prompt
Starship is installed but **not hooked**. The comment in `base.zsh` explains why:
```zsh
# Fast prompt: pure zsh, no external process on every redraw.
# Starship costs ~250ms per left prompt + ~250ms per right prompt on this machine.
PROMPT='%F{green}%n@%m%f %F{blue}%~%f %# '
RPROMPT=''
```
A plain `%n@%m %~ %#` prompt with colour escapes is used instead. No subprocess fork per prompt draw. See [[Starship Configuration]] for the Starship config that exists but isn't active by default.
## Tool Hooks
Most tools are lazy-loaded or deferred to avoid startup overhead:
```zsh
# zoxide — fast directory jumping (replaces z/autojump)
command -v zoxide &>/dev/null && _cached_eval zoxide zoxide init zsh
alias j=z
# direnv — NOT auto-hooked (costs ~250ms per precmd)
direnv-load() {
eval "$(direnv export zsh)"
}
# fzf — lazy-loaded on demand
fzf-load() {
source <(fzf --zsh)
}
# pyenv — lazy-loaded on first call
pyenv() {
unfunction pyenv
eval "$(command pyenv init -)"
pyenv "$@"
}
```
`direnv-load` must be called explicitly when entering a project directory — the auto-hook (`eval "$(direnv hook zsh)"`) was disabled because it adds ~250ms before every prompt.
## Aliases
Aliases are sourced from separate files at the end of `base.zsh`:
```zsh
source ~/.config/zsh/aliases/common.zsh # ls, navigation, clipboard
source ~/.config/zsh/aliases/git.zsh # git shorthands
source ~/.config/zsh/aliases/claude.zsh # Claude Code workflows
source ~/.config/zsh/aliases/go.zsh # Go toolchain shortcuts
```
Highlights from `common.zsh`:
```zsh
# Safe destructive ops
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'
# Directory shortcuts
alias ...='../..'
alias ....='../../..'
alias -- -='cd -'
# Global aliases (pipe shortcuts)
alias -g G='| grep'
alias -g L='| less'
alias -g NE='2> /dev/null'
alias -g NUL='> /dev/null 2>&1'
```
## Links
- [Antidote](https://getantidote.github.io/) — zsh plugin manager
- [zoxide](https://github.com/ajeetdsouza/zoxide) — smart `cd` with frecency ranking
- [zsh options reference](https://zsh.sourceforge.io/Doc/Release/Options.html)
- [zsh-defer](https://github.com/romkatv/zsh-defer) — deferred plugin loading
## Field notes from agent sessions (2026-11-10)
These notes come from agent sessions that tuned zsh startup and history search. They add facts about native widgets, plugin loading, profiling, caching, and completions that the sections above do not state.
- Bind the Up arrow to native prefix history search with `bindkey '^[[A' history-beginning-search-backward`. The widget matches what you already typed against earlier commands. It needs no plugin and adds no startup cost. Use `zsh-history-substring-search` only when you must match anywhere in the line.
- Bind `^G` to `PER_DIRECTORY_HISTORY_TOGGLE` to switch between per-directory and global history.
- oh-my-zsh startup cost is dominated by plugin loading and completion init. Stripping it and sourcing domain alias files directly cut startup from 3 seconds to about 140 ms without a loss of a meaningful feature.
- `zprof` attributes most CPU time to `compinit`. Wall time far above CPU time, 3 seconds against 0.05 seconds, means the real bottleneck is I/O and IPC wait, not parsing. Confirm both measurements before you optimize.
- `antidote` supports `kind:defer` natively. Without the annotation, every plugin sources eagerly at shell start. After you add the annotation, regenerate the bundle with `antidote bundle < ~/.config/zsh/plugins.txt > ~/.cache/antidote/plugins.zsh`.
- `zcompile -R <out> <src>` writes zsh bytecode to a path you choose instead of next to the source. The `-R` flag lets the cache live outside the stowed config tree.
- Cache tool init output with the cached-eval pattern. Hash the command together with its version string. Store the init output under that cache key, then `source` the cached file in subsequent shells.
- A startup `compinit -i -C` skips the scan for new completion files. After you install a completion, rebuild once with `zsh -i -c 'autoload -Uz compinit && compinit -i'`. To test a new completion without restarting the shell, run `autoload -Uz compinit && compinit -i` and press Tab on the new command.
### Observations
- [zsh] The native `history-beginning-search-backward` widget prefix-matches typed input with zero dependencies and zero startup cost. #history (source: agent sessions, 2026-02)
- [zsh] `PER_DIRECTORY_HISTORY_TOGGLE` on `^G` switches between per-directory and global history. #history (source: agent sessions, 2026-02)
- [zsh] oh-my-zsh startup cost is dominated by plugin loading and completion init. Sourcing domain alias files directly cut startup from 3 seconds to about 140 ms. #startup (source: agent sessions, 2026-02)
- [zsh] `zprof` shows CPU cost only. Wall time far above CPU time points to I/O and IPC wait, not parsing. #performance (source: agent sessions, 2026-04)
- [zsh] Without a `kind:defer` annotation, antidote sources every plugin eagerly at shell start. #plugins (source: agent sessions, 2026-04)
- [zsh] `zcompile -R <out> <src>` writes bytecode to a chosen path, which enables cache directories outside the stowed config tree. #startup (source: agent sessions, 2026-04)
- [zsh] A cached-eval hash over the command and its version string keeps tool init output reusable across shells. #caching (source: agent sessions, 2026-04)
- [zsh] A `compinit -i -C` startup skips the scan for new completion files. Rebuild once with `zsh -i -c 'autoload -Uz compinit && compinit -i'` after an install. #completions (source: agent sessions, 2026-10)
- [zsh] Run `autoload -Uz compinit && compinit -i` to test a new completion without restarting the shell. #completions (source: agent sessions, 2026-07)