Building a personal CLI in Bash — managing your workstation from the terminal
Most developers have dozens of aliases sitting in their .zshrc.
The problem: you stop finding them again. An alias with no documentation becomes a mystery after three weeks. And you can't combine several operations without writing a new throwaway script.
A personal CLI is the fix. One command, well-named subcommands, a system that documents itself.
This tutorial walks through how I built hub — the CLI that manages my Debian workstation: updates, Docker, my second brain, folder navigation, and more.
Prerequisites: Bash 5+, basic shell scripting knowledge, Linux or macOS. Source code:
~/.local/bin/hub— a single Bash file, ~1,400 lines.
What we're building
hub # RAM/disk/containers dashboard
hub update # update apt + flatpak + npm + pip
hub docker up penpot # start a Docker service
hub brain search "rag" # search the second brain
hub go bloko # navigate to ~/projects/bloko-platform
hub sys clean # clean up logs + apt cache
One entry point. Intuitive subcommands. Interactive interface via fzf when no argument is given.
1. The structure of a Bash CLI
A well-built Bash CLI relies on a main router that dispatches to dedicated functions.
#!/usr/bin/env bash
set -euo pipefail
# ── Main router ──────────────────────────────────────────
main() {
local cmd="${1:-}"; shift 2>/dev/null || true
case "$cmd" in
""|-d|dashboard) cmd_dashboard ;;
update|up) cmd_update "$@" ;;
docker|dk) cmd_docker "$@" ;;
brain|br) cmd_brain "$@" ;;
go|cd) cmd_go "$@" ;;
sys|system) cmd_sys "$@" ;;
help|-h|--help) cmd_help ;;
*)
_err "Unknown command: $cmd"
exit 1
;;
esac
}
main "$@"This pattern — case/esac on $1 with shift — lets you pass the remaining arguments down to the subcommand. cmd_docker "$@" then receives up penpot when you type hub docker up penpot.
Why
shift 2>/dev/null || true? If the user just typeshubwith no argument,shiftwould fail on an empty array.|| truekeepsset -efrom killing the script.
2. Color system and terminal UI
A readable terminal interface relies on consistent ANSI codes. Define them once, at the top of the file.
# ── Colors ──────────────────────────────────────────────────
R=$'\033[0;31m' # Red — error
G=$'\033[0;32m' # Green — success
Y=$'\033[1;33m' # Yellow — warning
B=$'\033[0;34m' # Blue — title
C=$'\033[0;36m' # Cyan — info
D=$'\033[2m' # Dim — metadata
N=$'\033[0m' # Reset
BOLD=$'\033[1m'
# ── Display helpers ───────────────────────────────────────
_ok() { echo -e " ${G}✓${N} $*"; }
_err() { echo -e " ${R}✗${N} $*" >&2; }
_warn() { echo -e " ${Y}!${N} $*"; }
_info() { echo -e " ${C}→${N} $*"; }
_line() { printf "${D}%s${N}\n" "$(printf '─%.0s' {1..58})"; }Usage:
_ok "Service started"
_err "File not found"
_info "Loading..."Tip: Use
>&2for errors. They go to stderr, not stdout — your pipes stay clean.
3. Interactive menus with fzf
fzf is a CLI fuzzy finder. It reads text from stdin and returns the selected line.
_fzf_pick() {
local prompt="${1:-Choose}"
shift
if command -v fzf &>/dev/null; then
printf '%s\n' "$@" | fzf \
--prompt=" ${prompt}: " \
--height=40% \
--border=rounded \
--margin=1,2 \
--no-info \
--pointer='›' \
2>/dev/null || true
else
# Fallback without fzf
select opt in "$@"; do echo "$opt"; break; done
fi
}Usage inside a subcommand:
cmd_docker() {
local sub="${1:-}"
if [ -z "$sub" ]; then
local choice
choice=$(_fzf_pick "Action" \
"▶ up — Start a service" \
"■ down — Stop a service" \
"📋 status — Container status")
case "$choice" in
*up*) _docker_up ;;
*down*) _docker_down ;;
*status*) _docker_status ;;
esac
return
fi
# Direct mode: hub docker up penpot
case "$sub" in
up) _docker_up "${2:-}" ;;
down) _docker_down "${2:-}" ;;
*) _err "Unknown subcommand: $sub" ;;
esac
}The pattern: no argument → interactive fzf menu. Argument → direct execution. Both modes coexist.
fzf documentation: github.com/junegunn/fzf — the
--bindsection lets you add keyboard shortcuts inside menus.
4. A spinner for long operations
Operations that take several seconds need visual feedback.
_spinner() {
local pid=$1 msg="${2:-Loading}"
local chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${C}%s${N} %s " "${chars:$((i % ${#chars})):1}" "$msg"
sleep 0.1
((i++))
done
printf "\r%-60s\r" " "
}Usage:
(sudo apt upgrade -y) &
_spinner $! "Updating apt..."
wait
_ok "Update complete"The process runs in the background (&). _spinner keeps spinning as long as the PID exists. wait waits for it to finish cleanly.
Watch out:
set -ecan interact oddly with background subshells. If the long-running command can fail, append|| true.
5. Docker management with registered services
Declare your services in a declare -A at the top of the file.
declare -A DOCKER_SERVICES=(
[penpot]="$HOME/penpot|http://localhost:9001|Design UI"
[listmonk]="$HOME/listmonk|http://localhost:9002|Email marketing"
)Format: path|url|description. The | separator makes parsing with IFS easy.
_docker_up() {
local svc="${1:-}"
if [ -z "$svc" ]; then
svc=$(_fzf_pick "Start" "${!DOCKER_SERVICES[@]}") || return
fi
local info="${DOCKER_SERVICES[$svc]:-}"
[ -z "$info" ] && { _err "Unknown service: $svc"; return 1; }
local dir url
IFS='|' read -r dir url _ <<< "$info"
_info "Starting ${BOLD}$svc${N}..."
(cd "$dir" && docker compose up -d)
_ok "$svc started → $url"
}Docker Compose documentation: docs.docker.com/compose/ — the
docker compose up -dcommand starts services in detached mode.
6. Folder navigation (the hub go case)
hub go bloko needs to change the current directory of the parent shell. That's impossible from a child script — a cd inside a subshell doesn't affect the parent.
The solution: a zsh/bash wrapper function that calls the script and interprets its output.
Inside the hub script:
cmd_go() {
local dest="${1:-}"
# ...
local path="${QUICK_GOTO[$dest]:-$PROJECTS_DIR/$dest}"
[ -d "$path" ] && echo "$path" || return 1
}Inside ~/.zshrc:
function hub() {
if [[ "$1" == "go" ]] || [[ "$1" == "cd" ]]; then
local target
target=$(command hub _resolve_go "${@:2}") && cd "$target" || command hub "$@"
else
command hub "$@"
fi
}The script prints the path to stdout. The zsh function captures it and does the cd itself.
Tip: This pattern — script prints, wrapper interprets — works for anything that needs to modify the parent shell's environment (variable exports, and so on).
7. Installation and updates
Put the script in ~/.local/bin/ — it's in the default PATH on most modern distributions.
mkdir -p ~/.local/bin
cp hub ~/.local/bin/hub
chmod +x ~/.local/bin/hubTo make sure ~/.local/bin is on the PATH:
# In ~/.zshrc or ~/.bashrc
export PATH="$HOME/.local/bin:$PATH"To update the CLI from itself:
cmd_update() {
case "${1:-all}" in
all) bash "$SCRIPTS_DIR/update-all.sh" ;;
apt) sudo apt update && sudo apt upgrade -y ;;
npm) npm update -g ;;
pip) pip3 list --outdated --format=freeze | cut -d= -f1 | xargs -r pip3 install --upgrade ;;
*) _err "Unknown source: $1" ;;
esac
}8. Self-documentation
A CLI that doesn't explain itself becomes unusable after three months.
cmd_help() {
cat << EOF
${BOLD}USAGE${N}
hub [command] [subcommand] [args]
${BOLD}COMMANDS${N}
${C}hub${N} System dashboard
${C}hub update${N} [source] Update (apt, flatpak, npm, pip...)
${C}hub docker${N} [action] Manage Docker containers
${C}hub brain${N} [action] Second brain (notes, projects, journal)
${C}hub go${N} [dest] Navigate to a folder
${C}hub sys${N} [action] System maintenance
${C}hub help${N} This help text
EOF
}Rule: Every subcommand gets its own section in
cmd_help. If you add a command, you add its docs at the same time.
9. Tips and common pitfalls
Never cd inside a function without a subshell. A cd /tmp inside a function changes the working directory for the entire script.
# ✗ Dangerous
_docker_up() {
cd "$dir"
docker compose up -d
}
# ✓ Correct — isolated subshell
_docker_up() {
(cd "$dir" && docker compose up -d)
}Always set -euo pipefail. -e stops on error, -u stops on undefined variables, -o pipefail propagates errors through pipes.
Check whether a command exists before calling it.
if ! command -v fzf &>/dev/null; then
_warn "fzf not installed — fallback mode enabled"
fideclare -A arrays aren't exportable. Bash associative arrays don't pass down to subshells via export. Define them in the main script, not in separately sourced files.
Recommended final structure
~/.local/bin/hub # Main script
~/Scripts/
├── update-all.sh # Update script (called by hub update)
├── brain-session.sh # Claude Code hook → Brain session note
└── launchers/
└── docker-app.sh # Docker launcher → Chromium --app
One main file, dedicated scripts for heavier operations.
Resources
- Bash Reference Manual — the complete reference
- fzf — fuzzy finder, the backbone of the interactive menus
- Docker Compose CLI — every
docker composesubcommand - Pure Bash Bible — Bash recipes with no external dependencies
→ The second brain this CLI drives → The AI assistant built on top of it