Skip to content

Python (click)

Guidance for implementing The CLI Spec in Python using click.


Structured Output (Principle 1)

import sys
import json
import click

def is_json(ctx):
    return ctx.obj.get("output") == "json" or not sys.stdout.isatty()

@click.group()
@click.option("--output", "-o", default="text",
              type=click.Choice(["text", "json"]),
              help="Output format")
@click.pass_context
def cli(ctx, output):
    ctx.ensure_object(dict)
    ctx.obj["output"] = output if output != "text" or sys.stdout.isatty() else "json"

@cli.command()
@click.pass_context
def list_items(ctx):
    items = fetch_items()
    if ctx.obj["output"] == "json":
        click.echo(json.dumps(items))
    else:
        for item in items:
            click.echo(f"{item['name']:<20} {item['status']}")

Errors go to stderr with a kind field:

import sys

def emit_error(kind: str, message: str):
    error = {"error": {"kind": kind, "message": message}}
    print(json.dumps(error), file=sys.stderr)
    sys.exit(1)

Schema Introspection (Principle 2)

Walk click's command tree to auto-generate a schema:

import json
import importlib.metadata
import click

def generate_schema(group):
    def walk(cmd):
        info = {
            "name": cmd.name,
            "description": cmd.get_short_help_str(),
        }
        args = []
        for param in cmd.params:
            if param.name in ("help",):
                continue
            arg = {
                "name": f"--{param.name}",
                "required": param.required,
                "type": param.type.name,
            }
            if param.default is not None:
                arg["default"] = param.default
            args.append(arg)
        if args:
            info["args"] = args
        if isinstance(cmd, click.Group):
            subs = [walk(c) for name, c in sorted(cmd.commands.items())]
            if subs:
                info["subcommands"] = subs
        return info

    return {
        "name": group.name,
        "version": importlib.metadata.version(group.name),
        "commands": [walk(c) for name, c in sorted(group.commands.items())],
    }

@cli.command()
def schema():
    """Output JSON schema for agent integration."""
    click.echo(json.dumps(generate_schema(cli), indent=2))

Non-Interactive (Principle 4)

import sys

@cli.command()
@click.option("--token", help="API token")
def init(token):
    if sys.stdin.isatty() and not token:
        token = click.prompt("API token", hide_input=True)
    elif not token:
        raise click.UsageError("--token required in non-interactive mode")

    # validate and save

Shell Completions

click has built-in completion support. Users activate it via environment variables:

# bash
eval "$(_MYTOOL_COMPLETE=bash_source mytool)"

# zsh
eval "$(_MYTOOL_COMPLETE=zsh_source mytool)"

# fish
_MYTOOL_COMPLETE=fish_source mytool | source

To provide a completions command that outputs the script directly:

import os

@cli.command()
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
@click.pass_context
def completions(ctx, shell):
    """Generate shell completions."""
    prog_name = ctx.find_root().info_name  # e.g. "mytool", not cli.name
    env_var = f"_{prog_name.upper()}_COMPLETE"
    os.environ[env_var] = f"{shell}_source"
    cli.main(args=[], prog_name=prog_name, standalone_mode=False)

Package Purpose
click CLI framework
rich Colored terminal output and tables
httpx HTTP client (async-capable)
pydantic Data validation and serialization
keyring OS credential storage