Well Structured CLI Tool
A guide for building well-structured Bash CLI tools using the dispatcher-namespace-command pattern.
A well-structured CLI tool follows consistent patterns that make it easy to extend, maintain, and use. This guide documents the dispatcher-namespace-command pattern used by three reference implementations: rd (ebook management), fm (file system management), and pd (iPod/music management).
Project structure
<tool>/
├── <tool> # Main dispatcher (executable)
├── lib/
│ ├── <tool>-common.sh # Shared utilities (styling, config, OS detection)
│ └── <tool>-*.sh # Tool-specific libraries (API clients, helpers)
├── <namespace1>/
│ ├── <tool>-<namespace1>.sh # Namespace handler with namespace_main()
│ ├── command1.sh # Individual command
│ └── command2.sh # Individual command
├── <namespace2>/
│ └── ...
├── Makefile # Installation and testing
├── CLAUDE.md # Architecture documentation
└── .gitignore
The tool name prefix (e.g., rd-, fm-, pd-) creates a clear namespace and makes it obvious which tool a script belongs to.
The dispatcher pattern
The main dispatcher is the single entry point that auto-discovers namespaces and routes commands:
#!/usr/bin/env bash
set -e
# Resolve script directory and set up paths
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -d "$SCRIPT_DIR/lib" ]; then
# Running from source
TOOL_ROOT="$SCRIPT_DIR"
TOOL_LIB_DIR="$SCRIPT_DIR/lib"
source "$TOOL_LIB_DIR/tool-common.sh"
elif [ -d "/usr/local/lib/tool" ]; then
# Installed system-wide
TOOL_ROOT="/usr/local/lib/tool"
TOOL_LIB_DIR="$TOOL_ROOT"
source "$TOOL_LIB_DIR/tool-common.sh"
else
echo "Error: Cannot find tool library directory"
exit 1
fi
export TOOL_ROOT
export TOOL_LIB_DIR
# Auto-discover namespaces by scanning for tool-<name>.sh files
discover_namespaces() {
local namespaces=()
for dir in "$TOOL_ROOT"/*/; do
if [ -d "$dir" ]; then
local ns=$(basename "$dir")
if [ -f "$dir/tool-${ns}.sh" ]; then
namespaces+=("$ns")
fi
fi
done
printf '%s\n' "${namespaces[@]}" | sort -u
}
# Show global help
show_help() {
tool_header "tool - Description"
echo "Usage:"
echo " tool <namespace> <command> [args]"
echo ""
echo "Available namespaces:"
discover_namespaces | while read -r ns; do
echo " $ns"
done
}
# Main dispatch logic
main() {
if ! command -v gum >/dev/null 2>&1; then
echo "Error: gum is required"
echo "Install with: brew install gum"
exit 1
fi
case "${1:-}" in
-h|--help|help|"")
show_help
;;
-v|--version|version)
echo "tool version 1.0.0"
;;
*)
local namespace="$1"
local handler="$TOOL_ROOT/$namespace/tool-${namespace}.sh"
if [ -f "$handler" ]; then
shift
export TOOL_NS_DIR="$TOOL_ROOT/$namespace"
source "$handler"
namespace_main "$@"
else
tool_error "Unknown namespace: $namespace"
discover_namespaces
exit 1
fi
;;
esac
}
main "$@"
The dispatcher exports three key variables:
TOOL_ROOT- Root directory of the tool installationTOOL_LIB_DIR- Path to shared librariesTOOL_NS_DIR- Current namespace directory (set per-namespace)
Namespace handlers
Each namespace has a handler file that routes to individual commands:
# <namespace>/tool-<namespace>.sh
namespace_show_help() {
tool_header "tool <namespace> - Description"
echo "Commands:"
echo " tool <namespace> command1 Do something"
echo " tool <namespace> command2 Do something else"
echo ""
echo "Options:"
echo " --dry-run, -n Show what would be done"
}
namespace_main() {
case "${1:-}" in
command1)
shift
source "$TOOL_NS_DIR/command1.sh"
command1_main "$@"
;;
command2)
shift
source "$TOOL_NS_DIR/command2.sh"
command2_main "$@"
;;
help|-h|--help|"")
namespace_show_help
;;
*)
tool_error "Unknown command: $1"
namespace_show_help
exit 1
;;
esac
}
Commands are sourced on-demand (lazy loading), which keeps startup fast and allows commands to have their own dependencies.
Command structure
Each command file has two required functions: <command>_main() for implementation and <command>_show_help() for usage:
# <namespace>/command.sh
command_show_help() {
echo "Usage: tool <namespace> command [options] <args>"
echo ""
echo "Description of what this command does."
echo ""
echo "Arguments:"
echo " <arg> Description of required argument"
echo ""
echo "Options:"
echo " -n, --dry-run Show what would be done without making changes"
echo " -h, --help Show this help message"
}
command_main() {
local dry_run=false
local target=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run)
dry_run=true
shift
;;
-h|--help)
command_show_help
return 0
;;
-*)
tool_error "Unknown option: $1"
command_show_help
return 1
;;
*)
target="$1"
shift
;;
esac
done
# Validate required arguments
if [[ -z "$target" ]]; then
tool_error "Missing required argument"
command_show_help
return 1
fi
# Load configuration
load_tool_env
# Implementation
if [[ "$dry_run" == true ]]; then
tool_warning "[DRY RUN] Would process: $target"
return 0
fi
# Actual work here
tool_success "Processed: $target"
}
Always support these flags in every command:
--help/-hfor usage information--dry-run/-nfor preview mode (where applicable)
Shared utilities library
The common library (lib/<tool>-common.sh) provides consistent styling and utilities across all commands.
Styled output with gum
tool_header() {
local title="$1"
gum style \
--foreground 212 --border-foreground 212 --border double \
--align center --width 60 --margin "1 2" --padding "1 2" \
"$title"
}
tool_success() {
gum style --foreground 42 "$1"
}
tool_warning() {
gum style --foreground 214 "$1" >&2
}
tool_error() {
gum style --foreground 196 "$1" >&2
}
tool_info() {
gum style --foreground 99 "$1"
}
Interactive prompts
tool_confirm() {
local prompt="$1"
local default="${2:-yes}"
if [[ "$default" == "yes" ]]; then
gum confirm --default=true "$prompt"
else
gum confirm --default=false "$prompt"
fi
}
tool_choose() {
gum choose "$@"
}
tool_choose_multi() {
gum choose --no-limit "$@"
}
tool_input() {
gum input "$@"
}
When using gum in scripts that might receive piped input, redirect from /dev/tty:
selection=$(echo "$options" | gum choose </dev/tty)
Dependency checking
require_commands() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
tool_error "Missing dependencies: ${missing[*]}"
echo "Install with: brew install ${missing[*]}"
exit 1
fi
}
Data directory management
TOOL_DATA_DIR="${TOOL_DATA_DIR:-$HOME/.tool}"
get_namespace_dir() {
local namespace="$1"
echo "${TOOL_DATA_DIR}/${namespace}"
}
ensure_namespace_dir() {
local namespace="$1"
local dir=$(get_namespace_dir "$namespace")
mkdir -p "$dir" "$dir/backups"
echo "$dir"
}
Configuration management
Configuration lives in a .env file in the tool’s data directory:
TOOL_DATA_DIR="${TOOL_DATA_DIR:-$HOME/.tool}"
TOOL_ENV_FILE="$TOOL_DATA_DIR/.env"
load_tool_env() {
if [[ -f "$TOOL_ENV_FILE" ]]; then
set -a # Auto-export all variables
source "$TOOL_ENV_FILE"
set +a
fi
}
ensure_tool_data_dir() {
mkdir -p "$TOOL_DATA_DIR"
if [[ ! -f "$TOOL_ENV_FILE" ]]; then
cat > "$TOOL_ENV_FILE" << 'EOF'
# Tool Configuration
# API_URL=http://localhost:8080
# API_KEY=your-api-key
# DATA_PATH=/path/to/data
EOF
tool_info "Created config template at $TOOL_ENV_FILE"
fi
}
Error handling
Use a three-layer approach for robust error handling:
#!/usr/bin/env bash
set -e # Layer 1: Exit on first unexpected error
# Layer 2: Function returns for expected errors
validate_config() {
if [[ ! -f "$CONFIG_FILE" ]]; then
tool_error "Config file not found: $CONFIG_FILE"
return 1
fi
return 0
}
# Layer 3: Styled messages for user feedback
if ! validate_config; then
tool_info "Run 'tool setup' to create configuration"
exit 1
fi
set -e: Catches programming errors and unexpected failures- Function returns: Handle expected failure conditions gracefully
- Styled messages: Communicate clearly with the user
Cross-platform support
Detect the operating system and provide platform-specific implementations:
detect_os() {
case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
TOOL_OS=$(detect_os)
export TOOL_OS
# Usage in commands
if [[ "$TOOL_OS" == "darwin" ]]; then
# macOS-specific code
mount_smbfs "$url" "$mount_point"
else
# Linux-specific code
sudo mount -t cifs "$share" "$mount_point"
fi
Makefile for installation
Provide standard targets for installation and testing:
.PHONY: help install uninstall check clean test
INSTALL_BIN = /usr/local/bin
INSTALL_LIB = /usr/local/lib/tool
help:
@echo "tool - Description"
@echo ""
@echo "Usage:"
@echo " make install Install to /usr/local/bin"
@echo " make uninstall Remove installed scripts"
@echo " make check Check dependencies"
@echo " make clean Remove temporary files"
@echo " make test Test the installation"
check:
@echo "Checking dependencies..."
@command -v bash >/dev/null 2>&1 || (echo "bash not found" && exit 1)
@command -v gum >/dev/null 2>&1 || (echo "gum not found" && exit 1)
@command -v jq >/dev/null 2>&1 || (echo "jq not found" && exit 1)
@echo "All dependencies installed!"
install: check
@mkdir -p $(INSTALL_BIN) $(INSTALL_LIB)
@cp tool $(INSTALL_BIN)/tool
@chmod +x $(INSTALL_BIN)/tool
@cp -r lib $(INSTALL_LIB)/
@for ns in */; do \
if [ -f "$$ns/tool-$${ns%/}.sh" ]; then \
mkdir -p $(INSTALL_LIB)/$$ns && \
cp $$ns/*.sh $(INSTALL_LIB)/$$ns/; \
fi \
done
@echo "Installed to $(INSTALL_BIN)/tool"
uninstall:
@rm -f $(INSTALL_BIN)/tool
@rm -rf $(INSTALL_LIB)
@echo "Uninstalled. Data in ~/.tool preserved."
clean:
@rm -rf /tmp/tool
test:
@./tool help
Documentation with CLAUDE.md
Include a CLAUDE.md file in the project root to document architecture for AI-assisted development:
# tool - Description
## Project overview
Brief description of what the tool does.
## Directory structure
Annotated tree diagram.
## Architecture patterns
- Namespace discovery
- Namespace handler pattern
- Command pattern
- Shared utilities
## Configuration
Where config lives and what variables are used.
## Dependencies
List of required tools with install commands.
## Adding new components
### New namespace
### New command
## Command reference
Quick reference for all commands.
## Testing commands
Examples for testing during development.
Common dependencies
| Tool | Purpose | Install (macOS) |
|---|---|---|
| bash 4+ | Modern shell features (arrays, associative arrays) | brew install bash |
| gum | Terminal UI (styling, prompts, spinners) | brew install gum |
| jq | JSON processing | brew install jq |
| yq | YAML processing | brew install yq |
| curl | HTTP requests | (built-in) |
- For JSON processing patterns, see Jq. For text processing, see Awk and Bash Substitution.
- For tools that need to manipulate YAML configuration, see Yq and Yq Frontmatter Manipulation.
- For information on gum see Gum.
Best practices
- Use
gumfor all interactive prompts instead of rawread - Always support
--dry-runand--helpflags - Use
</dev/ttyfor gum commands when input might be piped - Validate configuration before performing operations
- Initialize data directories on first run with
ensure_tool_data_dir - Preserve user data on uninstall (only remove installed scripts)
- Use function returns (0/1) instead of global exit codes where possible
Checklist for new tools
- Create main dispatcher with
discover_namespaces() - Create
lib/<tool>-common.shwith styling and utilities - Create first namespace with handler and at least one command
- Add
Makefilewith standard targets - Create
CLAUDE.mdwith architecture documentation - Create
~/.<tool>/.envconfiguration template - Test with
./tool helpand./tool <namespace> --help - Add
.gitignorefor data files and temp directories