Mert Özen Further With Every Line
Developer Tools Terminal and Command Line — #05

Modern CLI Tools: a Daily Workflow with fzf, ripgrep, fd, bat, eza, zoxide and jq

Mert Özen Nov 14, 2026 12 min 2 views
Modern CLI Tools: a Daily Workflow with fzf, ripgrep, fd, bat, eza, zoxide and jq

Because ripgrep and fd respect .gitignore, they find fewer but correct results than grep in the Bookstore repo, about 90 times faster; fzf is a picker you can put in front of any list. We cover installing the tools with brew, winget and apt (Ubuntu's fdfind/batcat names and old fzf), Ctrl-R/Ctrl-T integration, branch and test pickers, processing API, dotnet package list and npm ls output with jq, and why scripts shouldn't depend on these tools.

Version note: The commands in this post were run on Ubuntu 24.04 with zsh 5.9, bash 5.2 and PowerShell 7.6.10, in a clean environment with fzf 0.75.0, ripgrep 15.2.0, fd 10.5.2, bat 0.26.1, eza 0.23.5, zoxide 0.10.0, jq 1.8.2, PSFzf 2.7.12, .NET SDK 10.0.404 and Angular CLI 22.2.7 installed. Ubuntu 24.04's apt packages were also installed for comparison. The Windows (winget) and macOS (Homebrew) install commands come from the tools' official documentation.

Guess First

You are looking for places where isbn appears in the repo of Bookstore, the online bookstore used as the shared example across this site's series. The repo has .NET projects that have been built once (bin, obj) and an Angular project with a 309 MB node_modules; there are 18,541 files on disk, while git tracks 37. Two commands:

$ grep -rni "isbn" . | wc -l
$ rg -ni isbn | wc -l

Which one finds more lines, which finishes faster, and by how many times? Also think about this: does the one that finds more lines give the more correct result?

What They Replace and Why: ripgrep, fd, bat, eza, zoxide

None of these tools replaces learning the classic Unix tools; on a developer machine they do the same job with less typing and better defaults. What they share is respecting .gitignore and producing coloured, readable output. The answer to the guess:

$ time (grep -rni "isbn" . | wc -l)
grep: ./.git/index: binary file matches
grep: ./src/Bookstore.Api/bin/Debug/net10.0/Bookstore.Api.pdb: binary file matches
grep: ./src/Bookstore.Api/bin/Debug/net10.0/Bookstore.Api.dll: binary file matches
...
16

real	0m0.437s
user	0m0.388s
sys	0m0.049s
$ time (rg -ni isbn | wc -l)
13

real	0m0.005s
user	0m0.004s
sys	0m0.001s

grep printed warnings for 15 binary files and found 16 lines; some of them come from three files inside node_modules. ripgrep found 13 lines, all in the 7 files we wrote. The difference is that ripgrep follows the bin/, obj/ and node_modules/ rules in .gitignore and skips hidden folders (.git) and binary files by default. The result is fewer but correct lines, in roughly 90 times less time. When you also want to look in ignored places, rg -u turns off the ignore rules, rg -uu also includes hidden files, and rg -uuu binary files too.

fd applies the same idea to find:

$ fd -e cs
src/Bookstore.Api/Catalog/Book.cs
src/Bookstore.Api/Catalog/BookCatalog.cs
src/Bookstore.Api/Catalog/Isbn.cs
src/Bookstore.Api/Program.cs
tests/Bookstore.Api.Tests/BookCatalogTests.cs
tests/Bookstore.Api.Tests/IsbnTests.cs
$ fd -e cs | wc -l; find . -name "*.cs" | wc -l
6
12

The 6 extra files find found are .cs files the build generated in the obj folder. To get the same result with find, you have to exclude every ignored folder one by one with -not -path "*/obj/*".

bat is cat with syntax highlighting, line numbers and git change markers. It is fancy when writing to the terminal and behaves like plain cat when writing to a pipe:

$ bat src/Bookstore.Api/Program.cs | head -3
using Bookstore.Api.Catalog;

var builder = WebApplication.CreateBuilder(args);

Use bat -r 8:14 Program.cs to see specific lines and bat -l json to force a language. eza replaces ls: eza -l --git shows each file's git status, and eza --tree --level 2 --git-ignore shows the tree with ignored entries hidden. eza is the community-maintained continuation of exa, which is no longer maintained.

zoxide is a directory jumper that remembers your cds. It scores the folders you enter by how often and how recently you use them; with z you only type part of the name:

❯ z catal
src/Bookstore.Api/Catalog git:main
❯ z app
bookstore-web/src/app git:main node 24.22.3
❯ zoxide query --list --score | head -5
   8.0 /home/ada/bookstore/src/Bookstore.Api/Catalog
   8.0 /home/ada/bookstore/web/bookstore-web/src/app
   4.0 /home/ada/bookstore/tests/Bookstore.Api.Tests

Installation: brew, winget, apt and Ubuntu's Different Names

On macOS it is one line: brew install fzf ripgrep fd bat eza zoxide jq. On Windows the winget IDs are junegunn.fzf, BurntSushi.ripgrep.MSVC, sharkdp.fd, sharkdp.bat, eza-community.eza, ajeetdsouza.zoxide and jqlang.jq. On Ubuntu in WSL, installing with apt brings two surprises. The first is names: on Debian and Ubuntu, fd's command is fdfind and bat's is batcat, because those names clash with other packages in the distribution. As fd's README suggests, you link the short name into your own ~/.local/bin:

$ sudo apt install fzf ripgrep fd-find bat eza zoxide jq
$ mkdir -p ~/.local/bin
$ ln -s "$(command -v fdfind)" ~/.local/bin/fd
$ ln -s "$(command -v batcat)" ~/.local/bin/bat

The second is versions. Ubuntu 24.04 LTS packages are frozen when the release ships and only get security and critical bug fixes:

ToolUbuntu 24.04 aptLatest release (November 2026)
fzf0.44.10.75.0
ripgrep14.1.015.2.0
fd9.0.010.5.2
bat0.24.00.26.1
eza0.18.20.23.5
zoxide0.9.30.10.0
jq1.7.11.8.2

Most differences are small, but the fzf one affects your work directly. The shell integration line from fzf's README (source <(fzf --zsh)) exists in newer versions; apt's 0.44.1 doesn't recognise it:

$ /usr/bin/fzf --zsh
unknown option: --zsh

zoxide's README also requires at least fzf 0.51 for zi (interactive selection with fzf). So in WSL I recommend installing fzf from its official GitHub release into ~/.local/bin and removing the apt version. If you want to stay on the apt version, the Ubuntu package ships the integration scripts as /usr/share/doc/fzf/examples/key-bindings.zsh and completion.zsh; you source those.

fzf: Ctrl-R, Ctrl-T and Alt-C

fzf is a "fuzzy finder": it lets you pick from a list of lines coming from stdin by filtering on the letters you type, and prints your pick to stdout. On its own it is a small tool; its real strength comes from shell integration and from combining it with other commands. The section in .zshrc:

if (( $+commands[fzf] )); then
    export FZF_DEFAULT_COMMAND='fd --type f --strip-cwd-prefix'
    export FZF_CTRL_T_COMMAND=$FZF_DEFAULT_COMMAND
    export FZF_DEFAULT_OPTS='--height 40% --layout reverse --border'
    cache_completion fzf fzf --zsh
fi
cache_completion zoxide zoxide init zsh

cache_completion is the function we wrote for completion scripts in the previous post; it works just as well for init scripts. After adding these two lines, startup time was 24 ms. Shell integration brings three keys. Ctrl-R searches history with fzf; the list narrows as you type:

╭──────────────────────────────────────────────────────────────────────────────╮
│ > curl                                                                       │
│   1/12 (0) +S ────────────────────────────────────────────────────────────── │
│ ▌ 13      curl -s localhost:5080/api/books | jq length                       │
╰──────────────────────────────────────────────────────────────────────────────╯

Ctrl-T pastes a file path at the cursor. After typing bat and pressing Ctrl-T, the list comes from FZF_CTRL_T_COMMAND, that is from fd, and ignored files never show up:

╭──────────────────────────────────────────────────────────────────────────────╮
│ > catal                                                                      │
│   4/31 (0) ───────────────────────────────────────────────────────────────── │
│ ▌ src/Bookstore.Api/Catalog/Book.cs                                          │
│ ▌ src/Bookstore.Api/Catalog/Isbn.cs                                          │
│ ▌ src/Bookstore.Api/Catalog/BookCatalog.cs                                   │
│ ▌ tests/Bookstore.Api.Tests/BookCatalogTests.cs                              │
╰──────────────────────────────────────────────────────────────────────────────╯

(I trimmed the empty lines at the bottom of both boxes.) After pressing Enter, the line became bat src/Bookstore.Api/Catalog/Book.cs. Alt-C does a cd into the subfolder you pick. In bash, integration is turned on with eval "$(fzf --bash)".

In PowerShell the PSFzf module provides the same keys. The part I added to the profile:

if ((Get-Command fzf -ErrorAction Ignore) -and (Get-Module PSFzf -ListAvailable)) {
    Import-Module PSFzf
    Set-PsFzfOption -PSReadlineChordProvider 'Ctrl+t' -PSReadlineChordReverseHistory 'Ctrl+r'
}
if (Get-Command zoxide -ErrorAction Ignore) {
    Invoke-Expression (& { (zoxide init powershell | Out-String) })
}

After installing it with Install-PSResource PSFzf -Scope CurrentUser, Get-PSReadLineKeyHandler showed Fzf Provider Select for Ctrl+t and Fzf Reverse History Select for Ctrl+r; zoxide's z and zi commands also arrived in PowerShell as aliases.

Your Own Pickers with fzf: Branches and Tests

Where fzf helps most is that it can be put in front of any command that needs a choice from a list. To switch branches:

# Pick a branch with fzf and switch to it
gsw() {
    local branch
    branch=$(git branch --format='%(refname:short)' | fzf --prompt='branch> ') &&
        git switch "$branch"
}
╭──────────────────────────────────────────────────────────────────────────────╮
│ branch> isbn                                                                 │
│   1/4 ────────────────────────────────────────────────────────────────────── │
│ ▌ fix/isbn-validation                                                        │
╰──────────────────────────────────────────────────────────────────────────────╯
Switched to branch 'fix/isbn-validation'

I use --format='%(refname:short)' instead of git branch's plain output; the * and the leading spaces in the plain output would corrupt the picked value. If fzf is closed with Escape it exits with a non-zero code, and thanks to &&, git switch never runs.

The same idea for picking a test:

# Pick one test with fzf and run only that test
dtp() {
    local test
    test=$(dotnet test --list-tests --no-build 2>/dev/null |
        sed -n 's/^    //p' | sed 's/(.*//' | sort -u |
        fzf --prompt='test> ') &&
        dotnet test --no-build --filter "FullyQualifiedName=$test"
}

dotnet test --list-tests lists tests indented by four spaces; theories appear once per data row (Valid_isbn13_passes(value: "9780441172719")). sed drops the indentation and the parenthesised part, and sort -u removes duplicates. The result:

╭──────────────────────────────────────────────────────────────────────────────╮
│ test> wrong                                                                  │
│   2/6 ────────────────────────────────────────────────────────────────────── │
│ ▌ Bookstore.Api.Tests.IsbnTests.Wrong_length_fails                           │
│ ▌ Bookstore.Api.Tests.IsbnTests.Wrong_check_digit_fails                      │
╰──────────────────────────────────────────────────────────────────────────────╯
  Bookstore.Api.Tests test net10.0 succeeded (0.3s)
Test summary: total: 1, failed: 0, succeeded: 1, skipped: 0, duration: 0.3s

Here I used =, not ~: as we saw in the second post, ~ means "contains" and can catch other tests too.

Processing JSON with jq

jq is to JSON what sed and awk are to text: a filter language. In the previous post we saw PowerShell turn JSON into objects on its own; in bash and zsh, jq does the same job. With the Bookstore API running:

$ curl -s localhost:5080/api/books | jq '.[0]'
{
  "isbn": "9780441172719",
  "title": "Dune",
  "author": "Frank Herbert",
  "category": "science-fiction",
  "price": 420.00,
  "stock": 12,
  "currency": "TRY"
}

. is the input itself, .[0] the first element of the array, and .[] streams all elements one by one. The rest is combining these pieces with pipes:

$ curl -s localhost:5080/api/books | jq -r '.[] | select(.stock < 5) | "\(.isbn)\t\(.title)\t\(.stock)"'
9780141439587	Emma	0
9780141439549	Middlemarch	3
$ curl -s localhost:5080/api/books | jq 'map(.price * .stock) | add'
11575
$ curl -s localhost:5080/api/books | jq -c 'group_by(.category) | map({category: .[0].category, books: length})'
[{"category":"classics","books":3},{"category":"fantasy","books":1},{"category":"science-fiction","books":1}]
$ curl -s localhost:5080/api/books | jq -r --arg author "Jane Austen" '.[] | select(.author == $author) | .title'
Emma
Pride and Prejudice
$ curl -s localhost:5080/api/books | jq -r '.[] | [.isbn, .title, .price] | @csv'
"9780441172719","Dune",420.00
"9780141439587","Emma",185.50
"9780547928227","The Hobbit",310.00
"9780141439549","Middlemarch",265.00
"9780141439518","Pride and Prejudice",170.00

A few details you need to know. select(condition) drops elements that don't meet the condition from the stream. map(f) applies f to every element of an array and add sums them; the stock value came out as 11,575 TRY. -r (raw) prints strings without quotes; in a script you almost always need it when passing the value to another command. -c compacts output onto one line. --arg passes a shell variable into jq safely; concatenating the value into the filter text breaks the filter when the value contains a quote. Since jq 1.7 the way numbers are written is preserved, which is why 420.00 stayed 420.00 in the output.

jq with dotnet and npm Output

Outside APIs, jq helps most with CLIs that produce JSON. In the .NET SDK, dotnet package list (the new .NET 10 name for dotnet list package) gives machine-readable output with --format json. The direct packages of every project in the repo:

$ dotnet package list --format json | jq -r '.projects[] | .path as $p | .frameworks[]?.topLevelPackages[]? | "\($p | split("/") | last)\t\(.id)\t\(.resolvedVersion)"'
Bookstore.Api.Tests.csproj	coverlet.collector	6.0.4
Bookstore.Api.Tests.csproj	Microsoft.NET.Test.Sdk	17.14.1
Bookstore.Api.Tests.csproj	xunit	2.9.3
Bookstore.Api.Tests.csproj	xunit.runner.visualstudio	3.1.4

.path as $p stores the project path in a variable, and []? moves on without an error when the field is missing: the Bookstore.Api project has no packages and has no topLevelPackages field in its output at all. On Windows paths come with backslashes, so you use split("\\") there. The same approach works with --outdated and --vulnerable; you can write a CI check for "are there outdated packages" with it.

The direct dependencies of npm in the Angular project:

$ npm ls --json --depth=0 | jq -r '.dependencies | to_entries[] | "\(.key)@\(.value.version)"' | head -6
@angular/build@22.2.7
@angular/cli@22.2.7
@angular/common@22.2.6
@angular/compiler-cli@22.2.6
@angular/compiler@22.2.6
@angular/core@22.2.6

to_entries turns an object into an array of {key, value} pairs; it is the standard way to walk an object whose keys you don't know.

When These Tools Aren't on the Server

On a server you SSH into, inside a container or on a customer's machine, none of these tools may be there. That's why knowing their classic equivalents is a must:

ModernClassic equivalent
rg -n isbngrep -rn --exclude-dir={.git,bin,obj,node_modules} -I isbn .
fd -e csfind . -name '*.cs' -not -path '*/obj/*' -not -path '*/bin/*'
bat fileless -N file
eza --treefind . -maxdepth 2 -not -path '*/.*'
z catalcd and cd -, pushd/popd
jqUsually missing; python3 -m json.tool at least makes it readable

The -I in grep skips binary files and makes the 15 warnings above go away. How these classic tools are used on servers, and the basics of pipes and redirection, are covered in the Linux and Shell series post on reading logs.

In Production

These tools are for the developer machine. If a script you put in the repo depends on them, it breaks on the first line in CI. I tried it in the official .NET SDK container image:

$ docker run --rm mcr.microsoft.com/dotnet/sdk:10.0 bash -c 'rg -l TODO src; echo "exit code: $?"'
bash: line 1: rg: command not found
exit code: 127

The same image has grep and find, but not jq, fzf, fd or bat. My rule: scripts in the repo use POSIX tools; if jq is really needed, the script checks for it at the top and the CI step installs it explicitly. fzf never goes into a script, because it waits for a human to choose and can wait forever in CI. The details of writing bash scripts and using jq inside them are in the Bash Scripting series.

The second point is ripgrep's ignore rules: it gives a fast and correct answer to "does this string appear in the code", but it is the wrong tool for "is this secret left anywhere in the repo, build output included". For a security sweep use rg -uuu, or leave it to a secret scanner built for the job.

Common Pitfalls

The first is aliasing cat to bat. bat simplifies itself when writing to a pipe, but if you force colour and decorations, the text going through the pipe changes:

$ alias cat="bat --color=always --decorations=always"
$ cat src/Bookstore.Api/Program.cs | grep -c "MapGet(\"/api/books"
0
$ command cat src/Bookstore.Api/Program.cs | grep -c "MapGet(\"/api/books"
2

Because colour codes got into the middle of the line, grep couldn't find the text it was looking for; the 428-byte file went through the pipe as 3,949 bytes. If you want an alias, give it a name that doesn't force options (alias b='bat') and leave cat as it is.

The second is calling fzf --zsh with apt's old fzf. The error goes to stderr; because the cache_completion from the previous post discards stderr, the cache file stays empty and Ctrl-R silently works the old way. In my own test, PATH found /usr/bin/fzf in a non-login shell and exactly this happened; check which copy runs with which fzf and fzf --version.

The third is being surprised that ripgrep can't find a file: if the file is in .gitignore, in a hidden folder or binary, it isn't searched by default. rg --debug tells you why it was skipped. The fourth is the fd and bat commands not being found on Ubuntu; the names are fdfind and batcat.

A Small Experiment

★☆☆ Install the tools on your own operating system and compare grep -rn with rg -n, and find with fd, on the same search in the Bookstore repo. Explain why the result counts differ.

★★☆ With the API running, write a jq filter that gives the total stock value per category for books with fewer than 10 in stock (group_by, map, add). Answer the same question with the PowerShell pipeline from the previous post and compare the two results.

★★★ Extend dtp to allow picking several tests (fzf --multi) and join the picked tests with | into a single --filter expression. Write a PowerShell function that does the same with Invoke-Fzf.

The toolbox is complete. In the last post of the series we arrange all of it in a single window: panes and sessions with tmux, running dotnet watch, ng serve and the log side by side, and jobs that keep running even when the SSH connection drops.