CLI & Commands

AI-native CLI with typed commands, automatic argparse, MCP tools, and llms.txt.

5 min read 901 words

Milo'sCLIclass turns decorated Python functions into CLI commands, MCP tools, and llms.txt entries — all from a single definition. Type annotations drive argument parsing, schema generation, and help text.

Creating a CLI

from milo import CLI

cli = CLI(name="myapp", description="My tool", version="1.0.0")

The CLIis the entry point for your application. It manages commands, groups, global options, and dispatches to handlers.

Registering commands

Use the@cli.commanddecorator to register functions as CLI subcommands:

@cli.command("greet", description="Say hello")
def greet(name: str, loud: bool = False) -> str:
    msg = f"Hello, {name}!"
    return msg.upper() if loud else msg

Type annotations are used to:

  • Generate argparse arguments (--name, --loud)
  • Generate MCP tool schemas for AI agents
  • Determine required vs optional parameters (parameters with defaults are optional)
myapp greet --name Alice
myapp greet --name Alice --loud

Command options

@cli.command(
    "deploy",
    description="Deploy the application",
    aliases=("d",),          # Alternative names
    tags=("ops",),           # Grouping in llms.txt
    hidden=True,             # Omit from help and llms.txt
)
def deploy(target: str, dry_run: bool = False) -> dict: ...

Supported parameter types

Python type argparse JSON Schema
str --flag VALUE "string"
int --flag N(type=int) "integer"
float --flag N(type=float) "number"
bool --flag(store_true) "boolean"
list[str] --flag A B C(nargs=*) "array"
X | None optional unwrapped to base type

Output formatting

Every command gets a--formatflag automatically:

myapp greet --name Alice --format json
myapp greet --name Alice --format table
myapp greet --name Alice --format plain   # default

The handler's return value is serialized based on the chosen format. See Output Formatting for details.

Running the CLI

if __name__ == "__main__":
    cli.run()

cli.run() parses sys.argv, resolves the command, injects context, calls the handler, and formats the output.

Built-in flags

Every CLI gets these flags automatically:

Flag Description
--version Print version and exit
--llms-txt Output an llms.txt AI discovery document
--mcp Run as an MCP server (JSON-RPC on stdin/stdout)
-v / --verbose Increase verbosity (stackable:-vvfor debug)
-q / --quiet Suppress non-error output
--no-color Disable color output

Programmatic invocation

Call commands directly without going through argparse:

result = cli.call("greet", name="Alice")
result = cli.call("site.build", output="_site")  # dotted paths for group commands

This is how the MCP server dispatches tool calls internally.

Fuzzy matching

If a user mistypes a command, the CLI suggests the closest match:

$ myapp gret
Unknown command: 'gret'. Did you mean 'greet'?

Shell completions

Milo can generate shell completion scripts for bash, zsh, and fish. Every CLI gets a--completionsflag:

myapp --completions bash   # Print bash completion script
myapp --completions zsh    # Print zsh completion script
myapp --completions fish   # Print fish completion script

Add the output to your shell config to enable tab-completion for commands and flags:

eval "$(myapp --completions bash)"

Or add to ~/.bashrcfor persistence.

eval "$(myapp --completions zsh)"

Or add to ~/.zshrcfor persistence.

myapp --completions fish | source

Or save to~/.config/fish/completions/myapp.fishfor persistence.

Programmatically, useinstall_completions():

from milo.completions import install_completions

script = install_completions(cli, shell="zsh")  # or "bash", "fish"

If shell is omitted, it auto-detects from the $SHELLenvironment variable.

Doctor diagnostics

The doctor system runs health checks against your CLI environment. Define what to verify, andrun_doctorchecks it all at once:

from milo.doctor import run_doctor, format_doctor_report

report = run_doctor(
    cli,
    config_spec=spec,                          # Check config files exist
    required_env=("API_KEY", "DATABASE_URL"),   # Required env vars
    required_tools=("git", "node"),             # Required binaries on PATH
    custom_checks=(my_custom_check,),           # Callables returning Check
)

print(format_doctor_report(report))

Output:

  ✓ python: Python 3.14.0
  ✓ milo: milo 0.1.1
  ✓ config:myapp.toml: Found 1 file(s)
  ✓ env:API_KEY: Set
  ✗ env:DATABASE_URL: Not set
    hint: export DATABASE_URL=<value>
  ✓ tool:git: /usr/bin/git

5 passed, 0 warnings, 1 failures

Built-in checks include Python version, Milo version, config file discovery, and registered command count. Add custom checks by passing callables that return aCheck:

from milo.doctor import Check

def check_disk_space():
    free_gb = get_free_space()
    if free_gb < 1:
        return Check(name="disk", status="fail", message="Low disk space",
                     suggestion="Free up disk space")
    return Check(name="disk", status="ok", message=f"{free_gb:.1f} GB free")

The DoctorReport dataclass tracks counts: report.ok, report.warnings, report.failures.

Version checking

Milo can check PyPI for newer versions of your package:

from milo.version_check import check_version, format_version_notice

info = check_version("myapp", current_version="1.0.0")
if info and info.update_available:
    print(format_version_notice(info, prog="myapp"), file=sys.stderr)
A new version of myapp is available: 1.0.0 -> 1.2.0
  pip install --upgrade myapp

Key behaviors:

  • Caching — results are cached in~/.milo/cache/for 24 hours to avoid hitting PyPI on every invocation.
  • Silent failures — network errors, timeouts, and cache failures are swallowed;check_version() returns None.
  • Opt-out — setNO_UPDATE_CHECK=1 or CI=1to disable the check entirely.
  • Installer detectionformat_version_notice() detects uv vs pipand prints the correct upgrade command.

TheVersionInfo dataclass contains current, latest, update_available, and an optional message.

Tip

See Command Groups for organizing commands into nested namespaces, Context for injecting execution context into handlers, and Lazy Loading for deferred imports.