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
Naming convention

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 "$@"
Environment variables

The dispatcher exports three key variables:

  • TOOL_ROOT - Root directory of the tool installation
  • TOOL_LIB_DIR - Path to shared libraries
  • TOOL_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"
}
Standard flags

Always support these flags in every command:

  • --help / -h for usage information
  • --dry-run / -n for 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 "$@"
}
Handling piped 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
When to use each layer

  • 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

ToolPurposeInstall (macOS)
bash 4+Modern shell features (arrays, associative arrays)brew install bash
gumTerminal UI (styling, prompts, spinners)brew install gum
jqJSON processingbrew install jq
yqYAML processingbrew install yq
curlHTTP requests(built-in)

Best practices

  • Use gum for all interactive prompts instead of raw read
  • Always support --dry-run and --help flags
  • Use </dev/tty for 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.sh with styling and utilities
  • Create first namespace with handler and at least one command
  • Add Makefile with standard targets
  • Create CLAUDE.md with architecture documentation
  • Create ~/.<tool>/.env configuration template
  • Test with ./tool help and ./tool <namespace> --help
  • Add .gitignore for data files and temp directories