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
- Use subcommands: Organize functionality
- Provide defaults: Sensible defaults reduce friction
- Validate input: Clear error messages
- Show progress: For long operations
- Color output: Use colors for errors and success
- 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