A modular ZSH setup

December 2025 - 12 min read

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
}
copy

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"
copy

II. Shared helpers

The 05-helpers.zsh file loads first and provides utilities used throughout the config. OS detection functions avoid repeating $OSTYPE checks everywhere:

[[ "$OSTYPE" =~ ^darwin ]] && __IS_MACOS=1 || __IS_MACOS=0
[[ "$OSTYPE" =~ ^linux ]] && __IS_LINUX=1 || __IS_LINUX=0

is_macos() { (( __IS_MACOS )); }
is_linux() { (( __IS_LINUX )); }
copy

The has function checks if commands exist, supporting multiple arguments:

has() {
  local cmd
  for cmd in "$@"; do
    (( $+commands[$cmd] )) || return 1
  done
}

# Usage
has git && git status
has git fzf jq && echo "all installed"
copy

PATH management functions add directories only if they exist and aren't already present. ZSH's array subscript flags make this easy: the I flag returns the index of the first match, e requires an exact string match. When the result is 0, the path isn't in the array yet:

path_add() {
  [[ -d $1 ]] && (( ${path[(Ie)$1]} == 0 )) && path+=("$1")
}

path_prepend() {
  [[ -d $1 ]] && (( ${path[(Ie)$1]} == 0 )) && path=("$1" $path)
}
copy

This prevents duplicate entries from accumulating when sourcing the config multiple times. Used throughout tool configs for Go, Rust, Emacs, and Homebrew.

A cross-platform clipboard function handles macOS, Wayland, and X11:

clip() {
  if is_macos; then
    pbcopy
  elif has wl-copy; then
    wl-copy
  elif has xclip; then
    xclip -selection clipboard
  else
    cat
    echo 'No clipboard tool found, printed to stdout.' >&2
    return 1
  fi
}
copy

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-dot
copy

This 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 %=" "
copy

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"
fi
copy

Usage: 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"
copy

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"
}
copy

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-line
copy

g) 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_alias
copy

IV. 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)%# '
copy

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"
}
copy

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.

b) Prompt tags

The tag function adds custom text to the prompt:

__tag() {
  [[ -n "$_PROMPT_TAG" ]] && echo "%B%F{87}%K{20}[${(U)_PROMPT_TAG}]%b%f%k "
}

tag() { _PROMPT_TAG="$1" }
copy

Run tag production and your prompt shows [PRODUCTION] in bright colors. Useful when you have multiple terminals open for different environments.

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
}
copy

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"
}
copy

c) Conditional tool loading

Each tool config checks if the tool exists before loading:

has docker || return
copy

No 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
fi
copy

A 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
}
copy

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_plugins
copy

Syntax 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)
copy

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'
copy

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/*"'
copy

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
}
copy

Helper commands use this automatically:

vpython() { _avenv_ensure && python "$@" }
vpip()    { _avenv_ensure && pip "$@" }
vpytest() { _avenv_ensure && pytest "$@" }
copy

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 }
copy

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}"
}
copy

Get container IP address:

dip() {
  docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"
}
copy

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):" }
copy

VIII. Functions

Autoloaded functions in ~/.zsh/functions/ only load when first called:

fpath=("$fn_dir" $fpath)
autoload -U "$fn_dir"/*(:tN)
copy

Useful ones:

  • take dir, create directory and cd into it
  • cr, cd to git repository root
  • cdf file, cd to directory containing file
  • o [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
copy

~~~

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.

← back