$ Comprehensive Zsh Completions Setup & Troubleshooting on macOS
A deep dive into fixing broken zsh tab completions with Oh My Zsh, and how I turned the solution into a Claude Code skill. Learn why fpath order matters, how compinit timing breaks completions, and the pattern of encoding debugging knowledge for AI assistants.
The Moment Everything Clicked
I’d been fighting with my shell completions for weeks. brew <TAB> showed nothing. rustup <TAB> was silent. cargo <TAB>—the tool I use dozens of times daily—gave me nothing but a blinking cursor.
I tried the usual fixes: reinstalling Oh My Zsh, adding the zsh-completions plugin, running compinit manually. Nothing worked. Every Stack Overflow answer led to the same vague advice: “just add it to your fpath.”
Then I discovered the root cause, and it was so simple I almost laughed.
The problem wasn’t what I was adding. It was when I was adding it.
The Core Problem: Timing Matters
Here’s what I had in my .zshrc:
# My broken configuration
source $ZSH/oh-my-zsh.sh
fpath=("$(brew --prefix)/share/zsh/site-functions" $fpath) # Wrong!And here’s why it didn’t work:
Oh My Zsh calls compinit automatically when oh-my-zsh.sh is sourced. compinit is the function that scans your fpath directories and caches all completion functions. Any fpath modifications after this line are invisible to the completion system because compinit has already scanned and cached the paths.
It’s like adding books to a library catalog after the index has been printed. The books are there, but no one can find them.
The fix is embarrassingly simple:
# The correct configuration
fpath=("$(brew --prefix)/share/zsh/site-functions" $fpath) # First!
source $ZSH/oh-my-zsh.sh # Then compinit sees itFpath first, then source Oh My Zsh.
The Complete Fix: A Step-by-Step Guide
Step 1: Fix Your ~/.zshrc Configuration
Open your .zshrc and ensure your fpath modifications come before the Oh My Zsh source line:
# Path to Oh My Zsh
export ZSH="$HOME/.oh-my-zsh"
# Theme
ZSH_THEME="robbyrussell"
# Plugins
plugins=(git brew docker 1password zsh-autosuggestions fast-syntax-highlighting)
# === COMPLETIONS (BEFORE oh-my-zsh.sh) ===
# zsh-completions plugin (200+ additional completions)
fpath+=${ZSH_CUSTOM:-${ZSH:-~/.oh-my-zsh}/custom}/plugins/zsh-completions/src
# Homebrew completions (Apple Silicon + Intel compatible)
if type brew &>/dev/null; then
fpath=("$(brew --prefix)/share/zsh/site-functions" $fpath)
fi
# Custom user completions (optional)
# fpath=(~/.zsh/completions $fpath)
# === LOAD OH MY ZSH ===
source $ZSH/oh-my-zsh.sh
# === POST-LOAD INITIALIZATION ===
eval "$(starship init zsh)"
eval "$(zoxide init zsh)"
eval "$(atuin init zsh)"
export PATH="$HOME/.local/bin:$PATH"Step 2: Remove Manual compinit Calls
If you have a line like this anywhere in your .zshrc, delete it:
# DELETE THIS LINE if present
autoload -U compinit && compinitOh My Zsh handles compinit automatically. Having multiple calls causes confusion and can slow down shell startup.
Step 3: Rebuild the Completion Cache
After making changes to your .zshrc, you need to rebuild the completion cache:
rm -f ~/.zcompdump* && exec zshThis removes all cached completion data and starts a fresh shell session.
The Hidden Treasure: Tools That Generate Their Own Completions
Here’s something I didn’t know until I dug into this: many modern CLI tools can generate their own zsh completions, but they don’t install them automatically. You have to ask.
Tools with Built-in Completion Generation
| Tool | Command | Category |
|---|---|---|
| rustup | rustup completions zsh rustup > $(brew --prefix)/share/zsh/site-functions/_rustup | Rust toolchain |
| cargo | rustup completions zsh cargo > $(brew --prefix)/share/zsh/site-functions/_cargo | Rust packages |
| starship | starship completions zsh > $(brew --prefix)/share/zsh/site-functions/_starship | Shell prompt |
| uv | uv generate-shell-completion zsh > $(brew --prefix)/share/zsh/site-functions/_uv | Python packages |
| atuin | atuin gen-completions --shell zsh > $(brew --prefix)/share/zsh/site-functions/_atuin | Shell history |
| op | op completion zsh > $(brew --prefix)/share/zsh/site-functions/_op | 1Password CLI |
| fclones | fclones complete zsh > $(brew --prefix)/share/zsh/site-functions/_fclones | Duplicate finder |
| rclone | rclone completion zsh - > $(brew --prefix)/share/zsh/site-functions/_rclone | Cloud sync (note the -) |
| gh | gh completion -s zsh > $(brew --prefix)/share/zsh/site-functions/_gh | GitHub CLI |
Discovery Patterns
Not sure if a tool supports completion generation? Try these patterns:
# Most common patterns
<tool> completion zsh # Most common
<tool> completions zsh # Rust tools
<tool> complete zsh # Alternative
<tool> gen-completions --shell zsh # Some tools
<tool> generate-shell-completion zsh # Modern tools
# Discovery command
<tool> --help | grep -i completionVerification: Proving It Works
After making changes, you need to verify completions are actually loaded.
Check if Completions Exist
whence -v _brew # Should show path
whence -v _rustup # Should show path
whence -v _cargo # Should show pathIf these return “not found,” the completion isn’t loaded. If they show a path, you’re in business.
View Current fpath
echo $fpath | tr " " "\n"This should include:
/opt/homebrew/share/zsh/site-functions(or/usr/local/...on Intel)~/.oh-my-zsh/custom/plugins/zsh-completions/src
Test Tab Completion
brew <TAB>
rustup <TAB>
cargo <TAB>You should see a list of available commands and options.
Common Troubleshooting Scenarios
Issue 1: Completions Generated but Not Found
Symptom: whence -v _rustup shows “not found”
Cause: Completion cache not rebuilt after generating new completions
Fix:
rm -f ~/.zcompdump* && exec zshIssue 2: Permission Denied Writing Completions
Symptom: Cannot write to /opt/homebrew/share/zsh/site-functions/
Fix: Use a user directory instead:
mkdir -p ~/.zsh/completions
rustup completions zsh rustup > ~/.zsh/completions/_rustup
# Add to ~/.zshrc BEFORE oh-my-zsh.sh:
fpath=(~/.zsh/completions $fpath)Issue 3: Wrong Completion Takes Precedence
Symptom: System completion loads instead of your generated one
Cause: Your path is appended instead of prepended
Fix: Use prepend (=) instead of append (+=):
# Prepend - your completions take priority
fpath=("$(brew --prefix)/share/zsh/site-functions" $fpath)
# Append - system completions take priority
fpath+=("$(brew --prefix)/share/zsh/site-functions")Issue 4: Extended Attributes Blocking
Symptom: Files exist but aren’t loaded
Diagnosis:
ls -l@ $(brew --prefix)/share/zsh/site-functions/_rustup
# Shows: com.apple.provenanceFix: Clear the quarantine attribute:
xattr -c $(brew --prefix)/share/zsh/site-functions/_rustup
rm -f ~/.zcompdump* && exec zshTools That Don’t Support Completion Generation
Not every tool includes this feature. These popular tools require external completion sources:
Cloud/DevOps: docker, kubectl, helm, terraform, aws, gcloud, az
Languages: python, node, ruby (use system completions)
Python tools: ruff, black, mypy, pytest
Data tools: jq, yq
For these, rely on:
- Oh My Zsh plugins (e.g.,
plugins=(docker kubectl terraform)) - The zsh-completions plugin (200+ completions)
- System-provided completions from Homebrew
Systematic Completion Discovery
Want to find all tools in your system that support completion generation? Here’s a discovery script:
#!/usr/bin/env bash
# Discover all tools with completion support
PATTERNS=("completion zsh" "completions zsh" "complete zsh" "gen-completions --shell zsh")
for tool in /opt/homebrew/bin/*; do
tool_name=$(basename "$tool")
for pattern in "${PATTERNS[@]}"; do
if timeout 1s "$tool" $pattern --help &>/dev/null 2>&1; then
echo "$tool_name: $pattern"
break
fi
done
doneRun this, and you’ll get a list of every tool that responds to completion commands.
The Mental Model: Think of compinit as an Index
The key insight that made everything click for me:
compinit is like building an index. It scans all directories in fpath, finds all files starting with _, and caches them. Once the index is built, it doesn’t rescan.
This explains why:
- Adding to
fpathaftercompinitruns doesn’t work - You need to rebuild the cache after adding new completions
- Prepending paths matters for priority
If you think of ~/.zcompdump as a compiled index and fpath as the source directories, the behavior becomes predictable.
Key Insights & Best Practices
After hours of debugging, here’s what I learned:
-
Timing is Everything:
fpathmodifications MUST come before Oh My Zsh loads -
No Manual compinit: Let Oh My Zsh handle it—that’s what it’s designed to do
-
Portable Homebrew Path: Use
$(brew --prefix)for Apple Silicon/Intel compatibility -
Prepend for Priority: Use
fpath=(new $fpath)notfpath+=(new) -
Always Rebuild Cache: After any fpath changes:
rm -f ~/.zcompdump* && exec zsh -
Generate to Homebrew Location: Completions in
$(brew --prefix)/share/zsh/site-functions/are auto-discovered -
Verify with whence: Use
whence -v _toolnameto confirm loading
The Complete Setup Script
Here’s a one-shot script to set up completions for all common tools:
#!/usr/bin/env bash
# setup-completions.sh - Generate completions for modern CLI tools
DEST="$(brew --prefix)/share/zsh/site-functions"
echo "Generating completions to: $DEST"
# Rust tools (via rustup)
if command -v rustup &>/dev/null; then
rustup completions zsh rustup > "$DEST/_rustup" 2>/dev/null && echo "✓ rustup"
rustup completions zsh cargo > "$DEST/_cargo" 2>/dev/null && echo "✓ cargo"
fi
# Starship prompt
if command -v starship &>/dev/null; then
starship completions zsh > "$DEST/_starship" 2>/dev/null && echo "✓ starship"
fi
# UV (Python package manager)
if command -v uv &>/dev/null; then
uv generate-shell-completion zsh > "$DEST/_uv" 2>/dev/null && echo "✓ uv"
fi
# Atuin (shell history)
if command -v atuin &>/dev/null; then
atuin gen-completions --shell zsh > "$DEST/_atuin" 2>/dev/null && echo "✓ atuin"
fi
# 1Password CLI
if command -v op &>/dev/null; then
op completion zsh > "$DEST/_op" 2>/dev/null && echo "✓ op"
fi
# GitHub CLI
if command -v gh &>/dev/null; then
gh completion -s zsh > "$DEST/_gh" 2>/dev/null && echo "✓ gh"
fi
# Rclone
if command -v rclone &>/dev/null; then
rclone completion zsh - > "$DEST/_rclone" 2>/dev/null && echo "✓ rclone"
fi
echo ""
echo "Done! Run: rm -f ~/.zcompdump* && exec zsh"Why This Matters
Tab completion isn’t just a convenience—it’s a form of documentation. When cargo <TAB> shows me build, run, test, check, I’m not just saving keystrokes. I’m being reminded of what’s possible.
A shell without completions is like a codebase without types. You can work in it, but you’re flying blind.
The frustrating part about zsh completion problems is that they’re almost always configuration issues, not missing features. The completions exist. The system works. But the timing, the ordering, the caching—these invisible details make or break the experience.
Now that I understand the mental model, I can debug completion issues in minutes instead of hours. And hopefully, after reading this, you can too.
Codifying Knowledge: A Claude Code Skill
After spending hours debugging this, I realized something: this is exactly the kind of knowledge that gets lost. You solve it once, forget the details, and three months later you’re back on Stack Overflow trying to remember whether it was fpath+= or fpath=.
So I turned this troubleshooting knowledge into a Claude Code skill.
What’s a Claude Code Skill?
Claude Code supports custom skills—reusable prompts that encode domain expertise. When you encounter a problem that matches a skill’s domain, Claude can invoke it to get specialized guidance.
I created a skill called zsh-completions that contains:
- The diagnostic workflow (check fpath order, verify compinit timing)
- The complete list of tools with completion generation support
- All four troubleshooting scenarios and their fixes
- The verification steps to prove it’s working
Using the Skill
When I (or anyone with the skill installed) asks Claude Code about zsh completion issues, it now has immediate access to this entire troubleshooting guide:
> My brew completions stopped working after I updated my zshrc
Claude Code invokes the zsh-completions skill and immediately:
1. Checks fpath order relative to oh-my-zsh.sh
2. Identifies if compinit is being called manually
3. Generates missing completions
4. Verifies with whence -v
5. Rebuilds the cacheThe Pattern: Debug Once, Encode Forever
This is a pattern I’m increasingly adopting:
- Encounter a gnarly problem that takes hours to debug
- Document the solution thoroughly (like this blog post)
- Encode it as a skill so AI assistants can apply the knowledge
The skill doesn’t replace understanding—it augments recall. When I haven’t touched my zshrc in months and something breaks, I don’t have to remember everything. The skill contains the systematic approach.
Creating Your Own Skills
If you use Claude Code, consider encoding your own hard-won debugging knowledge:
# .claude/skills/your-skill.md
## When to use this skill
- User reports [specific symptom]
- User asks about [domain topic]
## Diagnostic steps
1. Check [first thing]
2. Verify [second thing]
...
## Common fixes
### Issue: [symptom]
**Cause**: [root cause]
**Fix**: [solution]The goal isn’t to replace expertise—it’s to make expertise reproducible. The next time someone (including future me) encounters broken zsh completions, the solution is one skill invocation away.
Quick Reference Card
# Check if a completion is loaded
whence -v _toolname
# View fpath directories
echo $fpath | tr " " "\n"
# Rebuild completion cache
rm -f ~/.zcompdump* && exec zsh
# Generate completions to Homebrew location
TOOL completions zsh > "$(brew --prefix)/share/zsh/site-functions/_TOOL"
# Clear extended attributes
xattr -c "$(brew --prefix)/share/zsh/site-functions/_TOOL"
# Key rule
# fpath modifications BEFORE source $ZSH/oh-my-zsh.shThat’s it. Order matters, cache rebuilds matter, and verification matters. Get those three right, and you’ll have a shell that responds to your every <TAB>.