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¶
-
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.
-
Not aliasing. If you install bat but still type
cat, you get zero benefit. Alias aggressively: -
Over-engineering jq queries. Start simple, iterate. Pipe through multiple jq calls if needed -- it is fast.
-
Ignoring tmux. Every production SSH session should be inside tmux. The day your connection drops during a migration, you will understand.
-
Not learning fzf keybindings. Tab for multi-select, Ctrl+/ for preview toggle, Ctrl+A/Ctrl+D for select/deselect all in multi mode.
-
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