# Recipe — Building an ergonomic CLI with `mgf-common`

> **Audience:** consumers writing a Python CLI that should "just work" —
> typed exit codes, structured logging, OTel, friendly error messages,
> and pluggable subcommands.
> **Closes:** the strategic-review finding that VManager rolled
> its own exit-code translator + subcommand registry instead of using
> `mgf.common.cli`. The library has both, you just need to know they exist.

## At a glance

```python
# my_app/cli.py
from mgf.common import bootstrap
from mgf.common.cli import (
    EXIT_SUCCESS,
    register_cli_subcommand,
    run_and_translate,
)

@register_cli_subcommand(name="hello")
def hello(args) -> int:
    """My first subcommand."""
    print(f"Hello, {args.name}!")
    return EXIT_SUCCESS

@hello.parser_setup
def _hello_args(parser):
    parser.add_argument("--name", default="world")

def main(argv: list[str]) -> int:
    with bootstrap(app_name="myapp", app_version="1.0.0"):
        # ... your real CLI logic here; or use build_cli for argparse
        return run_and_translate(_handle, argv)

if __name__ == "__main__":
    import sys
    sys.exit(main(sys.argv[1:]))
```

That's a complete CLI with:
- Typed exit codes (your `AppError` subclasses route to specific
  EXIT_CONFIG_ERROR / EXIT_OPERATION_ERROR / etc. via
  `run_and_translate`).
- Structured JSON logging (via `bootstrap()`).
- OTel auto-init (when `OTEL_EXPORTER_OTLP_ENDPOINT` is set).
- Crash reporter installed (writes to disk on unhandled exception).
- LIFO rollback if any wiring step fails.
- A pluggable subcommand registry — third-party plugins can add their
  own commands under your CLI.

## The three pieces

### 1. `bootstrap()` — wire identity + observability in one call

```python
from mgf.common import bootstrap

with bootstrap(app_name="myapp", app_version="1.0.0") as ctx:
    # ctx.settings is an MgfSettings instance
    # logging is at ctx.settings.logging.level
    # OTel is wired if the env var is set
    # crash reporter is installed
    do_work()
```

If `bootstrap()` fails partway (e.g., OTel SDK installed but endpoint
unreachable), it rolls back every wiring step and raises
`BootstrapError` — your CLI starts clean or doesn't start at all.

### 2. `run_and_translate()` — typed exceptions → exit codes

```python
from mgf.common.cli import run_and_translate, EXIT_SUCCESS

def main(argv: list[str]) -> int:
    return run_and_translate(_my_handler, argv)

def _my_handler(args: argparse.Namespace) -> int:
    # Your CLI logic. Raise typed exceptions for typed exit codes:
    #   AppConfigError    → EXIT_CONFIG_ERROR (1)
    #   ProviderError     → EXIT_PROVIDER_ERROR (2)
    #   OperationError    → EXIT_OPERATION_ERROR (3)
    #   GuestError        → EXIT_GUEST_ERROR (4)
    #   HostEnvironmentError → EXIT_ENVIRONMENT_ERROR (5)
    #   VaultError        → EXIT_VAULT_ERROR (6)
    #   any other AppError → EXIT_UNKNOWN (10)
    if not args.config_file:
        raise AppConfigError("--config-file is required")
    return EXIT_SUCCESS
```

The translator catches any `AppError` subclass, prints a clean
one-line message to stderr, and exits with the matching code.
Untyped exceptions (e.g., `ValueError`) get the EXIT_UNKNOWN code +
the crash reporter writes a full report to disk.

### 3. `register_cli_subcommand` — pluggable subcommands

```python
from mgf.common.cli import register_cli_subcommand, subcommand

# subcommand() is the short alias for register_cli_subcommand
@subcommand(name="users", description="Manage users")
def users(args) -> int:
    print(f"action: {args.action}")
    return 0

@users.parser_setup
def _users_args(parser):
    parser.add_argument("action", choices=["list", "create", "delete"])
```

Now `apiprobe users list` works. Plugins can register their own
subcommands by importing your CLI module — the decorator side-effect
populates the registry. List active subcommands with:

```bash
mgf-common doctor # surfaces every registered subcommand
```

## Putting it together with `build_cli()`

For a complete CLI with argparse handling + subcommand discovery:

```python
from mgf.common.cli import build_cli, run_and_translate

def main(argv: list[str]) -> int:
    parser = build_cli(prog="myapp")
    args = parser.parse_args(argv)
    if not getattr(args, "_handler", None):
        parser.print_help()
        return 0
    return run_and_translate(args._handler, args)
```

`build_cli()` walks the registry, sets up the argparse subparsers,
and wires each subcommand's `parser_setup` callback. No manual
`add_subparsers()` boilerplate.

## Common patterns

### Loading settings before subcommand dispatch

```python
def main(argv: list[str]) -> int:
    settings = MyAppSettings()  # raises AppConfigError on bad env vars
    with bootstrap(
        app_name="myapp",
        app_version="1.0.0",
        settings=settings.mgf,  # pass the mgf-common slice if you nest
    ):
        return run_and_translate(_dispatch, argv)
```

When `MyAppSettings()` raises (e.g., a required env var is missing),
the PEP 678 notes name the exact env var. Consumer sees:

```
ERROR: MyAppSettings: invalid configuration (db_url)
NOTE: Field 'db_url': missing. Set env var MYAPP_DB_URL=...
NOTE: Env vars are prefixed 'MYAPP_'; run `mgf-common explain` to see resolved values.
```

### Verifying the live config in production

```bash
# Set this BEFORE starting the app:
export MGF_DOCTOR_RUNTIME_PATH=/run/myapp/runtime.json

# Then in production:
mgf-common doctor --live
```

Prints what's actually wired right now — log handlers, OTel endpoint,
redaction patterns, app identity. Useful for "is OTel enabled?" /
"is my redaction set up correctly?" questions in 30 seconds.

### Confirming redaction coverage

```bash
echo '{"Authorization": "Bearer my-secret", "user": "alice"}' \
  | mgf-common doctor --redaction
```

Prints before+after, names the keys redacted vs. masked vs. passed
through. If your secret pattern doesn't match the standard
`Redactor`, register custom patterns via
`register_redaction_pattern_group()`.

## Anti-patterns to avoid

- **Don't `print()` from inside subcommand handlers — use logging.** The
  `bootstrap()` wires JSON-structured logging at WARNING level by
  default. `print()` bypasses it; aggregators won't see your output.
- **Don't catch and re-raise `AppError` to add context.** Use
  `add_help_note(exc, "tip")` to attach PEP 678 notes. The
  `run_and_translate` translator preserves the chain.
- **Don't roll your own exit-code map.** Every consumer that does it
  ends up subtly wrong. Use the constants in `mgf.common.cli`; they
  match the documented contract.

## See also

- `docs/public/01_lifecycle.md` — `bootstrap()` deep dive.
- `docs/public/04_cli_handler.md` — `run_and_translate` semantics.
- `docs/public/09_cli.md` — full subcommand registry walkthrough.
- `docs/public/recipes/testing.md` — pytest fixtures for CLI tests.
