# C12 CLI App — Typer Entry Point + Subcommand Routing + Operator Helpers **Task**: AZ-326_c12_cli_app **Name**: C12 CLI App **Description**: Implement the operator-tooling CLI shell that operators run on the workstation. Wires Typer (per the Click/Typer project pin) into `operator_tool/__main__.py`, registers six subcommands (`download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`), wires the E-CC-LOG (AZ-266) logger to a workstation-side structured-JSON log file (`~/.azaion/onboard/c12-tooling.log`), and ships the two trivial operator-side helpers from description.md § 2 — `set_sector_classification(area, sector_class)` (persists per-area classification to a local JSON file under the operator workstation's home directory) and `apply_freshness_threshold(sector_class) -> int (months)` (a pure-data lookup that maps the sector classification enum to the AC-NEW-6 months freshness budget). Each subcommand is a thin shell that resolves its service collaborator (`flights_api_client`, `build_cache`, `companion_bringup`, `post_landing_upload`, `operator_reloc_service` — all owned by sibling tasks AZ-489 / AZ-NNN T2..T5) from the composition root and delegates to it; on success returns 0; on a known error type maps to a documented non-zero exit code with a one-line operator-friendly message + remediation hint pulled from the underlying error's `remediation` attribute. The CLI app does NOT own any workflow logic itself — only command registration, argument parsing, logger wiring, exit-code mapping, and the two simple operator helpers. **ADR-010 amendment**: the `build-cache` subcommand accepts a mutually-exclusive pair `--flight-id | --flight-file ` and forwards the resolved `FlightDto` (via AZ-489 `FlightsApiClient`) to the orchestrator (AZ-328), which derives the bbox + takeoff origin from it. The legacy `--bbox` flag is dropped because the bbox is now derived; passing it is an error. **Complexity**: 3 points **Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-489_c12_flights_api_client (for the `FlightsApiClient` service collaborator + DTO definitions surfaced via `--flight-id` / `--flight-file`) **Component**: c12_operator_orchestrator (epic AZ-253 / E-C12) **Tracker**: AZ-326 **Epic**: AZ-253 (E-C12) ### Document Dependencies - `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (`set_sector_classification`, `apply_freshness_threshold` from `CacheBuildWorkflow`), § 5 (logging strategy table), § 7 (CLI-only this cycle, GUI deferred). - `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO/WARN/ERROR log shapes for operator events. ## Problem Without a real CLI shell: - F1 (pre-flight cache build) and F10 (post-landing upload) have no operator entry point — every workflow function in this epic is unreachable from the workstation. - AC-NEW-6 (freshness pipeline) collapses partially — sibling tasks have no canonical place to call `apply_freshness_threshold(sector_class)` so each invents its own table or hard-codes months. - Sector classification (active-conflict vs stable-rear) per description.md § 1 has no persistent surface; operator restarts lose all classifications. - Logging from C12 is silent — without the wiring of E-CC-LOG to the workstation-side log file, every operator action is invisible during incident review. - Sibling tasks T2..T5 have no consumer; their service classes ship but no end-to-end CLI flow exercises them. - Exit codes are inconsistent across subcommands — operators script `operator-orchestrator` runs and need `$?` to mean something specific per failure category. This task delivers the CLI shell + the two trivial operator helpers. It does NOT own `build_cache`, `verify_companion_ready`, `trigger_post_landing_upload`, or `OperatorReLocService` — those are sibling tasks invoked through the CLI. ## Outcome - A Typer-based CLI app at `src/operator_tool/`: - `src/operator_tool/__main__.py` — module entry point: `from operator_tool.cli import app; app()`. - `src/operator_tool/cli.py` — Typer `app = typer.Typer(name="operator-orchestrator", help="GPS-denied onboard pre-flight tooling (operator workstation)")`. Registers six subcommands via `@app.command(...)`. Each subcommand opens a logging context, calls into its service collaborator, catches the documented exception family for that command, maps to the documented exit code, and `raise typer.Exit(code=N)`. - `src/operator_tool/sector_classification_store.py` — `SectorClassificationStore` class: - Constructor: `__init__(self, *, store_path: Path, logger: Logger)`. - `set_classification(area: AreaIdentifier, sector_class: SectorClassification) -> None` — persists `{area_id: sector_class}` mapping to `store_path` (default: `~/.azaion/onboard/sector-classifications.json`) using atomic write (`tempfile + os.replace`). - `get_classification(area: AreaIdentifier) -> SectorClassification | None` — reads the JSON file; returns the classification for the given area or `None` if not set. - `list_classifications() -> dict[AreaIdentifier, SectorClassification]` — returns all current classifications. - File format: `{"area_id": "active_conflict" | "stable_rear", ...}`. - INFO log on every `set_classification` call (`kind="c12.sector.classification.set"`). - `src/operator_tool/freshness_table.py` — `freshness_threshold_months(sector_class: SectorClassification) -> int`: - Pure data: `active_conflict → 1 month`; `stable_rear → 12 months`. Documented inline as the AC-NEW-6 freshness budget per description.md § 1 + Plan-phase intent. - Module-level constant: `FRESHNESS_TABLE: dict[SectorClassification, int]`. - `src/operator_tool/exit_codes.py` — module-level constants: `EXIT_OK = 0`, `EXIT_GENERIC_ERROR = 1`, `EXIT_USAGE = 2`, `EXIT_COMPANION_UNREACHABLE = 10`, `EXIT_CONTENT_HASH_MISMATCH = 11`, `EXIT_DOWNLOAD_FAILURE = 20`, `EXIT_BUILD_FAILURE = 21`, `EXIT_FLIGHT_STATE_NOT_CONFIRMED = 30`, `EXIT_UPLOAD_FAILURE = 31`, `EXIT_GCS_LINK_ERROR = 40`, `EXIT_LOCK_HELD = 50`, `EXIT_FLIGHTS_API_UNREACHABLE = 60`, `EXIT_FLIGHTS_API_AUTH = 61`, `EXIT_FLIGHT_NOT_FOUND = 62`, `EXIT_FLIGHT_SCHEMA = 63`, `EXIT_EMPTY_WAYPOINTS = 64`. Sibling tasks may extend with documented additions. - A composition root entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`: - `build_operator_tool(config: Config) -> OperatorOrchestratorServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-orchestrator service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it. - Subcommand surface (each subcommand body lives in `cli.py`; service implementations live in sibling task files): - `download` — delegates to `tile_downloader.fetch(...)` (AZ-316). Maps `SatelliteProviderError → EXIT_DOWNLOAD_FAILURE`. - `build-cache` — accepts a mutually-exclusive pair `--flight-id | --flight-file ` (Typer-enforced via a callback that rejects both-set / neither-set with `EXIT_USAGE`), plus `--sector-class`, `--calibration-path`. Delegates to `build_cache_orchestrator.build_cache(...)` (sibling AZ-328) passing the resolved `FlightDto` (the orchestrator computes bbox + takeoff origin from it via AZ-489 helpers). Maps `CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE` (per `failure_phase`); `BuildLockHeldError → EXIT_LOCK_HELD`; `FlightsApiUnreachableError → EXIT_FLIGHTS_API_UNREACHABLE`; `FlightsApiAuthError → EXIT_FLIGHTS_API_AUTH`; `FlightNotFoundError → EXIT_FLIGHT_NOT_FOUND`; `FlightsApiSchemaError | FlightFileNotFoundError | WaypointSchemaError → EXIT_FLIGHT_SCHEMA`; `EmptyWaypointsError → EXIT_EMPTY_WAYPOINTS`. - `upload-pending` — delegates to `post_landing_upload.trigger_post_landing_upload(...)` (sibling T4). Maps `FlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED`; `UploadGateBlockedError → EXIT_UPLOAD_FAILURE`. - `reloc-confirm` — delegates to `operator_reloc_service.request_reloc(...)` (sibling T5). Maps `GcsLinkError → EXIT_GCS_LINK_ERROR`. - `verify-ready` — delegates to `companion_bringup.verify_companion_ready(...)` (sibling T2). Maps `CompanionUnreachableError → EXIT_COMPANION_UNREACHABLE`; `ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH`. - `set-sector` — delegates to `SectorClassificationStore.set_classification(...)`. - Each subcommand's `--help` includes a one-line summary + the AC IDs it supports (e.g. `build-cache: orchestrate F1 (AC-8.3, AC-NEW-1)`). - Logging is wired at app startup: a single rotating file handler at `~/.azaion/onboard/c12-tooling.log`, structured JSON formatter from E-CC-LOG (AZ-266). Console (stderr) handler at WARN level for operator visibility. - `pyproject.toml` registers `operator-orchestrator` as a console script entry point pointing at `operator_tool.__main__:main`. The `main` function in `__main__.py` calls `app()`. ## Scope ### Included - `operator_tool` package layout (`__init__.py`, `__main__.py`, `cli.py`, `sector_classification_store.py`, `freshness_table.py`, `exit_codes.py`). - The composition-root factory `build_operator_tool`. - Six subcommand registrations + per-subcommand `--help` text + per-subcommand exception → exit-code mapping. - `SectorClassificationStore` with atomic-write JSON persistence. - `freshness_threshold_months` pure-data lookup. - The exit-code constants module. - Logger wiring for the workstation-side log file (rotating file handler + structured JSON via AZ-266). - Console-script entry-point declaration in `pyproject.toml`. - Unit tests covering: subcommand registration, exception → exit-code mapping (using fakes for service collaborators), `SectorClassificationStore` round-trip (set, get, atomic write resilience), `freshness_threshold_months` for both enum values, console script invocability via `subprocess.run`. ### Excluded - The actual workflows for `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready` — owned by sibling tasks T2..T5. - The download workflow body — owned by AZ-316. - The MAVLink encoding for `reloc-confirm` — owned by sibling T5. - A GUI surface — Plan-phase carryforward, deferred per description.md § 7. - Anything that runs on the airborne companion (this entire package is operator-workstation-only per ADR-004). - Per-subcommand integration tests against real `satellite-provider` — those live in C12-AT-01 (test decompose). ## Acceptance Criteria **AC-1: All six subcommands register and appear in `--help`** Given the `operator-orchestrator` console script is installed When the operator runs `operator-orchestrator --help` Then the listed subcommands include exactly `download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`; no extras **AC-2: Successful subcommand exits 0** Given a subcommand whose service collaborator returns successfully When the subcommand is invoked through the CLI Then the process exit code is 0; no error message is printed to stderr; an INFO log entry is written **AC-3: Each documented exception maps to its documented exit code** Given a service collaborator raises one of the documented exception types in this task's outcome list When the subcommand is invoked Then the process exit code matches the constant in `exit_codes.py`; a one-line operator-friendly message is printed to stderr; an ERROR log entry is written with the exception type and the remediation hint **AC-4: `SectorClassificationStore` round-trips via atomic write** Given an empty store When `set_classification(area="Derkachi", sector_class=SectorClassification.active_conflict)` is called, then a fresh `SectorClassificationStore` is constructed pointing at the same path Then `get_classification("Derkachi")` returns `SectorClassification.active_conflict`; the on-disk JSON file matches the expected shape; the file's parent directory was created if missing **AC-5: `SectorClassificationStore` set is atomic under crash** Given an existing JSON file with one classification When the process is killed (`SIGKILL`) mid-write of a second classification (simulated via a `Path.replace` patch that raises after `tempfile.write` but before `os.replace`) Then the original JSON file remains intact and parseable; no `*.tmp` lingers **AC-6: `freshness_threshold_months` returns the documented values** Given the two enum values of `SectorClassification` When `freshness_threshold_months(...)` is called for each Then `active_conflict → 1`, `stable_rear → 12` **AC-7: Logging writes structured JSON to the workstation log file** Given a fresh CLI invocation with `~/.azaion/onboard/` empty When any subcommand runs to completion Then a `c12-tooling.log` file exists at `~/.azaion/onboard/`; its lines parse as JSON; each line carries `timestamp`, `level`, `kind`, plus subcommand-specific fields per AZ-266's record schema **AC-8: Console-script entry point is installed and runnable** Given the package is installed via `pip install -e .` When the shell runs `operator-orchestrator --help` Then the help text is printed; the exit code is 0; the binary resolves through the entry-point declared in `pyproject.toml` **AC-9: Subcommand `--help` references the relevant AC IDs** Given any subcommand When `operator-orchestrator --help` is run Then the help text body includes the AC IDs the subcommand supports (e.g. `build-cache` mentions `AC-8.3, AC-NEW-1`); operators reading `--help` can cross-reference to `acceptance_criteria.md` **AC-10: `set-sector` is idempotent for the same input** Given `set-sector --area Derkachi --class active_conflict` was just run When the same command is run again Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the log, not in the data file); the operator sees the same exit code 0 and the same INFO log line **AC-11: `build-cache --flight-id` happy path delegates to orchestrator with `FlightDto` (ADR-010)** Given a fake `FlightsApiClient.fetch_flight` returns a 3-waypoint `FlightDto` When `operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json` runs Then `build_cache_orchestrator.build_cache(...)` is called once with the resolved `FlightDto` (or its `(flight_id, bbox, takeoff_origin)` projection per AZ-328 signature); ZERO calls to `--bbox` legacy parsing **AC-12: `build-cache --flight-file` happy path uses offline loader** Given a local JSON file in the documented schema is on disk When `operator-orchestrator build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json` runs Then `FlightsApiClient.load_flight_file(/tmp/flight.json)` is called once; `fetch_flight` is NOT called; the orchestrator receives the same DTO shape **AC-13: `build-cache` with both `--flight-id` and `--flight-file` errors out** When `operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ...` runs Then exit code is `EXIT_USAGE = 2`; stderr names the conflict; ZERO calls to either client method **AC-14: `build-cache` with neither `--flight-id` nor `--flight-file` errors out** When `operator-orchestrator build-cache --sector-class stable_rear --calibration-path /tmp/cal.json` runs (no flight source) Then exit code is `EXIT_USAGE = 2`; stderr lists which flag must be supplied **AC-15: `FlightNotFoundError` maps to `EXIT_FLIGHT_NOT_FOUND`** Given `fetch_flight` raises `FlightNotFoundError` When `build-cache --flight-id ` runs Then exit code is `62`; ERROR log carries the offending flight_id; ZERO calls to C11/C10 **AC-16: `FlightsApiAuthError` maps to `EXIT_FLIGHTS_API_AUTH`** (and never logs the auth token) Given `fetch_flight` raises `FlightsApiAuthError` When `build-cache --flight-id ` runs Then exit code is `61`; the structured log entry does NOT contain the `auth_token` value **AC-17: `EmptyWaypointsError` maps to `EXIT_EMPTY_WAYPOINTS`** Given the fetched `FlightDto` has zero waypoints When `build-cache --flight-id ` runs (and the orchestrator calls `bbox_from_waypoints` → raises) Then exit code is `64`; the stderr message instructs the operator to re-plan in the Mission Planner UI ## Non-Functional Requirements **Performance** - CLI cold start (`operator-orchestrator --help`) ≤ 500 ms on a developer laptop. The Typer app must avoid eager-importing heavy dependencies (httpx, pymavlink, paramiko) — sibling tasks expose lazy-import accessors used by their respective subcommands, not at module load time. **Compatibility** - Click/Typer per the project pin (no version override). - The structured JSON log format matches AZ-266's record schema exactly; this task adds no new top-level field. **Reliability** - The `SectorClassificationStore` write path is atomic across process kill (per AC-5). - `~/.azaion/onboard/` is created with mode `0o700` if it does not exist. ## Unit Tests | AC Ref | What to Test | Required Outcome | |--------|-------------|-----------------| | AC-1 | `operator-orchestrator --help` output | All 6 subcommands listed | | AC-2 | Subcommand with success-returning fake service | Exit 0, INFO log, no stderr | | AC-3 | Subcommand with raising fake (each documented exception family) | Exit code matches `exit_codes.py`; ERROR log; one-line stderr | | AC-4 | Round-trip `SectorClassificationStore` set → read | Matches input | | AC-5 | Patched `os.replace` to raise mid-write | Original file intact, no `*.tmp` lingers | | AC-6 | `freshness_threshold_months` for both enums | `active_conflict → 1`, `stable_rear → 12` | | AC-7 | Subcommand run, then read log file | Each line parses as JSON; required fields present | | AC-8 | `subprocess.run(["operator-orchestrator", "--help"])` after `pip install -e .` | Exit 0, help text printed | | AC-9 | Per-subcommand `--help` text | Includes documented AC IDs | | AC-10 | Repeated `set-sector` for same area/class | On-disk JSON byte-identical | | AC-11 | `build-cache --flight-id` happy path | Orchestrator called once with resolved DTO | | AC-12 | `build-cache --flight-file` happy path | `load_flight_file` called; `fetch_flight` NOT called | | AC-13 | Both `--flight-id` and `--flight-file` | Exit 2; conflict message | | AC-14 | Neither flight source supplied | Exit 2; usage hint | | AC-15 | `FlightNotFoundError` | Exit 62; flight_id in log | | AC-16 | `FlightsApiAuthError` | Exit 61; auth_token NOT in log | | AC-17 | `EmptyWaypointsError` | Exit 64; Mission Planner UI hint | | NFR-perf-cold-start | Microbench `operator-orchestrator --help` × 10 | p99 ≤ 500 ms | ## Constraints - This task introduces NO new third-party dependencies — Click/Typer is already pinned by the project per description.md § 5. - Heavy dependencies (httpx, pymavlink, paramiko) MUST NOT be eager-imported in `cli.py` or `__main__.py`; they live behind the sibling tasks' service classes that are lazy-resolved. - The CLI is operator-workstation-only — `operator_tool` MUST NOT be importable from any airborne entry point. Verified at the SBOM-diff level by E-BOOT (the CI gate already enforces no `operator_tool` symbol in `production-binary`). - Atomic writes use `tempfile.NamedTemporaryFile(dir=store_path.parent) + os.replace`. Naked `Path.write_text()` is NOT acceptable per `coderule.mdc` "follow established project patterns" (see AZ-280's atomic-write pattern for the established convention; this task uses the simpler stdlib version since there is no SHA-256 sidecar requirement here). - Log file location is fixed at `~/.azaion/onboard/c12-tooling.log` per description.md § 9 — config-overrideable via `config.c12.log_path` for tests but the default MUST match the spec. - Subcommand naming is the source of truth for operators; renaming a subcommand requires a Plan-cycle change. ## Risks & Mitigation **Risk 1: Heavy imports leak into CLI startup** - *Risk*: A future sibling task lazily-imports a heavy dependency at the wrong scope (module level instead of function level), violating NFR-perf-cold-start. - *Mitigation*: AC-NFR-perf-cold-start microbenches startup; CI hooks the test. If a regression appears, the offending import is surfaced by `python -X importtime`. **Risk 2: Operator runs `set-sector` against a stale store path after upgrade** - *Risk*: An operator upgrades the operator-orchestrator tarball; the new version changes the default `store_path`; classifications appear lost. - *Mitigation*: The default path is fixed at `~/.azaion/onboard/sector-classifications.json` and treated as a stable contract. A future cycle that needs to migrate runs an explicit migration; this cycle does NOT change the path. **Risk 3: Console script collides with another tool** - *Risk*: The name `operator-orchestrator` is generic; another package on the operator's workstation could shadow it. - *Mitigation*: The package is shipped as part of the operator-tooling tarball with its own venv; no global install. README documents the tarball install procedure. **Risk 4: Atomic-write corner case — disk full mid-tempfile** - *Risk*: `tempfile.NamedTemporaryFile.write` could raise `OSError` mid-call; partial tempfile lingers. - *Mitigation*: `try/finally` deletes the tempfile path on any exception in the write path; AC-5 covers the kill-mid-replace case; the disk-full case surfaces as `OSError` to the caller and the original file remains intact.