Skip to content

Modern CLI Workflows -- Street Ops

The Speed Multiplier Mindset

The biggest time sinks in CLI work are: 1. Finding files (navigating directories, typing paths) 2. Searching content (slow grep, wrong flags) 3. Parsing structured output (eyeballing JSON/YAML) 4. Repeating setups (env vars, project context) 5. Losing terminal state (SSH disconnects, context switching)

Each tool below kills one of these time sinks.


fd -- Find Files Without Suffering

Replaces: find . -name "*.yaml" -not -path "./.git/*"

# Basic: find all YAML files
fd -e yaml

# Find by name pattern
fd 'config.*\.json'

# Find only directories
fd -t d node_modules

# Find and execute
fd -e log -x rm {}

# Find, excluding directories
fd -e py --exclude __pycache__

# Find hidden files too
fd -H .env

# Find changed files (combine with git)
fd -e go --changed-within 1d

Key flags: - -e extension filter - -t f files only, -t d dirs only - -H include hidden files - -I do not respect .gitignore - -x execute per result, -X execute with all results as args - --changed-within / --changed-before time filtering

Heuristic: If you are typing find . -name, stop and use fd instead. The syntax is shorter and the defaults are sane.


ripgrep (rg) -- Search Content Fast

Replaces: grep -rn --include="*.py", ack, ag

# Search for pattern
rg 'TODO|FIXME'

# Search specific file type
rg -t py 'import requests'

# Search with context
rg -C 3 'panic'

# Count matches per file
rg -c 'error' --sort path

# Search and replace (preview)
rg 'old_function' --replace 'new_function'

# Fixed string (no regex)
rg -F '$$special$$chars'

# Only filenames
rg -l 'password'

# Invert match
rg -v 'DEBUG' app.log

# Search compressed files
rg -z 'error' logs.gz

# Multiline match
rg -U 'func.*\n.*return nil'

# JSON output (for piping to jq)
rg --json 'TODO' | jq 'select(.type=="match")'

Key flags: - -t type filter (py, js, go, yaml, etc.) - -g glob pattern (-g '!vendor/*' to exclude) - -l filenames only - -c count per file - -w word boundary match - --json structured output - -U multiline mode - -z search compressed files - --no-heading for piping (one result per line)

Heuristic: rg is fast enough that you can search first, filter second. Do not waste time crafting the perfect regex -- search broad, then narrow.


fzf -- The Universal Selector

fzf turns any list into an interactive fuzzy-searchable menu.

Core Patterns

# Basic: pipe any list to fzf
cat /etc/services | fzf

# File selection with preview
fzf --preview 'bat --color=always {}'

# Multi-select
fzf --multi

# With header
echo "Select a branch:" && git branch | fzf

Shell Integration (The Real Power)

# Ctrl+T: fuzzy file finder (insert path at cursor)
# Ctrl+R: fuzzy command history
# Alt+C: fuzzy cd into directory

# These work out of the box after:
# eval "$(fzf --bash)"   # bash
# eval "$(fzf --zsh)"    # zsh

Killer Compositions

# Git branch checkout
git checkout $(git branch --all | fzf | tr -d ' ')

# Kill a process
kill $(ps aux | fzf | awk '{print $2}')

# SSH to a host
ssh $(grep '^Host ' ~/.ssh/config | awk '{print $2}' | fzf)

# Docker container exec
docker exec -it $(docker ps --format '{{.Names}}' | fzf) bash

# kubectl context switch
kubectl config use-context $(kubectl config get-contexts -o name | fzf)

# kubectl pod selection
kubectl logs $(kubectl get pods -o name | fzf)

# Edit a recently modified file
vim $(fd -t f --changed-within 2h | fzf --preview 'bat --color=always {}')

fzf with Preview Windows

# File preview
fzf --preview 'bat --color=always --line-range :50 {}'

# Git log with diff preview
git log --oneline | fzf --preview 'git show --color=always {1}'

# Process list with details
ps aux | fzf --preview 'echo {}' --preview-window=down:3:wrap

# Kubernetes pod selection with describe preview
kubectl get pods -o name | fzf --preview 'kubectl describe {}'

Heuristic: If you are choosing from a list of more than 3 items, pipe it to fzf.


jq -- Parse JSON Like a Pro

Essential Patterns

# Pretty print
cat data.json | jq .

# Extract field
jq '.name'

# Array element
jq '.[0]'

# Nested access
jq '.metadata.labels'

# Iterate array
jq '.items[] | .metadata.name'

# Select/filter
jq '.items[] | select(.status.phase == "Running")'

# Multiple fields
jq '.items[] | {name: .metadata.name, status: .status.phase}'

# Length
jq '.items | length'

# Sort
jq '.items | sort_by(.metadata.name)'

# Raw output (no quotes)
jq -r '.name'

# Compact output
jq -c '.items[]'

Kubernetes jq Recipes

# Pod names and statuses
kubectl get pods -o json | jq -r '.items[] | "\(.metadata.name)\t\(.status.phase)"'

# Pods with restart count > 0
kubectl get pods -o json | jq '.items[] | select(.status.containerStatuses[]?.restartCount > 0) | .metadata.name'

# All container images in use
kubectl get pods -A -o json | jq -r '.items[].spec.containers[].image' | sort -u

# Nodes and their capacity
kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, cpu: .status.capacity.cpu, mem: .status.capacity.memory}'

# Events sorted by time
kubectl get events -o json | jq '.items | sort_by(.lastTimestamp) | .[] | "\(.lastTimestamp) \(.reason): \(.message)"'

# Resource requests sum
kubectl get pods -o json | jq '[.items[].spec.containers[].resources.requests.memory // "0"] | map(rtrimstr("Mi") | tonumber) | add'

Cloud CLI jq Recipes

# AWS: instance IDs and types
aws ec2 describe-instances | jq -r '.Reservations[].Instances[] | "\(.InstanceId)\t\(.InstanceType)\t\(.State.Name)"'

# AWS: security group rules
aws ec2 describe-security-groups | jq '.SecurityGroups[] | {name: .GroupName, rules: [.IpPermissions[] | {port: .FromPort, cidr: .IpRanges[].CidrIp}]}'

# GCP: instance list
gcloud compute instances list --format=json | jq '.[] | {name, zone, status}'

jq Advanced

# Conditional transformation
jq 'if .status == "error" then .message else empty end'

# Group by
jq 'group_by(.category) | map({key: .[0].category, count: length})'

# Flatten nested arrays
jq '[.items[].spec.containers[].env[]?] | flatten'

# Merge objects
jq '. * {"newfield": "value"}'

# String interpolation
jq -r '"Name: \(.name), Age: \(.age)"'

# Input from multiple files
jq -s '.[0] * .[1]' defaults.json overrides.json

Heuristic: Learn these five jq patterns and you cover 90% of cases: .field, .[], select(), {key: .val}, -r for raw output.


yq -- jq for YAML

# Read a field
yq '.metadata.name' deployment.yaml

# Modify in place
yq -i '.spec.replicas = 5' deployment.yaml

# Convert YAML to JSON
yq -o json . config.yaml

# Convert JSON to YAML
yq -P . data.json

# Merge files
yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' base.yaml overlay.yaml

# Delete a field
yq 'del(.metadata.annotations)' resource.yaml

# Add an element to an array
yq '.spec.containers += [{"name": "sidecar", "image": "proxy:latest"}]' pod.yaml

bat -- cat With Superpowers

# Syntax highlighted output
bat deployment.yaml

# Show specific lines
bat --line-range 10:20 main.go

# Show non-printable characters
bat -A config.txt

# No decoration (for piping)
bat --plain file.txt

# Set language for stdin
echo '{"key": "value"}' | bat -l json

# Diff highlighting
bat --diff file.txt

Heuristic: Alias cat to bat for daily use. Use bat --plain when piping.


eza, zoxide, dust -- Basics

For introductory usage and key flags, see Modern CLI Primer. The sections below in Composition Patterns and Decision Tree show where these tools fit into real workflows.


delta -- Better Diffs

# Git integration (add to .gitconfig)
# [core]
#   pager = delta
# [delta]
#   navigate = true
#   side-by-side = true

# Standalone diff
delta file1.txt file2.txt

# Pipe diff output
diff -u old new | delta

direnv -- Project-Specific Environments

# Create .envrc in project directory
echo 'export DATABASE_URL=postgres://localhost/mydb' > .envrc

# Allow the directory
direnv allow

# Now every time you cd into this directory, vars are loaded
# They are unloaded when you leave

# Use with tools
echo 'export AWS_PROFILE=staging' > .envrc
echo 'export KUBECONFIG=~/.kube/staging-config' >> .envrc
direnv allow

Heuristic: Every project directory should have a .envrc if it needs specific env vars. Beats remembering to source files.


tmux -- Session Persistence

Essentials Only

# New session
tmux new -s work

# Detach: Ctrl+B then D

# List sessions
tmux ls

# Reattach
tmux attach -t work

# Split horizontal
Ctrl+B then "

# Split vertical
Ctrl+B then %

# Switch panes
Ctrl+B then arrow keys

# New window
Ctrl+B then C

# Switch window
Ctrl+B then N (next) / P (previous) / 0-9

# Kill pane
Ctrl+B then X

Heuristic: Always SSH into servers inside a tmux session. If your connection drops, the session survives. Use tmux attach || tmux new -s main as your SSH entry point.


Composition Patterns -- The Real Value

Pattern 1: Search, Select, Act

# Find a file, select it, edit it
fd -e py | fzf --preview 'bat --color=always {}' | xargs vim

# Search content, select match, open at line
rg -n 'TODO' | fzf | awk -F: '{print "+"$2, $1}' | xargs vim

Pattern 2: API Output to Structured Query

# Kubernetes: find high-restart pods
kubectl get pods -A -o json | jq -r '
  .items[]
  | select(.status.containerStatuses[]?.restartCount > 5)
  | "\(.metadata.namespace)/\(.metadata.name): \(.status.containerStatuses[0].restartCount) restarts"'

# AWS: find untagged resources
aws ec2 describe-instances | jq '.Reservations[].Instances[] | select(.Tags == null or (.Tags | length == 0)) | .InstanceId'

Pattern 3: Interactive Operations

# Select and delete docker images
docker images --format '{{.Repository}}:{{.Tag}}' | fzf --multi | xargs docker rmi

# Select k8s namespace, then select pod, then tail logs
NS=$(kubectl get ns -o name | fzf | cut -d/ -f2)
POD=$(kubectl get pods -n $NS -o name | fzf)
kubectl logs -f $POD -n $NS

# Git interactive fixup
COMMIT=$(git log --oneline -20 | fzf | awk '{print $1}')
git commit --fixup=$COMMIT

Pattern 4: Shell Functions for Repeated Workflows

# Add to .bashrc/.zshrc

# Kubernetes: select pod and get logs
kl() {
  local pod=$(kubectl get pods -o name "${@}" | fzf --preview 'kubectl logs --tail=20 {}')
  [ -n "$pod" ] && kubectl logs -f "$pod" "${@}"
}

# Kubernetes: select pod and exec into it
ke() {
  local pod=$(kubectl get pods -o name "${@}" | fzf)
  [ -n "$pod" ] && kubectl exec -it "$pod" "${@}" -- bash
}

# Git: checkout branch with fzf
gcb() {
  git checkout $(git branch --all | sed 's/remotes\/origin\///' | sort -u | fzf)
}

# Docker: select container and attach
da() {
  docker exec -it $(docker ps --format '{{.Names}}\t{{.Image}}' | fzf | awk '{print $1}') bash
}

Common Pitfalls

  1. Installing tools but not setting up shell integration. fzf without Ctrl+R/Ctrl+T integration is 30% of its value. zoxide without shell init does nothing.

  2. Not aliasing. If you install bat but still type cat, you get zero benefit. Alias aggressively:

    alias cat='bat --paging=never'
    alias ls='eza'
    alias find='fd'
    alias grep='rg'
    alias du='dust'
    alias diff='delta'
    

  3. Over-engineering jq queries. Start simple, iterate. Pipe through multiple jq calls if needed -- it is fast.

  4. Ignoring tmux. Every production SSH session should be inside tmux. The day your connection drops during a migration, you will understand.

  5. Not learning fzf keybindings. Tab for multi-select, Ctrl+/ for preview toggle, Ctrl+A/Ctrl+D for select/deselect all in multi mode.

  6. Using yq v3 syntax with v4. yq had a major syntax change. Check your version. mikefarah/yq v4 uses a different expression syntax than v3.

Decision Tree: Which Tool When

Need to find a FILE by name?      -> fd
Need to find CONTENT in files?    -> rg
Need to CHOOSE from a list?       -> fzf
Need to PARSE JSON?               -> jq
Need to PARSE/EDIT YAML?          -> yq
Need to VIEW a file?              -> bat
Need to LIST directory contents?  -> eza
Need to JUMP to a directory?      -> zoxide
Need to CHECK disk usage?         -> dust
Need to COMPARE files/diffs?      -> delta
Need to LOOK UP a command?        -> tldr
Need PROJECT-SPECIFIC env vars?   -> direnv
Need SESSION PERSISTENCE?         -> tmux