LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The Modern Terminal Stack: A Complete CLI Upgrade Guide

cliterminalproductivitylinuxzshfishdevtools

The Modern Terminal Stack: A Complete CLI Upgrade Guide

The terminal is where engineers live. And yet most of us spend years using a setup that hasn’t changed much since the 1990s — a bare bash prompt, ls, grep, cat, and maybe a .bashrc with a few aliases. The tooling ecosystem has quietly undergone a renaissance. There are now faster, smarter, and more ergonomic replacements for nearly every core Unix utility, plus entirely new categories of tools that didn’t exist before.

This guide covers the full modern terminal stack: which shell to use, how to configure it, and which tools belong in your $PATH. We’ll also cover how to make the whole thing portable and reproducible across machines.


The Shell Layer

bash: The Default You Might Already Be Using

Bash is everywhere. It’s the default shell on most Linux distros, present on macOS (though replaced by zsh as default in Catalina), and an assumed baseline in most shell scripting. But interactive bash out of the box is stripped-down: no multi-line editing, weak tab completion, no syntax highlighting as you type.

You can improve it significantly with:

  • bash-completion (package) for better tab completion
  • blesh (Bash Line Editor) for syntax highlighting and completion widgets
  • A carefully tuned .bashrc

But at some point you’re better served by switching shells for interactive use while keeping bash for scripts.

zsh: The Pragmatic Power User Shell

Zsh has been the default macOS shell since 2019. It’s POSIX-compatible for scripting purposes, has excellent plugin and theme infrastructure via Oh My Zsh and Zinit, and supports features bash lacks: better globbing, spelling correction, suffix aliases, and a truly excellent completion system.

The ecosystem is huge. Some highlights:

zsh-autosuggestions — suggests commands as you type, drawn from your history:

1
2
3
# Install manually or via your plugin manager
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'

zsh-syntax-highlighting — colors your command line green/red as you type, so you catch typos before hitting Enter:

1
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

zsh-history-substring-search — search history by substring with Up/Down arrows:

1
2
3
source ~/.zsh/zsh-history-substring-search/zsh-history-substring-search.zsh
bindkey '^[[A' history-substring-search-up
bindkey '^[[B' history-substring-search-down

Plugin Managers for zsh

Oh My Zsh is the most popular entry point. It bundles hundreds of plugins and themes, has a huge community, and is easy to get started with. The downside: it can be slow to load if you enable too many plugins.

Zinit (formerly zplugin) is the performance-focused alternative. It supports lazy loading, turbo mode (load plugins asynchronously after the prompt appears), and installing plugins from any git repo, GitHub release, or snippet:

1
2
3
4
5
6
# zinit snippet for a single file from a URL
zinit snippet https://raw.githubusercontent.com/.../plugin.zsh

# Load plugin with turbo mode (after prompt renders)
zinit ice wait lucid
zinit light zdharma-continuum/fast-syntax-highlighting

Antidote is a newer, simpler alternative focused on correctness and static configuration (define plugins in a text file, generate a static loader script).

fish: Friendly Interactive Shell

Fish is the most opinionated choice — and the most beginner-friendly. It’s not POSIX-compatible, which means fish scripts won’t run in bash, but that’s a trade-off for a shell designed from scratch around interactive use.

Fish’s headline features:

  • Autosuggestions out of the box — no plugin needed
  • Syntax highlighting out of the box
  • Web-based configurationfish_config opens a browser UI
  • Functions, not aliases — fish uses functions for customization, which are more powerful
  • Universal variables — variables set with set -U persist across sessions automatically
1
2
3
4
5
6
7
8
9
# Fish syntax: noticeably different from bash/zsh
function mkcd
    mkdir -p $argv && cd $argv
end

# Conditions use begin/end, not { }
if test -f ~/.config/fish/local.fish
    source ~/.config/fish/local.fish
end

The ecosystem has Fisher (package manager) and a rich set of plugins. For most developers switching from bash, fish will feel immediately better without any configuration — but you’ll need to keep bash/zsh around for scripts and CI.

Which to choose? If you want a drop-in upgrade and script compatibility, use zsh + Zinit. If you want the best out-of-the-box experience and don’t mind a separate scripting language, use fish.


The Prompt: Starship

Starship is a cross-shell prompt written in Rust. It works identically in bash, zsh, fish, PowerShell, and more. It’s fast (benchmarked at under 5ms), extensible, and shows contextual information without configuration:

  • Git branch, status, ahead/behind count
  • Language version (node, python, go, rust, etc.) when relevant files are present
  • Kubernetes context
  • AWS/GCP profile
  • Exit code of last command
  • Duration of long-running commands

Install it and add one line to your shell config:

1
2
3
4
5
6
7
8
# Install (one-liner)
curl -sS https://starship.rs/install.sh | sh

# zsh: add to ~/.zshrc
eval "$(starship init zsh)"

# fish: add to ~/.config/fish/config.fish
starship init fish | source

Configure via ~/.config/starship.toml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# starship.toml

# Overall format: show username only if SSH
format = """
$username$hostname$directory$git_branch$git_status$python$node$rust$golang$cmd_duration$line_break$character"""

[directory]
truncation_length = 3
truncate_to_repo = true

[git_branch]
symbol = " "
format = "[$symbol$branch]($style) "

[git_status]
format = '([\[$all_status$ahead_behind\]]($style) )'
conflicted = "⚡"
untracked = "?"
modified = "!"
staged = "+"

[cmd_duration]
min_time = 2_000
format = "took [$duration]($style) "

[character]
success_symbol = "[❯](green)"
error_symbol = "[❯](red)"

Starship supports dozens of modules. The key insight is that modules are only shown when relevant — a Go module only appears when there’s a go.mod in scope. This keeps the prompt clean.


cd is the most-typed command in any terminal session. zoxide replaces it with a smart jump command that learns which directories you visit and lets you navigate to them by partial name.

1
2
3
4
5
6
7
8
# Install
curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh

# Add to ~/.zshrc
eval "$(zoxide init zsh)"

# Add to ~/.config/fish/config.fish
zoxide init fish | source

Usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Normal cd still works
cd ~/projects/lunarops_tech_blog

# After visiting a few times, jump by partial name
z lunarops       # jumps to ~/projects/lunarops_tech_blog
z blog           # same, if that's the best match
z tech blog      # multi-token matching

# zi opens an interactive fzf picker of your most visited dirs
zi

zoxide uses a frecency algorithm (frequency + recency) to rank directories. The more you visit a directory, the higher it ranks. Directories you haven’t visited recently decay in score.


Fuzzy Finding: fzf

fzf is an interactive fuzzy finder for the terminal. It reads lines from stdin, lets you type to filter them, and outputs the selected lines. That simple model makes it composable with almost everything.

1
2
3
# Install via package manager or:
git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf
~/.fzf/install

After installation, fzf provides three key bindings out of the box:

  • Ctrl+R — fuzzy search command history (replaces the built-in reverse search)
  • Ctrl+T — fuzzy find files and insert path into command line
  • Alt+C — fuzzy find directories and cd into selected

But fzf’s real power is in composition:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Kill a process interactively
kill -9 $(ps aux | fzf | awk '{print $2}')

# Open a file in vim, selected via fzf
vim $(fzf)

# Switch git branches
git checkout $(git branch | fzf)

# Preview files as you browse (requires bat)
fzf --preview 'bat --color=always {}'

# Search only tracked git files
git ls-files | fzf

# Select multiple files with Tab, open all in vim
vim $(fzf -m)

Configure fzf defaults in your shell config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export FZF_DEFAULT_OPTS='
  --height 40%
  --layout=reverse
  --border
  --info=inline
  --preview-window=right:50%:wrap
'

# Use fd instead of find for Ctrl+T (respects .gitignore)
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"

Search: ripgrep

ripgrep (rg) is a line-oriented search tool that recursively searches directories for a regex pattern. It’s written in Rust, respects .gitignore by default, and is often 10–100x faster than grep for real-world use.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Install
apt install ripgrep   # Debian/Ubuntu
brew install ripgrep  # macOS

# Basic usage: same as grep
rg 'pattern' path/

# rg defaults to recursive search from current dir
rg 'TODO'

# Case insensitive
rg -i 'error'

# Search specific file types
rg -t py 'import torch'
rg -t go 'func main'

# Show context (3 lines before/after)
rg -C 3 'panic'

# Fixed string, not regex
rg -F 'user.email'

# Only print file names
rg -l 'main()'

# Multiline patterns
rg -U 'struct \{[^}]*field_name'

# Global config file: ~/.config/ripgrep/config or $RIPGREP_CONFIG_PATH

Create ~/.config/ripgrep/config:

--smart-case
--hidden
--glob=!.git
--glob=!node_modules
--glob=!target
--glob=!dist

--smart-case makes searches case-insensitive unless your pattern contains uppercase letters — the best default behavior.


File Finding: fd

fd is a simple, fast alternative to find. Same Rust pedigree as ripgrep, respects .gitignore, and has a much more ergonomic interface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Find files by name pattern
fd 'config'           # any filename containing "config"
fd '\.yaml$'          # regex pattern
fd -e yaml            # by extension (no dot needed)

# Find only directories
fd -t d 'tests'

# Exclude patterns
fd -E node_modules -E .git '\.js$'

# Execute a command on results
fd -e log -x rm       # delete all .log files
fd -e jpg -x convert {} {.}.png  # convert images (parallel)

# Find and pipe to fzf
fd -t f | fzf

Better cat: bat

bat is a cat clone with syntax highlighting, line numbers, Git diff markers, and paging. It automatically detects the language from the file extension.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install
apt install bat   # or "batcat" on some distros
brew install bat

# Basic usage — drop-in for cat
bat file.py
bat --style=plain file.py  # no line numbers/decorations
bat -l json < <(curl -s api.example.com/data)  # pipe with explicit language

# Integration: use bat as the pager for man pages
export MANPAGER="sh -c 'col -bx | bat -l man -p'"

# Integration: use bat for git diff
git diff | bat --language=diff

Configure in ~/.config/bat/config:

--theme="Catppuccin-mocha"
--style="numbers,changes,header"
--pager="less -FR"

bat integrates naturally with fzf previews:

1
fzf --preview 'bat --color=always --style=numbers --line-range=:500 {}'

Better ls: eza

eza is a modern ls replacement (fork of the unmaintained exa) with colors, icons, Git status indicators, and a tree view.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Install
apt install eza
brew install eza

# Basic usage
eza                    # colorized ls
eza -la                # long format, show hidden
eza --icons            # Nerd Font icons (requires a patched font)
eza -la --git          # show git status for each file
eza --tree --level=2   # tree view, 2 levels deep
eza --sort=modified    # sort by modification time
eza --group-directories-first

Recommended aliases:

1
2
3
4
5
# ~/.zshrc or ~/.config/fish/conf.d/aliases.fish
alias ls='eza --icons --group-directories-first'
alias ll='eza -la --icons --git --group-directories-first'
alias lt='eza --tree --icons --level=2'
alias la='eza -a --icons'

Shell History: atuin

atuin replaces your shell history with a SQLite database and provides fuzzy, context-aware search. It records the working directory, exit code, session, hostname, and duration for every command.

The killer feature: optional sync. You can sync your encrypted shell history across machines via a self-hosted or their managed server.

1
2
3
4
5
6
7
8
# Install
curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh

# Add to ~/.zshrc
eval "$(atuin init zsh)"

# Add to ~/.config/fish/config.fish
atuin init fish | source

Usage:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Ctrl+R now opens atuin's TUI search
# Or use the command directly:
atuin search 'docker'

# Search with filters
atuin search --exit 0 'git push'     # only successful commands
atuin search --cwd ~/projects 'make' # only from this directory
atuin search --before '2025-01-01'   # time filters

# Stats
atuin stats              # top commands, usage over time

# Import existing history
atuin import auto        # detects your shell and imports

# Self-hosted sync
atuin login -u <user> -p <pass> -k <key>
atuin sync

atuin stores history in ~/.local/share/atuin/history.db. It’s just SQLite — you can query it directly:

1
2
3
4
5
6
-- Most used commands
SELECT command, count(*) as n
FROM history
GROUP BY command
ORDER BY n DESC
LIMIT 20;

For sync, self-hosting is straightforward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# docker-compose.yml for atuin server
services:
  atuin:
    image: ghcr.io/atuinsh/atuin:latest
    command: server start
    ports:
      - "8888:8888"
    environment:
      ATUIN_HOST: "0.0.0.0"
      ATUIN_PORT: 8888
      ATUIN_OPEN_REGISTRATION: "true"
      ATUIN_DB_URI: "postgresql://atuin:password@db/atuin"
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: atuin
      POSTGRES_PASSWORD: password
      POSTGRES_DB: atuin
    volumes:
      - atuin-db:/var/lib/postgresql/data

Other Tools Worth Installing

delta — Git Diff Pager

delta is a syntax-highlighting pager for git diff and git log. Configure it in ~/.gitconfig:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[core]
    pager = delta

[delta]
    navigate = true
    light = false
    side-by-side = true
    line-numbers = true
    syntax-theme = "Catppuccin Mocha"

[interactive]
    diffFilter = delta --color-only

lazygit — Terminal Git UI

lazygit is a terminal UI for git operations. Stage hunks, resolve conflicts, manage branches, squash commits — all from a keyboard-driven TUI:

1
2
3
4
5
brew install lazygit
apt install lazygit  # via PPA

# Launch in any git repo
lazygit

btop — Resource Monitor

btop (or bpytop) is a beautiful, information-dense resource monitor. It shows CPU, memory, disk, network, and processes with full mouse support and a responsive layout:

1
2
apt install btop
brew install btop

jq / yq — JSON and YAML Processing

jq is standard; yq (from Mike Farah) extends the same model to YAML, TOML, and XML:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Parse and pretty-print JSON
curl -s api.example.com | jq .

# Extract fields
jq '.users[].email' data.json

# Transform
jq '[.items[] | {name: .name, active: (.status == "active")}]'

# yq: YAML queries
yq '.spec.containers[].image' deployment.yaml

# yq: in-place edit
yq -i '.spec.replicas = 3' deployment.yaml

httpie / xh — Better curl

httpie and the faster Rust port xh provide a human-friendly HTTP client:

1
2
3
4
5
# xh (Rust, faster)
xh get api.example.com/users
xh post api.example.com/users name=alice age:=30
xh -b get api.example.com/data  # response body only
xh --json post api.example.com/login < payload.json

direnv — Per-Directory Environment Variables

direnv loads .envrc files when you enter a directory and unloads them when you leave. Essential for managing per-project API keys, PATH additions, and tool versions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install
apt install direnv

# Add to ~/.zshrc
eval "$(direnv hook zsh)"

# In a project directory
echo 'export DATABASE_URL=postgres://localhost/myapp' >> .envrc
echo 'export AWS_PROFILE=dev' >> .envrc
direnv allow  # must explicitly trust each .envrc

# Use with Nix flakes
echo 'use flake' >> .envrc  # automatically activates the devShell

Making It Reproducible

The frustrating thing about a great terminal setup is migrating it to a new machine. The modern answer is to manage your dotfiles as code.

Approach 1: Bare Git Repo (Lightweight)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Initialize
git init --bare $HOME/.dotfiles
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dotfiles config --local status.showUntrackedFiles no

# Add files
dotfiles add ~/.zshrc ~/.config/starship.toml ~/.config/bat/config
dotfiles commit -m "Initial dotfiles"
dotfiles remote add origin git@github.com:you/dotfiles
dotfiles push

# Restore on a new machine
git clone --bare git@github.com:you/dotfiles $HOME/.dotfiles
alias dotfiles='/usr/bin/git --git-dir=$HOME/.dotfiles/ --work-tree=$HOME'
dotfiles checkout

chezmoi is a dotfile manager that handles the hard cases: per-machine differences, secrets, templates, and encrypted files.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Install
sh -c "$(curl -fsLS get.chezmoi.io)"

# Initialize
chezmoi init

# Add dotfiles
chezmoi add ~/.zshrc
chezmoi add ~/.config/starship.toml

# Edit via chezmoi (opens in $EDITOR)
chezmoi edit ~/.zshrc

# Apply changes
chezmoi apply

# See what would change
chezmoi diff

# Template example: machine-specific config
# ~/.local/share/chezmoi/dot_zshrc.tmpl
{{ if eq .chezmoi.hostname "workstation" -}}
export WORK_VPN_HOST=vpn.company.com
{{ end -}}

# Secrets via 1Password/Bitwarden/pass integration
# {{ onepasswordDetailsFields "item-uuid" "field-name" }}

Approach 3: Nix Home Manager

If you’re using Nix (see our Nix Flakes post), Home Manager is the most reproducible approach. Your entire user environment — packages, dotfiles, shell config — declared in one file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# ~/.config/home-manager/home.nix
{ pkgs, ... }: {
  home.packages = with pkgs; [
    zoxide fzf ripgrep fd bat eza atuin starship
    lazygit delta btop jq yq httpie direnv
  ];

  programs.zsh = {
    enable = true;
    enableAutosuggestions = true;
    syntaxHighlighting.enable = true;
    initExtra = ''
      eval "$(zoxide init zsh)"
      eval "$(atuin init zsh)"
    '';
  };

  programs.starship = {
    enable = true;
    settings = {
      directory.truncation_length = 3;
      git_status.format = ''([\[$all_status$ahead_behind\]]($style) )'';
    };
  };

  programs.bat = {
    enable = true;
    config.theme = "Catppuccin-mocha";
  };
}

The Minimal Stack

If you want to start small, install these five tools and you’ll immediately notice the difference:

Tool Replaces Why
zoxide cd Jump to dirs by partial name
fzf Ctrl+R history search Fuzzy find everything
ripgrep grep 10–100x faster, .gitignore-aware
bat cat Syntax highlighting with no config
atuin bash history Searchable, cross-machine history

Add starship for a better prompt and eza for a better ls, and you have a genuinely modern setup that requires minimal ongoing maintenance.


Performance Considerations

Shell startup time matters. A prompt that takes 500ms to load breaks your flow. Test with:

1
2
3
4
5
# Measure zsh startup
time zsh -i -c exit

# Or more precisely
for i in $(seq 1 10); do time zsh -i -c exit; done 2>&1 | grep real | awk '{print $2}' | sort

Targets:

  • Under 100ms: excellent
  • 100–250ms: acceptable
  • Over 250ms: investigate

Common culprits:

  • Too many plugins loading synchronously (use Zinit’s turbo mode)
  • nvm or rbenv init in shell startup (replace with mise or asdf)
  • conda init adding 200ms (move to lazy load)
  • brew shellenv on Apple Silicon (cache the output)

For mise (universal version manager), add it to your shell init instead of nvm/pyenv/rbenv:

1
2
# ~/.zshrc — replaces nvm, rbenv, pyenv, goenv, etc.
eval "$(mise activate zsh)"

Mise lazily activates tools, has a global/per-directory config, and is far faster than the individual version managers it replaces.


Summary

A complete modern terminal stack:

Shell: zsh + Zinit (or fish for newcomers) Prompt: starship Navigation: zoxide History: atuin Fuzzy finder: fzf Search: ripgrep + fd Better cat: bat Better ls: eza Git TUI: lazygit + delta JSON/YAML: jq + yq HTTP client: xh Env vars: direnv Dotfile management: chezmoi (or Nix Home Manager) Version management: mise

None of these tools require you to change how you work — they slot into existing muscle memory and just make everything faster and more pleasant. Start with the minimal five, build up from there, and commit your dotfiles to git so you never configure from scratch again.

The terminal in 2026 is faster, prettier, and more capable than it’s ever been. The only question is how long you want to keep using the defaults.

Comments