Click builds Python CLI tools. pip install click. Command: import click. @click.command() def hello(): click.echo("Hello!"). if __name__ == "__main__": hello(). Option: @click.option("--name", default="World", help="Name to greet"). def hello(name): click.echo(f"Hello, {name}!"). Required: @click.option("--email", required=True). Argument: @click.argument("filename"). Type: @click.option("--count", type=int, default=1). click.Choice(["json","yaml","toml"]). click.Path(exists=True, file_okay=True). click.File("r"). click.IntRange(1,100). click.DateTime(formats=["%Y-%m-%d"]). Flag: @click.option("--verbose/--no-verbose", default=False). @click.option("--dry-run", is_flag=True). Multiple: @click.option("--tag", multiple=True) — --tag a --tag b → ("a","b"). Group: @click.group() def cli(): pass. @cli.command() def create(): pass. @cli.command() def delete(): pass. Subcommand: @cli.group() def users(): pass. @users.command() def list(): pass. Context: @click.pass_context def cmd(ctx): ctx.ensure_object(dict); ctx.obj["key"] = val. @click.pass_obj def subcmd(obj): .... invoke_without_command: @click.group(invoke_without_command=True) def cli(ctx): if ctx.invoked_subcommand is None: click.echo("run a subcommand"). Prompt: @click.option("--name", prompt="Your name"). Password: @click.option("--password", prompt=True, hide_input=True, confirmation_prompt=True). Confirm: click.confirm("Delete?", abort=True). progressbar: with click.progressbar(items, label="Processing") as bar: for item in bar: .... Style: click.style("text", fg="green", bold=True). click.secho("msg", fg="red", err=True). Testing: from click.testing import CliRunner. runner = CliRunner(); result = runner.invoke(cmd, ["--name","Alice"]); assert result.exit_code == 0; assert "Alice" in result.output. Claude Code generates Click command groups, typed option decorators, and CliRunner test suites.
CLAUDE.md for Click
## Click Stack
- Version: click >= 8.1 | pip install click
- Command: @click.command() | @click.group() for multi-command CLIs
- Options: @click.option("--flag", type=click.Choice([...]), required=True)
- Arguments: @click.argument("PATH", type=click.Path(exists=True))
- Context: @click.pass_context / @click.pass_obj for shared state across commands
- Test: CliRunner().invoke(cmd, args) — captures output and exit_code
- Echo: click.echo() for stdout | click.secho(fg="red", err=True) for stderr
Click CLI Pipeline
#!/usr/bin/env python3
# app/cli.py — Click CLI with groups, typed options, context, and tests
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Optional
import click
from click.testing import CliRunner
# ─────────────────────────────────────────────────────────────────────────────
# Shared state — passed via context object
# ─────────────────────────────────────────────────────────────────────────────
class Config:
def __init__(self, verbose: bool = False, output_format: str = "text") -> None:
self.verbose = verbose
self.output_format = output_format
self._data: list[dict] = [
{"id": 1, "email": "[email protected]", "role": "admin"},
{"id": 2, "email": "[email protected]", "role": "user"},
]
def emit(self, data: object) -> None:
if self.output_format == "json":
click.echo(json.dumps(data, indent=2))
else:
click.echo(str(data))
def vlog(self, message: str) -> None:
if self.verbose:
click.secho(f"[DEBUG] {message}", fg="cyan", err=True)
# ─────────────────────────────────────────────────────────────────────────────
# Root group
# ─────────────────────────────────────────────────────────────────────────────
@click.group()
@click.option("--verbose", "-v", is_flag=True, default=False,
help="Enable verbose output.")
@click.option("--format", "output_format",
type=click.Choice(["text", "json"], case_sensitive=False),
default="text", show_default=True,
help="Output format.")
@click.version_option(version="1.0.0", prog_name="myapp")
@click.pass_context
def cli(ctx: click.Context, verbose: bool, output_format: str) -> None:
"""myapp — example CLI built with Click."""
ctx.ensure_object(dict)
ctx.obj = Config(verbose=verbose, output_format=output_format)
# ─────────────────────────────────────────────────────────────────────────────
# users sub-group
# ─────────────────────────────────────────────────────────────────────────────
@cli.group()
def users() -> None:
"""Manage users."""
@users.command("list")
@click.option("--role", type=click.Choice(["user", "moderator", "admin"]),
default=None, help="Filter by role.")
@click.option("--limit", type=click.IntRange(1, 1000), default=20,
show_default=True, help="Maximum results.")
@click.pass_obj
def users_list(cfg: Config, role: Optional[str], limit: int) -> None:
"""List users, optionally filtered by role."""
cfg.vlog(f"listing users role={role} limit={limit}")
data = cfg._data
if role:
data = [u for u in data if u["role"] == role]
cfg.emit(data[:limit])
@users.command("get")
@click.argument("user_id", type=int)
@click.pass_obj
def users_get(cfg: Config, user_id: int) -> None:
"""Get a single user by ID."""
cfg.vlog(f"fetching user_id={user_id}")
user = next((u for u in cfg._data if u["id"] == user_id), None)
if user is None:
click.secho(f"Error: user {user_id} not found.", fg="red", err=True)
raise SystemExit(1)
cfg.emit(user)
@users.command("create")
@click.option("--email", required=True, help="User email address.")
@click.option("--role", type=click.Choice(["user", "moderator", "admin"]),
default="user", show_default=True, help="User role.")
@click.option("--password", prompt=True, hide_input=True,
confirmation_prompt=True, help="Password (prompted if omitted).")
@click.pass_obj
def users_create(cfg: Config, email: str, role: str, password: str) -> None:
"""Create a new user."""
cfg.vlog(f"creating user email={email} role={role}")
new_id = max(u["id"] for u in cfg._data) + 1
user = {"id": new_id, "email": email, "role": role}
cfg._data.append(user)
click.secho(f"Created user #{new_id}: {email}", fg="green")
cfg.emit(user)
@users.command("delete")
@click.argument("user_id", type=int)
@click.option("--yes", "-y", is_flag=True, default=False,
help="Skip confirmation prompt.")
@click.pass_obj
def users_delete(cfg: Config, user_id: int, yes: bool) -> None:
"""Delete a user by ID."""
user = next((u for u in cfg._data if u["id"] == user_id), None)
if user is None:
click.secho(f"Error: user {user_id} not found.", fg="red", err=True)
raise SystemExit(1)
if not yes:
click.confirm(f"Delete user #{user_id} ({user['email']})?", abort=True)
cfg._data = [u for u in cfg._data if u["id"] != user_id]
click.secho(f"Deleted user #{user_id}.", fg="yellow")
# ─────────────────────────────────────────────────────────────────────────────
# File processing command — click.Path + click.File
# ─────────────────────────────────────────────────────────────────────────────
@cli.command("import")
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False))
@click.option("--output", "-o",
type=click.Path(dir_okay=False, writable=True),
default="-",
help="Output file path (default: stdout).")
@click.option("--dry-run", is_flag=True, default=False,
help="Validate without writing output.")
@click.pass_obj
def import_data(cfg: Config, input_file: str, output: str, dry_run: bool) -> None:
"""Import users from a JSON file."""
cfg.vlog(f"importing from {input_file}")
with open(input_file) as f:
records = json.load(f)
if not isinstance(records, list):
click.secho("Error: file must contain a JSON array.", fg="red", err=True)
raise SystemExit(1)
click.echo(f"Found {len(records)} record(s).")
if dry_run:
click.secho("Dry run — no changes written.", fg="yellow")
return
out_data = json.dumps(records, indent=2)
if output == "-":
click.echo(out_data)
else:
Path(output).write_text(out_data)
click.secho(f"Written to {output}", fg="green")
# ─────────────────────────────────────────────────────────────────────────────
# progressbar demo
# ─────────────────────────────────────────────────────────────────────────────
@cli.command("process")
@click.argument("count", type=click.IntRange(1, 10_000), default=5)
@click.pass_obj
def process_items(cfg: Config, count: int) -> None:
"""Process N items with a progress bar."""
items = range(count)
with click.progressbar(items, label=f"Processing {count} items") as bar:
results = []
for i in bar:
results.append(i * 2)
click.secho(f"Done. Processed {len(results)} items.", fg="green")
# ─────────────────────────────────────────────────────────────────────────────
# Tests — CliRunner
# ─────────────────────────────────────────────────────────────────────────────
def run_tests() -> None:
runner = CliRunner()
# List all users
result = runner.invoke(cli, ["users", "list"])
assert result.exit_code == 0, result.output
assert "[email protected]" in result.output
print("PASS: users list")
# List with JSON format
result = runner.invoke(cli, ["--format", "json", "users", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data) == 2
print("PASS: users list --format json")
# Filter by role
result = runner.invoke(cli, ["users", "list", "--role", "admin"])
assert result.exit_code == 0
assert "alice" in result.output
assert "bob" not in result.output
print("PASS: users list --role admin")
# Get existing user
result = runner.invoke(cli, ["users", "get", "1"])
assert result.exit_code == 0
assert "alice" in result.output
print("PASS: users get 1")
# Get missing user
result = runner.invoke(cli, ["users", "get", "999"])
assert result.exit_code == 1
print("PASS: users get 999 (not found)")
# Create user — CliRunner supplies password via input
result = runner.invoke(cli, ["users", "create", "--email", "[email protected]"],
input="pass1234\npass1234\n")
assert result.exit_code == 0, result.output
assert "[email protected]" in result.output
print("PASS: users create")
# Delete with --yes to skip confirmation
result = runner.invoke(cli, ["users", "delete", "1", "--yes"])
assert result.exit_code == 0
assert "Deleted" in result.output
print("PASS: users delete --yes")
# Import from temp file
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump([{"email": "[email protected]", "role": "user"}], f)
tmp = f.name
try:
result = runner.invoke(cli, ["import", tmp, "--dry-run"])
assert result.exit_code == 0
assert "1 record" in result.output
print("PASS: import --dry-run")
finally:
os.unlink(tmp)
# --version
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "1.0.0" in result.output
print("PASS: --version")
print("\nAll Click tests passed.")
if __name__ == "__main__":
if "--test" in sys.argv:
run_tests()
else:
cli()
For the argparse alternative — argparse requires building an ArgumentParser, calling add_argument() for each parameter, calling parse_args(), and manually routing to subcommand functions, while Click’s @click.command() and @click.option() decorators attach argument definitions directly to the function they configure — the function signature documents and validates the CLI in one place, @click.pass_obj propagates shared state without passing a namespace manually, and CliRunner().invoke(cmd, args) tests the full CLI without spawning a subprocess. For the typer alternative — Typer builds on Click and infers option names and types from Python function annotations (def cmd(name: str, count: int = 1)), which reduces boilerplate for simple CLIs, while Click’s explicit @click.option("--name", type=click.Choice([...]), callback=fn) gives fine-grained control over eager options, multi-value options, environment variable overrides, and custom type converters — Click is the right choice when you need that level of precision. The Claude Skills 360 bundle includes Click skill sets covering @click.command and @click.group, @click.option with Choice/Path/File/IntRange/DateTime types, required/prompt/hide_input/is_flag/multiple options, @click.argument positional parameters, @click.pass_context and @click.pass_obj shared state, invoke_without_command, click.confirm and click.progressbar, click.secho with color and err=True, CliRunner for test automation, and multi-level command group nesting. Start with the free tier to try CLI framework code generation.