Image for MacBook to Mission Control Part 3: Modern Shell Productivity on macOS
Technology Jun 24, 2026 • 14 min read

MacBook to Mission Control Part 3: Modern Shell Productivity on macOS

Upgrade your macOS terminal with Starship, zoxide, fzf, eza, bat, ripgrep, and fd. Turn a default shell into a developer command center.

Share:
Lee Foropoulos

Lee Foropoulos

14 min read

Continue where you left off?
Text size:

Contents

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.

The tools you use every hour of every workday deserve more than their factory settings.
8+
hours per day the average developer spends in a terminal environment
Terminal screen with colorful prompt and code output
A configured shell surfaces context that the defaults hide entirely.

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.

bash
brew install starship zoxide fzf eza bat ripgrep fd jq tree

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

Open field with a single path leading toward the horizon at golden hour
One install command clears the path. Configuration is what comes next.

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.

9
tools installed with a single brew command

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 ToolReplacementPrimary Improvement
lsezaColor output, icons, Git status per file
catbatSyntax highlighting, line numbers, paging
findfdFaster, simpler syntax, respects .gitignore
grepripgrepSignificantly faster, respects .gitignore
cdzoxideFrecency-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.

Close-up of a terminal screen showing colorful text output
Starship turns a blank prompt into a live status display for your current working context.
40,000+
GitHub stars for the Starship prompt project

Installing and activating Starship

Homebrew already handled the install. The activation step is a single line added to ~/.zshrc:

bash
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:

bash
source ~/.zshrc

Your 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 prompt should tell you where you are, what state things are in, and whether the last thing you did worked. Starship does all three.

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:

bash
eval "$(zoxide init zsh)"

Place this after your PATH configuration and before your aliases. Source the file:

bash
source ~/.zshrc

Drop-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:

bash
z project-workspace

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

zoxide doesn't change how you think about navigation. It just removes the part where you have to remember the exact path.

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.

Multiple screens showing code and data in a dark environment
fzf turns any list-based operation into an interactive search. History, files, branches, processes.

Installing fzf and running its setup script

Homebrew installed the fzf binary. But fzf requires a second step to wire up its shell integrations:

bash
$(brew --prefix)/opt/fzf/install

Run 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 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:

bash
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:

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

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

5-10x
ripgrep's typical speed advantage over GNU grep on large repository searches

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:

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

The best tool is the one that gets out of your way. ripgrep gets out of your way.

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.

zsh
fd package.json
fd README

Compare 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:

zsh
jq . package.json

That 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:

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

zsh
source ~/.zshrc

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

Developer working at a desk with multiple monitors showing code
A clean, labeled .zshrc is the difference between a shell you understand and one you're afraid to touch.

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:

zsh
grep 'starship' ~/.zshrc
grep 'zoxide' ~/.zshrc

If 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 3 Completion Checklist 0/10

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.

How was this article?

Share

Link copied to clipboard!

You Might Also Like

Lee Foropoulos

Lee Foropoulos

Business Development Lead at Lookatmedia, fractional executive, and founder of gotHABITS.

🔔

Never Miss a Post

Get notified when new articles are published. No email required.

You will see a banner on the site when a new post is published, plus a browser notification if you allow it.

Browser notifications only. No spam, no email.

0 / 0