"""CLI contracts for the capture utility.""" from __future__ import annotations import argparse import json import sys from collections.abc import Sequence from dataclasses import replace from pathlib import Path from game_data_capture.config import AppConfig, ConfigError, ServerConfig, load_config from game_data_capture.doctor import doctor_as_dict, run_doctor from game_data_capture.paths import PathConfigurationError, RuntimePaths, resolve_runtime_paths from game_data_capture.runtime import CaptureRuntime, RuntimeFailure EXIT_OK = 0 EXIT_CONFIG_ERROR = 2 EXIT_UNIMPLEMENTED = 3 EXIT_RUNTIME_ERROR = 4 def _add_config_argument(parser: argparse.ArgumentParser) -> None: parser.add_argument("--config", type=Path, help="absolute path to a TOML configuration file") def _port_argument(raw_value: str) -> int: try: port = int(raw_value) except ValueError as error: raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") from error if not 1024 <= port <= 65535: raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") return port def build_parser() -> argparse.ArgumentParser: """Build the stable command surface without initializing runtime resources.""" parser = argparse.ArgumentParser( prog="shd-capture", description="Local SHD.build game-data capture" ) subcommands = parser.add_subparsers(dest="command", required=True) doctor = subcommands.add_parser("doctor", help="report local prerequisite status") _add_config_argument(doctor) doctor.add_argument("--json", action="store_true", help="emit JSON diagnostics") serve = subcommands.add_parser("serve", help="start the localhost review service") _add_config_argument(serve) serve.add_argument("--host", help="loopback IP literal override") serve.add_argument("--port", type=_port_argument, help="TCP port override") watch = subcommands.add_parser("watch", help="watch the Wayland clipboard for images") _add_config_argument(watch) watch_modes = watch.add_mutually_exclusive_group() watch_modes.add_argument("--arm", action="store_true", help="arm live clipboard capture") watch_modes.add_argument( "--dry-run", action="store_true", help="validate the capture runtime plan" ) ingest_image = subcommands.add_parser("ingest-image", help="stage one image for capture") _add_config_argument(ingest_image) ingest_image.add_argument("image", type=Path) import_video = subcommands.add_parser("import-video", help="stage a video for frame extraction") _add_config_argument(import_video) import_video.add_argument("video", type=Path) export = subcommands.add_parser("export", help="write a versioned ISAC handoff") _add_config_argument(export) export.add_argument("destination", type=Path) verify = subcommands.add_parser("verify", help="verify an evidence archive") _add_config_argument(verify) verify.add_argument("archive", type=Path) evaluate = subcommands.add_parser("eval", help="evaluate local OCR and classification") _add_config_argument(evaluate) evaluate.add_argument("corpus", type=Path) purge = subcommands.add_parser("purge", help="purge retained local data") _add_config_argument(purge) purge.add_argument("--confirm", action="store_true", help="confirm a future destructive purge") return parser def _load_cli_config(config_path: Path | None) -> tuple[AppConfig, RuntimePaths]: paths = resolve_runtime_paths() return load_config(config_path=config_path, paths=paths), paths def _doctor(arguments: argparse.Namespace) -> int: config, paths = _load_cli_config(arguments.config) diagnostics = doctor_as_dict(run_doctor(config, paths)) if arguments.json: print(json.dumps(diagnostics, separators=(",", ":"), sort_keys=True)) else: for diagnostic in diagnostics: print(f"{diagnostic['name']}: {diagnostic['status']} ({diagnostic['detail']})") return EXIT_OK def _serve_contract(arguments: argparse.Namespace) -> int: config, paths = _load_cli_config(arguments.config) host = arguments.host if arguments.host is not None else config.server.host port = arguments.port if arguments.port is not None else config.server.port configured = replace(config, server=ServerConfig(host=host, port=port)) CaptureRuntime(configured, paths).serve() return EXIT_OK def _unimplemented(command: str) -> int: print( f"{command}: subsystem is not implemented; requires gdc-runtime-integration", file=sys.stderr, ) return EXIT_UNIMPLEMENTED def _watch(arguments: argparse.Namespace) -> int: config, paths = _load_cli_config(arguments.config) if not arguments.arm and not arguments.dry_run: return _unimplemented("watch") runtime = CaptureRuntime(config, paths) if arguments.dry_run: runtime.validate_plan() print("watch dry-run: capture runtime plan is valid") return EXIT_OK runtime.watch() return EXIT_OK def _ingest_image(arguments: argparse.Namespace) -> int: config, paths = _load_cli_config(arguments.config) CaptureRuntime(config, paths).ingest_image(arguments.image) print("image capture stored") return EXIT_OK def main(argv: Sequence[str] | None = None) -> int: """Run one CLI command and return a stable process exit status.""" parser = build_parser() arguments = parser.parse_args(argv) try: if arguments.command == "doctor": return _doctor(arguments) if arguments.command == "serve": return _serve_contract(arguments) if arguments.command == "watch": return _watch(arguments) if arguments.command == "ingest-image": return _ingest_image(arguments) _load_cli_config(arguments.config) return _unimplemented(arguments.command) except (ConfigError, PathConfigurationError) as error: print(f"configuration error: {error}", file=sys.stderr) return EXIT_CONFIG_ERROR except RuntimeFailure: print("capture runtime failed", file=sys.stderr) return EXIT_RUNTIME_ERROR