Adopt Milo in a Mature CLI

Migrate an established framework CLI without losing argv, output, exit, startup, or agent contracts.

7 min read 1318 words

A mature CLI migration is a compatibility project, not a decorator rewrite. Users and automation already depend on argv spellings, help meaning, output channels, exit codes, import cost, and failure behavior. Milo adds programmatic, MCP, and llms.txt surfaces, so the migration must prove those new surfaces without silently changing the old one.

Use this workflow for framework CLIs, developer tools, and other command trees where a clean-slate recipe is not enough. For a single small parser, start with From argparse.

1. Freeze the compatibility inventory

Record behavior from the released CLI before changing registration. Pin the source tag or commit used for the inventory.

Surface What to record Minimum proof
Command tree Names, groups, aliases, ordering Root and subcommand help
Parameters Positional/option shape, defaults, choices Valid and invalid argv
Process contract stdout, stderr, exit0/1/2 Black-box subprocess tests
Structured output JSON keys and version Parse and exact-key assertions
Startup Modules imported by help/version Fresh-process import receipt
Side effects Filesystem, network, database, server lifecycle Explicit command classification
Agent surface Commands safe for discovery and invocation MCP and llms.txt allowlist

Do not update a golden output merely because the new parser differs. Classify each difference as preserved, intentionally migrated with release notes, or a bug in one implementation.

2. Build typed adapters before switching entry points

Keep domain behavior in the downstream project. Add thin typed functions that accept ordinary Python values, call that behavior, and return structured values. Do not pass anargparse.Namespace, parser object, or captured terminal text into Milo.

This representative adapter preserves positionals, a legacy option spelling, custom version flags, terminal presentation, grouping, and command visibility:

from typing import Annotated

from milo import CLI, Context, Option, Positional


def version_report() -> str:
    return "framework 4.2.0\nPython 3.14"


def render_check(result: dict[str, object], _ctx: Context) -> str:
    return f"Checked {result['app']}: {result['issues']} issue(s)"


cli = CLI(
    name="framework",
    description="Framework developer commands",
    version="4.2.0",
    version_flags=("-V", "--version"),
    version_report=version_report,
)


@cli.command(
    "check",
    description="Validate an application",
    annotations={"readOnlyHint": True},
    terminal_renderer=render_check,
)
def check(
    app: Annotated[str, Positional("APP")],
    warnings_as_errors: bool = False,
    baseline: Annotated[
        str | None,
        Option(aliases=("-b",), metavar="PATH"),
    ] = None,
    ctx: Context = None,
) -> dict[str, object]:
    """Validate one application.

    Args:
        app: Import path of the application.
        warnings_as_errors: Treat warnings as failures.
        baseline: Optional compatibility baseline.
        ctx: Milo's injected host context.
    """
    return {
        "app": app,
        "issues": 0,
        "warnings_as_errors": warnings_as_errors,
        "baseline": baseline,
    }


@cli.command("serve", description="Run the server", surfaces=("cli",))
def serve(app: Annotated[str, Positional("APP")]) -> dict[str, str]:
    """Run the downstream-owned server lifecycle.

    Args:
        app: Import path of the application.
    """
    return {"app": app, "status": "starting"}


database = cli.group("database", description="Database operations")


@database.command(
    "migrate",
    description="Apply database migrations",
    surfaces=("cli",),
    annotations={"destructiveHint": True, "idempotentHint": True},
)
def migrate(database_url: Annotated[str, Positional("DATABASE_URL")]) -> dict[str, str]:
    """Apply pending migrations.

    Args:
        database_url: Database connection URL.
    """
    # Call the downstream-owned migration service here. Do not return the URL.
    return {"status": "migrated"}

Contextis injected by dispatch and omitted from JSON Schema. Keep host-owned diagnostics, progress, and confirmation onContext; keep reusable command results in return values. Aterminal_rendererformats only plain human output, whilecall(), call_raw(), JSON output, and MCP retain the structured value.

3. Preserve lazy startup deliberately

For large command trees, use publicCLI.lazy_command()to register a deferred import with a precomputed schema. Help, MCP discovery, and llms.txt can then inspect the contract without loading the handler module:

cli.lazy_command(
    "inspect",
    "framework_cli.inspect:inspect_app",
    description="Inspect an application",
    schema={
        "type": "object",
        "properties": {
            "app": {
                "type": "string",
                "description": "Application import path.",
                "x-milo-cli": {"kind": "positional", "metavar": "APP"},
            }
        },
        "required": ["app"],
    },
    annotations={"readOnlyHint": True},
)

Treat the precomputed schema as a generated or reviewed contract artifact. Test it against the handler signature so it cannot become a second, drifting schema source. Lazy import failures exit nonzero and retain structured repair data; do not replace them with a silent fallback.

4. Classify commands before agent exposure

CLI availability does not imply that a command is safe for agents.

  • Expose finite inspection commands through("cli", "mcp", "llms")and declarereadOnlyHintwhen truthful.
  • Keep servers, watchers, interactive shells, and unreviewed mutations on surfaces=("cli",).
  • AdddestructiveHintand human/host confirmation policy before exposing a mutation through MCP.
  • Never put credentials, private paths, or raw database URLs in schemas, snapshots, logs, or structured errors.

Milo enforces the allowlist consistently: a CLI-only command is absent from tools/list and llms.txt and cannot be reached through tools/call.

5. Prove every dispatch surface

Use public test helpers and assert values rather than scraping human output:

import json

from app import cli
from milo import generate_llms_txt
from milo.testing import MCPClient


def test_framework_command_parity() -> None:
    terminal = cli.invoke(["check", "project:app", "--format", "json"])
    assert terminal.exit_code == 0
    assert json.loads(terminal.output)["app"] == "project:app"

    called = cli.call_raw("check", app="project:app", warnings_as_errors=True)
    assert called["warnings_as_errors"] is True

    mcp = MCPClient(cli)
    assert [tool.name for tool in mcp.list_tools()] == ["check"]
    result = mcp.call("check", app="project:app")
    assert result.is_error is False
    assert result.structured["issues"] == 0

    llms = generate_llms_txt(cli)
    assert "**check**" in llms
    assert "**serve**" not in llms


def test_usage_errors_remain_process_errors() -> None:
    result = cli.invoke(["check", "--unknown-option"])
    assert result.exit_code == 2
    assert result.output == ""
    assert "error:" in result.stderr

The release gate should cover this parity matrix:

Surface Required assertion
Legacy subprocess Golden help, channels, and exit codes
CLI.invoke Equivalent parsing, result, output, stderr, and exit
CLI.call / call_raw Structured values without terminal prints
Schema Requiredness, defaults, aliases, constraints, descriptions
MCPtools/list Only reviewed tools with truthful schemas/annotations
MCPtools/call Same defaults, validation, results, and structured errors
llms.txt Same finite discovery set and parameter descriptions
Lazy startup Help/version/discovery do not import handler modules
Free-threading Registration, discovery, and calls pass withPYTHON_GIL=0

Runmilo verifyagainst the assembled application before switching the packaged entry point. The verifier complements downstream golden tests; it does not replace them.

6. Cut over in reversible phases

  1. Land the black-box inventory while the old parser still owns the entry point.
  2. Add typed adapters and programmatic tests without changing user-visible dispatch.
  3. Migrate finite read-only commands first.
  4. Migrate filesystem/database commands after annotations and confirmation policy are reviewed.
  5. Migrate long-running commands last and keep them CLI-only.
  6. Switch the packaged entry point only when both implementations pass the same golden suite.
  7. Keep a short rollback window; remove the old parser after a released canary proves the new path.

If an old option spelling must remain, preserve it withOption(aliases=...). Milo normalizes aliases before the handler runs, so a spelling-specific deprecation warning belongs in a temporary argv compatibility shim, not in the domain function. Publish the removal release before deleting that shim.

7. Version the downstream proof

A canary must identify what it tested. Pin exact released versions and an immutable downstream source identity; do not follow either default branch. Advance a compatible dependency range only after more than one exact pair has passed and release notes explain downstream-visible changes.

Milo's first mature-framework receipt is the Chirp downstream canary. It pinsmilo-cli==0.4.1, bengal-chirp==0.10.0, and Chirp commit 3f80f81d587e81a72dfacc7f7148e79bf1134d99. The deeper compatibility inventory maps all eleven commands and ownership boundaries. Chirp 0.10.0 ships the packaged entry-point migration from Chirp issue #572, withcheck, diff, and routesexplicitly exposed through MCP and llms.txt while the remaining commands stay CLI-only.

Next references

  • Commands — groups, positionals, aliases, version reports, and visibility
  • Lazy Commands — deferred imports and structured failures
  • Output — terminal renderers and structured values
  • Context — host-owned I/O and interaction policy
  • MCP — annotations, resources, and protocol behavior
  • Dispatch Referenceinvoke, call, call_raw, and MCP parity
  • Testing — free-threaded and cross-surface proof