unitport 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
unitport/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ # -*- coding: utf-8 -*-
2
+ """unitport — UnitPort local helper (headless daemon).
3
+
4
+ Package-level constants shared across modules. Keep this import cheap: NO heavy
5
+ third-party imports here (websockets etc. are imported lazily where used) so that
6
+ ``config`` / ``doctor`` / ``capabilities`` stay usable even in a broken env.
7
+
8
+ Authoritative design: ``knowledges/local_helper_design.md`` (LH-D1…D6, C10/C11).
9
+ """
10
+
11
+ __version__ = "0.0.1"
12
+
13
+ # Wire / schema identifiers — must match the design doc and the cloud edge.
14
+ CAPABILITIES_SCHEMA = "unitport.helper.capabilities@1" # design §5
15
+ PROTOCOL_VERSION = 1 # envelope { v, kind, id, verb, run_id?, payload, ts } §2
16
+
17
+ # Cloud edge (Cloudflare Workers, design §11). Overridable via config.toml
18
+ # [cloud].base_url or the UNITPORT_BASE_URL env var — never hard-required here.
19
+ DEFAULT_BASE_URL = "https://svc.unitport.ai"
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "CAPABILITIES_SCHEMA",
24
+ "PROTOCOL_VERSION",
25
+ "DEFAULT_BASE_URL",
26
+ ]
unitport/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enable ``python -m unitport`` (mirrors the ``unitport`` console script)."""
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
unitport/bundle.py ADDED
@@ -0,0 +1,152 @@
1
+ # -*- coding: utf-8 -*-
2
+ """DispatchBundle model + materialization (contract C4 / design §8, LH-P4).
3
+
4
+ A DispatchBundle is what the cloud sends via ``receive-bundle``. It is the
5
+ complete, executable payload the compile chain produced
6
+ (``web_export_chain_plan.md`` §6): env-cfg source + TrainingSpec + asset URI
7
+ list + provenance + run_id. Large asset bodies never ride inside it — they are
8
+ referenced by D2 URIs (``github://`` / ``helper://`` / ``storage://``) and
9
+ resolved locally at materialize time.
10
+
11
+ Wire shape (payload of the ``receive-bundle`` frame)::
12
+
13
+ {
14
+ "run_id": "r-abc123",
15
+ "backend": "isaac_lab" | "sb3_mujoco",
16
+ "provenance": { "owner_uid": "<uuid>", "canvas_project": "...", "signed_at": "..." },
17
+ "env_cfg": { "file_name": "unitport_env_cfg.py", "source": "<python source>" },
18
+ "spec": { "algorithm": "PPO"|"AMP_PPO", "max_iterations": 100, "seed": 1,
19
+ "robot_sku": "...", "robot_family": "...",
20
+ "resume_checkpoint": null | "helper://.../model_X.pt",
21
+ "warm_start_actor": false },
22
+ "assets": [ { "uri": "helper://<registered-path>", "dest": "assets/go2.xml" }, ... ]
23
+ }
24
+
25
+ ``materialize`` verifies provenance (own-canvas), lays out the run dir, writes
26
+ the env-cfg + spec + metadata, and resolves ``helper://`` assets against the
27
+ registered roots. It does NOT launch training (that is ``runner.start``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import shutil
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List
36
+
37
+ from . import config as _config
38
+ from . import security
39
+ from .errors import VerbError
40
+
41
+ VALID_BACKENDS = frozenset({"isaac_lab", "sb3_mujoco"})
42
+
43
+
44
+ class MaterializedRun:
45
+ """Result of materializing a bundle — everything ``runner`` needs."""
46
+
47
+ def __init__(self, run_id: str, backend: str, run_dir: Path, config_file: Path, spec: Dict[str, Any]):
48
+ self.run_id = run_id
49
+ self.backend = backend
50
+ self.run_dir = run_dir
51
+ self.config_file = config_file # compiled env-cfg .py
52
+ self.spec = spec
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ return {
56
+ "run_id": self.run_id,
57
+ "backend": self.backend,
58
+ "run_dir": str(self.run_dir),
59
+ "config_file": str(self.config_file),
60
+ "spec": self.spec,
61
+ }
62
+
63
+
64
+ def _require(bundle: Dict[str, Any], key: str) -> Any:
65
+ if key not in bundle or bundle[key] in (None, ""):
66
+ raise VerbError(f"bundle missing required field: {key!r}")
67
+ return bundle[key]
68
+
69
+
70
+ def run_root(cfg: Dict[str, Any], backend: str, run_id: str) -> Path:
71
+ ws = Path((cfg.get("workspace", {}) or {}).get("dir") or (_config.config_dir() / "workspace")).expanduser()
72
+ return ws / "training" / "runs" / backend / run_id
73
+
74
+
75
+ def materialize(bundle: Dict[str, Any], paired_uid: str, cfg: Dict[str, Any] | None = None) -> MaterializedRun:
76
+ """Verify + lay down a bundle on disk. Fail loud on anything malformed or
77
+ provenance/asset-boundary violations (C11 §7)."""
78
+ cfg = cfg if cfg is not None else _config.load_config()
79
+
80
+ run_id = str(_require(bundle, "run_id"))
81
+ backend = str(_require(bundle, "backend"))
82
+ if backend not in VALID_BACKENDS:
83
+ raise VerbError(f"unknown backend {backend!r} (allowed: {sorted(VALID_BACKENDS)})")
84
+
85
+ provenance = bundle.get("provenance") or {}
86
+ security.assert_own_canvas(str(provenance.get("owner_uid") or ""), paired_uid)
87
+
88
+ env_cfg = _require(bundle, "env_cfg")
89
+ source = env_cfg.get("source")
90
+ if not source:
91
+ raise VerbError("bundle env_cfg.source is empty (helper consumes precompiled env-cfg, §8)")
92
+ file_name = env_cfg.get("file_name") or "unitport_env_cfg.py"
93
+
94
+ spec = dict(bundle.get("spec") or {})
95
+
96
+ run_dir = run_root(cfg, backend, run_id)
97
+ run_dir.mkdir(parents=True, exist_ok=True)
98
+
99
+ config_file = run_dir / file_name
100
+ config_file.write_text(source, encoding="utf-8")
101
+ (run_dir / "spec.json").write_text(json.dumps(spec, indent=2, ensure_ascii=False), encoding="utf-8")
102
+
103
+ assets_resolved = _resolve_assets(bundle.get("assets") or [], run_dir, cfg)
104
+
105
+ (run_dir / "run.json").write_text(
106
+ json.dumps(
107
+ {
108
+ "run_id": run_id,
109
+ "backend": backend,
110
+ "owner_uid": paired_uid,
111
+ "config_file": str(config_file),
112
+ "assets": assets_resolved,
113
+ "materialized_at": _config.now_iso(),
114
+ "status": "materialized",
115
+ },
116
+ indent=2,
117
+ ensure_ascii=False,
118
+ ),
119
+ encoding="utf-8",
120
+ )
121
+ return MaterializedRun(run_id, backend, run_dir, config_file, spec)
122
+
123
+
124
+ def _resolve_assets(assets: List[Dict[str, Any]], run_dir: Path, cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
125
+ """Resolve each asset URI. ``helper://`` stays referenced by default (copied
126
+ into the run dir only if a ``dest`` is given). ``github://`` / ``storage://``
127
+ are declared-but-not-yet-fetched here (LH-P4 tail / needs network+storage);
128
+ they fail loud rather than silently skip."""
129
+ roots = (cfg.get("resource", {}) or {}).get("roots", []) or []
130
+ out: List[Dict[str, Any]] = []
131
+ for a in assets:
132
+ uri = a.get("uri") or ""
133
+ if uri.startswith("helper://"):
134
+ src = security.resolve_helper_uri(uri, roots)
135
+ entry = {"uri": uri, "resolved": str(src)}
136
+ dest = a.get("dest")
137
+ if dest:
138
+ dst = (run_dir / dest).resolve()
139
+ # dest must stay inside the run dir (no escape).
140
+ if run_dir.resolve() not in dst.parents and dst != run_dir.resolve():
141
+ from .errors import SecurityError
142
+
143
+ raise SecurityError(f"asset dest escapes run dir: {dest!r}")
144
+ dst.parent.mkdir(parents=True, exist_ok=True)
145
+ shutil.copy2(src, dst)
146
+ entry["copied_to"] = str(dst)
147
+ out.append(entry)
148
+ elif uri.startswith(("github://", "storage://")):
149
+ raise VerbError(f"asset scheme not yet materialized in this build: {uri!r} (LH-P4 tail)")
150
+ else:
151
+ raise VerbError(f"unknown asset URI scheme: {uri!r}")
152
+ return out
@@ -0,0 +1,196 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Capability manifest (``report-capabilities`` verb, design §5).
3
+
4
+ Probes the host for training engines + GPU and emits the
5
+ ``unitport.helper.capabilities@1`` payload the cloud uses to light up
6
+ `BTN:helper` and gate which backends are dispatchable (no Isaac Lab → Isaac
7
+ dispatch disabled, fail-loud rather than silent downgrade).
8
+
9
+ Probing is intentionally **fail-soft**: a missing engine/command reports
10
+ ``installed: false`` (or an empty list), never raises. This is capability
11
+ *discovery*, distinct from the §7 security red line (which is fail-loud).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import platform
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ from . import CAPABILITIES_SCHEMA
24
+ from . import config as _config
25
+
26
+ # A probe importing a heavy engine could hang; keep every subprocess bounded.
27
+ _PROBE_TIMEOUT = 20 # seconds
28
+ _TOOL_TIMEOUT = 10
29
+
30
+
31
+ def _run(cmd: List[str], timeout: int) -> Optional[str]:
32
+ """Run ``cmd``, return stripped stdout or None on any failure/timeout."""
33
+ try:
34
+ out = subprocess.run(
35
+ cmd,
36
+ stdout=subprocess.PIPE,
37
+ stderr=subprocess.DEVNULL,
38
+ timeout=timeout,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ )
43
+ except (OSError, subprocess.SubprocessError):
44
+ return None
45
+ if out.returncode != 0:
46
+ return None
47
+ return (out.stdout or "").strip()
48
+
49
+
50
+ def _probe_import(python_exe: str, module: str, version_expr: str) -> Dict[str, Any]:
51
+ """Probe ``module`` inside ``python_exe``; report installed + version.
52
+
53
+ Runs an isolated one-liner (``-I``) so we don't inherit our own site-packages
54
+ when probing a *different* interpreter. Fail-soft.
55
+ """
56
+ code = (
57
+ f"import importlib,sys\n"
58
+ f"try:\n"
59
+ f" m=importlib.import_module({module!r})\n"
60
+ f" print({version_expr})\n"
61
+ f"except Exception:\n"
62
+ f" sys.exit(3)\n"
63
+ )
64
+ ver = _run([python_exe, "-I", "-c", code], _PROBE_TIMEOUT)
65
+ if ver is None:
66
+ return {"installed": False, "version": None}
67
+ return {"installed": True, "version": ver or "unknown"}
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Engines
72
+ # ---------------------------------------------------------------------------
73
+ def detect_isaac_lab(cfg: Dict[str, Any]) -> Dict[str, Any]:
74
+ """Isaac Lab lives behind its OWN interpreter (Isaac Sim's python), declared
75
+ via ``[installs.isaac_lab].python``. We report installed by path existence;
76
+ we do NOT import ``isaaclab`` here (it needs the Kit runtime and can hang) —
77
+ a deep version probe is deferred to `doctor`/training launch."""
78
+ entry = (cfg.get("installs", {}) or {}).get("isaac_lab", {}) or {}
79
+ py = entry.get("python")
80
+ if not py:
81
+ return {"installed": False, "version": None, "python": None}
82
+ from pathlib import Path
83
+
84
+ exists = Path(py).exists()
85
+ return {
86
+ "installed": bool(exists),
87
+ "version": None, # deep probe deferred (Kit runtime); §5 note
88
+ "python": _config._norm_path(py) if py else None,
89
+ "python_exists": bool(exists),
90
+ }
91
+
92
+
93
+ def detect_mujoco(cfg: Dict[str, Any]) -> Dict[str, Any]:
94
+ """MuJoCo (SB3 path) can run in the helper's own interpreter or a declared
95
+ one. Probe the declared interpreter if present, else ``sys.executable``."""
96
+ py = ((cfg.get("installs", {}) or {}).get("mujoco", {}) or {}).get("python") or sys.executable
97
+ return _probe_import(py, "mujoco", "getattr(m,'__version__','unknown')")
98
+
99
+
100
+ def detect_sb3(cfg: Dict[str, Any]) -> Dict[str, Any]:
101
+ py = ((cfg.get("installs", {}) or {}).get("sb3", {}) or {}).get("python") or sys.executable
102
+ return _probe_import(py, "stable_baselines3", "getattr(m,'__version__','unknown')")
103
+
104
+
105
+ def detect_conda_envs() -> List[str]:
106
+ if not shutil.which("conda"):
107
+ return []
108
+ out = _run(["conda", "env", "list", "--json"], _TOOL_TIMEOUT)
109
+ if not out:
110
+ return []
111
+ try:
112
+ import json
113
+
114
+ envs = json.loads(out).get("envs", [])
115
+ except Exception:
116
+ return []
117
+ names = []
118
+ for path in envs:
119
+ name = str(path).replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
120
+ if name:
121
+ names.append(name)
122
+ return names
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # GPU (nvidia-smi)
127
+ # ---------------------------------------------------------------------------
128
+ def detect_gpu() -> Dict[str, Any]:
129
+ smi = shutil.which("nvidia-smi")
130
+ if not smi:
131
+ return {"nvidia_smi": []}
132
+ cuda = _detect_cuda_version(smi)
133
+ out = _run(
134
+ [
135
+ smi,
136
+ "--query-gpu=name,memory.total,driver_version",
137
+ "--format=csv,noheader,nounits",
138
+ ],
139
+ _TOOL_TIMEOUT,
140
+ )
141
+ gpus: List[Dict[str, Any]] = []
142
+ if out:
143
+ for line in out.splitlines():
144
+ parts = [p.strip() for p in line.split(",")]
145
+ if len(parts) < 3:
146
+ continue
147
+ name, mem, driver = parts[0], parts[1], parts[2]
148
+ try:
149
+ mem_mb = int(float(mem))
150
+ except ValueError:
151
+ mem_mb = None
152
+ gpus.append({"name": name, "mem_mb": mem_mb, "driver": driver, "cuda": cuda})
153
+ return {"nvidia_smi": gpus}
154
+
155
+
156
+ def _detect_cuda_version(smi: str) -> Optional[str]:
157
+ banner = _run([smi], _TOOL_TIMEOUT)
158
+ if not banner:
159
+ return None
160
+ m = re.search(r"CUDA Version:\s*([0-9.]+)", banner)
161
+ return m.group(1) if m else None
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Manifest
166
+ # ---------------------------------------------------------------------------
167
+ def _os_name() -> str:
168
+ sysname = platform.system().lower()
169
+ if sysname.startswith("win"):
170
+ return "windows"
171
+ if sysname == "linux":
172
+ return "linux"
173
+ return sysname or "unknown"
174
+
175
+
176
+ def build_manifest(cfg: Optional[Dict[str, Any]] = None, device_id: Optional[str] = None) -> Dict[str, Any]:
177
+ """Assemble the full capability manifest payload (design §5)."""
178
+ cfg = cfg if cfg is not None else _config.load_config()
179
+ device_id = device_id or _config.get_or_create_device_id()
180
+ return {
181
+ "schema": CAPABILITIES_SCHEMA,
182
+ "device_id": device_id,
183
+ "host": {
184
+ "os": _os_name(),
185
+ "arch": platform.machine() or "unknown",
186
+ "hostname": platform.node() or "unknown",
187
+ },
188
+ "engines": {
189
+ "isaac_lab": detect_isaac_lab(cfg),
190
+ "mujoco": detect_mujoco(cfg),
191
+ "sb3": detect_sb3(cfg),
192
+ },
193
+ "conda_envs": detect_conda_envs(),
194
+ "gpu": detect_gpu(),
195
+ "reported_at": _config.now_iso(),
196
+ }
unitport/cli.py ADDED
@@ -0,0 +1,196 @@
1
+ # -*- coding: utf-8 -*-
2
+ """`unitport` command-line entrypoint (headless — design §10).
3
+
4
+ Subcommands: login · up/serve · status · config · doctor · logout, plus a hidden
5
+ `capabilities` for probing/debugging. There are deliberately NO pull/train/push
6
+ subcommands — those are *remote* verbs driven from the canvas (C11 §4).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ # Windows console defaults to a legacy code page; force UTF-8 (img_generate.py style).
16
+ try: # pragma: no cover
17
+ sys.stdout.reconfigure(encoding="utf-8")
18
+ sys.stderr.reconfigure(encoding="utf-8")
19
+ except Exception: # pragma: no cover
20
+ pass
21
+
22
+ from . import __version__
23
+ from . import capabilities as _caps
24
+ from . import config as _config
25
+ from . import doctor as _doctor
26
+ from .errors import UnitportError
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Command handlers
31
+ # ---------------------------------------------------------------------------
32
+ def _cmd_login(args: argparse.Namespace) -> int:
33
+ from . import pairing # lazy: pulls no heavy deps, but keep startup lean
34
+
35
+ pairing.login(base_url=args.base_url)
36
+ return 0
37
+
38
+
39
+ def _cmd_up(args: argparse.Namespace) -> int:
40
+ from . import transport # lazy: imports websockets only on connect
41
+
42
+ return transport.run(base_url=args.base_url)
43
+
44
+
45
+ def _cmd_status(_args: argparse.Namespace) -> int:
46
+ cfg = _config.load_config()
47
+ tok = None
48
+ try:
49
+ tok = _config.load_token()
50
+ except UnitportError as exc:
51
+ print(f" token: ERROR ({exc})")
52
+
53
+ isaac = (cfg.get("installs", {}) or {}).get("isaac_lab", {}) or {}
54
+ roots = (cfg.get("resource", {}) or {}).get("roots", []) or []
55
+
56
+ print(f"unitport v{__version__}")
57
+ print(f" config dir : {_config.config_dir()}")
58
+ print(f" cloud : {_config.base_url(cfg)}")
59
+ print(f" device_id : {_config.get_or_create_device_id()}")
60
+ if tok:
61
+ print(f" paired : yes (uid={tok.get('uid')})")
62
+ print(f" token exp : {tok.get('expires_at', '?')}")
63
+ else:
64
+ print(" paired : no (run `unitport login`)")
65
+ print(f" isaac py : {isaac.get('python') or '(not set)'}")
66
+ print(f" resource : {len(roots)} root(s)")
67
+ return 0
68
+
69
+
70
+ _ENGINE_FIELD_ACTIONS = {
71
+ "isaac-python": ("isaac_lab", "python"),
72
+ "isaac-launcher": ("isaac_lab", "launcher"),
73
+ "isaac-path": ("isaac_lab", "isaac_lab_path"),
74
+ "sb3-python": ("sb3_mujoco", "python"),
75
+ "sb3-launcher": ("sb3_mujoco", "launcher"),
76
+ "mujoco-python": ("mujoco", "python"),
77
+ }
78
+
79
+
80
+ def _cmd_config(args: argparse.Namespace) -> int:
81
+ action = args.config_action
82
+ if action == "show" or action is None:
83
+ print(json.dumps(_config.load_config(), indent=2, ensure_ascii=False))
84
+ return 0
85
+ if action in _ENGINE_FIELD_ACTIONS:
86
+ engine, field = _ENGINE_FIELD_ACTIONS[action]
87
+ _config.set_engine_field(engine, field, args.path)
88
+ print(f" {engine}.{field} set: {args.path}")
89
+ return 0
90
+ if action == "workspace":
91
+ _config.set_workspace(args.path)
92
+ print(f" workspace set: {args.path}")
93
+ return 0
94
+ if action == "add-root":
95
+ cfg = _config.add_resource_root(args.path)
96
+ roots = cfg["resource"]["roots"]
97
+ print(f" resource roots ({len(roots)}): {', '.join(roots)}")
98
+ return 0
99
+ if action == "base-url":
100
+ _config.set_base_url(args.url)
101
+ print(f" cloud base_url set: {args.url}")
102
+ return 0
103
+ print(f"unknown config action: {action}", file=sys.stderr)
104
+ return 2
105
+
106
+
107
+ def _cmd_doctor(_args: argparse.Namespace) -> int:
108
+ return _doctor.run()
109
+
110
+
111
+ def _cmd_capabilities(args: argparse.Namespace) -> int:
112
+ manifest = _caps.build_manifest()
113
+ print(json.dumps(manifest, indent=2, ensure_ascii=False))
114
+ return 0
115
+
116
+
117
+ def _cmd_logout(_args: argparse.Namespace) -> int:
118
+ from . import pairing
119
+
120
+ pairing.logout()
121
+ print(" logged out (helper token cleared).")
122
+ return 0
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Parser
127
+ # ---------------------------------------------------------------------------
128
+ def build_parser() -> argparse.ArgumentParser:
129
+ p = argparse.ArgumentParser(
130
+ prog="unitport",
131
+ description="UnitPort local helper — headless daemon (canvas is the remote control).",
132
+ )
133
+ p.add_argument("-V", "--version", action="version", version=f"unitport {__version__}")
134
+ sub = p.add_subparsers(dest="command")
135
+
136
+ sp = sub.add_parser("login", help="browser-approve device pairing (C10)")
137
+ sp.add_argument("--base-url", default=None, help="override cloud base URL")
138
+ sp.set_defaults(func=_cmd_login)
139
+
140
+ for name in ("up", "serve"):
141
+ sp = sub.add_parser(name, help="dial out to the relay and serve the fixed verb set")
142
+ sp.add_argument("--base-url", default=None, help="override cloud base URL")
143
+ sp.set_defaults(func=_cmd_up)
144
+
145
+ sp = sub.add_parser("status", help="connection / config / paired-account summary")
146
+ sp.set_defaults(func=_cmd_status)
147
+
148
+ sp = sub.add_parser("config", help="declare engine interpreters + resource roots")
149
+ csub = sp.add_subparsers(dest="config_action")
150
+ csub.add_parser("show", help="print effective config")
151
+ for act, helptext in (
152
+ ("isaac-python", "Isaac Sim venv python.exe"),
153
+ ("isaac-launcher", "path to il_train_launcher.py"),
154
+ ("isaac-path", "Isaac Lab root (subprocess cwd)"),
155
+ ("sb3-python", "SB3/MuJoCo python"),
156
+ ("sb3-launcher", "SB3 launcher script"),
157
+ ("mujoco-python", "MuJoCo probe python"),
158
+ ("workspace", "workspace root (Isaac: under PROJECTS_DIR)"),
159
+ ("add-root", "add a resource scan root"),
160
+ ):
161
+ cp = csub.add_parser(act, help=helptext)
162
+ cp.add_argument("path", help="filesystem path")
163
+ cp = csub.add_parser("base-url", help="set cloud base URL")
164
+ cp.add_argument("url", help="e.g. https://svc.unitport.ai")
165
+ sp.set_defaults(func=_cmd_config)
166
+
167
+ sp = sub.add_parser("doctor", help="fail-loud self-check")
168
+ sp.set_defaults(func=_cmd_doctor)
169
+
170
+ sp = sub.add_parser("capabilities", help="print the capability manifest (debug)")
171
+ sp.set_defaults(func=_cmd_capabilities)
172
+
173
+ sp = sub.add_parser("logout", help="clear the stored helper token")
174
+ sp.set_defaults(func=_cmd_logout)
175
+
176
+ return p
177
+
178
+
179
+ def main(argv=None) -> int:
180
+ parser = build_parser()
181
+ args = parser.parse_args(argv)
182
+ if not getattr(args, "command", None):
183
+ parser.print_help()
184
+ return 0
185
+ try:
186
+ return int(args.func(args) or 0)
187
+ except UnitportError as exc:
188
+ print(f"error: {exc}", file=sys.stderr)
189
+ return 1
190
+ except KeyboardInterrupt:
191
+ print("\naborted.", file=sys.stderr)
192
+ return 130
193
+
194
+
195
+ if __name__ == "__main__": # pragma: no cover
196
+ sys.exit(main())