---
title: Mastering Zsh: A Complete Guide to Configuration, Plugins, and Developer Productivity
description: This Mac runs Zsh 5.9. Upstream published the next patch on 12 July 2026. A minimal zshrc took 48.2 ms median over 20 warm starts, against 4.2 ms with no rc files.
url: https://www.factualminds.com/blog/mastering-zsh/
datePublished: 2026-10-11T00:00:00.000Z
dateModified: 2026-10-11T00:00:00.000Z
author: palaniappan-p
category: DevOps & CI/CD
tags: zsh, linux, macos, devops
---

# Mastering Zsh: A Complete Guide to Configuration, Plugins, and Developer Productivity

> This Mac runs Zsh 5.9. Upstream published the next patch on 12 July 2026. A minimal zshrc took 48.2 ms median over 20 warm starts, against 4.2 ms with no rc files.

On 11 October 2026 `/bin/zsh` on this Mac was Zsh 5.9 (`arm64-apple-darwin26.0`). The upstream production tarball is 5.9 patch 2, published 12 July 2026 at [zsh.org/pub](https://www.zsh.org/pub/). macOS has not caught up. The manual checked for this page is the [Zsh 5.9 release manual](https://zsh.sourceforge.io/Doc/Release/), including [startup files](https://zsh.sourceforge.io/Doc/Release/Files.html), the [line editor](https://zsh.sourceforge.io/Doc/Release/Zsh-Line-Editor.html), the [completion system](https://zsh.sourceforge.io/Doc/Release/Completion-System.html), and [options](https://zsh.sourceforge.io/Doc/Release/Options.html).

A shell with no rc files (`zsh -f -c exit`) had a median of 4.2 ms over 20 runs. The same binary, after one warmup, loading only [minimal.zshrc](/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/minimal.zshrc) from a temporary `ZDOTDIR`, had a median of 48.2 ms (minimum 46.2, maximum 72.5). That is one workstation, `uname` Darwin 27.0 arm64. It is not a budget for your laptop.

> **What broke** — `/bin/sh -c 'print $ZSH_VERSION'` exited 127 with `/bin/sh: print: command not found`. `print` and `ZSH_VERSION` are Zsh. The lab script's own process was `bash` while `SHELL` was `/bin/zsh`. Copying a `.zshrc` alias into a `#!/bin/sh` script fails the same way.

> **Reproduce this** — From a checkout of this site, run `bash examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/check-shell.sh`. On 11 October 2026 it printed `zsh_version=zsh 5.9 (arm64-apple-darwin26.0)`, `sh_print_exit=127`, `bare_median_ms=4.2`, `minimal_rc_median_ms=48.2`, and `lab=ok`. The temporary directory is removed on exit. Published copy: [/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/check-shell.sh](/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/check-shell.sh).

We recommend learning the builtins, then adding one plugin loader and a short list: completions, autosuggestions, history search, and one syntax highlighter, plus fzf, zoxide, and Starship if you want them. The trade-off is that you do not get Oh My Zsh's alias catalog on day one. You also do not get two frameworks fighting over Ctrl-R.

Zsh does not replace `ls`, the terminal emulator, Homebrew or apt, or kubectl. It is the program those tools run inside.

## What Zsh is

Zsh is a Unix shell: an interactive command language and a scripting language. It was first written in 1990. The production release published for download on 12 July 2026 is 5.9 patch 2. The binary on this Mac is still the system 5.9.

Bash is the shell most Linux servers start for scripts, and the language of a large amount of existing automation. Zsh is the default login shell on current macOS. People install it on Linux because the interactive features are ahead of the Bash that ships as `/bin/sh` on older images. Neither shell is a terminal. Terminal, iTerm2, Ghostty, and the Windows Terminal draw the window. Zsh is the process inside it.

Features worth learning before any plugin:

- Filename generation, including recursive `**/` and qualifiers such as `(.)` for regular files.
- A completion system (`compinit`) that is programmable per command.
- An interactive history with timestamps, sharing, and a space prefix that skips one entry.
- `PROMPT` and `RPROMPT`, edited with prompt escapes, not with a theme language.
- Arrays that are 1-indexed unless `KSH_ARRAYS` is set, and associative arrays.
- Functions, the line editor (`zle`), and `bindkey`.

| Topic | Zsh | Bash |
| --- | --- | --- |
| Scripts | `#!/usr/bin/env zsh`. Not a valid `#!/bin/sh`. | `#!/usr/bin/env bash` for Bash, `#!/bin/sh` for POSIX. |
| Interactive defaults | Completion, glob qualifiers, spelling correction if you turn it on. | Readline, programmable completion if bash-completion is installed. |
| Startup files | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin`, `.zlogout`. | `.bash_profile` or `.bashrc`, depending on login and interactive. |
| Arrays | 1-indexed. `typeset -A` for associative arrays. | 0-indexed. Associative arrays need Bash 4. |
| Globs | Unmatched patterns error. In a non-interactive script the shell exits 1. | Unmatched patterns stay literal unless `failglob` or `nullglob` is set. |
| Completion | `compinit`, then `compdef`. | `complete` and `compgen`. |
| Portability | Installed by default on macOS. A package elsewhere. | Common on Linux. macOS `/bin/bash` can be older than a Homebrew Bash. |
| Typical job | The shell you type in. | Scripts, CI, and images that already have Bash. |

Do not paste `.zshrc` into a Bash script. Aliases, `print`, `setopt`, glob qualifiers, and `ZSH_VERSION` are not a shared language. A function you want in both shells has to be written twice, or written as POSIX `sh` and tested with `sh`.

## Install Zsh and choose the login shell

Installing the package and changing the login shell are different steps. Confirm the binary before `chsh`.

Context: this Mac, Zsh 5.9, read-only. `zsh -f` skips rc files.

```zsh
zsh --version
command -v zsh
echo "$ZSH_VERSION"
ps -p $$ -o comm=
echo "$SHELL"
```

`zsh --version` and `command -v zsh` name the binary you would execute. `ZSH_VERSION` is set only inside Zsh. `ps` names the current process. `SHELL` is the login shell recorded for your user. The lab script was bash and still printed `SHELL=/bin/zsh`.

Where the system lists login shells, read that file. Do not append to it until the path you want is a real binary.

```zsh
grep zsh /etc/shells
```

On this Mac, `/etc/shells` contains `/bin/zsh`.

**macOS.** Zsh is already the login shell on current releases. `/bin/zsh` here is 5.9. Install a newer Zsh with Homebrew only if you need 5.9.2 or later, and do not `chsh` to the Homebrew path until that path is listed in `/etc/shells`. Two Zsh binaries on `PATH` is a normal way to get confused. `whence -a zsh` shows each one.

**Debian and Ubuntu.** `sudo apt update` and `sudo apt install zsh`. These commands were not run for this page.

**Fedora and RHEL.** `sudo dnf install zsh`.

**Arch.** `sudo pacman -S zsh`.

**WSL.** Install Zsh inside the Linux distribution with that distribution's package manager. The Windows Terminal profile chooses the program that starts. A profile that launches `bash` will not read `.zshrc`, even if `chsh` inside the distro succeeded. Open a tab with that profile and check `echo $ZSH_VERSION`.

**Already installed.** Stop. `command -v zsh` is enough. A second install from a curl script does not make the shell newer than the binary you actually exec.

Set the login shell only after the path is in `/etc/shells`:

```zsh
chsh -s "$(command -v zsh)"
```

Open a new terminal. `echo $ZSH_VERSION` should print a version. To go back, `chsh -s` to the previous path from `/etc/shells`, often `/bin/bash` on Linux. On this Mac the login shell is already Zsh, so there is nothing to switch. `chsh` was not run here.

A syntax error in `.zshenv` breaks Zsh scripts as well as terminals, because every Zsh reads that file. `zsh -f` starts without rc files so you can edit them.

## Startup files

From the [Files chapter](https://zsh.sourceforge.io/Doc/Release/Files.html), read on 11 October 2026. If `ZDOTDIR` is unset, `$HOME` is used. `/etc` can be a different directory in some builds. `RCS` and `GLOBAL_RCS` are on by default. Unsetting `RCS` stops later startup files. `zsh -f` does that.

Every Zsh process:

1. `/etc/zshenv`
2. `$ZDOTDIR/.zshenv`

Login shells then:

3. `/etc/zprofile`
4. `$ZDOTDIR/.zprofile`

Interactive shells then:

5. `/etc/zshrc`
6. `$ZDOTDIR/.zshrc`

Login shells then:

7. `/etc/zlogin`
8. `$ZDOTDIR/.zlogin`

When a login shell exits, `$ZDOTDIR/.zlogout` runs, then `/etc/zlogout`. `exec` of another program skips the logout files. If `RCS` is unset at exit, history is not written.

A script (`zsh script.zsh`, or `zsh -c`) is usually non-interactive and not a login shell. It reads the `zshenv` files and does not read `.zshrc`. That is why an alias in `.zshrc` is invisible to scripts, and why a heavy `eval` in `.zshenv` slows every script and every `make` recipe that calls Zsh.

Put interactive pieces in `.zshrc`: aliases, `compinit`, the prompt, plugin loading, fzf, zoxide, Starship. Put the smallest possible `PATH` adjustment in `.zshenv`, and only if scripts need it. Put login-only environment in `.zprofile` (for example a login-time `PATH` you do not want reapplied on every subshell).

`.zlogin` runs after `.zshrc` for login shells. People who set the prompt in both files discover that `.zlogin` wins on the first terminal and `.zshrc` wins on nested shells. Pick `.zshrc` for the prompt.

A maintainable layout is three files plus the plugin list, not a tree of snippets:

- `.zshenv` for exported variables scripts need
- `.zprofile` for login-only setup
- `.zshrc` for interactive setup
- `.zsh_plugins.txt` if you use Antidote

Split further when two people edit the same `.zshrc` and the diffs hurt. Until then a second directory is another place for a `source` to fail at startup.

`source` of a missing file aborts `.zshrc` at that line when `NOMATCH` or a failed `source` stops the rest. Guard optional tools with `[[ -r file ]]` or `command -v`.

## Built-in productivity

Context: Zsh 5.9, `zsh -f` unless a block says it uses the minimal rc. These commands were run on this Mac, or they are options `setopt` accepted under `zsh -f`.

### Navigation

`pwd` prints the current directory. `pwd -P` resolves symlinks. `cd` changes it. `~` is your home directory. A path that starts with `/` is absolute.

The directory stack is built in. `pushd /tmp` goes there and remembers the previous directory. `popd` returns. `dirs -v` lists the stack. That is enough for two or three working trees. zoxide, later, is for dozens.

Quote names that contain spaces.

```zsh
mv -- 'my notes.txt' ./archive/
```

`rm` deletes. There is no trash folder. `echo` the expanded command before `rm` when the line contains a glob or a variable. The [Linux commands](/blog/mastering-linux-commands/) page covers `rm` in more detail.

### Finding the command you are about to run

On this Zsh, `whence`, `where`, `which`, `type`, and `command` are builtins. `/usr/bin/which` also exists. The builtin and the external program do not answer the same question. `whence -v` reports aliases and functions. `where` lists every match. The external `which` does not know your shell functions.

```zsh
whence -v zsh
where zsh
command -v zsh
type ls
```

`command -v` is the one to use in a script that might run under `sh`, because it is not a Zsh-only builtin. `whence` is the one to use while you are typing in Zsh.

`functions` lists functions. `alias` lists aliases. `bindkey` lists key bindings. `setopt` lists options that are on. `man zsh` opens the manual. `man zshoptions`, `man zshzle`, and `man zshcompsys` are the chapters you will actually use.

### History

`history` and `fc -l` print recent commands. History expansion (`!!`, `!$`, `^old^new`) re-runs text from the history list. That text can be a token you did not mean to type again.

Ctrl-R in the default emacs keymap is `history-incremental-search-backward`. Confirmed with `bindkey '^R'` under `zsh -f` after `bindkey -e`.

The minimal config sets:

- `HISTFILE` next to the other dotfiles (`$ZDOTDIR` or `$HOME`)
- `HISTSIZE` and `SAVEHIST` of 50000
- `EXTENDED_HISTORY` so the file stores a timestamp
- `SHARE_HISTORY` so new commands are appended immediately and other interactive shells can import them
- `HIST_IGNORE_ALL_DUPS` so a repeated command keeps one entry
- `HIST_IGNORE_SPACE` so a command that starts with a space is not stored
- `HIST_REDUCE_BLANKS` and `HIST_FCNTL_LOCK`
- `INTERACTIVE_COMMENTS` so `#` works at the prompt

`INC_APPEND_HISTORY` appends immediately without importing other sessions. Use it instead of `SHARE_HISTORY` if you do not want one terminal to replay another's commands. The shipped file uses `SHARE_HISTORY` only.

`HIST_IGNORE_SPACE` is not a security boundary. If you forget the space, the password, the presigned URL, or the `curl` line with a token is on disk in `HISTFILE`. Do not type secrets as arguments. Prefer a prompt, a file mode `0600`, or a tool that reads from an agent or a secret store. Anyone who can read `HISTFILE` can read what you typed.

### Completion

`compinit` loads the completion system. The minimal file calls it with `-d` so the dump file follows `ZDOTDIR` and a lab under `/tmp` does not write `~/.zcompdump`.

Tab completes according to the context. Styles change how the list is presented. This pair is optional and is not in the minimal file:

```zsh
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Za-z}'
```

The dump file is a cache. If completions are stale after a tool upgrade, remove that one file, the path you passed to `-d`, and start a new shell. `compinit` rebuilds it. Do not delete a whole cache directory to fix one dump.

`compaudit` reports directories on `fpath` that are group-writable or world-writable. `compinit` refuses to use them until you fix the permissions. `compinit -u`, `-i`, and `-C` skip or weaken that check. `-C` is faster because it skips the check. Do not make it the default. A completion file is shell code.

Extra definitions come from the command itself (`kubectl completion zsh`, `docker completion zsh`) or from a project such as `zsh-completions`. They are still code you source. Prefer the completion shipped with the CLI you installed, and add `zsh-completions` for commands that ship none.

### Prompt and the line editor

`PROMPT` is the left prompt. `RPROMPT` is the right prompt. In the minimal file, `%F{cyan}%~%f %#` is the directory in cyan, then `%` for a normal user and `#` for root. Prompt escapes are documented in `man zshmisc` under prompt expansion.

`bindkey -e` selects the emacs keymap. `bindkey -v` selects vi. `bindkey -l` on this binary lists `emacs`, `viins`, `vicmd`, and others. Under emacs mode, Ctrl-A is beginning-of-line and Ctrl-E is end-of-line, confirmed with `bindkey` under `zsh -f`.

The line editor is ZLE. Widgets are functions bound to keys. `zle -N` registers one. You do not need a custom widget to be productive. Autosuggestions, later, work by wrapping widgets that already exist.

`setopt CORRECT` asks before it runs a command name it thinks is misspelled. It also asks when the name was right and the correction is wrong. It is not in the shipped configs.

### Aliases and functions

An alias is text replacement before the command runs. A function is a command with arguments and a local scope.

```zsh
alias ll='ls -la'
mkcd() {
  mkdir -p -- "$1" && cd -- "$1"
}
```

A global alias (`alias -g`) expands in the middle of a line, including after a pipe. That surprises people. Do not put one in a shared config.

Aliases are not expanded in the same way inside scripts, and they are invisible to programs that are not this Zsh. `ll` in a `#!/bin/sh` script is a missing command. Type the real command in scripts, in runbooks, and in anything an agent will copy.

### Options that belong in a script, not in .zshrc

`set -euo pipefail` is accepted by this Zsh 5.9. `-e` is `ERR_EXIT`, `-u` is `NO_UNSET`, and `pipefail` is `PIPE_FAIL`. On 11 October 2026 a temporary interactive Zsh whose `.zshrc` contained only that line exited 1 when the next command was `false`. The word `survived` was not printed.

Use `ERR_EXIT`, `NO_UNSET`, and `PIPE_FAIL` in a script, near the top, after `emulate -L zsh` if you want a known option set. Do not put them in `.zshrc`. An interactive shell has to tolerate `grep` finding nothing.

Other options and what they actually do here:

- `AUTO_CD`: a directory name as a command cds there. Convenient, and easy to cd by mistake.
- `EXTENDED_GLOB`: enables `^`, `~`, `#`, and the `(#q...)` qualifier form. Plain qualifiers such as `(.)` already worked on this 5.9 with `EXTENDED_GLOB` off. A directory `b.txt` was skipped by `*.txt(.)`. The file `a.txt` was not.
- `NULL_GLOB`: an unmatched pattern becomes empty instead of an error. Dangerous next to `rm`.
- `NOMATCH`: on by default (`nonomatch` was off under `zsh -f`). An unmatched pattern prints `no matches found`. In a non-interactive script the shell then exits 1 and later lines do not run. Confirmed with `zsh -f -c` and a pattern that matched nothing.

## Advanced features

Context: Zsh 5.9 on this Mac. Blocks that set `EXTENDED_GLOB` say so. Bash equivalents are named where the syntax collides.

### Arrays

Zsh arrays are 1-indexed. This printed `first=p` and `count=3`.

```zsh
typeset -a xs=(p q r)
print -r -- "first=${xs[1]} count=${#xs}"
typeset -A m=([region]=us-east-1)
print -r -- "map=${m[region]}"
print -r -- "upper=${(U)m[region]}"
```

Bash arrays are 0-indexed, and `${xs[1]}` there is the second element. `typeset -A` is Zsh. Bash 4 uses `declare -A`. Do not mix them in one file.

`${(U)name}` uppercases. That flag is Zsh parameter expansion, not Bash.

### Globs you will use while cleaning a repo

`**/*.txt` listed `a/b/file.txt` under `zsh -f` without `EXTENDED_GLOB`.

```zsh
print -l -- **/*.txt
print -l -- *.txt(.)
```

`**/*` walks directories. `(.)` keeps regular files. Other qualifiers in the manual include `/` for directories and `@` for symlinks. `rm **/*.log` is still `rm`. Print the expansion first.

Brace expansion is not a glob. `{a,b}.txt` becomes two words even if the files do not exist.

```zsh
print -r -- {src,tests}/index.ts
```

### Substitution and pipelines

Command substitution runs a command and keeps the output. Quote it unless you mean the shell to split words.

```zsh
branch=$(git branch --show-current)
print -r -- "branch=$branch"
```

Process substitution feeds a command's output as if it were a file. Useful for a diff of two commands. It is not POSIX `sh`.

```zsh
diff -u <(git show HEAD:package.json) <(git show origin/main:package.json)
```

Pipelines run concurrently. The exit status is the last command unless `PIPE_FAIL` is set, in which case any failed stage fails the pipeline. Redirects: `>` truncates, `>>` appends, `2>` is stderr. `>` on an existing file replaces it.

### Functions, jobs, and traps

`local` limits a variable to the function. Without it, the assignment leaks into the interactive shell.

```zsh
count_js() {
  local n
  n=$(print -l -- **/*.js(.N) | wc -l)
  print -r -- "$n"
}
```

`(.N)` is "regular files, and no error if none match". The `N` is per-pattern `NULL_GLOB`. Use it when zero files is a valid answer. Do not use it on a `rm` line to hide a typo.

`jobs` lists background work. `cmd &` starts one. `fg` brings it back. Ctrl-Z suspends the foreground job.

```zsh
TRAPINT() { print -ru2 -- 'interrupt'; return 0 }
```

A trap runs your code on a signal. `TRAPINT` handles interrupt. An `EXIT` trap runs on shell exit. Keep traps short. A trap that starts more work makes a failing script harder to stop.

### A small widget

You rarely need this. The pattern is: a function, `zle -N`, then `bindkey`.

```zsh
fm-pwd() { zle -M "$(pwd)" }
zle -N fm-pwd
bindkey '^[p' fm-pwd
```

`zle -M` prints a message below the prompt. Alt-P depends on the terminal sending `^[p`. If it does nothing, the sequence is different. `cat -v` then the key shows what the terminal sends. This widget was not bound in the shipped configs.

### Debugging a function

`functions -t name` traces that function. `zsh -x script.zsh` traces a file. Turn the trace off with `functions +t name` when you are done. Traces print expanded commands, which can include values you did not want in a scrollback.

### Where Bash users get the test wrong

Inside `[[ ]]`, both shells accept `==` for strings. Inside `[ ]`, prefer a single `=`. Zsh and Bash also disagree on unquoted empty variables. Write `"$var"` unless you have a reason not to.

`for` loops and `if` are familiar. A Zsh script that uses `print`, arrays from index 1, or glob qualifiers is not a Bash script. Say so in the header.

## Scripts and interactive configuration

Four different things get called "shell config":

| Kind | File | Read when |
| --- | --- | --- |
| Interactive | `.zshrc` | Interactive Zsh |
| Login environment | `.zprofile` | Login Zsh |
| Every Zsh, including scripts | `.zshenv` | Always, unless `-f` |
| A program | a file with a shebang | When you execute that file |

Shebangs:

- `#!/usr/bin/env zsh` for a script that uses Zsh on purpose.
- `#!/usr/bin/env bash` for Bash.
- `#!/bin/sh` for POSIX only. No arrays, no `[[ ]]`, no process substitution, no `print`.

`chmod +x` makes the file executable. `./script.zsh` then uses the shebang. `zsh script.zsh` forces Zsh even if the shebang says something else, which hides a mistake. Test with the shebang path: `./script.zsh`.

Arguments are `"$@"`. Quote them. The exit code is the status of the last command, or an explicit `exit`. Temporary files: `mktemp`. Remove them in an `EXIT` trap if the script created them.

```zsh
#!/usr/bin/env zsh
emulate -L zsh
setopt ERR_EXIT PIPE_FAIL NO_UNSET
f=$(mktemp)
trap 'rm -f "$f"' EXIT
print -r -- "args=$#" >"$f"
```

`zsh -n script.zsh` parses without running. `zsh -x script.zsh` traces. The lab runs `zsh -n` on both example rc files and expects `zsh_n=ok`.

## Plugin managers

A framework is a distribution of config, aliases, and a prompt. A plugin manager clones repositories and sources them. A plugin is a file of shell code. A theme is a prompt. A standalone tool is a binary in `PATH` that may also print a few lines of shell setup.

Plugins are not sandboxed. `source` runs as you.

| Approach | Setup | What you get | Cost | Use it when |
| --- | --- | --- | --- | --- |
| Files you source yourself | Lowest | Exactly the files you read | You track clones | The list is three plugins or fewer |
| [Oh My Zsh](https://github.com/ohmyzsh/ohmyzsh) | Installer or a clone | A large plugin and theme catalog | Heavier startup, many aliases | You want `git` aliases and a theme today |
| [Antidote](https://github.com/mattmc3/antidote) 2.3.0 | Clone or `brew install antidote` | A plugins file and a static load script | You write the list yourself | You want an explicit, small set |
| [Zinit](https://github.com/zdharma-continuum/zinit) | Upstream install edits `.zshrc` | Deferred loading (`zinit ice wait`) | More syntax to learn | You will use that deferral |
| [Prezto](https://github.com/sorin-ionescu/prezto) | `git clone --recursive` into `.zprezto` | A full framework, minimum Zsh 4.3.11 | It replaces several rc files via symlinks | You want Prezto's modules, not a plugin list |

Pick one. Do not install Oh My Zsh and Antidote together. Do not `source` a plugin that the manager also loads.

**Antidote is the loader in the developer example.** Release 2.3.0 was published on 7 August 2026. `antidote load` writes a static file (next to your plugins file, with a `.zsh` suffix) and sources that file. If the rebuild fails, the previous static file is left in place. That behavior is in the 2.3.0 `antidote-load` function, read for this page. The project also documents a hand-written `if [[ ! file.zsh -nt file.txt ]]` snippet. Upstream describes the extra speed as usually not worth it, and that snippet truncates the static file directly. The example uses `antidote load` instead. Do not also call `antidote init`. That command is a compatibility wrapper and is not the recommended path.

Install from the [current instructions](https://antidote.sh/install):

```zsh
git clone --depth=1 https://github.com/mattmc3/antidote.git ${ZDOTDIR:-$HOME}/.antidote
```

Or `brew install antidote`. The Homebrew file on Apple Silicon is `/opt/homebrew/opt/antidote/share/antidote/antidote.zsh`. On Intel Homebrew it is under `/usr/local/opt/antidote`. This Mac did not have either file (`antidote_file=absent`). The example was parsed with `zsh -n` and was not sourced, so it did not clone plugins into this account.

`pin:` takes a full 40-character commit. `min-age`, added in 2.2.0 (27 July 2026), keeps bundles a number of days behind upstream. The Antidote release note says commit dates are attacker-controlled, so `min-age` is a cushion, not a guarantee. Pin a commit you have read if you want a freeze. This page does not invent a hash.

**Oh My Zsh** is the other reasonable start. The upstream install is a remote script. Read it, or clone the repository and source `oh-my-zsh.sh` yourself. Plugins go in a whitespace-separated array. Commas are not separators. The [plugin list](https://github.com/ohmyzsh/ohmyzsh/wiki/Plugins), generated from the wiki and edited 18 September 2026, is the index. Enable the few you will remember.

**Zinit** can defer plugins (`zinit ice wait`, then `zinit light user/repo`). The README's easiest install is a remote script that writes into `.zshrc`. That is a poor fit if you already have a config. It was not run here.

**Prezto** is a framework. The manual install clones with `--recursive` and then symlinks runcoms into your home directory. Follow the current README rather than a copied `ln` loop. Those symlinks replace existing rc files if you are not careful.

Update plugins by reading the diff, not by updating all of them on Monday morning and hoping the prompt still works. One manager, one update command, one shell restart, then `zsh -n` on the rc file if you changed it.

## Plugins and terminal tools

Versions below are what `check-shell.sh` printed on this Mac on 11 October 2026, or what the upstream page said when it was read the same day. Atuin is not installed here. Nothing in this section was installed for the article.

### zsh-autosuggestions

A Zsh plugin. [zsh-users/zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions). As you type, a grey suggestion appears from history by default. Right arrow (`forward-char`) or End (`end-of-line`) accepts it when the cursor is at the end of the line. `forward-word` accepts one word. That is not Tab. Tab runs completion.

`ZSH_AUTOSUGGEST_STRATEGY` is an array. Built-in strategies are `history`, `completion`, and `match_prev_cmd`. The default path is history. `match_prev_cmd` does not behave as documented if `HIST_IGNORE_ALL_DUPS` is set, and the shipped configs set that option. Leave the strategy at `history`.

The `completion` strategy uses the completion system and costs more. Turn it on only if history suggestions are not enough. Suggestions are asynchronous on Zsh 5.0.8 and newer, which includes 5.9.

### zsh-syntax-highlighting

A Zsh plugin. [zsh-users/zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting). Commands are colored while you type, so a missing program is visible before Enter.

Source it at the end of the plugin list. The project says it must be sourced at the end of `.zshrc` because it hooks the line editor. On Zsh 5.8 and older it wraps widgets, and widgets created afterward do not refresh highlighting. On Zsh newer than 5.8, including 5.9 and 5.9.2, it registers a `zle-line-pre-redraw` hook, and that hook should be registered after other hooks that change the buffer.

`fzf --zsh` on fzf 0.74.4 does not register that hook. The developer file therefore loads fzf after Antidote. Highlighting is still the last plugin inside the Antidote list.

### fast-syntax-highlighting

A different plugin. [zdharma-continuum/fast-syntax-highlighting](https://github.com/zdharma-continuum/fast-syntax-highlighting). More highlighters, and Antidote's own examples show it with `kind:defer`. It solves the same problem as `zsh-syntax-highlighting`. Load one. The example loads `zsh-syntax-highlighting` because the load-order contract is documented by the zsh-users project and the hook behavior matches Zsh 5.9. Use fast-syntax-highlighting if you have already chosen it and you are not also loading the other one.

### zsh-completions

A Zsh plugin of completion functions. [zsh-users/zsh-completions](https://github.com/zsh-users/zsh-completions). Antidote line: `zsh-users/zsh-completions kind:fpath path:src`. `kind:fpath` only adds the `src` directory to `fpath`. It does not execute every file. `compinit` autoloads what it needs.

Antidote's default `fpath` rule is `append` (2.3.0 source: `_ANTIDOTE_FPATH_RULE=append`). Appended directories are searched after the ones already on `fpath`, so a completion that already exists is not replaced. That is what you want. `fpath-rule:prepend` would let this repository override a completion shipped with the system. Do that only for a command you know is missing or wrong.

The example calls `fm_zsh_compinit` in a `post:` annotation on that line, so `compinit` runs after `fpath` is updated and before the later plugins. `post:` is emitted as a function call in the static file. The function has to exist before `antidote load`. It is defined earlier in `developer.zshrc`.

### fzf

A program, with a Zsh integration. [junegunn/fzf](https://github.com/junegunn/fzf). This Mac has 0.74.4 from Homebrew. `fzf --zsh` prints the shell code. The integration binds Ctrl-T to a file widget, Alt-C to a directory widget, Ctrl-R to history, and Tab to `fzf-completion`. Tab still uses ordinary completion unless the word ends with the trigger, which defaults to `**`.

```zsh
if command -v fzf >/dev/null 2>&1; then
  source <(fzf --zsh)
fi
```

Those bindings were read from `fzf --zsh` on this machine. The widgets were not clicked in a terminal for this page. Ctrl-R will be taken by Atuin if you enable Atuin later. Decide which program owns that key.

### zoxide

A program. [ajeetdsouza/zoxide](https://github.com/ajeetdsouza/zoxide). This Mac has 0.10.0. It remembers directories you visit and jumps with `z`. `cd` still works.

```zsh
if command -v zoxide >/dev/null 2>&1; then
  eval "$(zoxide init zsh)"
fi
```

`zoxide init zsh` can take an fzf flag in current upstream docs if you want an interactive picker. Add that only after plain `z` is familiar. Migrating from `autojump` or `z` (the older tool) is a database import documented by zoxide. Do not run both jumpers.

### Atuin

A program. [atuinsh/atuin](https://github.com/atuinsh/atuin). Not installed here (`atuin=absent`). The docs were read, the binary was not run.

Atuin stores history in its own database with directory, exit status, and duration, and it can sync that database. The Zsh line in the current install guide is `eval "$(atuin init zsh)"`. It rebinds Ctrl-R, and up-arrow is configurable. Native `HISTFILE` remains unless you stop writing it. You then have two histories.

The README's quickstart is a remote install script, then `atuin register`, which signs you up for Atuin's hosted sync. Prefer your package manager (`brew install atuin`, or the distro package) and read the [sync guide](https://docs.atuin.sh/latest/guide/sync/) before you register. The README says you can skip sync and keep history local. Do that if the history should not leave the machine.

Sync is described as end-to-end encrypted. That does not make the laptop safe, and it does not make the host that stores ciphertext irrelevant. The shell sees the command in the clear before anything is encrypted. `HIST_IGNORE_SPACE` still matters. A token in a command line is still a token on that machine.

The developer file leaves Atuin commented. Uncomment it only if it should own Ctrl-R, and then accept that fzf's history widget is no longer on that key.

### Starship

A program. [starship/starship](https://github.com/starship/starship). This Mac has 1.26.0, built 28 June 2026. It is not a Zsh plugin and not a terminal theme.

```zsh
if command -v starship >/dev/null 2>&1; then
  eval "$(starship init zsh)"
fi
```

Configuration is `~/.config/starship.toml`. Git status in the prompt is useful and it is also the usual reason a prompt is slow in a large repository. Disable the modules you do not read. The minimal file's `PROMPT` remains for machines without Starship.

### direnv

A program. [direnv/direnv](https://github.com/direnv/direnv). This Mac has 2.38.2. The hook runs on directory entry and, after you have allowed a file, evaluates the environment that `.envrc` produces.

`.envrc` is code. `direnv allow` is the trust decision. Do not allow a file you have not read. Do not allow every repository an agent clones. The hook is commented out in the developer file. Uncomment it when you want the integration, then allow projects one by one.

`eval "$(direnv hook zsh)"` is the upstream line. The hook evaluates direnv's export. That is still your shell, not a sandbox.

### Oh My Zsh plugins worth knowing about

These names are on the wiki index as of 18 September 2026. They are useful inside Oh My Zsh. They are not a list to paste into `plugins=(...)` all at once. If you use Antidote, prefer each tool's own completion so you do not also take the aliases.

| Plugin | What it adds | Watch for |
| --- | --- | --- |
| `git` | Many git aliases and a few functions | An alias can hide a flag you meant to type. `whence -v gco` before you depend on it. |
| `docker` | Completion and aliases | Aliases that shadow `docker` subcommands. Docker 29.8.2 on this Mac already provides `docker completion zsh`. |
| `kubectl` | Completion and short aliases | `kubectl completion zsh` on client 1.36.1 printed a `#compdef` script. You do not need both. |
| `aws` | Completion for AWS CLI v2, plus profile helpers | The wiki says CLI v1 is not supported. Do not use the plugin to store keys. |
| `brew` | Aliases for Homebrew | macOS and Linuxbrew only. |
| `npm` | Completion and aliases | Can surprise you if the alias is not the npm you meant. |
| `bun` | Completion for Bun | Bun 1.4.0 is on this Mac. Completion still belongs to one provider. |
| `python` | Aliases for Python commands | `python` versus `python3` differs by OS. |
| `composer` | Completion, aliases, and Composer global bins on `PATH` | Composer 2.10.3's own `completion` help says only bash is supported. The plugin is the Zsh path if you already use Oh My Zsh. |
| `sudo` | Escape twice prefixes the current or previous command with sudo | Easy to elevate the wrong line. |
| `command-not-found` | Suggests a package | Needs the distro's command-not-found handler. It is not a solver. |
| `colored-man-pages` | Color in man pages | Harmless. Still optional. |
| `extract` | An `extract` function for several archive types | It runs the matching unpacker. Look at the file name first. |

### Powerlevel10k

[romkatv/powerlevel10k](https://github.com/romkatv/powerlevel10k) is a Zsh prompt theme. The README, checked for this page, says the project has very limited support, no new features are in the works, most bugs will go unfixed, and help requests will be ignored. The maintainer has also said it can keep working without new features. That is a reason to leave an existing setup alone. It is not a reason to choose it for a new one.

Starship is the prompt in the developer example. A one-line `PROMPT` is the prompt in the minimal example. Oh My Zsh themes are a third path if you already chose Oh My Zsh. Do not run Powerlevel10k and Starship together. Both set the prompt.

### Which set to install

- **Minimal.** The minimal file. No plugin manager. Completion, history, a one-line prompt.
- **Daily.** Antidote, `zsh-completions`, `zsh-autosuggestions`, `zsh-history-substring-search`, `zsh-syntax-highlighting`, fzf, zoxide, Starship.
- **macOS.** The daily set. Add the `brew` plugin only if you are inside Oh My Zsh and you want those aliases. The system Zsh may be older than Homebrew Zsh. Know which `command -v zsh` prints.
- **AWS and DevOps.** Daily set, plus the CLI's own completion for `aws`, `kubectl`, and `docker`. Not the full Oh My Zsh alias pack on top.
- **Advanced.** Zinit, if you will use deferred loading. Still one highlighter. Still one prompt.
- **Privacy or offline.** No Atuin sync. Leave Atuin commented, or run it without `atuin register`. Do not put tokens in `HISTFILE`. Do not `direnv allow` a repo you have not read.

Autosuggestions are not completion. Native history is not Atuin. A plugin is not a binary in `/opt/homebrew/bin`. Oh My Zsh is not Antidote. Starship is not Ghostty.

## Two configurations

Both files live in the repo. `zsh -n` accepts them. The lab sources only the minimal file, and only inside a temporary `ZDOTDIR`.

### Minimal

Context: Zsh 5.9. No plugins. Copy to `${ZDOTDIR:-$HOME}/.zshrc`.

```zsh
# Minimal interactive Zsh. Zsh 5.9 on the lab Mac. No plugins.
# Copy to ${ZDOTDIR:-$HOME}/.zshrc. Do not copy it into Bash scripts.

# History stays next to the other dotfiles. A leading space skips one entry.
HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt EXTENDED_HISTORY
setopt SHARE_HISTORY
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_REDUCE_BLANKS
setopt HIST_FCNTL_LOCK
setopt INTERACTIVE_COMMENTS

# Completion dump follows ZDOTDIR so a throwaway lab does not write into $HOME.
autoload -Uz compinit
compinit -d "${ZDOTDIR:-$HOME}/.zcompdump"

# %~ is the directory, shortened with ~. %# is % for a user and # for root.
PROMPT='%F{cyan}%~%f %# '
```

Published copy: [/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/minimal.zshrc](/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/minimal.zshrc).

### Developer

Context: Zsh 5.9 or newer, Antidote 2.3.0. Copy `developer.zshrc` to `${ZDOTDIR:-$HOME}/.zshrc`. Copy the plugins file to `${ZDOTDIR:-$HOME}/.zsh_plugins.txt`. Install Antidote first. The `command -v` guards skip fzf, zoxide, and Starship when they are absent. Atuin and direnv stay commented.

Plugins file:

```zsh
# Antidote 2.3.0 plugins file. One bundle per line. Comments start with #.
# Copy to ${ZDOTDIR:-$HOME}/.zsh_plugins.txt
# fm_zsh_compinit and fm_zsh_history_keys are defined in developer.zshrc
# before this file is sourced. Do not add a second syntax highlighter.

zsh-users/zsh-completions kind:fpath path:src post:fm_zsh_compinit
zsh-users/zsh-autosuggestions
zsh-users/zsh-history-substring-search post:fm_zsh_history_keys
zsh-users/zsh-syntax-highlighting
```

`zsh-history-substring-search` wants Up and Down to walk history that contains the buffer. Its README tells you to bind the keys after the plugin is sourced, and it also says to load syntax highlighting first. The highlighter's README says the highlighter has to be last. This file follows the highlighter: history search, then its keys, then highlighting. If Up and Down do nothing, the arrow sequence from `cat -v` is not `^[[A` / `^[[B`. Replace those two strings. Do not load a second highlighter to "fix" the keys.

`developer.zshrc`:

```zsh
# Developer Zsh. Zsh 5.9 or newer. Antidote 2.3.0 static bundle.
# Copy to ${ZDOTDIR:-$HOME}/.zshrc and copy developer.zsh_plugins.txt to
# ${ZDOTDIR:-$HOME}/.zsh_plugins.txt. Do not also start Oh My Zsh.

HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history
HISTSIZE=50000
SAVEHIST=50000
setopt EXTENDED_HISTORY
setopt SHARE_HISTORY
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_REDUCE_BLANKS
setopt HIST_FCNTL_LOCK
setopt INTERACTIVE_COMMENTS

# Used only when Starship is absent. Starship replaces PROMPT below.
PROMPT='%F{cyan}%~%f %# '

# post:fm_zsh_compinit runs after zsh-completions is added to fpath.
fm_zsh_compinit() {
  autoload -Uz compinit
  compinit -d "${ZDOTDIR:-$HOME}/.zcompdump"
}

# post:fm_zsh_history_keys runs after history-substring-search is sourced
# and before zsh-syntax-highlighting. Arrow sequences differ by terminal.
fm_zsh_history_keys() {
  bindkey '^[[A' history-substring-search-up
  bindkey '^[[B' history-substring-search-down
  bindkey -M emacs '^P' history-substring-search-up
  bindkey -M emacs '^N' history-substring-search-down
}

# Stat known paths. `brew --prefix` is a process, so it is not on this path.
antidote_zsh=
for candidate in \
  "${ZDOTDIR:-$HOME}/.antidote/antidote.zsh" \
  /opt/homebrew/opt/antidote/share/antidote/antidote.zsh \
  /usr/local/opt/antidote/share/antidote/antidote.zsh
do
  if [[ -r $candidate ]]; then
    antidote_zsh=$candidate
    break
  fi
done

plugins_txt=${ZDOTDIR:-$HOME}/.zsh_plugins.txt
if [[ -n $antidote_zsh && -r $plugins_txt ]]; then
  # antidote load writes a static file and sources it. On a failed rebuild
  # it keeps the previous static file. Do not also call antidote init.
  source "$antidote_zsh"
  antidote load "$plugins_txt"
else
  fm_zsh_compinit
fi

# fzf is a program. `fzf --zsh` prints the integration; it is not a plugin repo.
if command -v fzf >/dev/null 2>&1; then
  source <(fzf --zsh)
fi

if command -v zoxide >/dev/null 2>&1; then
  eval "$(zoxide init zsh)"
fi

if command -v starship >/dev/null 2>&1; then
  eval "$(starship init zsh)"
fi

# Atuin also binds Ctrl-R. Uncomment only if Atuin should own history search.
# if command -v atuin >/dev/null 2>&1; then
#   eval "$(atuin init zsh)"
# fi

# direnv runs an allowed .envrc on directory entry. Uncomment after you
# have read https://direnv.net/ and you will not allow a file you have not read.
# if command -v direnv >/dev/null 2>&1; then
#   eval "$(direnv hook zsh)"
# fi
```

Published copies: [/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/developer.zshrc](/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/developer.zshrc) and [/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/developer.zsh_plugins.txt](/examples/architecture-blog-2026/mastering-developer-tools/mastering-zsh/developer.zsh_plugins.txt).

The first interactive start after you add the plugins file will clone those four repositories into Antidote's cache (`~/Library/Caches/antidote` on macOS, unless `ANTIDOTE_HOME` is set). That needs a network. Later starts source the static file.

## Developer workflows

These are shell integrations, not tutorials for the tools. Command references for the tools themselves are the other parts of this series: [Git](/blog/mastering-git-commands/), [AWS CLI](/blog/mastering-aws-cli/), [Docker](/blog/mastering-docker-commands/), and [Kubernetes](/blog/mastering-kubernetes-commands/).

### PATH

The first matching executable wins. `where node` and `whence -av node` show every copy. On this Mac, `node` is Homebrew, `bun` is `~/.bun/bin/bun` (1.4.0), and `php` is Homebrew 8.5.11. An alias named `node` or a function named `aws` hides the real binary. Check with `whence -v` before you blame the install.

Do not prepend a project `node_modules/.bin` in `.zshenv`. That directory is different in every repo. Use `npx`, a package script, or direnv after you have read the `.envrc`.

### Git

The shell's job is completion and history, not a second set of git aliases, unless you already like Oh My Zsh's `git` plugin. `git` completion ships with Git. Confirm with `whence -v git`.

Switching repositories is `cd`, `pushd`, or `z` from zoxide. Finding a previous git command is Ctrl-R or, with the history-substring plugin, Up after you type `git`. Reviewing what an agent changed is still `git status` and `git diff`, covered on the Git page.

### Node, Bun, PHP

Node and Bun both install a `node` only if you asked them to. Bun on this machine did not replace `command -v node`. Keep it that way unless you mean to. `bun --version` and `node --version` are the check.

Composer 2.10.3's `composer completion --help` says bash is the supported shell. Do not pass `zsh` to that command and expect a Zsh function. Use the Oh My Zsh `composer` plugin if you are already on Oh My Zsh, or a bash completion loaded through `bashcompinit` if you have read that script. Do not alias `php`.

Python virtual environments belong to the project. `source .venv/bin/activate` is explicit. direnv can do it from an `.envrc` you have allowed. Activating a venv from `.zshrc` applies one environment to every directory, which is the wrong scope.

### Docker and kubectl

On this Mac, `docker completion zsh` (Docker 29.8.2) exited 0 and wrote a completion script, which was discarded, not sourced. `kubectl completion zsh` (client 1.36.1) printed a `#compdef` header.

Put the output on `fpath` or source it after `compinit`, using the command the current CLI documents. Do not also enable the Oh My Zsh aliases unless you want `dcup` and similar names. An alias that redefines `kubectl` to add a default namespace will surprise a script that expected the binary.

### AWS CLI

AWS CLI 2.37.12 is on this Mac, and `aws_completer` is on `PATH`. The usual Zsh wiring uses bash completion compatibility because the completer is written that way:

```zsh
autoload -Uz bashcompinit && bashcompinit
complete -C aws_completer aws
```

That block is not in the shipped rc files. Add it if Tab on `aws` does nothing. The Oh My Zsh `aws` plugin is the alternative when Oh My Zsh is your loader. It does not support CLI v1.

Do not export `AWS_ACCESS_KEY_ID` or `AWS_SECRET_ACCESS_KEY` from `.zshrc`. Use a profile, Identity Center, or a role. `AWS_PROFILE` for one shell is `export AWS_PROFILE=name` in that shell, or an `.envrc` you have read. `aws sts get-caller-identity` before a command that can spend money is the check on the [AWS CLI](/blog/mastering-aws-cli/) page.

A missing program is `command -v name` and then the package manager. `command-not-found` is a hint, not an installer you should trust blindly.

## Startup time

Measure before you delete plugins. A single `/usr/bin/time -p zsh -f -c exit` on this Mac printed `real 0.00`, because that timer reports hundredths of a second and the process was faster than that. The lab uses Python `time.perf_counter` around 20 runs instead. Warm the completion dump once, then record the median. Compare the same machine, the same `ZDOTDIR`, before and after one change.

`zprof` attributes time inside one startup. Put this at the top of the file under test and the report at the bottom. Remove both when you are finished. Left in place, `zprof` prints a table on every new terminal.

```zsh
zmodload zsh/zprof
# ...the rest of the file...
zprof
```

What usually shows up:

- A `source` of a framework you forgot was there.
- `compinit` rebuilding `.zcompdump` because the dump is missing or older than `fpath`.
- `antidote bundle` running on every start because the static file is not newer than the plugins list. `antidote load` should skip that once the static file is current.
- A prompt that runs `git status` in a huge work tree. That cost is per prompt, not only at startup. Starship and Powerlevel10k both do this if the git module is on.
- `brew --prefix`, `nvm.sh`, and other processes launched from `.zshrc`. The developer file checks three antidote paths with `[[ -r ]]` and does not call `brew --prefix`.

Lazy loading helps when a plugin is expensive and you rarely need it before the first prompt. It hurts when the first command pays the cost anyway and the failure is harder to see. Antidote's `kind:defer` needs `zsh-defer`. The example does not defer the highlighter or the suggestions, so the order stays obvious.

Do not pass `-C` to `compinit` to win a millisecond. Fix the directory `compaudit` names, or accept the check.

The 48.2 ms figure includes `compinit` and the history options. It does not include Antidote, fzf, zoxide, or Starship. Those were not sourced in the timing run. There is no target number in this page.

## Security

`source ./file` runs that file as your user. A plugin from GitHub does too. A theme does too. There is no container around `.zshrc`.

Before you trust a repository: read the commit you are about to source, prefer a tagged release or a pinned full SHA, and prefer the distro package or Homebrew when one exists. The Oh My Zsh, Zinit, and Atuin quickstarts are remote scripts. A remote script can edit your rc files. Read it, or clone the repository yourself.

`eval "$(some-tool init zsh)"` runs whatever the tool prints. That is normal for Starship, zoxide, direnv, and Atuin, and it is only as safe as that binary. Do not `eval` a string you copied from a chat.

History: see `HIST_IGNORE_SPACE` above. It misses anything you did not prefix with a space. Do not put tokens in the command line.

Unquoted variables and unquoted globs expand. `rm $dir` when `dir` is empty, or `rm *` in the wrong directory, deletes more than you think. Quote. `echo` the line first.

`sudo` runs the command as root. It is not a retry for "command not found" or "permission denied" until you have read the path. The Oh My Zsh `sudo` plugin makes that retry a double tap of Escape.

`direnv allow` is an approval to execute `.envrc`. Withdrawing approval is `direnv deny`. Do not allow a directory an agent just cloned until you have opened the file.

`PATH` order is the other shadowing problem. A writable directory at the front of `PATH` means a program named `ls` or `git` in that directory runs instead of the system one. Prepend a directory only when you mean to override what follows.

Updating every plugin at once applies code you have not read. Update one, open a new terminal, and see if completion and the prompt still work.

## Troubleshooting

| Symptom | Check | Fix | Verify |
| --- | --- | --- | --- |
| Terminal window opens and closes | `zsh -n ~/.zshrc` and `zsh -f` | The parse error is the line `zsh -n` names. `zsh -f` gives you a shell so you can edit. | A new terminal stays open and `echo $ZSH_VERSION` prints a version. |
| Bash starts, or `ZSH_VERSION` is empty | `ps -p $$ -o comm=` and the terminal profile | The profile or SSH command starts bash. `chsh` does not rewrite a profile that names bash. | `echo $ZSH_VERSION` in that profile. |
| `PATH` is wrong | `print -r -- $PATH` and `whence -a name` | Edit `.zshenv` or `.zprofile`, not a random plugin. Later entries do not win. | `command -v name` is the binary you expect. |
| Command not found | `command -v name` and `type name` | Install it, or remove an alias that replaced it. | `whence -v name` says the path or the function you wanted. |
| Plugin did not load | The static file and `antidote load` output | The plugins file has to be at `${ZDOTDIR:-$HOME}/.zsh_plugins.txt`. A failed clone leaves the previous static file. | `whence -v` a function the plugin defines, after a new shell. |
| Tab does nothing useful | `whence -v compinit` and `echo $fpath` | `compinit` never ran, or it ran before the completion directory was on `fpath`. | Tab on `git` offers subcommands. |
| Completions are stale | The dump path passed to `compinit -d` | Delete that dump file only. The next `compinit` rebuilds it. | Tab shows the new subcommand. |
| Keys do nothing | `bindkey '^R'` and `cat -v` plus the key | The sequence does not match. Arrow codes differ. Another plugin bound the key later. | `bindkey` shows the widget you chose. |
| No grey suggestion | History is empty, or the plugin is not sourced | Type a command, run it, then start the same prefix. `HIST_IGNORE_SPACE` hides lines that began with a space. | A suggestion appears and End accepts it. |
| Colors never update | Load order | Highlighting is not last among plugins, or a second highlighter is also loaded. | A missing command is colored before Enter. |
| Prompt boxes or missing glyphs | The terminal font | The font has no Nerd Font icons. Starship's symbols need a font that contains them, or set the preset to plain text. | The prompt uses ASCII, or the font includes the icons. |
| Startup is slow | `zprof`, then remove it | One `source`, a prompt git status, or a dump rebuild. See the previous section. | A second timed run on the same machine is lower after one change, and Tab still works. |
| Two aliases, one name | `whence -v name` and `alias name` | The later `alias` or `source` wins. Remove one. | `alias` lists a single definition. |
| `no matches found` | The glob, under `zsh -f` | Quote it, or add `(N)` on that pattern if zero matches is acceptable. Do not `setopt NULL_GLOB` globally next to `rm`. | `print -r --` the pattern shows the files you meant. |
| History is empty next week | `echo $HISTFILE` and `ls -l` that file | `HISTFILE` is unset, or `RCS` was off at exit, or the file is not writable. | A new shell shows yesterday's `history`. |
| A plugin broke another | `zsh -f`, then source half the list | Two highlighters, two Ctrl-R owners, or Oh My Zsh plus Antidote. | One loader, one highlighter, one history key. |
| SSH looks different | Login versus interactive | SSH often starts a login shell. A setting only in `.zshrc` is present. A setting only in `.zprofile` is present for SSH and missing in a nested `zsh`. | `echo $ZSH_VERSION` and `whence -v` the tool, in both places. |

## Exercises

1. **See the shell you have.** Run `zsh --version`, `echo $ZSH_VERSION`, `echo $SHELL`, and `ps -p $$ -o comm=`. Pass: `ZSH_VERSION` matches `zsh --version`. If you run the same echoes under `bash`, `ZSH_VERSION` is empty.
2. **Minimal rc in a temporary directory.** `mkdir` a directory under `/tmp`, copy `minimal.zshrc` to `$that/.zshrc`, and start `ZDOTDIR=that zsh -ic 'alias; echo $HISTFILE; exit'`. Pass: `HISTFILE` is inside that directory, not your home directory. Exit the shell. Delete the directory.
3. **History.** In that temporary shell, run `echo hello`, then ` echo secret` with a leading space, then `history`. Pass: `hello` is listed and `secret` is not. Confirm the history file does not contain `secret`.
4. **Completion.** Start the minimal shell and press Tab after `git `. Pass: subcommands appear. If they do not, `whence -v compinit` should show the function, and you are not in `zsh -f`.
5. **One plugin, after you choose a loader.** Install Antidote or clone one plugin and `source` it. Do not install a second manager. Pass: a new shell defines a function from that plugin (`whence -v`) and `zsh -n` on your rc file prints nothing.
6. **fzf and zoxide.** Install them with your package manager if `command -v` misses them. Add the guarded lines from the developer file. Pass: `command -v fzf` and `command -v zoxide` print paths. `z` jumps to a directory you have visited. Ctrl-T is fzf's file widget only if you sourced `fzf --zsh`.
7. **Time a change.** Run the lab script. Change one thing in a copy of the minimal file under `/tmp` (for example add `zprof`). Time 20 warm starts again. Pass: you can say which number moved, and you removed `zprof` afterward. Do not publish someone else's number as your own.
8. **Put the files in git.** Keep `.zshrc` and `.zsh_plugins.txt` in a dotfiles repository. Do not commit `HISTFILE`, `.zcompdump`, or a file that contains a token. Pass: a second machine can copy the rc files, install the same binaries, and open a shell. Completions for tools that are not installed stay behind `command -v` and do not abort startup.

### Checklist

- `echo $ZSH_VERSION` matches the binary you meant. `$SHELL` is not that test.
- Interactive config is in `.zshrc`. `.zshenv` is short.
- One plugin loader. One syntax highlighter. One prompt.
- `compinit` runs after extra completion directories are on `fpath`, and without `-C`.
- Optional programs are behind `command -v` or `[[ -r ]]`.
- You timed one change on your machine before you called the shell slow.
- The same rc files work on the next machine without a hardcoded secret.
- `HISTFILE` is not a secret store. `.envrc` is not allowed unread.
- Plugin updates are diffs you looked at.

## What this post does not cover

Zsh 5.9.2 was not installed here, so behavior that exists only in that release was not run. Fish, Nushell, and PowerShell are out of scope. So is a catalog of Oh My Zsh themes, a Powerlevel10k `p10k configure` walkthrough, and operating an Atuin server. Windows Terminal profile JSON is only mentioned, not listed. Completion for every AWS subcommand is the AWS CLI's job. The [AI agent tools](/blog/mastering-ai-agent-tools/) page covers the coding-agent CLIs. This page covers the shell those agents type into.

## What to do this week

1. Run the lab script and keep the output. Your medians will differ.
2. If `.zshrc` is a stack of installers, move interactive setup into one file and start from the minimal example in a temporary `ZDOTDIR` before you replace the real file.
3. Choose Antidote or Oh My Zsh. Remove the other if both are installed.
4. Add fzf and zoxide only after `command -v` shows they are missing and you want them.
5. Leave `ERR_EXIT` out of the interactive file. Put it in scripts you execute on purpose.

## Further reading

- [Zsh manual](https://zsh.sourceforge.io/Doc/Release/)
- [Startup files](https://zsh.sourceforge.io/Doc/Release/Files.html)
- [Antidote](https://antidote.sh/)
- [Oh My Zsh plugins](https://github.com/ohmyzsh/ohmyzsh/wiki/Plugins)
- [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)
- [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)
- [fzf](https://github.com/junegunn/fzf)
- [zoxide](https://github.com/ajeetdsouza/zoxide)
- [Starship](https://github.com/starship/starship)
- [Atuin](https://github.com/atuinsh/atuin)
- [direnv](https://github.com/direnv/direnv)
- [Powerlevel10k](https://github.com/romkatv/powerlevel10k)

Series: [Git](/blog/mastering-git-commands/), [Linux commands](/blog/mastering-linux-commands/), [AWS CLI](/blog/mastering-aws-cli/), [Docker](/blog/mastering-docker-commands/), [Kubernetes](/blog/mastering-kubernetes-commands/), [Bedrock CLIs](/blog/mastering-bedrock-cli/), [AI agent tools](/blog/mastering-ai-agent-tools/).

[Contact us](/contact-us/) or see [AWS DevOps consulting](/services/devops-pipeline-setup/) if you want this shell setup written down for a team that shares laptops and CI images.

## FAQ

### When should you not put ERR_EXIT or pipefail in .zshrc?
In an interactive shell. On 11 October 2026 a throwaway ZDOTDIR whose .zshrc was only set -euo pipefail exited 1 on false and did not print the next command. Use those options in a script. Leave the interactive shell able to run a command that is expected to fail.


### When should you not run direnv allow?
When you have not read the .envrc. direnv executes that file in your shell after you allow it. A clone from someone else is untrusted code until you read it. The hook in the developer example is commented out for that reason.


### Should Oh My Zsh and Antidote both be installed?
No. Pick one loader. Two managers can source the same plugin twice, bind the same keys twice, and make a slow startup harder to attribute. This page uses Antidote for the developer example and describes Oh My Zsh as the other starting point.


### Does echo $SHELL tell you which shell is running?
No. SHELL is the login shell from the user database. The lab script printed SHELL=/bin/zsh while ps reported the script itself as bash. Use echo $ZSH_VERSION, or ps -p $$ -o comm=, for the process you are in.


### Is Powerlevel10k the prompt to install on a new setup?
Not as the default. The upstream README says support is very limited, no new features are planned, and most bugs will go unfixed. Keep it if you already rely on it. A new setup in this guide uses Starship, which is a separate program, or the small PROMPT in the minimal file.


### Should compinit -C be used to make startup faster?
Not as a habit. -C skips the security check of directories on fpath. compaudit is the check. Speed up a prompt or drop a plugin you do not use before you disable that check.


---

*Source: https://www.factualminds.com/blog/mastering-zsh/*
