[ ] Software Engineering 7 min read

$ 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.

Cover image for: Comprehensive Zsh Completions Setup & Troubleshooting on macOS
// cover_image.render()

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 it

Fpath 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 && compinit

Oh 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 zsh

This 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

ToolCommandCategory
rustuprustup completions zsh rustup > $(brew --prefix)/share/zsh/site-functions/_rustupRust toolchain
cargorustup completions zsh cargo > $(brew --prefix)/share/zsh/site-functions/_cargoRust packages
starshipstarship completions zsh > $(brew --prefix)/share/zsh/site-functions/_starshipShell prompt
uvuv generate-shell-completion zsh > $(brew --prefix)/share/zsh/site-functions/_uvPython packages
atuinatuin gen-completions --shell zsh > $(brew --prefix)/share/zsh/site-functions/_atuinShell history
opop completion zsh > $(brew --prefix)/share/zsh/site-functions/_op1Password CLI
fclonesfclones complete zsh > $(brew --prefix)/share/zsh/site-functions/_fclonesDuplicate finder
rclonerclone completion zsh - > $(brew --prefix)/share/zsh/site-functions/_rcloneCloud sync (note the -)
ghgh completion -s zsh > $(brew --prefix)/share/zsh/site-functions/_ghGitHub 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 completion

Verification: 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 path

If 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 zsh

Issue 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.provenance

Fix: Clear the quarantine attribute:

xattr -c $(brew --prefix)/share/zsh/site-functions/_rustup
rm -f ~/.zcompdump* && exec zsh

Tools 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
done

Run 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 fpath after compinit runs 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:

  1. Timing is Everything: fpath modifications MUST come before Oh My Zsh loads

  2. No Manual compinit: Let Oh My Zsh handle it—that’s what it’s designed to do

  3. Portable Homebrew Path: Use $(brew --prefix) for Apple Silicon/Intel compatibility

  4. Prepend for Priority: Use fpath=(new $fpath) not fpath+=(new)

  5. Always Rebuild Cache: After any fpath changes: rm -f ~/.zcompdump* && exec zsh

  6. Generate to Homebrew Location: Completions in $(brew --prefix)/share/zsh/site-functions/ are auto-discovered

  7. Verify with whence: Use whence -v _toolname to 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 cache

The Pattern: Debug Once, Encode Forever

This is a pattern I’m increasingly adopting:

  1. Encounter a gnarly problem that takes hours to debug
  2. Document the solution thoroughly (like this blog post)
  3. 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.sh

That’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>.

// WAS THIS HELPFUL?