A modular ZSH setup
Introduction
ZSH has been my shell of choice for a long time. Along the way, my configuration has accumulated a collection of hacks, tricks, and quality-of-life improvements. This post walks through the interesting parts of my ZSH config (7e20928), focusing on the non-obvious bits that make daily shell work smoother.
I. Config structure
The configuration is split across multiple files:
~/.zshenv, environment variables, loaded first for all shells~/.zprofile, login shell profile, sourced once at login~/.zshrc, main entrypoint for interactive shells~/.zsh/core/, core shell settings~/.zsh/tools/, tool-specific configs~/.zsh/functions/, autoloaded shell functions
The distinction matters: .zshenv runs first for all
shells (including scripts), .zprofile runs once at login,
and .zshrc runs for interactive shells only. For GUI
environments like Sway, variables in
.zprofile are inherited by the compositor and all apps.
Things like $EDITOR, $GOPATH, and PATH
additions go there so they’re available everywhere, not just in
terminals.
Files are numbered to control load order. 05-helpers.zsh
loads first with shared utilities, 10-general.zsh sets
shell options, and 90-bind-rationalise-dots.zsh runs last
(it needs to override the default dot behavior after everything else is
loaded).
A loader function iterates through directories and sources files:
load_zsh_dir() {
local dir=$1 file
[[ -d $dir && -r $dir ]] || return 0
for file in "$dir"/*.zsh(N); do
[[ -r $file ]] || continue
_zsh_compile_if_needed "$file"
source "$file"
done
}Machine-specific overrides, work-related configs, and private
settings live in .zshrc.private, which isn't committed to
the repository. It's loaded last, so it can override anything:
# private configs (loaded last)
[[ -f $HOME/.zshrc.private ]] && source "$HOME/.zshrc.private"III. Hacks and tricks
These are the non-obvious bits that make the shell more pleasant to use. Very opinionated to my personal liking, but perhaps useful as inspiration.
a) Rationalise dots
Typing multiple dots expands into parent directory traversal. Type
... and get ../... Type .... and
get ../../... No more counting dots or typing slashes.
Cleaner than the common alias ..='cd ..',
alias ...='cd ../..' pattern, and works with any command,
not just cd:
__rationalise-dot() {
[[ $LBUFFER = *.. ]] && LBUFFER+=/.. || LBUFFER+=.
}
zle -N __rationalise-dot
bindkey "." __rationalise-dotThis widget intercepts the dot key. If the buffer already ends with
.., it appends /.. instead of just a dot.
b) The
$ and % aliases
A clever trick that solves two problems at once. These aliases expand to a single space:
alias \$=" "
alias %=" "Problem 1: Copying commands from
documentation. When you copy $ git status from a README,
the $ gets included. With this alias, you can paste
directly and it works. The $ expands to a space, and the
rest runs normally.
Problem 2: Private commands.
Combined with HISTIGNORESPACE, any command starting with a
space isn't saved to history. So % export SECRET=xyz won't
appear in your shell history, and you get a visual indicator that it's
private. Both $ and % work the same way.
c) Global aliases for piping
Global aliases expand anywhere in a command, not just at the start. They're shortcuts for common pipe patterns:
alias -g G="| grep"
alias -g L="| less"
alias -g H="| head"
alias -g T="| tail"
alias -g S="| sort"
alias -g NE="2> /dev/null"
# clipboard - platform-aware
if [[ "$OSTYPE" =~ ^darwin ]]; then
alias -g C="| pbcopy"
elif command -v wl-copy >/dev/null 2>&1; then
alias -g C="| wl-copy"
else
alias -g C="| xclip -selection clipboard"
fiUsage: ps aux G nginx S expands to
ps aux | grep nginx | sort. Or cat file C to
copy file contents to clipboard.
d) Named directories
The hash -d builtin creates named directories that work
with tilde expansion:
hash -d \
d="$HOME/dotfiles" \
c="$HOME/code" \
dw="$HOME/Downloads" \
ssh="$HOME/.ssh" \
zsh="$HOME/.zsh" \
config="$HOME/.config"Now cd ~c goes to ~/code,
ls ~ssh lists your SSH directory, and
vim ~d/.zshrc edits your zsh config. Works everywhere paths
are expected.
e) Auto-generate git aliases
Instead of manually defining shell aliases for git commands, this
reads your git config and creates g<alias> versions
automatically:
() {
local line key name
local git_alias_lines
git_alias_lines=("${(@f)$(git config --get-regexp '^alias\.' 2>/dev/null)}")
for line in $git_alias_lines; do
key=${line%% *} # "alias.co"
name=${key#alias.} # "co"
alias "g${name}=git ${name}"
done
alias g="git"
}If your gitconfig has [alias] co = checkout, you
automatically get gco in your shell. Add a new git alias,
restart your shell, and the corresponding g* alias
exists.
f) Edit command line in
$EDITOR
Ctrl+e opens the current command in your editor. Essential for complex multi-line commands or fixing a long pipeline:
autoload -U edit-command-line
zle -N edit-command-line
bindkey '^e' edit-command-lineg) Expand aliases before running
Ctrl+a expands aliases in place, so you can see what will actually run before hitting enter:
zle -C alias-expansion complete-word _generic
bindkey '^a' alias-expansion
zstyle ':completion:alias-expansion:*' completer _expand_aliasIV. Custom prompt
I prefer rolling my own prompt over tools like Starship or Oh My Zsh themes. Simpler to maintain, no external dependencies, and full control over what it displays. The prompt shows error status, optional tag, Python virtualenv, directory, and git info:
PROMPT='%(?..%F{red}?%? )$(__tag)$(__is_venv)%f%3~%f$(__git_prompt_segment)%# 'a) Git status
The git segment shows branch, dirty state, root marker, and a special
PAUSED badge. The GIT_OPTIONAL_LOCKS=0 environment variable
prevents git from acquiring locks, avoiding delays on busy repositories.
The --no-ahead-behind flag skips counting commits
ahead/behind the remote, which speeds things up. Both git commands run
in a single subshell with NUL separators for efficient parsing:
__git_prompt_segment() {
local info gstatus subject branch dirty root paused
info=$(
export GIT_OPTIONAL_LOCKS=0
git status --porcelain=v2 -b --no-ahead-behind 2>/dev/null
printf '\0'
git log -1 --format=%s 2>/dev/null
)
# Parse NUL-separated output: gstatus\0subject
gstatus=${info%%$'\x00'*}
subject=${info#*$'\x00'}
# Branch from "# branch.head <name>" line in porcelain v2 output
branch=${gstatus#*branch.head }
branch=${branch%%$'\n'*}
[[ -z "$branch" || "$branch" == "# "* ]] && return
# Dirty if any non-header line exists (file status lines don't start with #)
[[ $gstatus == *$'\n'[^#]* ]] && dirty='*'
# At root if .git exists in current directory
[[ -e .git ]] && root='~'
# PAUSED badge for git-pause workflow (commit with "PAUSED: ..." message)
[[ $subject == PAUSED* ]] && paused=' %B%F{198}%K{52}[PAUSED]%b%f%k'
printf '%s%%F{yellow} (%s%s%s)%%f ' "$paused" "$branch" "$dirty" "$root"
}I considered alternatives like gitstatus (very performant, but requires a background daemon), or rewriting the prompt in Rust or Go. But the performance gain is barely noticeable for my use case, and I prefer not having a daemon running just for shell prompts. Plain ZSH is fast enough, keeps the setup simple, and is much easier to maintain or update.
The PAUSED badge is a workflow convention. Before switching contexts, commit with a message starting with "PAUSED" (e.g., "PAUSED: working on auth refactor"). The prompt reminds you there's incomplete work.
V. Performance
a) Bytecode compilation
ZSH can compile scripts to bytecode (.zwc files). Called
in load_zsh_dir before sourcing each file:
_zsh_compile_if_needed() {
local src=$1 dst="${1}.zwc"
if [[ ! -f $dst || $src -nt $dst ]]; then
zcompile "$src" 2>/dev/null
fi
}b) Lazy completion initialization
The completion system is expensive to initialize. Instead of running
compinit fully on every startup, the dump is cached and
compiled to bytecode for faster loading:
() {
local _zcompdump=${XDG_CACHE_HOME:-$HOME/.cache}/zsh/.zcompdump
mkdir -p "${_zcompdump:h}" >/dev/null 2>&1
if [[ ! -f $_zcompdump ]]; then
compinit -d "$_zcompdump"
else
compinit -C -d "$_zcompdump" # -C skips security check
fi
_zsh_compile_if_needed "$_zcompdump"
}c) Conditional tool loading
Each tool config checks if the tool exists before loading:
has docker || returnNo errors on machines without certain tools, and no time wasted loading configs for tools that aren't installed.
d) Profiling
Set DEBUG_ZSH_PERF=1 before starting a shell to enable
profiling:
if (( ${+DEBUG_ZSH_PERF} )); then
zmodload zsh/zprof
fi
# ... config loads ...
if (( ${+DEBUG_ZSH_PERF} )); then
zprof
fiA timezsh function runs multiple shell startups and
averages the time:
$ timezsh 10
run 1: 42 ms
run 2: 41 ms
...
Average: 41 ms
copy
VI. Plugins
Two plugins loaded from system locations (no plugin manager):
- zsh-autosuggestions, shows history-based suggestions as you type
- zsh-syntax-highlighting, colors commands and highlights errors
A loader function probes OS-specific paths to find and source each plugin:
__load_plugins() {
# ... probe OS-specific base dirs (homebrew on macOS, /usr/share on Linux)
for plugin in zsh-autosuggestions zsh-syntax-highlighting; do
[[ -f "$dir/$plugin/$plugin.zsh" ]] && source "$dir/$plugin/$plugin.zsh"
done
}Plugins are deferred until the first prompt appears, shaving milliseconds off startup time. The autosuggestions plugin needs a manual reinit after deferred loading, otherwise suggestions won’t appear until you open a new line:
__deferred_load_plugins() {
__load_plugins
__set_zsh_highlight_styles
# reinitialize autosuggestions after deferred load
(( $+functions[_zsh_autosuggest_start] )) && _zsh_autosuggest_start
# unhook after first run
add-zsh-hook -d precmd __deferred_load_plugins
unfunction __deferred_load_plugins
}
autoload -Uz add-zsh-hook
add-zsh-hook precmd __deferred_load_pluginsSyntax highlighting is configured to be minimal, with dangerous patterns highlighted:
ZSH_HIGHLIGHT_STYLES[unknown-token]=fg=red,bold
ZSH_HIGHLIGHT_STYLES[single-quoted-argument]=fg=yellow
ZSH_HIGHLIGHT_STYLES[double-quoted-argument]=fg=yellow
# Highlight rm and sudo
ZSH_HIGHLIGHT_REGEXP+=('^rm .*' fg=90,bold)
ZSH_HIGHLIGHT_REGEXP+=('\bsudo\b' fg=164,bold)VII. Tool configs
a) Git SSH hardening
Git SSH commands use hardened options for reliability:
export GIT_SSH_COMMAND='ssh -4 \
-o ConnectTimeout=10 \
-o ServerAliveInterval=20 \
-o ServerAliveCountMax=3 \
-o TCPKeepAlive=yes \
-o GSSAPIAuthentication=no \
-o ControlMaster=no'b) FZF with ripgrep
FZF uses ripgrep as backend and has minimal colors:
bindkey '^W' fzf-history-widget # Ctrl+W for history
export FZF_DEFAULT_OPTS="
--reverse
--color=bg:-1,bg+:-1,fg:-1,fg+:-1
--color=hl:33,hl+:33
"
export FZF_DEFAULT_COMMAND='rg --files --hidden --glob "!.git/*"'c) Python virtualenv activation
A function walks up the directory tree to find and activate the
nearest .venv:
avenv() {
local dir=$PWD
while :; do
if [[ -f "$dir/.venv/bin/activate" ]]; then
echo "Activating virtualenv from $dir/.venv"
source "$dir/.venv/bin/activate"
return 0
fi
[[ "$dir" == / ]] && break
dir=$(dirname "$dir")
done
echo "No .venv found" >&2
return 1
}Helper commands use this automatically:
vpython() { _avenv_ensure && python "$@" }
vpip() { _avenv_ensure && pip "$@" }
vpytest() { _avenv_ensure && pytest "$@" }d) Docker helpers
Formatted output and common operations. A helper formats output using column alignment:
_docker_ps() {
local fmt='{{.ID}} ¬¬¬ {{.Image}} ¬¬¬ {{.Names}} ¬¬¬ {{.Status}} ¬¬¬ {{.Ports}}'
docker ps "$@" --format "$fmt" | column -t -s '¬¬¬'
}
dps() { _docker_ps }
dpsa() { _docker_ps -a }Exec into a container with an optional shell override:
dexe() {
if [[ -z $1 ]]; then
echo "Usage: dexe <container> [shell]" >&2
return 1
fi
docker exec -it "$1" "${2:-/bin/sh}"
}Get container IP address:
dip() {
docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"
}e) Tailscale with FZF
FZF-powered device selection for Tailscale operations:
_ts_select_device() {
tailscale status --json \
| jq -r '.Peer[] | "\(.DNSName | split(".")[0])\t\(.TailscaleIPs[0])"' \
| fzf --with-nth=1.. \
| cut -f1
}
ts-ssh() { ssh "$(_ts_select_device)" }
ts-ping() { tailscale ping "$(_ts_select_device)" }
ts-send() { tailscale file cp "$@" "$(_ts_select_device):" }VIII. Functions
Autoloaded functions in ~/.zsh/functions/ only load when
first called:
fpath=("$fn_dir" $fpath)
autoload -U "$fn_dir"/*(:tN)Useful ones:
take dir, create directory and cd into itcr, cd to git repository rootcdf file, cd to directory containing fileo [path], open in file manager (xdg-open/open)timezsh [n], benchmark shell startup time
The take function is a one-liner:
mkdir -p "$@" && cd "$@" || return~~~
Wrapping up
The philosophy behind this configuration is to rely on ZSH's built-in features rather than frameworks or heavy extensions. No Oh My Zsh, no Prezto, no plugin managers. Just plain shell scripts that I understand and control. Keep it simple, keep it fast, and customize it precisely to my workflow.
Links
- My dotfiles (7e20928), full configuration including ZSH
- ZSH documentation, the official reference
- zsh-users, community plugins and completions