Skip to main content

Building CLI Tools: Command-Line Applications with Modern Tools

Published: March 12, 2026 Updated: May 24, 2026 Larry Qu 7 min read

Introduction

Command-line tools remain essential for developer productivity. A well-designed CLI can become a daily driver for users. This guide covers building CLI applications with modern frameworks, handling arguments, and creating delightful experiences.

A great CLI is discoverable, consistent, and provides helpful feedback.

Architecture

┌─────────────────────────────────────────────────────────────┐
│                    CLI Architecture                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────┐                                           │
│   │   Entry     │                                           │
│   │   Point     │                                           │
│   └──────┬──────┘                                           │
│          │                                                   │
│   ┌──────▼──────┐                                           │
│   │   Command   │                                           │
│   │   Parser    │                                           │
│   └──────┬──────┘                                           │
│          │                                                   │
│   ┌──────▼──────┐                                           │
│   │   Commands  │                                           │
│   └──────┬──────┘                                           │
│          │                                                   │
│   ┌──────▼──────┐                                           │
│   │   Business  │                                           │
│   │    Logic    │                                           │
│   └─────────────┘                                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Python CLI with Click

import click
from typing import Optional

@click.group()
@click.version_option(version="1.0.0")
def cli():
    """A sample CLI application."""
    pass

@cli.command()
@click.argument("name")
@click.option(
    "--count", "-c",
    default=1,
    type=int,
    help="Number of greetings"
)
@click.option(
    "--excited",
    is_flag=True,
    help="Add excitement"
)
def hello(name: str, count: int, excited: bool):
    """Greet NAME with enthusiasm."""
    exclamation = "!" if excited else "."
    
    for _ in range(count):
        if excited:
            click.echo(f"Hello, {name}{exclamation * 3}")
        else:
            click.echo(f"Hello, {name}{exclamation}")

@cli.command()
@click.argument("file", type=click.Path(exists=True))
@click.option("--lines", "-n", type=int, help="Number of lines")
def tail(file: str, lines: Optional[int]):
    """Display the last lines of FILE."""
    with open(file) as f:
        content = f.readlines()
    
    start = -lines if lines else -10
    for line in content[start:]:
        click.echo(line.rstrip())

@cli.group()
def config():
    """Manage configuration."""
    pass

@config.command("set")
@click.argument("key")
@click.argument("value")
def config_set(key: str, value: str):
    """Set a configuration value."""
    click.echo(f"Setting {key}={value}")

@config.command("get")
@click.argument("key")
def config_get(key: str):
    """Get a configuration value."""
    click.echo(f"{key}=some_value")

if __name__ == "__main__":
    cli()

Go CLI with Cobra

package main

import (
	"fmt"
	"github.com/spf13/cobra"
	"os"
)

var (
	rootCmd = &cobra.Command{
		Use:   "app",
		Short: "A sample CLI application",
		Long:  `A longer description that spans multiple lines.`,
	}

	name    string
	excited bool
	count   int
)

func init() {
	rootCmd.AddCommand(helloCmd)
	
	helloCmd.Flags().StringVarP(&name, "name", "n", "World", "Name to greet")
	helloCmd.Flags().BoolVarP(&excited, "excited", "e", false, "Add excitement")
	helloCmd.Flags().IntVarP(&count, "count", "c", 1, "Number of greetings")
}

var helloCmd = &cobra.Command{
	Use:   "hello",
	Short: "Greet someone",
	Run: func(cmd *cobra.Command, args []string) {
		exclamation := "."
		if excited {
			exclamation = "!!!"
		}
		
		for i := 0; i < count; i++ {
			fmt.Printf("Hello, %s%s\n", name, exclamation)
		}
	},
}

func main() {
	if err := rootCmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Interactive Prompts

import click
from click import prompt

@cli.command()
def create_project():
    """Create a new project."""
    name = prompt("Project name", default="my-project")
    
    # With validation
    framework = prompt(
        "Framework",
        type=click.Choice(["react", "vue", "angular", "svelte"]),
        default="react"
    )
    
    # Boolean confirmation
    use_typescript = click.confirm("Use TypeScript?", default=True)
    
    # Password input
    api_key = click.prompt("API Key", hide_input=True)
    
    # Display summary
    click.echo(f"\nCreating project: {name}")
    click.echo(f"Framework: {framework}")
    click.echo(f"TypeScript: {use_typescript}")

Progress and Feedback

import click
import time

@cli.command()
def process_files():
    """Process multiple files with progress."""
    files = [f"file_{i}.txt" for i in range(10)]
    
    with click.progressbar(files, label="Processing files") as bar:
        for file in bar:
            # Simulate processing
            time.sleep(0.1)

@cli.command()
def long_task():
    """A long-running task with spinner."""
    with click_spinner.spinner():
        # Do work
        time.sleep(3)
    
    click.echo("Done!")

@cli.command()
def download():
    """Download with progress bar."""
    with click.progressbar(length=100, label="Downloading") as bar:
        for i in range(10):
            time.sleep(0.1)
            bar.update(10)

Error Handling and Exit Codes

Professional CLIs use proper exit codes and structured error messages:

import sys
import click

class CLIError(Exception):
    """Base exception for CLI errors."""
    def __init__(self, message: str, exit_code: int = 1):
        self.message = message
        self.exit_code = exit_code
        super().__init__(message)

class ConfigError(CLIError):
    """Configuration related errors."""
    pass

class NetworkError(CLIError):
    """Network related errors."""
    def __init__(self, message: str, status_code: int = 500):
        self.status_code = status_code
        super().__init__(message, exit_code=2)

class ValidationError(CLIError):
    """Input validation errors."""
    def __init__(self, message: str):
        super().__init__(message, exit_code=3)

# Centralized error handler
def handle_error(error: CLIError):
    """Display error and exit with appropriate code."""
    click.echo(f"Error: {error.message}", err=True)
    if isinstance(error, ValidationError):
        click.echo("Use --help for usage information", err=True)
    elif isinstance(error, NetworkError):
        click.echo(f"HTTP {error.status_code}: check your connection", err=True)
    sys.exit(error.exit_code)

@cli.command()
@click.argument("hostname")
def ping(hostname: str):
    """Ping a host and return latency."""
    try:
        if not hostname.strip():
            raise ValidationError("hostname cannot be empty")
        result = network_ping(hostname)
        click.echo(f"{hostname}: {result.latency}ms")
    except CLIError as e:
        handle_error(e)

Configuration Management

Loading from Multiple Sources

Professional CLIs load configuration from multiple sources with clear precedence:

import os
import yaml
from pathlib import Path
from typing import Any, Dict

class ConfigManager:
    """Load configuration with layered precedence.
    
    Precedence (highest to lowest):
    1. CLI flags
    2. Environment variables
    3. User config file (~/.myapp/config.yaml)
    4. Project config file (.myapp.yaml in current dir)
    5. Defaults
    """

    DEFAULTS = {
        "host": "localhost",
        "port": 8080,
        "timeout": 30,
        "verbose": False,
        "format": "json"
    }

    def __init__(self, app_name: str):
        self.app_name = app_name
        self.config: Dict[str, Any] = {}
        self._load_defaults()
        self._load_project_config()
        self._load_user_config()
        self._load_env_vars()

    def _load_defaults(self):
        self.config = dict(self.DEFAULTS)

    def _load_project_config(self):
        path = Path.cwd() / f".{self.app_name}.yaml"
        if path.exists():
            with open(path) as f:
                self.config.update(yaml.safe_load(f) or {})

    def _load_user_config(self):
        path = Path.home() / f".{self.app_name}" / "config.yaml"
        if path.exists():
            with open(path) as f:
                self.config.update(yaml.safe_load(f) or {})

    def _load_env_vars(self):
        prefix = self.app_name.upper()
        for key in self.config:
            env_key = f"{prefix}_{key.upper()}"
            if env_key in os.environ:
                value = os.environ[env_key]
                # Type conversion
                if isinstance(self.config[key], bool):
                    self.config[key] = value.lower() in ("true", "1", "yes")
                elif isinstance(self.config[key], int):
                    self.config[key] = int(value)
                else:
                    self.config[key] = value

    def get(self, key: str, default=None):
        return self.config.get(key, default)

    def override(self, key: str, value):
        """Override with CLI flag values (highest priority)."""
        self.config[key] = value

# Usage
config = ConfigManager("myapp")
host = config.get("host")  # From env MYAPP_HOST or config file

Color and Rich Output

Modern CLIs use colors, formatting, and structured output:

from rich.console import Console
from rich.table import Table
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.panel import Panel
from rich.syntax import Syntax

console = Console()

@cli.command()
@click.option("--format", type=click.Choice(["table", "json", "yaml"]), default="table")
def list_services(format: str):
    """List all services with status."""
    services = [
        {"name": "api-gateway", "status": "running", "version": "1.2.3", "uptime": "14d"},
        {"name": "auth-service", "status": "running", "version": "2.0.1", "uptime": "7d"},
        {"name": "payment-worker", "status": "degraded", "version": "1.9.0", "uptime": "1h"},
        {"name": "notification", "status": "stopped", "version": "0.8.0", "uptime": "0s"},
    ]

    if format == "table":
        table = Table(title="Services")
        table.add_column("Name", style="cyan")
        table.add_column("Status", style="bold")
        table.add_column("Version", style="green")
        table.add_column("Uptime")

        for s in services:
            status_style = {
                "running": "green",
                "degraded": "yellow",
                "stopped": "red"
            }.get(s["status"], "white")
            table.add_row(
                s["name"],
                f"[{status_style}]{s['status']}[/]",
                s["version"],
                s["uptime"]
            )
        console.print(table)
    elif format == "json":
        import json
        console.print(json.dumps(services, indent=2))
    elif format == "yaml":
        console.print(yaml.dump(services, default_flow_style=False))

Shell Completion

Generate shell completion scripts for better user experience:

@cli.command()
def completion():
    """Generate shell completion script."""
    import click_completion
    click_completion.init()

    shell = click.prompt(
        "Shell",
        type=click.Choice(["bash", "zsh", "fish"]),
        default="bash"
    )

    script = click_completion.get_code(shell=shell)
    click.echo(script)

    click.echo(f"\nAdd the above to your ~/.{shell}rc:", err=True)
    click.echo(f"  eval '$({APP_NAME} completion)'", err=True)

# Usage
# $ myapp completion
# Then add to .bashrc: eval "$(myapp completion)"

Testing CLI Applications

CLI tools need rigorous testing of argument parsing, output, and error paths:

import pytest
from click.testing import CliRunner

class TestCLI:
    def setup_method(self):
        self.runner = CliRunner()

    def test_hello_with_name(self):
        """Test basic greeting."""
        result = self.runner.invoke(cli, ["hello", "--name", "World"])
        assert result.exit_code == 0
        assert "Hello, World" in result.output

    def test_hello_excited(self):
        """Test excited flag."""
        result = self.runner.invoke(cli, [
            "hello", "--name", "Test", "--excited"
        ])
        assert result.exit_code == 0
        assert "!" in result.output

    def test_tail_with_file(self):
        """Test file reading."""
        with self.runner.isolated_filesystem():
            # Create test file
            with open("test.txt", "w") as f:
                f.write("line1\nline2\nline3\n")

            result = self.runner.invoke(cli, ["tail", "test.txt", "--lines", "2"])
            assert result.exit_code == 0
            assert "line2" in result.output
            assert "line3" in result.output

    def test_tail_nonexistent_file(self):
        """Test error handling for missing file."""
        result = self.runner.invoke(cli, ["tail", "nonexistent.txt"])
        assert result.exit_code != 0  # Should fail
        assert "Error" in result.output or "error" in result.output

    def test_invalid_input_fails(self):
        """Test validation."""
        result = self.runner.invoke(cli, ["ping", ""])
        assert result.exit_code == 3  # ValidationError
        assert "empty" in result.output.lower()

    def test_help_output(self):
        """Test help text is informative."""
        result = self.runner.invoke(cli, ["--help"])
        assert result.exit_code == 0
        assert "Usage:" in result.output
        assert "Commands:" in result.output

    def test_json_output_format(self):
        """Test structured output."""
        result = self.runner.invoke(cli, [
            "list-services", "--format", "json"
        ])
        assert result.exit_code == 0
        import json
        data = json.loads(result.output)
        assert isinstance(data, list)

Best Practices

  1. Use subcommands: Organize functionality
  2. Provide defaults: Sensible defaults reduce friction
  3. Validate input: Clear error messages
  4. Show progress: For long operations
  5. Color output: Use colors for errors and success
  6. Document thoroughly: Help text for every command

Conclusion

Great CLI tools empower users to be productive. By following these patterns and using modern frameworks, you can create CLI applications that are a joy to use.

Comments

👍 Was this article helpful?