Skip to content

Python (click)

Guidance for implementing The CLI Spec in Python using click.


Structured Output (Principle 1)

Default the flag to auto so TTY detection only applies when the user did not choose a format. An explicit -o text must produce text even when piped (defaulting to "text" makes the explicit choice indistinguishable from the default, so it gets overridden):

import sys
import json
import click

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

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

Errors exit non-zero, with the envelope as the last line of stderr:

import sys

def emit_error(kind: str, message: str, hint: str | None = None, exit_code: int = 1):
    error = {"kind": kind, "message": message}
    if hint:
        error["hint"] = hint
    print(json.dumps({"error": error}), file=sys.stderr)
    sys.exit(exit_code)

Schema Introspection (Principle 2)

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

Options render as --flags; click.Argument params are positional and keep their bare name. Flags registered on the group go into the top-level global_args array so agents discover --output and friends:

import json
import importlib.metadata
import click

def param_to_arg(param):
    is_flag = isinstance(param, click.Option)
    # param.opts keeps the declared spellings ("--config-file", "-c");
    # param.name is normalized to config_file, which is not a valid flag.
    arg = {
        "name": max(param.opts, key=len) if is_flag else param.name,
        "required": param.required,
        "type": param.type.name,
    }
    if param.default is not None:
        arg["default"] = param.default
    if isinstance(param.type, click.Choice):
        arg["enum"] = list(param.type.choices)
    return arg

def generate_schema(group):
    def walk(cmd):
        info = {
            "name": cmd.name,
            "description": cmd.get_short_help_str(),
        }
        if args := [param_to_arg(p) for p in cmd.params]:
            info["args"] = args
        if isinstance(cmd, click.Group):
            if subs := [walk(c) for name, c in sorted(cmd.commands.items())]:
                info["subcommands"] = subs
        return info

    return {
        "clispec": "0.2",
        "name": group.name,
        # The distribution name is not always the command name; pass it explicitly.
        "version": importlib.metadata.version("mytool-dist"),
        "global_args": [param_to_arg(p) for p in group.params],
        "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)

Secrets never travel via argv (see General Guidance in the spec): prompt on a TTY, read stdin or an environment variable otherwise.

import os
import sys

@cli.command()
@click.option("--token-stdin", is_flag=True, help="Read API token from stdin")
def init(token_stdin):
    if token_stdin:
        token = sys.stdin.readline().strip()
    elif os.environ.get("MYTOOL_TOKEN"):
        token = os.environ["MYTOOL_TOKEN"]
    elif sys.stdin.isatty():
        token = click.prompt("API token", hide_input=True)
    else:
        raise click.UsageError(
            "set MYTOOL_TOKEN or pass --token-stdin 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