Part 2 got Homebrew installed and iTerm2 configured with a Nerd Font and a color scheme worth looking at. That foundation matters here, because this part builds directly on top of it. If you skipped either of those, circle back before continuing. Everything in this part assumes both are in place.
The terminal is where a developer spends a significant portion of their working life. Not a dramatic statement. Just arithmetic. Hours per day, five days a week, compounded across months and years. The tools that live inside that terminal either compound in your favor or quietly drain time and attention you never notice losing. macOS ships with a functional shell. Functional is not the same as good.
This part of the MacBook to Mission Control series is about closing the gap between what ships by default and what modern developers actually use.
Why Your Default Shell Is Holding You Back
The gap between macOS defaults and modern developer expectations
macOS has shipped zsh as the default shell since Catalina. That was a meaningful upgrade from bash. But zsh out of the box is essentially a blank slate. No prompt intelligence, no directory jump history, no fuzzy search, no syntax highlighting, no meaningful visual feedback. Apple made the right call switching to zsh. They just didn't configure it for anyone who plans to use it seriously.
The gap isn't theoretical. Developers navigate directories dozens of times per session, search command history constantly, and scan file output more often than they write new code. Every one of those actions is slower with the defaults than it needs to be. Marginal gains per action don't feel significant until you multiply them across a full workday.
What this part of the series covers
This part installs and configures eight tools: Starship, zoxide, fzf, eza, bat, ripgrep, fd, and jq. Each one replaces or substantially improves a default behavior. Together they add up to a shell environment that works with how developers actually think, not against it.
This part is self-contained. You don't need to read every prior entry to follow along, but Homebrew from Part 1 and iTerm2 from Part 2 are both assumed to be installed and working. The series arc runs seven parts total. Parts 1 and 2 handled the foundation. This part handles the shell layer. Parts 4 through 7 move into dotfile management, Git workflow, terminal multiplexing, and scripting patterns that tie everything together.
Install the Full Productivity Toolkit in One Shot
The one-liner that installs everything
Before breaking down each tool individually, install all of them at once. One command, one wait, done.
brew install starship zoxide fzf eza bat ripgrep fd jq treeThe reason to front-load the installation is practical. Homebrew resolves dependencies, downloads packages, and compiles anything that needs compiling. Running that once takes a few minutes. Running it eight times across eight separate steps takes the same total time but fragments your attention. Install everything, then configure each tool with full focus.
Actively Maintained
Every tool in this list is an open-source project with substantial adoption and active maintenance. None of them are abandoned side projects. Starship, ripgrep, and fd each have hundreds of contributors and millions of downloads per month.
What each tool is replacing
Each tool in this list has a direct predecessor that ships with macOS. The replacements aren't different for the sake of being different. They're faster, more readable, or meaningfully more capable.
| Default Tool | Replacement | Primary Improvement |
|---|---|---|
ls | eza | Color output, icons, Git status per file |
cat | bat | Syntax highlighting, line numbers, paging |
find | fd | Faster, simpler syntax, respects .gitignore |
grep | ripgrep | Significantly faster, respects .gitignore |
cd | zoxide | Frecency-based jump with partial names |
tree ships separately but eza handles tree-style output via the --tree flag. The brew install tree fallback is there if you want the standalone binary, but most developers won't need it once eza aliases are in place. Every tool in this list can be adopted one at a time. You don't have to wire up all nine on day one. Start with whichever default frustrates you most.
Starship: A Prompt That Shows You What Actually Matters
What Starship does and why it matters
The default zsh prompt tells you almost nothing. A % symbol, maybe a hostname, maybe a directory name. That's it. You're flying without instruments.
Starship is a prompt written in Rust, designed to be fast enough that it never adds perceptible latency between commands. It's cross-shell and cross-platform, which means the same configuration works on zsh, bash, fish, and PowerShell. But on macOS with zsh, it slots in with two lines and immediately starts surfacing information that was previously invisible.
Installing and activating Starship
Homebrew already handled the install. The activation step is a single line added to ~/.zshrc:
eval "$(starship init zsh)"That line must go near the bottom of your .zshrc. Starship hooks into the prompt rendering pipeline, and if it runs before other tools initialize, those tools may overwrite its configuration or fail to integrate correctly. Place the eval line after your PATH exports, after your aliases, and after any other tool initializations. Source the file after saving:
source ~/.zshrcYour prompt changes immediately.
What your prompt now displays
Starship's default configuration surfaces several things at once, without any additional setup.
Current directory appears in a readable shortened form. Long paths don't scroll off the line. Git branch name displays whenever you're inside a repository. No more running git branch to remember where you are. Dirty state shows a symbol when the working tree has uncommitted changes. You know before you type a single Git command whether the repo is clean.
Runtime version detection is automatic. Navigate into a Node project and the prompt shows the Node version in use. Switch to a Python project and it shows the Python version. Go, Rust, Ruby, and a dozen others are detected the same way. This eliminates an entire category of "why is this behaving differently" debugging that happens when you forget which runtime version a project expects.
The exit status indicator is one of the more underrated features. When the last command exits with a non-zero code, the prompt marks it visibly. You don't need to run echo $? to know something failed. The feedback is immediate and impossible to miss.
One prerequisite: Starship uses icons from the Nerd Font icon set. If those icons render as blank squares or question marks, the font isn't configured. Part 2 of this series covered Nerd Font installation in iTerm2. If you followed that setup, everything renders correctly.
zoxide: Navigate Your Filesystem Like You Have a Memory
Why cd is the wrong tool for how developers actually work
cd works exactly as designed. You type a path, you go there. The problem is that paths are long, directory structures are deep, and developers navigate the same dozen locations hundreds of times per week. Typing the full path to a project directory every time isn't a workflow. It's a tax.
zoxide learns where you go. Every directory you visit gets recorded with a weight that reflects how often and how recently you've been there. That's the frecency algorithm: frequency plus recency, combined into a single score. When you jump with a partial name, zoxide finds the highest-scoring match and takes you there.
Installing and initializing zoxide
Homebrew handled the install. Activation is one line in ~/.zshrc:
eval "$(zoxide init zsh)"Place this after your PATH configuration and before your aliases. Source the file:
source ~/.zshrcDrop-in Replacement
zoxide registers the z command by default, but you can alias it to cd in your .zshrc with alias cd='z'. From that point forward, you type cd and get zoxide's behavior everywhere.
Real-world usage patterns
The jump syntax is minimal. If you've visited a directory called project-workspace before, you navigate there with:
z project-workspacePartial matches work. z src jumps to whichever src directory scores highest in your history. z docs does the same for documentation directories. The more you use a location, the more reliably it wins the match.
When multiple directories match a fragment and you're not sure which one zoxide will pick, use zi instead. This opens an interactive selection list powered by fzf, which the next section covers in detail. You see all matching options, arrow through them, and press Enter on the right one.
The practical difference is clearest on a machine with many active projects. A developer working across twelve repositories doesn't want to maintain a mental map of absolute paths. With zoxide, the workflow is: type a recognizable fragment, arrive at the right place. The tool builds its own map so you don't have to carry one.
fzf: Fuzzy Finding for History, Files, and Everything Else
What fuzzy finding means in practice
A fuzzy finder takes a list and turns it into a searchable, interactive interface. Type a few characters, see ranked matches update in real time, select one. That description sounds simple. The impact on daily terminal use is not.
fzf is the standard fuzzy finder for shell environments. It's fast, composable, and integrates with zsh at a level that changes several default behaviors for the better. It also serves as the backend for zoxide's zi command, which is why installing both together makes sense.
Installing fzf and running its setup script
Homebrew installed the fzf binary. But fzf requires a second step to wire up its shell integrations:
$(brew --prefix)/opt/fzf/installRun that command and answer yes to the prompts. This script adds key bindings and shell completions to your .zshrc automatically. Without it, fzf exists as a standalone binary but none of the shell-level integrations activate. The install script is not optional if you want the full experience.
The killer use case: interactive shell history search
The default reverse history search in zsh is Control+R. It works, but it's linear. You type a fragment, it finds the most recent match, and you cycle through matches one at a time with repeated Control+R presses. If the command you want is buried, you're cycling.
After fzf installs its shell integrations, Control+R becomes a different experience entirely. Press it and a full-screen fuzzy search interface opens over your terminal. Your entire command history is searchable. Type any fragment from anywhere in the command, not just the beginning, and matching entries appear ranked by relevance. Arrow through results, press Enter, and the selected command populates your prompt.
"fzf turns every list into a searchable interface."
Two other key bindings ship with the fzf install script. Control+T opens a fuzzy file finder rooted at the current directory. Start typing a filename fragment and matching files appear. Select one and its path populates your current command line. Alt+C opens a fuzzy directory jump, which complements zoxide for cases where you want to browse rather than jump directly.
fzf is also composable with any command that produces a list. The Git branch checkout pattern is a clean example:
git checkout $(git branch | fzf)That pipes the branch list into fzf, lets you select interactively, and passes the result to git checkout. The same pattern works with process IDs, Docker containers, SSH hosts, or any other enumerable list. Once you start thinking of fzf as a list-to-selection pipe, it shows up everywhere.
Part 4 takes the shell configuration further into dotfile management: how to version-control everything built in this part, sync it across machines, and structure a .zshrc that stays readable as it grows.
eza, bat, ripgrep, and fd: Replacing the Core Unix Primitives
Part 2 got Starship, zoxide, and fzf installed and wired into your shell. The prompt looks good, directory jumping works, and history search actually feels fast. Now the work shifts to the tools you reach for dozens of times per session: listing files, reading them, searching through them, and finding them. The defaults that ship with macOS are functional. They're also thirty years old.
eza: ls with color, icons, and git awareness
eza is a modern replacement for ls, written in Rust, and it does things the original never could. Every file entry gets color-coded by type, optional icons pulled from Nerd Fonts, and per-file git status right in the listing column. You can see at a glance which files are staged, modified, or untracked without running a separate git status.
Three aliases cover the majority of daily use:
1alias ls='eza'
2alias ll='eza -la'
3alias tree='eza --tree'That last one matters. eza --tree renders a recursive directory tree inline, which replaces the standalone tree command for most purposes. It respects .gitignore by default, so you're not wading through node_modules unless you ask for it.
bat: cat with syntax highlighting and line numbers
bat is what cat should have been. It adds syntax highlighting for dozens of languages, line numbers in the left gutter, and git diff indicators showing which lines changed since the last commit.
alias cat='bat'Run bat README.md and you get a fully rendered view: line numbers, markdown syntax coloring, and a visible diff if that file has uncommitted edits. It's the difference between reading a file and actually seeing it.
When you're piping output into another command, color codes interfere. Use bat --plain to strip formatting and get clean text. Or just call the real cat when a script depends on it.
ripgrep: grep that respects your time
ripgrep searches file contents faster than GNU grep, and the margin isn't close.
It respects .gitignore by default, which means searching a large monorepo doesn't drown you in results from node_modules, build artifacts, or vendored dependencies. Everyday usage is simple:
rg 'TODO'
rg 'functionName'Both commands search recursively from the current directory. No flags required, no path arguments needed in most cases. It just works the way you'd expect grep to work if grep were designed today.
fd: find without the cryptic flags
fd is a simpler, faster alternative to find. The syntax is human-readable, the defaults are sensible, and it's .gitignore-aware like everything else in this stack.
fd package.json
fd READMECompare that to the equivalent find invocations with their flags, quoted patterns, and arcane syntax. fd doesn't require any of that. It searches the current directory recursively, case-insensitively by default, and skips ignored files automatically.
All four tools share one property worth calling out explicitly: .gitignore awareness. In a monorepo or any project with heavy build output, that default behavior alone saves minutes per day.
jq and tree: Inspecting JSON and Directory Structure
jq: the command-line JSON Swiss Army knife
jq is the standard tool for working with JSON at the command line. It parses, filters, and transforms JSON output, and it does it with color and proper indentation. The simplest use case is also the most common:
jq . package.jsonThat single command pretty-prints the entire file with syntax highlighting. From there, the patterns build naturally. jq '.dependencies' package.json pulls just the dependencies object. Piping curl output directly into jq turns a wall of raw API response into something readable in under a second.
jq is also essential when working with REST APIs, AWS CLI output, and GitHub Actions artifacts. The AWS CLI in particular dumps deeply nested JSON that's nearly unreadable without it. Once you start piping CLI output through jq, you won't stop.
When jq becomes essential
If you work with REST APIs, AWS CLI, or GitHub Actions, jq stops being optional fast. aws s3api list-buckets | jq '.Buckets[].Name' is the kind of one-liner that replaces ten minutes of squinting at raw output.
tree: quick visual directory maps
tree with a depth limit is still useful even after you've aliased eza --tree. The command tree -L 2 gives you a two-level directory visualization, which is the right tool when you're auditing an unfamiliar project before diving into it. You want structure, not every file in the repository.
eza's tree mode is better for day-to-day use, but tree remains worth keeping for quick, depth-limited snapshots where you want clean output without eza's formatting overhead.
Wiring It All Together: Your .zshrc Configuration Block
The canonical .zshrc additions for this part
Here's the complete block to add to your .zshrc:
1# ── MacBook to Mission Control: Part 3 ──────────────────────────────
2eval "$(starship init zsh)"
3eval "$(zoxide init zsh)"
4
5alias ls='eza'
6alias ll='eza -la'
7alias tree='eza --tree'
8alias cat='bat'That comment header matters more than it seems. Six months from now, when you're troubleshooting a broken shell, you'll want to know exactly which block you added and when.
Order matters: where to place each line
The eval lines should go near the bottom of your .zshrc, after your PATH declarations and any plugin manager calls. Starship and zoxide need the environment fully configured before they initialize. Aliases can go anywhere, but grouping them together in a labeled block keeps the file readable as it grows. Dotfiles management, including version controlling your .zshrc with symlinks, is covered later in the series.
Reloading the shell
After editing .zshrc, run:
source ~/.zshrcThat reloads the configuration in your current session without opening a new terminal window. It's faster, but it comes with a caveat.
Opening a new terminal tab is always the safer test. If your .zshrc has a syntax error and you source it in the current session, the shell can end up partially configured with no clean way to recover without closing the window entirely.
Common Mistakes and How to Avoid Them
Duplicate lines in .zshrc
Sourcing .zshrc multiple times in a session, or copy-pasting from multiple guides, creates duplicate eval lines. Two eval "$(starship init zsh)" calls won't break anything immediately, but they slow down shell startup and create confusing behavior over time. Check for duplicates before you finalize:
grep 'starship' ~/.zshrc
grep 'zoxide' ~/.zshrcIf you see the same line twice, remove one. Simple.
The duplicate eval problem is more common than it looks
If your shell feels slow to open, run grep 'eval' ~/.zshrc and count the lines. Duplicate initializations are a frequent cause of sluggish startup times, and they accumulate quietly over months of editing.
Aliasing tools before understanding their differences
Aliasing cat to bat is convenient until a script pipes cat output into something that doesn't tolerate ANSI color codes. The fix is bat --plain, which strips formatting and behaves like the real cat. Know this before you alias.
The tree alias is a subtler problem. eza --tree doesn't support every flag that standalone tree does. If you rely on flags like --du for disk usage or -J for JSON output, overwriting the tree alias silently breaks those workflows. Keep the original tree binary accessible and alias selectively.
One more: fzf requires running its install script after brew install fzf. The formula installs the binary but the key bindings, including Control+R for history search, don't activate until you run /opt/homebrew/opt/fzf/install. Skipping that step is the most common reason fzf feels broken after installation.
Installing without learning
Eight tools installed in an afternoon is eight tools you won't remember next week. Pick one per day. Use eza exclusively for a day before moving to bat. Let rg replace your grep habit before you add fd. Zoxide only becomes useful after it's seen your directories, and it learns over time, not instantly. Give it a few days of normal navigation before judging it.
Part 3 Completion Checklist
Part 4 moves into the editor layer: configuring Neovim or VS Code with the plugins and settings that make the terminal-first workflow actually productive. The shell is fast now. The next step is making sure the editor keeps up.