"""UofA CLI entry point — argparse dispatcher for all subcommands.""" from __future__ import annotations import argparse import sys from uofa_cli import __version__ from uofa_cli.output import set_color, error from uofa_cli.paths import find_repo_root def _force_utf8_streams() -> None: """Reconfigure stdout/stderr to UTF-8 with replacement. The CLI emits Unicode characters (`══`, `✓`, `✗`, status emoji) in its progress output. Windows consoles default to cp1252 ("charmap"), which can't encode those bytes and raises ``UnicodeEncodeError``, crashing the CLI mid-run. Python 3.7+ exposes ``reconfigure`` on text streams; we set encoding=utf-8 + errors='replace' so any truly-unencodable byte falls back to ``?`` rather than crashing. No-op on streams that don't support reconfigure (PIPEs without text wrapping, redirected file handles in some environments). """ for stream_name in ("stdout", "stderr"): stream = getattr(sys, stream_name, None) if stream is None: continue reconfigure = getattr(stream, "reconfigure", None) if reconfigure is None: continue try: reconfigure(encoding="utf-8", errors="replace") except (OSError, ValueError): # Stream is closed, redirected to a non-text sink, or the # codec isn't available; degrade silently. pass def main(): _force_utf8_streams() sys.exit(_run() or 0) def _run(): # Shared flags inherited by all subcommands parent = argparse.ArgumentParser(add_help=False) parent.add_argument("--no-color", action="store_true", help="disable colored output") parent.add_argument("--verbose", action="store_true", help="show full tracebacks on error") parent.add_argument("--repo-root", metavar="PATH", help="override repo root auto-detection") parent.add_argument("--pack", metavar="NAME", action="append", help="pack(s) to use for shapes, rules, and templates (default: vv40). " "May be repeated: --pack vv40 --pack nasa-7009b") parser = argparse.ArgumentParser( prog="uofa", description="Create, validate, and sign Unit of Assurance evidence packages.", parents=[parent], ) parser.add_argument("--version", action="version", version=f"uofa {__version__}") parser.add_argument("--help-all", action="store_true", help="emit markdown documentation for every subcommand to stdout and exit") sub = parser.add_subparsers(dest="command", title="commands") # ── Register subcommands ────────────────────────────────── from uofa_cli.commands import keygen, sign, verify, shacl, rules, check, validate, init, diff, schema, packs, migrate, import_excel, extract_cmd, adversarial, catalog, setup, demo, interrogate, decision, report from uofa_cli.commands import explain as explain_cmd from uofa_cli.commands import guardrail as guardrail_cmd modules = { "keygen": keygen, "sign": sign, "verify": verify, "shacl": shacl, "rules": rules, "check": check, "report": report, "validate": validate, "init": init, "diff": diff, "explain": explain_cmd, "schema": schema, "packs": packs, "catalog": catalog, "migrate": migrate, "import": import_excel, "extract": extract_cmd, "adversarial": adversarial, "setup": setup, "demo": demo, "interrogate": interrogate, "decision": decision, "guardrail": guardrail_cmd, } subparsers: dict[str, argparse.ArgumentParser] = {} for name, mod in modules.items(): sp = sub.add_parser(name, help=mod.HELP, parents=[parent]) mod.add_arguments(sp) subparsers[name] = sp # Pre-parse --pack so values supplied BEFORE the subcommand are preserved. # With parents=[parent], subparsers inherit a --pack action whose default # of None clobbers a top-level --pack value at full-parse time. The pre- # parse recovers it so `uofa --pack X catalog` and `uofa catalog --pack X` # behave identically. _pre_pack = argparse.ArgumentParser(add_help=False) _pre_pack.add_argument("--pack", action="append") _pre_args, _ = _pre_pack.parse_known_args() args = parser.parse_args() if getattr(args, "help_all", False): sys.stdout.write(_render_help_all(modules, subparsers)) return 0 if not args.command: parser.print_help() return 0 if args.no_color: set_color(False) # Resolve active pack(s) and thread them explicitly on args (P2d-3). The # process global is gone; commands read args.active_packs (via # paths.resolve_active_packs) and pass it down explicitly. args.active_packs = _pre_args.pack or args.pack or ["vv40"] # Resolve repo root early so commands can use paths.* try: find_repo_root(args.repo_root) except FileNotFoundError as exc: error(str(exc)) return 1 # Dispatch mod = modules[args.command] try: return mod.run(args) except FileNotFoundError as exc: error(str(exc)) if args.verbose: raise return 1 except Exception as exc: error(str(exc)) if args.verbose: raise return 1 def _render_help_all( modules: dict, subparsers: dict[str, argparse.ArgumentParser], ) -> str: """Emit a markdown reference for every subcommand. One section per command. Each section: heading, one-line summary, ``Usage`` block (from argparse format_usage), then a table of options when the parser has any beyond the inherited parent flags. """ from datetime import datetime, timezone generated = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") lines: list[str] = [ "---", f"title: '`uofa` CLI reference (v{__version__})'", "description: 'Auto-generated CLI reference covering every subcommand and flag. Re-runs on every site build via `uofa --help-all`.'", f"generated: {generated}", f"cli_version: v{__version__}", f"command_count: {len(modules)}", "---", "", f"Generated from `uofa --help-all` at {generated}. ", f"{len(modules)} subcommand{'s' if len(modules) != 1 else ''} available.", "", "## Synopsis", "", "```text", "uofa [--no-color] [--verbose] [--repo-root PATH] [--pack NAME] [...]", "```", "", "Global flags inherited by every subcommand: `--no-color`, `--verbose`, " "`--repo-root`, `--pack`. See `uofa --help` for details.", "", "## Commands", "", ] for name in modules: mod = modules[name] sp = subparsers[name] usage = sp.format_usage().strip().replace("usage: ", "") lines.extend([ f"### `uofa {name}`", "", f"_{getattr(mod, 'HELP', '').strip()}_", "", "```text", usage, "```", "", ]) rows = _option_rows(sp) if rows: lines.extend([ "| Flag | Description |", "|---|---|", ]) lines.extend(rows) lines.append("") return "\n".join(lines) + "\n" def _option_rows(sp: argparse.ArgumentParser) -> list[str]: """Return one markdown table row per command-specific option. Excludes inherited parent flags (--no-color, --verbose, --repo-root, --pack, -h/--help) since those are documented once in the synopsis. """ skip = {"--no-color", "--verbose", "--repo-root", "--pack", "-h", "--help"} rows: list[str] = [] for action in sp._actions: flags = action.option_strings if not flags or any(f in skip for f in flags): continue flag_str = ", ".join(f"`{f}`" for f in flags) if action.metavar: flag_str += f" `{action.metavar}`" elif action.choices: flag_str += f" {{{', '.join(str(c) for c in action.choices)}}}" help_text = (action.help or "").replace("|", "\\|").replace("\n", " ") rows.append(f"| {flag_str} | {help_text} |") return rows if __name__ == "__main__": main()