A personal CLI in Bash: managing your Linux workstation from the terminal (full guide)

8 min readApril 30, 2026#bash#cli#terminal#zsh#fzf#docker#workstation#linux#outils

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 types hub with no argument, shift would fail on an empty array. || true keeps set -e from 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 >&2 for 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 --bind section 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 -e can 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 -d command 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/hub

To 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"
fi

declare -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.


~/.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


The second brain this CLI drives The AI assistant built on top of it