openral-cli 0.1.0__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.
openral_cli/main.py ADDED
@@ -0,0 +1,4108 @@
1
+ """openral CLI entry point — ``openral`` command.
2
+
3
+ Two modes of use:
4
+
5
+ * **One-shot**: ``openral <subcommand> [args...]`` runs a single command and
6
+ exits. Use this in scripts and CI. ``openral --help`` lists the surface.
7
+ * **Interactive REPL**: ``openral`` with no arguments drops into a prompt
8
+ where subcommands run bare (``sim run --config …`` instead of
9
+ ``openral sim run --config …``). Type ``help`` for the menu, ``exit`` or
10
+ Ctrl-D to leave.
11
+
12
+ Sub-commands
13
+ ------------
14
+ doctor Diagnose the host environment (Python, OS, ROS 2, GPU, USB).
15
+ detect Probe hardware and write a full RobotDescription robot.yaml.
16
+ connect Open a HAL connection to a robot and verify it responds.
17
+ calibrate camera Calibrate a camera sensor using ros2 camera_calibration.
18
+ install Install opt-in dependency groups (sim, ros, libero, …).
19
+ rskill search Find installable rSkills on the OpenRAL HF Hub org.
20
+ rskill install Download an rSkill from the HF Hub and register it locally.
21
+ rskill list List all locally installed rSkills.
22
+ rskill check Report which installed rSkills will run on the current host.
23
+ rskill new Scaffold a new local rSkill from rskills/template/.
24
+ collision lower Lower a robot's URDF/SRDF into its self-collision model.
25
+ collision check Fail if a manifest drifts from its lowered collision model.
26
+ check Cross-validate every robot/skill/scene manifest in one pass.
27
+
28
+ Run ``openral --help`` for full usage.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import json as _json
35
+ import os
36
+ import platform
37
+ import re
38
+ import shlex
39
+ import shutil
40
+ import socket
41
+ import subprocess
42
+ import sys
43
+ from glob import glob
44
+ from importlib.metadata import PackageNotFoundError, version
45
+ from pathlib import Path
46
+ from typing import TYPE_CHECKING, Final, NamedTuple, cast
47
+ from urllib.parse import urlparse
48
+
49
+ import click
50
+ import typer
51
+ from openral_core.exceptions import ROSConfigError, ROSRuntimeError
52
+ from openral_observability import (
53
+ cli_command_span,
54
+ configure_observability,
55
+ semconv,
56
+ )
57
+ from openral_sim.cli import sim_app
58
+ from rich.box import MINIMAL, ROUNDED
59
+ from rich.console import Console, Group, RenderableType
60
+ from rich.panel import Panel
61
+ from rich.rule import Rule
62
+ from rich.table import Table
63
+ from rich.text import Text
64
+
65
+ from openral_cli.check import check_command
66
+ from openral_cli.collision import collision_app
67
+ from openral_cli.dataset import dataset_app
68
+ from openral_cli.deploy_sim import deploy_sim_command
69
+ from openral_cli.install import install_app
70
+ from openral_cli.prompt import prompt_command
71
+
72
+ if TYPE_CHECKING:
73
+ from openral_core import (
74
+ BenchmarkScene,
75
+ RobotDescription,
76
+ RSkillEvalResult,
77
+ SensorSpec,
78
+ VLASpec,
79
+ )
80
+ from openral_core.schemas import RSkillManifest
81
+ from openral_detect import CompatibilityReport, DetectionReport, RSkillCompatRow
82
+ from openral_detect.report import GpuProbeResult
83
+
84
+ from openral_cli._rskill_intel import RSkillFamily, RSkillPatch
85
+
86
+ app = typer.Typer(
87
+ name="openral",
88
+ help="OpenRAL — open-source robot agent harness for rSkill / VLA models",
89
+ invoke_without_command=True,
90
+ )
91
+ console = Console()
92
+
93
+ # ── REPL ──────────────────────────────────────────────────────────────────────
94
+
95
+ # Solid-block OpenRAL logo mark (single white weight, no gradient), sized to the
96
+ # wordmark's 6 rows: horns flaring out and down into a rounded head, eyes below.
97
+ _LOGO_ART: Final[str] = "\n".join(
98
+ [
99
+ "█ █",
100
+ "██▄ ▄██",
101
+ "████▄▄ ▄▄████",
102
+ "▀██████ ██████▀",
103
+ " ▀███████▀ ",
104
+ " ▀ ▀███▀ ▀ ",
105
+ ]
106
+ )
107
+ # OPENRAL block-letter wordmark.
108
+ _WORDMARK_ART: Final[str] = "\n".join(
109
+ [
110
+ " ██████╗ ██████╗ ███████╗███╗ ██╗██████╗ █████╗ ██╗",
111
+ "██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔══██╗██╔══██╗██║",
112
+ "██║ ██║██████╔╝█████╗ ██╔██╗ ██║██████╔╝███████║██║",
113
+ "██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██╗██╔══██║██║",
114
+ "╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║██║ ██║███████╗",
115
+ " ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝",
116
+ ]
117
+ )
118
+ _TAGLINE_TAIL: Final[str] = " — Open Robot Agentic Layer (harness) for embodied AI"
119
+ _CAPABILITIES: Final[str] = "fast policies · slow reasoning · rewards · perception · control"
120
+ # Community links rendered in the top-right cell.
121
+ _LINKS: Final[tuple[tuple[str, str], ...]] = (
122
+ ("Discord", "discord.gg/3paXT2bVyB"),
123
+ ("GitHub", "github.com/OpenRAL/openral"),
124
+ ("Hugging Face", "huggingface.co/OpenRAL"),
125
+ ("Website", "openral.com"),
126
+ )
127
+ # Quick-start commands rendered in the bottom-right cell.
128
+ _COMMANDS: Final[tuple[tuple[str, str], ...]] = (
129
+ ("doctor", "diagnose your host setup"),
130
+ ("rskill search", "find installable skills"),
131
+ ("help", "list every command"),
132
+ ("exit", "leave the repl · Ctrl-D"),
133
+ )
134
+
135
+ # Minimum terminal columns each (content-sized) layout occupies — measured from
136
+ # the rendered box so the richest layout that still fits the terminal is chosen and
137
+ # the box never overflows. Below the side-by-side floor the logo stacks above the
138
+ # wordmark; below the stacked width the terminal is simply too narrow to fit.
139
+ _WIDE_MIN: Final[int] = 127 # two-column (logo|wordmark · links/divider/commands)
140
+ _SIDE_BY_SIDE_MIN: Final[int] = 82 # single column, logo + wordmark share a line
141
+
142
+
143
+ def _kv_grid(rows: tuple[tuple[str, str], ...], key_style: str) -> Table:
144
+ """A two-column ``key value`` grid (styled key, dim value) with no borders."""
145
+ grid = Table.grid(padding=(0, 2))
146
+ grid.add_column(style=key_style, no_wrap=True)
147
+ grid.add_column(style="dim")
148
+ for key, value in rows:
149
+ grid.add_row(key, value)
150
+ return grid
151
+
152
+
153
+ def _logo_wordmark(*, stacked: bool) -> RenderableType:
154
+ """Logo mark + OPENRAL wordmark, side by side (wide) or stacked (narrow)."""
155
+ logo = Text(_LOGO_ART, style="bold white")
156
+ wordmark = Text(_WORDMARK_ART, style="bold white")
157
+ if stacked:
158
+ return Group(logo, wordmark)
159
+ grid = Table.grid(padding=(0, 2))
160
+ grid.add_column(vertical="middle")
161
+ grid.add_column(vertical="middle")
162
+ grid.add_row(logo, wordmark)
163
+ return grid
164
+
165
+
166
+ def _identity(*, stacked: bool) -> RenderableType:
167
+ """Logo + wordmark above the centred tagline and capability strip."""
168
+ return Group(
169
+ _logo_wordmark(stacked=stacked),
170
+ Text(""),
171
+ Text.assemble(("OpenRAL", "bold white"), (_TAGLINE_TAIL, "white"), justify="center"),
172
+ Text(_CAPABILITIES, style="dim", justify="center"),
173
+ )
174
+
175
+
176
+ def render_banner(version_str: str, *, width: int | None = None) -> RenderableType:
177
+ """Build the REPL welcome box as a rich renderable (Claude-Code style).
178
+
179
+ Returns a :class:`rich.panel.Panel` so the same renderable can be printed to
180
+ the live console *and* exported to plain text in tests, independent of
181
+ terminal/TTY/colour state. The white-bordered rounded box carries ``OPENRAL
182
+ v<version>`` inline in the top border and adapts to ``width``:
183
+
184
+ * **Wide** (``>= _WIDE_MIN``): two columns — the logo mark beside the OPENRAL
185
+ wordmark with the tagline below on the left; the community links above a
186
+ horizontal divider above the quick-start commands on the right, split by a
187
+ vertical divider.
188
+ * **Narrow**: a single stacked column keeping every section; the logo sits
189
+ beside the wordmark while it fits and stacks above it once it does not.
190
+
191
+ Args:
192
+ version_str: The installed ``openral-cli`` version, rendered as ``vX.Y.Z``.
193
+ width: Target terminal width in columns; defaults to a wide layout.
194
+
195
+ Example:
196
+ >>> from io import StringIO
197
+ >>> from rich.console import Console
198
+ >>> con = Console(file=StringIO(), width=120)
199
+ >>> con.print(render_banner("0.1.0", width=120))
200
+ >>> "OPENRAL v0.1.0" in con.file.getvalue()
201
+ True
202
+ """
203
+ cols = width if width is not None else _WIDE_MIN
204
+ links = _kv_grid(_LINKS, "bold white")
205
+ commands = _kv_grid(_COMMANDS, "bold cyan")
206
+
207
+ body: RenderableType
208
+ if cols >= _WIDE_MIN:
209
+ columns = Table(
210
+ box=MINIMAL,
211
+ show_header=False,
212
+ show_edge=False,
213
+ show_lines=False,
214
+ pad_edge=False,
215
+ padding=(0, 2),
216
+ border_style="dim",
217
+ expand=False,
218
+ )
219
+ columns.add_column(vertical="top")
220
+ columns.add_column(vertical="top")
221
+ columns.add_row(
222
+ _identity(stacked=False),
223
+ Group(links, Rule(style="dim"), commands),
224
+ )
225
+ body = columns
226
+ else:
227
+ body = Group(
228
+ _identity(stacked=cols < _SIDE_BY_SIDE_MIN),
229
+ Text(""),
230
+ links,
231
+ Rule(style="dim"),
232
+ commands,
233
+ )
234
+
235
+ # Size the box to its content (expand=False) rather than stretching it to the
236
+ # terminal: a compact box keeps slack on wide terminals and only breaks if the
237
+ # window is later dragged narrower than the box itself (printed output cannot
238
+ # reflow). The layout above is chosen so the content always fits ``cols``.
239
+ return Panel(
240
+ body,
241
+ box=ROUNDED,
242
+ border_style="white",
243
+ title=f"OPENRAL v{version_str}",
244
+ title_align="left",
245
+ padding=(1, 2),
246
+ expand=False,
247
+ )
248
+
249
+
250
+ def _cli_version() -> str:
251
+ """Best-effort ``openral-cli`` version string for the banner (never raises)."""
252
+ with contextlib.suppress(PackageNotFoundError):
253
+ return version("openral-cli")
254
+ return "0.0.0"
255
+
256
+
257
+ def _print_banner() -> None:
258
+ """Print the OpenRAL welcome box, sized to the live terminal width."""
259
+ console.print(render_banner(_cli_version(), width=console.width))
260
+
261
+
262
+ def _dispatch_repl_line(line: str) -> None:
263
+ """Tokenise a REPL line and re-enter the Typer app as if invoked from a shell.
264
+
265
+ Uses ``shlex.split`` so quoting works (``sim run --config 'path with
266
+ spaces.yaml'``). The Typer app is invoked with ``standalone_mode=False``
267
+ so ``typer.Exit`` and ``click.exceptions.UsageError`` don't tear down
268
+ the REPL. Each line spawns its own top-level callback + tracing scope.
269
+ """
270
+ try:
271
+ tokens = shlex.split(line)
272
+ except ValueError as exc:
273
+ console.print(f"[red]parse error:[/red] {exc}")
274
+ return
275
+ if not tokens:
276
+ return
277
+ head = tokens[0].lower()
278
+ if head in {"exit", "quit", ":q"}:
279
+ raise EOFError
280
+ if head in {"help", "?"}:
281
+ # Re-enter with --help so Typer prints the full surface.
282
+ tokens = ["--help"]
283
+ try:
284
+ app(args=tokens, prog_name="openral", standalone_mode=False)
285
+ except click.exceptions.UsageError as exc:
286
+ exc.show()
287
+ except click.exceptions.Abort:
288
+ console.print("[yellow]aborted[/yellow]")
289
+ except SystemExit:
290
+ # Some commands still call sys.exit; swallow it so the REPL survives.
291
+ pass
292
+ except Exception as exc: # reason: keep REPL alive on subcommand crashes
293
+ console.print(f"[red]error:[/red] {exc}")
294
+
295
+
296
+ def _path_completer(text: str, state: int) -> str | None:
297
+ """``readline``-shaped tab-completion function for filesystem paths.
298
+
299
+ Expands a leading ``~`` against ``$HOME``, globs ``<text>*``, suffixes
300
+ directory matches with ``/`` so a second Tab descends into them, and
301
+ rewrites the home prefix back to ``~`` on return so a user who typed
302
+ ``~/foo`` does not see their line buffer silently rewritten to an
303
+ absolute path. ``state`` is readline's call-counter contract: state=0
304
+ returns the first match, state=N returns the (N+1)-th, and we return
305
+ ``None`` past the end to signal exhaustion.
306
+ """
307
+ import glob
308
+ import os
309
+
310
+ expanded = os.path.expanduser(text) if text else ""
311
+ raw = sorted(glob.glob(expanded + "*"))
312
+ matches = [m + "/" if os.path.isdir(m) else m for m in raw]
313
+
314
+ if text.startswith("~"):
315
+ home = os.path.expanduser("~")
316
+ if home and home != "~":
317
+ matches = [
318
+ "~" + m[len(home) :] if m == home or m.startswith(home + os.sep) else m
319
+ for m in matches
320
+ ]
321
+
322
+ if state < len(matches):
323
+ return matches[state]
324
+ return None
325
+
326
+
327
+ def _run_repl() -> None:
328
+ """Run the interactive ``openral>`` shell until EOF or ``exit``.
329
+
330
+ Uses stdlib ``input()`` + optional ``readline`` (stdlib) for arrow-key
331
+ history and Tab path completion. Deliberately avoids a hard dependency
332
+ on ``prompt_toolkit`` so the curl-bash Tier-0 install (uv +
333
+ openral-cli only) is enough.
334
+ """
335
+ import contextlib
336
+
337
+ with contextlib.suppress(ImportError):
338
+ # readline is absent on Windows; REPL still works, just without
339
+ # history or Tab completion.
340
+ import readline
341
+
342
+ readline.set_completer(_path_completer)
343
+ # Shell-shaped delimiters: split on whitespace and shell
344
+ # metacharacters only, so a path token like "~/foo/bar.yaml" is
345
+ # passed to the completer whole instead of being chopped at "~",
346
+ # "/", or ".".
347
+ readline.set_completer_delims(" \t\n=;|&><")
348
+ # macOS ships libedit-backed readline whose bind syntax differs.
349
+ if "libedit" in getattr(readline, "__doc__", "") or "":
350
+ readline.parse_and_bind("bind ^I rl_complete")
351
+ else:
352
+ readline.parse_and_bind("tab: complete")
353
+
354
+ _print_banner()
355
+ while True:
356
+ try:
357
+ line = input("openral> ").strip()
358
+ except (EOFError, KeyboardInterrupt):
359
+ console.print() # newline after ^D / ^C
360
+ break
361
+ if not line:
362
+ continue
363
+ try:
364
+ _dispatch_repl_line(line)
365
+ except EOFError:
366
+ break
367
+
368
+
369
+ _RUN_MODE_BY_SUBCOMMAND: dict[str, str] = {
370
+ "sim": semconv.RUN_MODE_SIM,
371
+ "benchmark": semconv.RUN_MODE_BENCHMARK,
372
+ "deploy": semconv.RUN_MODE_HARDWARE,
373
+ "connect": semconv.RUN_MODE_HARDWARE,
374
+ }
375
+
376
+ # Hardware deployments at >=100 Hz over 24 h would emit millions of tick spans
377
+ # per day at ALWAYS_ON. The 2026-05-17 sampling-policy amendment calls for a
378
+ # 10% ratio sampler on hardware mode and ALWAYS_ON for sim / benchmark
379
+ # / one-shot subcommands (doctor / detect / skill install / …) where the
380
+ # total volume is bounded by a single invocation.
381
+ _SAMPLE_RATIO_BY_MODE: dict[str, float] = {
382
+ semconv.RUN_MODE_HARDWARE: 0.1,
383
+ }
384
+
385
+
386
+ @app.callback()
387
+ def _root(ctx: typer.Context) -> None:
388
+ """Initialise tracing+logs, open the ``cli.command`` root span, or enter REPL.
389
+
390
+ Bare ``openral`` invocations (no subcommand) drop into the interactive
391
+ REPL where each entered line is re-dispatched through the Typer app as
392
+ if typed on the shell. Subcommand invocations behave exactly as before:
393
+ a single ``cli.command`` root span wraps the call and the sampler is
394
+ chosen by ``openral.run.mode`` (hardware → 10% ratio, others → always-on)
395
+ per the 2026-05-17 sampling-policy amendment. ``OPENRAL_OTEL_SAMPLE_RATIO``
396
+ overrides for ad-hoc debugging.
397
+ """
398
+ if ctx.invoked_subcommand is None:
399
+ # Configure tracing with always-on (REPL == bounded session), then
400
+ # drop into the prompt. Each dispatched line re-enters this callback
401
+ # with its own subcommand, so the per-command span tree stays intact.
402
+ configure_observability(service_name="openral", sample_ratio=None)
403
+ _run_repl()
404
+ return
405
+
406
+ subcommand = ctx.invoked_subcommand
407
+ mode = _RUN_MODE_BY_SUBCOMMAND.get(subcommand)
408
+ sample_ratio = _SAMPLE_RATIO_BY_MODE.get(mode) if mode is not None else None
409
+ configure_observability(service_name="openral", sample_ratio=sample_ratio)
410
+ ctx.with_resource(cli_command_span(subcommand, mode=mode))
411
+
412
+
413
+ # Status values used throughout; kept as plain str for JSON serialisation.
414
+ # Colour mapping: ok→green, absent/info→yellow, everything else→red.
415
+ _YELLOW_STATUSES = frozenset({"absent", "info", "warn"})
416
+
417
+
418
+ class CheckResult(NamedTuple):
419
+ """One row in the ``openral doctor`` output table.
420
+
421
+ Attributes:
422
+ check: Short name of the thing being checked.
423
+ status: One of ``ok``, ``fail``, ``missing``, ``absent``, ``info``, ``warn``.
424
+ details: Human-readable detail string (path, version, device list, …).
425
+ """
426
+
427
+ check: str
428
+ status: str
429
+ details: str
430
+
431
+
432
+ # ── Individual check functions (each independently testable) ──────────────────
433
+
434
+
435
+ def _check_python() -> CheckResult:
436
+ ok = sys.version_info >= (3, 10)
437
+ return CheckResult("Python", "ok" if ok else "fail", platform.python_version())
438
+
439
+
440
+ def _check_platform() -> CheckResult:
441
+ return CheckResult("Platform", "info", f"{platform.system()} {platform.release()}")
442
+
443
+
444
+ def _check_openral_core() -> CheckResult:
445
+ try:
446
+ v = version("openral-core")
447
+ return CheckResult("openral-core", "ok", v)
448
+ except PackageNotFoundError as exc:
449
+ return CheckResult("openral-core", "fail", str(exc))
450
+
451
+
452
+ def _check_ros2() -> list[CheckResult]:
453
+ """Return one or more rows covering the ROS 2 binary, distro, and RMW."""
454
+ results: list[CheckResult] = []
455
+
456
+ ros2_path = shutil.which("ros2")
457
+ if not ros2_path:
458
+ results.append(CheckResult("ROS 2 binary", "missing", "not found"))
459
+ return results
460
+ results.append(CheckResult("ROS 2 binary", "ok", ros2_path))
461
+
462
+ # Distro — set by sourcing /opt/ros/<distro>/setup.bash
463
+ distro = os.environ.get("ROS_DISTRO", "")
464
+ if distro:
465
+ results.append(CheckResult("ROS 2 distro", "ok", distro))
466
+ else:
467
+ installed = sorted(glob("/opt/ros/*/setup.bash"))
468
+ if installed:
469
+ names = [p.split("/")[3] for p in installed]
470
+ results.append(
471
+ CheckResult(
472
+ "ROS 2 distro",
473
+ "info",
474
+ f"installed: {', '.join(names)} — run: source /opt/ros/<distro>/setup.bash",
475
+ )
476
+ )
477
+ else:
478
+ results.append(
479
+ CheckResult(
480
+ "ROS 2 distro",
481
+ "missing",
482
+ "ROS_DISTRO not set and no /opt/ros/* found",
483
+ )
484
+ )
485
+
486
+ # RMW implementation
487
+ rmw = os.environ.get("RMW_IMPLEMENTATION", "rmw_fastrtps_cpp (default)")
488
+ results.append(CheckResult("RMW", "info", rmw))
489
+
490
+ return results
491
+
492
+
493
+ def _check_colcon() -> CheckResult:
494
+ path = shutil.which("colcon")
495
+ return CheckResult("colcon", "ok" if path else "missing", path or "")
496
+
497
+
498
+ def _check_gpu(result: GpuProbeResult, warnings: list[str]) -> list[CheckResult]:
499
+ """Return one row per detected GPU / SoC accelerator.
500
+
501
+ Args:
502
+ result: Pre-probed :class:`~openral_detect.GpuProbeResult` (shared
503
+ with :func:`_check_compute_spec` so the probe runs exactly once).
504
+ warnings: Non-fatal probe warnings to surface when no GPU is found.
505
+ """
506
+ rows: list[CheckResult] = []
507
+ for gpu in result.nvidia:
508
+ rows.append(
509
+ CheckResult(
510
+ f"GPU {gpu.index}",
511
+ "ok",
512
+ f"{gpu.name} ({gpu.vram_total_mib} MiB, "
513
+ f"sm_{gpu.cuda_compute_capability[0]}{gpu.cuda_compute_capability[1]})",
514
+ )
515
+ )
516
+ if result.jetson is not None:
517
+ rows.append(
518
+ CheckResult(
519
+ "Jetson",
520
+ "ok",
521
+ f"{result.jetson.board} ({result.jetson.tops:.0f} TOPS, "
522
+ f"{result.jetson.ram_gb:.0f} GB unified)",
523
+ )
524
+ )
525
+ if result.apple_silicon is not None:
526
+ rows.append(CheckResult("GPU", "info", f"Apple Silicon — {result.apple_silicon.chip}"))
527
+ if not rows:
528
+ if warnings:
529
+ rows.append(CheckResult("GPU", "absent", warnings[0]))
530
+ else:
531
+ rows.append(CheckResult("GPU", "absent", "no accelerator detected"))
532
+ return rows
533
+
534
+
535
+ def _check_compute_spec(result: GpuProbeResult) -> list[CheckResult]:
536
+ """Build :class:`~openral_core.ComputeSpec` rows from the GPU probe.
537
+
538
+ Shares the same :class:`~openral_detect.GpuProbeResult` already obtained
539
+ by :func:`_check_gpu` so no second probe is issued. The assembled
540
+ ``ComputeSpec`` mirrors exactly what ``openral detect`` would write into
541
+ ``RobotDescription.compute_edge`` / ``compute_local`` — doctor and detect
542
+ stay in sync.
543
+
544
+ Tier labelling:
545
+ - Jetson SoC detected → rows prefixed ``ComputeSpec (edge)``
546
+ - Discrete NVIDIA / Apple Silicon / CPU-only → ``ComputeSpec (local)``
547
+
548
+ Rows emitted per tier:
549
+ - **runtimes** — comma-separated list of supported runtimes.
550
+ - **dtypes** — comma-separated list of supported quantization dtypes.
551
+ - **cuMotion** — ok when ``supports_cumotion()`` is True, info otherwise.
552
+ - **NVMM** — ok/absent for zero-copy NVMM availability.
553
+ """
554
+ import datetime
555
+
556
+ from openral_detect import build_compute_spec
557
+ from openral_detect.report import DetectionReport, GpuProbeResult
558
+
559
+ def _spec_rows(gpu: GpuProbeResult, tier: str) -> list[CheckResult]:
560
+ report = DetectionReport(
561
+ detected_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
562
+ gpu=gpu,
563
+ )
564
+ spec = build_compute_spec(gpu, report)
565
+ prefix = f"ComputeSpec ({tier})"
566
+ rows: list[CheckResult] = []
567
+
568
+ runtimes_str = ", ".join(r.value for r in spec.gpu_supported_runtimes) or "none"
569
+ rows.append(CheckResult(f"{prefix} / runtimes", "info", runtimes_str))
570
+
571
+ dtypes_str = ", ".join(d.value for d in spec.gpu_supported_dtypes) or "none"
572
+ rows.append(CheckResult(f"{prefix} / dtypes", "info", dtypes_str))
573
+
574
+ cumotion = spec.supports_cumotion()
575
+ rows.append(
576
+ CheckResult(
577
+ f"{prefix} / cuMotion",
578
+ "ok" if cumotion else "info",
579
+ "supported" if cumotion else "not supported (needs Ampere+, CUDA≥13, ≥8 GB VRAM)",
580
+ )
581
+ )
582
+
583
+ rows.append(
584
+ CheckResult(
585
+ f"{prefix} / NVMM",
586
+ "ok" if spec.nvmm_available else "absent",
587
+ "zero-copy NVMM available" if spec.nvmm_available else "not available",
588
+ )
589
+ )
590
+ return rows
591
+
592
+ all_rows: list[CheckResult] = []
593
+ if result.jetson is not None:
594
+ gpu_edge = GpuProbeResult(jetson=result.jetson, backend="jtop")
595
+ all_rows.extend(_spec_rows(gpu_edge, "edge"))
596
+ if result.nvidia or result.apple_silicon or result.jetson is None:
597
+ gpu_local = GpuProbeResult(
598
+ nvidia=result.nvidia,
599
+ apple_silicon=result.apple_silicon,
600
+ backend=result.backend,
601
+ )
602
+ all_rows.extend(_spec_rows(gpu_local, "local"))
603
+ return all_rows
604
+
605
+
606
+ def _check_usb() -> list[CheckResult]:
607
+ """Return one row listing USB serial devices that could be robot controllers."""
608
+ if platform.system() == "Linux":
609
+ patterns = ["/dev/ttyUSB*", "/dev/ttyACM*"]
610
+ elif platform.system() == "Darwin":
611
+ patterns = ["/dev/cu.usbserial*", "/dev/cu.usbmodem*"]
612
+ else:
613
+ return [CheckResult("USB devices", "info", "enumeration not supported on this OS")]
614
+
615
+ devices: list[str] = []
616
+ for pattern in patterns:
617
+ devices.extend(sorted(glob(pattern)))
618
+
619
+ if devices:
620
+ return [CheckResult("USB devices", "ok", ", ".join(devices))]
621
+ return [CheckResult("USB devices", "info", "none found")]
622
+
623
+
624
+ def _check_just() -> CheckResult:
625
+ path = shutil.which("just")
626
+ # `just` is a developer-convenience task runner, not a runtime requirement
627
+ # of `openral`; report absence with `warn` rather than `missing` so doctor
628
+ # still exits 0 on hosts that only need to run skills.
629
+ return CheckResult("just", "ok" if path else "warn", path or "not found")
630
+
631
+
632
+ # PROVIDER values whose endpoint enforces auth and so require
633
+ # OPENRAL_REASONER_LLM_API_KEY. Bare ``openai-compatible`` and ``ollama``
634
+ # are the exceptions because a local Ollama / llama-server doesn't.
635
+ _REASONER_PROVIDERS_REQUIRING_KEY: frozenset[str] = frozenset(
636
+ {"anthropic", "openrouter", "gemini", "xai", "deepseek", "huggingface"}
637
+ )
638
+
639
+ # Provider-default base URLs used when the user hasn't set
640
+ # OPENRAL_REASONER_LLM_BASE_URL. Mirrors tool_use.py constants but kept
641
+ # local to avoid forcing the CLI to import the (optionally-installed)
642
+ # reasoner package on every `openral doctor` invocation.
643
+ _REASONER_PROVIDER_DEFAULT_BASE_URL: dict[str, str] = {
644
+ "anthropic": "https://api.anthropic.com",
645
+ "openai-compatible": "https://api.openai.com/v1",
646
+ "openrouter": "https://openrouter.ai/api/v1",
647
+ "ollama": "http://localhost:11434/v1",
648
+ "vllm": "http://localhost:8000/v1",
649
+ "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/",
650
+ "xai": "https://api.x.ai/v1",
651
+ "deepseek": "https://api.deepseek.com",
652
+ "huggingface": "https://router.huggingface.co/v1",
653
+ }
654
+
655
+
656
+ def _is_local_base_url(url: str) -> bool:
657
+ """Return True when ``url``'s host resolves to a loopback name."""
658
+ host = urlparse(url).hostname or ""
659
+ return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
660
+
661
+
662
+ def _probe_tcp(host: str, port: int, *, timeout_s: float = 0.2) -> bool:
663
+ """Return True if a TCP connection to ``host:port`` succeeds quickly."""
664
+ with contextlib.suppress(OSError), socket.create_connection((host, port), timeout=timeout_s):
665
+ return True
666
+ return False
667
+
668
+
669
+ def _check_reasoner_llm() -> list[CheckResult]:
670
+ """Return rows describing the reasoner LLM env configuration.
671
+
672
+ Always emits a leading ``Reasoner LLM`` summary row. When the
673
+ provider is set but the rest of the config is incomplete, follow-up
674
+ rows name each missing variable so the user can read the table
675
+ top-to-bottom and see exactly what to export.
676
+
677
+ When the resolved endpoint is loopback (Ollama / local vLLM /
678
+ llama-server), an additional probe row (labelled ``vLLM`` for
679
+ ``provider=vllm``, else ``Ollama``) TCP-probes the port so a user
680
+ trying the local baseline gets an immediate diagnosis if the daemon
681
+ isn't running.
682
+
683
+ The API key value is never printed — only ``set`` / ``unset``.
684
+ """
685
+ rows: list[CheckResult] = []
686
+ provider_raw = os.environ.get("OPENRAL_REASONER_LLM_PROVIDER", "").strip()
687
+ provider = provider_raw.lower()
688
+
689
+ if not provider:
690
+ rows.append(
691
+ CheckResult(
692
+ "Reasoner LLM",
693
+ "absent",
694
+ "OPENRAL_REASONER_LLM_PROVIDER unset — see "
695
+ "packages/openral_reasoner_ros/README.md for the three baseline configs "
696
+ "(anthropic / openrouter / openai-compatible).",
697
+ )
698
+ )
699
+ return rows
700
+
701
+ if provider not in _REASONER_PROVIDER_DEFAULT_BASE_URL:
702
+ rows.append(
703
+ CheckResult(
704
+ "Reasoner LLM",
705
+ "fail",
706
+ f"OPENRAL_REASONER_LLM_PROVIDER={provider_raw!r}; expected one of "
707
+ f"{sorted(_REASONER_PROVIDER_DEFAULT_BASE_URL)!r}.",
708
+ )
709
+ )
710
+ return rows
711
+
712
+ model = os.environ.get("OPENRAL_REASONER_LLM_MODEL", "").strip()
713
+ api_key = os.environ.get("OPENRAL_REASONER_LLM_API_KEY", "").strip()
714
+ base_url_env = os.environ.get("OPENRAL_REASONER_LLM_BASE_URL", "").strip()
715
+ base_url = base_url_env or _REASONER_PROVIDER_DEFAULT_BASE_URL[provider]
716
+
717
+ key_required = provider in _REASONER_PROVIDERS_REQUIRING_KEY
718
+ key_status = "set" if api_key else "unset"
719
+ parts = [
720
+ f"provider={provider}",
721
+ f"model={model or '<unset>'}",
722
+ f"api_key={key_status}",
723
+ f"base_url={base_url}",
724
+ ]
725
+ summary = " ".join(parts)
726
+
727
+ incomplete: list[CheckResult] = []
728
+ if not model:
729
+ incomplete.append(
730
+ CheckResult(
731
+ "Reasoner MODEL",
732
+ "missing",
733
+ "OPENRAL_REASONER_LLM_MODEL unset — required for every provider.",
734
+ )
735
+ )
736
+ if key_required and not api_key:
737
+ incomplete.append(
738
+ CheckResult(
739
+ "Reasoner API_KEY",
740
+ "missing",
741
+ f"OPENRAL_REASONER_LLM_API_KEY unset — required for provider={provider}.",
742
+ )
743
+ )
744
+
745
+ if incomplete:
746
+ rows.append(CheckResult("Reasoner LLM", "warn", summary))
747
+ rows.extend(incomplete)
748
+ else:
749
+ rows.append(CheckResult("Reasoner LLM", "ok", summary))
750
+
751
+ # Local-endpoint probe. Only meaningful when the resolved base_url is
752
+ # loopback; we never reach out to a cloud endpoint from `openral doctor`.
753
+ # Label + remediation are provider-aware so a vLLM endpoint isn't
754
+ # mislabelled as Ollama. The default port matches the daemon's own:
755
+ # 8000 for vLLM, 11434 otherwise.
756
+ if _is_local_base_url(base_url):
757
+ parsed = urlparse(base_url)
758
+ host = parsed.hostname or "localhost"
759
+ if provider == "vllm":
760
+ probe_label = "vLLM"
761
+ default_port = 8000
762
+ hint = "start the server with `vllm serve <model>`"
763
+ else:
764
+ probe_label = "Ollama"
765
+ default_port = 11434
766
+ hint = "run `just bootstrap-ollama` or `ollama serve`"
767
+ port = parsed.port or default_port
768
+ if _probe_tcp(host, port):
769
+ rows.append(
770
+ CheckResult(
771
+ probe_label,
772
+ "ok",
773
+ f"endpoint reachable at {host}:{port}",
774
+ )
775
+ )
776
+ else:
777
+ rows.append(
778
+ CheckResult(
779
+ probe_label,
780
+ "warn",
781
+ f"endpoint unreachable at {host}:{port} — {hint}.",
782
+ )
783
+ )
784
+
785
+ return rows
786
+
787
+
788
+ def _gather_checks() -> list[CheckResult]:
789
+ """Run all checks and return the combined result list.
790
+
791
+ The GPU probe is issued exactly once and shared between
792
+ :func:`_check_gpu` (hardware rows) and :func:`_check_compute_spec`
793
+ (derived ``ComputeSpec`` rows) so ``openral doctor`` and
794
+ ``openral detect`` build the same ``ComputeSpec`` from the same data.
795
+
796
+ Returns:
797
+ List of `CheckResult` in display order.
798
+ """
799
+ from openral_detect.probes import probe_gpus
800
+
801
+ checks: list[CheckResult] = []
802
+ checks.append(_check_python())
803
+ checks.append(_check_platform())
804
+ checks.append(_check_openral_core())
805
+ checks.extend(_check_ros2())
806
+ checks.append(_check_colcon())
807
+ gpu_warnings: list[str] = []
808
+ gpu_result = probe_gpus(warnings=gpu_warnings)
809
+ checks.extend(_check_gpu(gpu_result, gpu_warnings))
810
+ checks.extend(_check_compute_spec(gpu_result))
811
+ checks.extend(_check_usb())
812
+ checks.append(_check_just())
813
+ checks.extend(_check_reasoner_llm())
814
+ return checks
815
+
816
+
817
+ # ── CLI commands ──────────────────────────────────────────────────────────────
818
+
819
+
820
+ @app.command()
821
+ def doctor(
822
+ json: bool = typer.Option(False, "--json", help="Output machine-readable JSON"),
823
+ ) -> None:
824
+ """Diagnose the host: Python, OS, ROS 2 distro, GPU, USB devices.
825
+
826
+ Exits 0 when every check is ``ok``, ``absent``, or ``info``; exits 1 if
827
+ any check has status ``fail`` or ``missing``.
828
+
829
+ Example:
830
+ >>> # openral doctor
831
+ >>> # openral doctor --json
832
+ """
833
+ checks = _gather_checks()
834
+
835
+ if json:
836
+ result = [{"check": c.check, "status": c.status, "details": c.details} for c in checks]
837
+ console.print_json(_json.dumps(result))
838
+ else:
839
+ table = Table(title="openral doctor")
840
+ table.add_column("check", style="bold")
841
+ table.add_column("status")
842
+ table.add_column("details")
843
+ for c in checks:
844
+ style = (
845
+ "green" if c.status == "ok" else "yellow" if c.status in _YELLOW_STATUSES else "red"
846
+ )
847
+ table.add_row(c.check, f"[{style}]{c.status}[/{style}]", c.details)
848
+ console.print(table)
849
+
850
+ fatal = {"fail", "missing"}
851
+ if any(c.status in fatal for c in checks):
852
+ raise typer.Exit(code=1)
853
+
854
+
855
+ @app.command()
856
+ def detect(
857
+ output: Path = typer.Option(
858
+ Path("robot.yaml"), "--output", "-o", help="Output robot.yaml path"
859
+ ),
860
+ robot_type: str | None = typer.Option(
861
+ None,
862
+ "--robot",
863
+ "--as",
864
+ help="Force the canonical base manifest (slug e.g. 'so100' or dir name "
865
+ "'so100_follower'), overriding USB/DDS inference. A bare Feetech "
866
+ "plug-in defaults to the SO-101; use this to select the SO-100 (the "
867
+ "two are indistinguishable over USB).",
868
+ ),
869
+ report: Path | None = typer.Option(
870
+ None,
871
+ "--report",
872
+ help="Optional path to dump the raw DetectionReport as JSON.",
873
+ ),
874
+ dds_timeout: float = typer.Option(
875
+ 5.0, "--dds-timeout", help="DDS topic discovery timeout in seconds"
876
+ ),
877
+ include: str | None = typer.Option(
878
+ None,
879
+ "--include",
880
+ help="Comma-separated probe names to run (default: all). "
881
+ "Choices: usb, dds, gpu, cameras_v4l2, cameras_realsense, network.",
882
+ ),
883
+ no_write: bool = typer.Option(
884
+ False, "--no-write", help="Print summary and skip writing robot.yaml"
885
+ ),
886
+ deployment: Path | None = typer.Option(
887
+ None,
888
+ "--deployment",
889
+ help="Also scaffold a DeployScene YAML at this path (robot_id + "
890
+ "`sensors:` bindings from the camera wizard; safety left to the "
891
+ "robot manifest). Paste-able as `openral deploy run --config`.",
892
+ ),
893
+ yes: bool = typer.Option(
894
+ False, "--yes", "-y", help="Overwrite existing file without prompting"
895
+ ),
896
+ ) -> None:
897
+ """Probe the host and emit a complete RobotDescription robot.yaml.
898
+
899
+ Runs the auto-provisioning flow from ``openral_detect``:
900
+
901
+ 1. Probe USB / DDS / GPU / V4L2 / RealSense / network.
902
+ 2. Identify the rig (USB VID/PID match or DDS topology). If a
903
+ known robot is detected, load the canonical
904
+ ``robots/<name>/robot.yaml`` directly; otherwise synthesize a
905
+ minimal scaffold.
906
+ 3. Reverse-look up each detected sensor in the catalog so its
907
+ ``SensorSpec`` carries **real** intrinsics, FOV, encoding, rate.
908
+ 4. Promote detected GPU / Jetson / Apple Silicon caps onto
909
+ ``RobotCapabilities`` so ``openral rskill check`` can match
910
+ ``RSkillManifest.runtime`` / ``quantization.dtype``.
911
+
912
+ Example:
913
+ >>> # openral detect
914
+ >>> # openral detect --include gpu,network --no-write
915
+ """
916
+ import yaml as _yaml
917
+ from openral_detect import (
918
+ assemble_robot_description,
919
+ detect_hardware,
920
+ )
921
+
922
+ include_set: set[str] | None = (
923
+ {p.strip() for p in include.split(",") if p.strip()} if include else None
924
+ )
925
+
926
+ console.print("[bold]openral detect[/bold] — probing host …")
927
+ detection = detect_hardware(dds_timeout_s=dds_timeout, include=include_set)
928
+
929
+ _render_detection_summary(detection)
930
+
931
+ if report is not None:
932
+ report.write_text(detection.model_dump_json(indent=2), encoding="utf-8")
933
+ console.print(f"[green]Wrote[/green] {report} (raw DetectionReport)")
934
+
935
+ # Probe-only inspection short-circuits the builder (keeps CI / --report
936
+ # non-interactive). Building a manifest is always interactive.
937
+ if no_write:
938
+ try:
939
+ canonical = assemble_robot_description(
940
+ detection, force_robot_type=robot_type, enrich_cameras=True
941
+ )
942
+ except ROSConfigError as exc:
943
+ console.print(f"[red]detect:[/red] {exc}")
944
+ raise typer.Exit(code=1) from exc
945
+ console.print("\n[dim]--no-write set — printing yaml to stdout:[/dim]\n")
946
+ console.print(
947
+ _yaml.safe_dump(
948
+ canonical.model_dump(mode="json"), sort_keys=False, default_flow_style=False
949
+ )
950
+ )
951
+ if deployment is not None:
952
+ console.print(
953
+ "[yellow]--deployment ignored under --no-write — no files written.[/yellow]"
954
+ )
955
+ return
956
+
957
+ try:
958
+ canonical = assemble_robot_description(
959
+ detection, force_robot_type=robot_type, enrich_cameras=False
960
+ )
961
+ except ROSConfigError as exc:
962
+ console.print(f"[red]detect:[/red] {exc}")
963
+ raise typer.Exit(code=1) from exc
964
+
965
+ default_name = str(canonical.name)
966
+ robot_name = _prompt_robot_name(default_name)
967
+
968
+ robot_sensors, scene_sensor_specs = _run_camera_binding_wizard(canonical, detection)
969
+ description = canonical.model_copy(
970
+ update={"name": robot_name, "sensors": robot_sensors}, deep=True
971
+ )
972
+ description = _maybe_customize_limits(description)
973
+
974
+ if output == Path("robot.yaml"):
975
+ output = Path("robots") / robot_name / "robot.yaml"
976
+ output.parent.mkdir(parents=True, exist_ok=True)
977
+
978
+ from openral_detect.registry import canonical_robot_path
979
+
980
+ inferred = (
981
+ robot_type
982
+ or detection.ros2.inferred_robot_type
983
+ or next((m.bh_robot_type for m in detection.usb.matches if m.bh_robot_type), None)
984
+ )
985
+ canon_path = canonical_robot_path(inferred) if inferred else None
986
+ canonical_dir = canon_path.parent if canon_path is not None else None
987
+ description = _relocate_file_assets(
988
+ description, canonical_dir=canonical_dir, output_path=output
989
+ )
990
+
991
+ yaml_text = _yaml.safe_dump(
992
+ description.model_dump(mode="json"),
993
+ sort_keys=False,
994
+ default_flow_style=False,
995
+ )
996
+
997
+ if output.exists() and not yes:
998
+ overwrite = typer.confirm(f"{output} already exists. Overwrite?", default=False)
999
+ if not overwrite:
1000
+ console.print("[yellow]Aborted.[/yellow]")
1001
+ raise typer.Exit(code=0)
1002
+
1003
+ output.write_text(yaml_text, encoding="utf-8")
1004
+ console.print(f"\n[green]Wrote[/green] {output} (RobotDescription, {description.name})")
1005
+ console.print(f"[dim]Next step:[/dim] openral rskill check --robot {output}")
1006
+
1007
+ if deployment is not None:
1008
+ _write_deploy_scene_scaffold(
1009
+ deployment, description, scene_sensor_specs, detection=detection, assume_yes=yes
1010
+ )
1011
+
1012
+
1013
+ def _relocate_file_assets(
1014
+ description: RobotDescription, *, canonical_dir: Path | None, output_path: Path
1015
+ ) -> RobotDescription:
1016
+ """Rewrite ``file:`` asset refs to repo-root-relative for a relocated manifest.
1017
+
1018
+ ``file:<rel>`` resolves against the manifest dir then the repo root; a
1019
+ relocated manifest loses the manifest-dir hit, so we repoint each ref at
1020
+ ``file:<repo-root-relative path to the canonical file>`` (which the repo-root
1021
+ fallback resolves). Mesh paths inside the URDF keep resolving from the
1022
+ canonical file's own location because we point at it in place, not copy it.
1023
+ """
1024
+ from openral_core.assets import _REPO_ROOT # reason: match the resolver's root exactly
1025
+
1026
+ if canonical_dir is None or canonical_dir.resolve() == output_path.parent.resolve():
1027
+ return description
1028
+
1029
+ root = _REPO_ROOT.resolve()
1030
+
1031
+ def _rewrite(ref: str | None) -> str | None:
1032
+ if not ref or not ref.startswith("file:"):
1033
+ return ref
1034
+ abs_path = (canonical_dir / ref[len("file:") :]).resolve()
1035
+ try:
1036
+ return f"file:{abs_path.relative_to(root)}"
1037
+ except ValueError:
1038
+ return f"file:{abs_path}" # canonical dir outside repo → absolute
1039
+
1040
+ assets = description.assets
1041
+ new_urdf = (
1042
+ assets.urdf.model_copy(update={"ref": _rewrite(assets.urdf.ref)})
1043
+ if assets.urdf is not None
1044
+ else None
1045
+ )
1046
+ new_assets = assets.model_copy(
1047
+ update={"urdf": new_urdf, "mjcf": _rewrite(assets.mjcf), "srdf": _rewrite(assets.srdf)}
1048
+ )
1049
+ return description.model_copy(update={"assets": new_assets})
1050
+
1051
+
1052
+ _ROBOT_NAME_RE = re.compile(r"[a-z0-9_]+")
1053
+
1054
+
1055
+ def _prompt_robot_name(default: str) -> str:
1056
+ """Prompt for the custom rig name written into robots/<name>/robot.yaml.
1057
+
1058
+ Constrained to the slug convention every in-tree ``robots/<name>/`` dir
1059
+ already uses (lowercase letters, digits, underscores) so the answer can't
1060
+ escape the ``robots/`` tree via ``/`` or break the ``robot_id`` used by
1061
+ ``deploy run`` / ``rskill check`` via spaces or other punctuation.
1062
+ Re-prompts on an invalid answer instead of writing it through.
1063
+
1064
+ ``default`` itself isn't guaranteed slug-safe — an unrecognised rig's
1065
+ scaffolded name embeds the raw hostname (e.g. ``unknown_my-laptop``),
1066
+ which can carry a hyphen. Sanitize it before offering it so accepting the
1067
+ default with a bare Enter always succeeds instead of re-prompting.
1068
+ """
1069
+ if not _ROBOT_NAME_RE.fullmatch(default):
1070
+ default = re.sub(r"[^a-z0-9_]+", "_", default.lower()).strip("_") or "robot"
1071
+ while True:
1072
+ name = typer.prompt("Robot name (writes robots/<name>/robot.yaml)", default=default).strip()
1073
+ name = name or default
1074
+ if _ROBOT_NAME_RE.fullmatch(name):
1075
+ return name
1076
+ console.print(
1077
+ f" [yellow]{name!r} invalid — use lowercase letters, digits, underscores "
1078
+ f"(e.g. my_so101_bench).[/yellow]"
1079
+ )
1080
+
1081
+
1082
+ def _prompt_float(label: str, default: float) -> float:
1083
+ """Enter-to-keep-default float prompt; non-numeric input keeps the default."""
1084
+ raw = typer.prompt(f" {label}", default=str(default), show_default=True).strip()
1085
+ if not raw:
1086
+ return default
1087
+ try:
1088
+ return float(raw)
1089
+ except ValueError:
1090
+ console.print(f" [yellow]not a number — keeping {default}[/yellow]")
1091
+ return default
1092
+
1093
+
1094
+ def _maybe_customize_limits(description: RobotDescription) -> RobotDescription:
1095
+ """Opt-in per-joint limit + safety-scalar override; declining inherits canonical.
1096
+
1097
+ Every ``JointSpec`` field (``position_limits``/``velocity_limit``/
1098
+ ``effort_limit``) is Optional — a canonical manifest may leave any of them
1099
+ unset. Only prompt for a field when the canonical value is present; a
1100
+ ``None`` field is carried through unchanged (never coerced to a number).
1101
+ """
1102
+ if not typer.confirm("Customize joint limits & safety envelope?", default=False):
1103
+ return description
1104
+
1105
+ new_joints = []
1106
+ for j in description.joints:
1107
+ update: dict[str, object] = {}
1108
+ if j.position_limits is not None:
1109
+ lo = _prompt_float(f"{j.name} position min", j.position_limits[0])
1110
+ hi = _prompt_float(f"{j.name} position max", j.position_limits[1])
1111
+ update["position_limits"] = (lo, hi)
1112
+ if j.velocity_limit is not None:
1113
+ update["velocity_limit"] = _prompt_float(f"{j.name} velocity_limit", j.velocity_limit)
1114
+ if j.effort_limit is not None:
1115
+ update["effort_limit"] = _prompt_float(f"{j.name} effort_limit", j.effort_limit)
1116
+ new_joints.append(j.model_copy(update=update) if update else j)
1117
+
1118
+ s = description.safety
1119
+ new_safety = s.model_copy(
1120
+ update={
1121
+ "max_ee_speed_m_s": _prompt_float("safety max_ee_speed_m_s", s.max_ee_speed_m_s),
1122
+ "max_joint_speed_factor": _prompt_float(
1123
+ "safety max_joint_speed_factor", s.max_joint_speed_factor
1124
+ ),
1125
+ "max_force_n": _prompt_float("safety max_force_n", s.max_force_n),
1126
+ "max_torque_nm": _prompt_float("safety max_torque_nm", s.max_torque_nm),
1127
+ }
1128
+ )
1129
+ return description.model_copy(update={"joints": new_joints, "safety": new_safety}, deep=True)
1130
+
1131
+
1132
+ def _iter_wizard_cameras(detection: DetectionReport) -> list[tuple[str, str, str]]:
1133
+ """(device_path, human_label, serial) per bindable camera.
1134
+
1135
+ V4L2 cameras bind on ``device_path`` (``serial`` empty). RealSense/Orbbec
1136
+ depth cameras have no ``/dev/video`` path here, so ``device_path`` is
1137
+ empty and the binding keys on ``serial`` instead.
1138
+ """
1139
+ cams: list[tuple[str, str, str]] = [(c.device_path, c.name, "") for c in detection.cameras.v4l2]
1140
+ cams += [
1141
+ ("", f"{d.name} (realsense {d.serial})", d.serial) for d in detection.cameras.realsense
1142
+ ]
1143
+ cams += [("", f"{d.name} (orbbec {d.serial})", d.serial) for d in detection.cameras.orbbec]
1144
+ return cams
1145
+
1146
+
1147
+ def _run_camera_binding_wizard( # noqa: PLR0915 # reason: one linear per-camera bind loop (thumbnail → prompt → dedup → reuse/new/workcell branch); splitting it hurts readability
1148
+ canonical: RobotDescription, detection: DetectionReport
1149
+ ) -> tuple[list[SensorSpec], list[SensorSpec]]:
1150
+ """Bind each detected camera to a sensor.
1151
+
1152
+ Returns ``(robot_sensors, workcell_sensors)``: a canonical name reuses that
1153
+ manifest ``SensorSpec`` verbatim (+ real device binding) → robot manifest;
1154
+ a ``w:<name>`` answer → workcell camera → DeployScene; a new name → new
1155
+ robot sensor. Enter skips the device (dropping unbound canonical sensors).
1156
+ """
1157
+ import tempfile
1158
+
1159
+ from openral_core import SensorDeployBinding, SensorSpec
1160
+
1161
+ canonical_rgb = {s.name: s for s in canonical.sensors if s.modality == "rgb"}
1162
+ robot_sensors: list[SensorSpec] = []
1163
+ workcell_sensors: list[SensorSpec] = []
1164
+ # Names actually bound so far, keyed by target manifest — a canonical-name
1165
+ # reuse or a brand-new sensor both claim a robot-sensor name; a `w:<name>`
1166
+ # answer claims a workcell-sensor name. Skipping a camera (Enter) never
1167
+ # claims anything. Seeded empty: canonical names only become claimed once
1168
+ # an operator actually binds a camera to them.
1169
+ robot_claimed: set[str] = set()
1170
+ workcell_claimed: set[str] = set()
1171
+ thumb_dir = Path(tempfile.mkdtemp(prefix="openral_detect_cams_"))
1172
+
1173
+ for device_path, label, serial in _iter_wizard_cameras(detection):
1174
+ thumb = _grab_camera_thumbnail(device_path, thumb_dir) if device_path else None
1175
+ console.print(
1176
+ f"\n[bold]{device_path or label}[/bold] — {label}"
1177
+ + (f" [dim](thumbnail: {thumb})[/dim]" if thumb else " [dim](no thumbnail)[/dim]")
1178
+ )
1179
+ options = ", ".join(canonical_rgb) if canonical_rgb else "<none in manifest>"
1180
+
1181
+ # Re-prompt the same camera when the resolved target name is already
1182
+ # claimed, so two cameras can never land on the same sensor name
1183
+ # (RobotDescription/DeployScene have no uniqueness validator).
1184
+ answer = ""
1185
+ while True:
1186
+ answer = typer.prompt(
1187
+ f" Which sensor is this? [{options}] or w:<name> for a workcell "
1188
+ f"camera (Enter = skip)",
1189
+ default="",
1190
+ show_default=False,
1191
+ ).strip()
1192
+ if not answer:
1193
+ break
1194
+ if answer.startswith("w:") and answer.removeprefix("w:").strip():
1195
+ name = answer.removeprefix("w:").strip()
1196
+ if name in workcell_claimed:
1197
+ console.print(
1198
+ f" [yellow]{name!r} already bound to another camera — choose "
1199
+ f"a different sensor.[/yellow]"
1200
+ )
1201
+ continue
1202
+ elif answer in robot_claimed:
1203
+ console.print(
1204
+ f" [yellow]{answer!r} already bound to another camera — choose "
1205
+ f"a different sensor.[/yellow]"
1206
+ )
1207
+ continue
1208
+ break
1209
+
1210
+ if not answer:
1211
+ continue
1212
+ params: dict[str, object] = {"fps": 30}
1213
+ if device_path:
1214
+ params["device"] = device_path
1215
+ elif serial:
1216
+ params["serial"] = serial
1217
+ binding = SensorDeployBinding(backend_params=params if (device_path or serial) else {})
1218
+ if answer in canonical_rgb:
1219
+ ref = canonical_rgb[answer]
1220
+ console.print(
1221
+ f" [yellow]reusing canonical intrinsics for {answer!r} — best to "
1222
+ f"supply your rig's calibrated fx/fy/cx/cy.[/yellow]"
1223
+ )
1224
+ robot_sensors.append(ref.model_copy(update={"deploy_binding": binding}, deep=True))
1225
+ robot_claimed.add(answer)
1226
+ console.print(
1227
+ f" [green]bound[/green] {answer} → {device_path or label} (robot sensor)"
1228
+ )
1229
+ elif answer.startswith("w:") and answer.removeprefix("w:").strip():
1230
+ name = answer.removeprefix("w:").strip()
1231
+ workcell_sensors.append(
1232
+ SensorSpec(
1233
+ name=name,
1234
+ modality="rgb",
1235
+ frame_id=name,
1236
+ rate_hz=30.0,
1237
+ deploy_binding=binding,
1238
+ )
1239
+ )
1240
+ workcell_claimed.add(name)
1241
+ console.print(f" [green]workcell[/green] {name} → {device_path or label}")
1242
+ else:
1243
+ parent = typer.prompt(" New robot sensor — parent_frame", default="base").strip()
1244
+ console.print(
1245
+ f" [yellow]new sensor {answer!r} uses generic intrinsics — supply "
1246
+ f"calibrated fx/fy/cx/cy before production.[/yellow]"
1247
+ )
1248
+ robot_sensors.append(
1249
+ SensorSpec(
1250
+ name=answer,
1251
+ modality="rgb",
1252
+ frame_id=f"{answer}_optical_frame",
1253
+ parent_frame=parent or "base",
1254
+ rate_hz=30.0,
1255
+ encoding="rgb8",
1256
+ deploy_binding=binding,
1257
+ )
1258
+ )
1259
+ robot_claimed.add(answer)
1260
+ console.print(
1261
+ f" [green]bound[/green] {answer} → {device_path or label} (new robot sensor)"
1262
+ )
1263
+
1264
+ return robot_sensors, workcell_sensors
1265
+
1266
+
1267
+ # Print-once guard for the "opencv missing" hint. A set (mutated, never
1268
+ # rebound) avoids a `global` statement while staying a single-process latch.
1269
+ _CV2_WARNED: set[str] = set()
1270
+
1271
+
1272
+ def _grab_camera_thumbnail(device_path: str, out_dir: Path) -> Path | None:
1273
+ """Grab one JPEG frame from ``device_path`` so the operator can SEE the camera.
1274
+
1275
+ Best-effort: returns ``None`` when opencv is missing or the device won't
1276
+ deliver a frame (in use, no permission). Never raises — a thumbnail is a
1277
+ convenience, not a requirement.
1278
+ """
1279
+ try:
1280
+ import cv2 # reason: optional dep, ships with the CLI
1281
+ except ImportError:
1282
+ if not _CV2_WARNED:
1283
+ console.print("[yellow]opencv not installed — camera thumbnails disabled.[/yellow]")
1284
+ _CV2_WARNED.add("warned")
1285
+ return None
1286
+ cap = cv2.VideoCapture(device_path)
1287
+ try:
1288
+ ok, frame = cap.read()
1289
+ finally:
1290
+ cap.release()
1291
+ if not ok or frame is None:
1292
+ return None
1293
+ out_dir.mkdir(parents=True, exist_ok=True)
1294
+ out = out_dir / (device_path.strip("/").replace("/", "_") + ".jpg")
1295
+ if not cv2.imwrite(str(out), frame):
1296
+ return None
1297
+ return out
1298
+
1299
+
1300
+ def _write_deploy_scene_scaffold(
1301
+ path: Path,
1302
+ description: RobotDescription,
1303
+ sensor_specs: list[SensorSpec],
1304
+ *,
1305
+ detection: DetectionReport,
1306
+ assume_yes: bool,
1307
+ ) -> None:
1308
+ """Scaffold a ``DeployScene`` YAML next to the detected robot.
1309
+
1310
+ Carries ``robot_id`` + the wizard's workspace/workcell camera bindings —
1311
+ ``sensor_specs`` here are always workcell cameras; robot-mounted cameras
1312
+ live in the robot manifest itself and never reach this scaffold. ``safety``
1313
+ is left unset so the robot manifest's own envelope applies. Validated
1314
+ through :class:`DeployScene` before writing so a malformed scaffold fails
1315
+ here, not on ``deploy run``.
1316
+ """
1317
+ import yaml as _yaml
1318
+ from openral_core import DeployScene, HalParameters, SceneSpec
1319
+
1320
+ robot_id = str(description.name)
1321
+ # Seed the scene's HAL binding from the robot manifest's own
1322
+ # hal.parameters.defaults (e.g. serial port) + lerobot calibration
1323
+ # placeholders, so the scaffolded scene is a self-contained `deploy run`
1324
+ # target (no `--hal` needed once the operator fills the real port + commits
1325
+ # the calibration). Only serial (`port`) HALs get the calibration stub.
1326
+ hal_defaults: dict[str, object] = dict(description.hal.parameters.defaults)
1327
+ if "port" in hal_defaults:
1328
+ hal_defaults.setdefault("id", f"{robot_id}")
1329
+ hal_defaults.setdefault("calibration_dir", "calibration")
1330
+ hal_defaults.setdefault("calibrate_on_connect", False)
1331
+ # Override the stale manifest default port with the one actually probed on
1332
+ # this host, so the scaffolded scene targets real hardware out of the box.
1333
+ detected_port = next((m.device.port for m in detection.usb.matches if m.device.port), None)
1334
+ if detected_port and "port" in hal_defaults:
1335
+ hal_defaults["port"] = detected_port
1336
+ scene = DeployScene(
1337
+ scene=SceneSpec(id=f"{robot_id}_workcell"),
1338
+ robot_id=robot_id,
1339
+ hal=HalParameters(defaults=hal_defaults) if hal_defaults else None,
1340
+ sensors=list(sensor_specs),
1341
+ )
1342
+ if path.exists() and not assume_yes:
1343
+ overwrite = typer.confirm(f"{path} already exists. Overwrite?", default=False)
1344
+ if not overwrite:
1345
+ console.print("[yellow]Deployment scaffold aborted.[/yellow]")
1346
+ return
1347
+ banner = (
1348
+ "# DeployScene scaffolded by `openral detect --deployment` — review before\n"
1349
+ "# `openral deploy run --config <this file>`.\n"
1350
+ "# - safety: unset → the robot manifest's envelope applies as-is.\n"
1351
+ "# - hal: host-specific HAL binding — set the real serial `port` for this\n"
1352
+ "# host and commit the lerobot calibration to `<scene dir>/calibration/`\n"
1353
+ "# (a relative `calibration_dir` resolves against this file's directory).\n"
1354
+ "# - sensors: workspace (workcell) camera bindings; robot-mounted cameras\n"
1355
+ "# live in the robot manifest.\n"
1356
+ "# - No rSkill is pinned here: the reasoner selects it at runtime.\n"
1357
+ )
1358
+ path.write_text(
1359
+ banner
1360
+ + _yaml.safe_dump(
1361
+ scene.model_dump(mode="json", exclude_none=True),
1362
+ sort_keys=False,
1363
+ default_flow_style=False,
1364
+ ),
1365
+ encoding="utf-8",
1366
+ )
1367
+ console.print(f"[green]Wrote[/green] {path} (DeployScene, {robot_id})")
1368
+ console.print(f"[dim]Next step:[/dim] openral deploy run --config {path}")
1369
+
1370
+ # Serial (`port`) HALs are the lerobot Feetech followers (SO-100/SO-101/…);
1371
+ # they cannot connect without a lerobot calibration (per-servo IDs + homing
1372
+ # offsets). Nudge the operator to supply one when the calibration dir is
1373
+ # still empty, rather than letting `deploy run` fail on first connect.
1374
+ if "port" in hal_defaults:
1375
+ cal_dir = path.parent / str(hal_defaults.get("calibration_dir", "calibration"))
1376
+ cal_file = f"{hal_defaults.get('id', robot_id)}.json"
1377
+ has_cal = cal_dir.is_dir() and any(cal_dir.glob("*.json"))
1378
+ if not has_cal:
1379
+ console.print(
1380
+ "[yellow]Calibration required:[/yellow] this arm's serial HAL needs a "
1381
+ "lerobot calibration (per-servo IDs + homing offsets) before "
1382
+ "`deploy run` can connect.\n"
1383
+ f" Link an existing calibration into [bold]{cal_dir}/{cal_file}[/bold], "
1384
+ "or generate one with the lerobot calibrate flow:\n"
1385
+ " [cyan]https://huggingface.co/docs/lerobot/v0.6.0/en/so101#calibrate[/cyan]"
1386
+ )
1387
+
1388
+
1389
+ def _render_detection_summary(detection: object) -> None:
1390
+ """Print a compact per-probe summary table of the detection report."""
1391
+ from openral_detect import DetectionReport
1392
+
1393
+ assert isinstance(detection, DetectionReport) # reason: typed input
1394
+ table = Table(title="openral detect")
1395
+ table.add_column("probe", style="bold")
1396
+ table.add_column("result")
1397
+ table.add_row(
1398
+ "usb", f"{len(detection.usb.devices)} device(s), {len(detection.usb.matches)} matched"
1399
+ )
1400
+ if detection.gpu.nvidia:
1401
+ table.add_row(
1402
+ "gpu (nvidia)",
1403
+ ", ".join(f"{g.name} ({g.vram_total_mib // 1024} GiB)" for g in detection.gpu.nvidia),
1404
+ )
1405
+ if detection.gpu.jetson is not None:
1406
+ table.add_row(
1407
+ "gpu (jetson)", f"{detection.gpu.jetson.board} ({detection.gpu.jetson.tops:.0f} TOPS)"
1408
+ )
1409
+ if detection.gpu.apple_silicon is not None:
1410
+ table.add_row("gpu (apple)", detection.gpu.apple_silicon.chip)
1411
+ if (
1412
+ not detection.gpu.nvidia
1413
+ and detection.gpu.jetson is None
1414
+ and detection.gpu.apple_silicon is None
1415
+ ):
1416
+ table.add_row("gpu", "[yellow]none detected[/yellow]")
1417
+ table.add_row(
1418
+ "cameras",
1419
+ f"v4l2={len(detection.cameras.v4l2)}, "
1420
+ f"realsense={len(detection.cameras.realsense)}, "
1421
+ f"orbbec={len(detection.cameras.orbbec)}",
1422
+ )
1423
+ inferred = detection.ros2.inferred_robot_type or "-"
1424
+ table.add_row(
1425
+ "ros2",
1426
+ f"{len(detection.ros2.topics)} topic(s), inferred={inferred}",
1427
+ )
1428
+ table.add_row(
1429
+ "network",
1430
+ f"{detection.network.hostname}, "
1431
+ f"{len(detection.network.interfaces)} iface(s), "
1432
+ f"route={detection.network.default_route or '-'}",
1433
+ )
1434
+ console.print(table)
1435
+ if detection.warnings:
1436
+ console.print("[dim]warnings:[/dim]")
1437
+ for w in detection.warnings:
1438
+ console.print(f" [yellow]·[/yellow] {w}")
1439
+
1440
+
1441
+ @app.command()
1442
+ def connect(
1443
+ robot: str = typer.Option(..., help="Robot type (so100, so101, g1, ur5e, …)"),
1444
+ port: str = typer.Option("", "--port", help="USB/serial port override, e.g. /dev/ttyUSB0"),
1445
+ ) -> None:
1446
+ """Open a HAL connection to a robot, read one joint state, and disconnect.
1447
+
1448
+ Exits 0 on success; exits 1 with an error message on failure.
1449
+
1450
+ Supported robots: so100, so101 (both drive the shared ``SO100FollowerHAL``
1451
+ Feetech serial bus — the SO-101 is the same controller as the SO-100).
1452
+
1453
+ Example:
1454
+ >>> # openral connect --robot so100
1455
+ >>> # openral connect --robot so101 --port /dev/ttyUSB1
1456
+ """
1457
+ # so100 and so101 share the Feetech SO100FollowerHAL (identical USB
1458
+ # controller + driver); the label only changes the console banner.
1459
+ so_follower_labels = {"so100": "SO-100", "so101": "SO-101"}
1460
+ if robot in so_follower_labels:
1461
+ _connect_so_follower(so_follower_labels[robot], port or "/dev/ttyUSB0")
1462
+ else:
1463
+ console.print(f"[red]Unknown robot '{robot}'. Supported: so100, so101[/red]")
1464
+ raise typer.Exit(code=1)
1465
+
1466
+
1467
+ def _connect_so_follower(label: str, port: str) -> None:
1468
+ """Connect to an SO-100/SO-101 follower arm, read state, and disconnect."""
1469
+ try:
1470
+ from openral_hal.so100_follower import SO100FollowerHAL
1471
+ except ImportError:
1472
+ console.print("[red]openral-hal is not installed. Run: uv sync --all-packages[/red]")
1473
+ raise typer.Exit(code=1) # noqa: B904
1474
+
1475
+ hal = SO100FollowerHAL(port=port)
1476
+ console.print(f"Connecting to {label} on [bold]{port}[/bold] …")
1477
+ try:
1478
+ hal.connect()
1479
+ except ROSConfigError as exc:
1480
+ console.print(f"[red]Configuration error:[/red] {exc}")
1481
+ raise typer.Exit(code=1) # noqa: B904
1482
+ except ROSRuntimeError as exc:
1483
+ console.print(f"[red]Runtime error:[/red] {exc}")
1484
+ raise typer.Exit(code=1) # noqa: B904
1485
+
1486
+ try:
1487
+ state = hal.read_state()
1488
+ joint_summary = ", ".join(
1489
+ f"{n}={v:.3f} rad" for n, v in zip(state.name, state.position, strict=True)
1490
+ )
1491
+ console.print(f"[green]Connected.[/green] Joint state: {joint_summary}")
1492
+ finally:
1493
+ hal.disconnect()
1494
+ console.print("Disconnected.")
1495
+
1496
+
1497
+ # ── calibrate sub-app ─────────────────────────────────────────────────────────
1498
+
1499
+ calibrate_app = typer.Typer(
1500
+ name="calibrate",
1501
+ help="Sensor calibration helpers.",
1502
+ no_args_is_help=True,
1503
+ )
1504
+ app.add_typer(calibrate_app, name="calibrate")
1505
+
1506
+
1507
+ @calibrate_app.command("camera")
1508
+ def calibrate_camera(
1509
+ sensor: str = typer.Option(
1510
+ ...,
1511
+ "--sensor",
1512
+ "-s",
1513
+ help="Sensor name as it appears in robot.yaml (e.g. head_color).",
1514
+ ),
1515
+ topic: str = typer.Option(
1516
+ "",
1517
+ "--topic",
1518
+ help="Override the image topic (default: derived from sensor name).",
1519
+ ),
1520
+ chessboard_size: str = typer.Option(
1521
+ "8x6",
1522
+ "--chessboard-size",
1523
+ help="Internal corners COLSxROWS of the calibration target.",
1524
+ ),
1525
+ square_size: float = typer.Option(
1526
+ 0.025,
1527
+ "--square-size",
1528
+ help="Physical size of one chessboard square in metres.",
1529
+ ),
1530
+ dry_run: bool = typer.Option(
1531
+ False,
1532
+ "--dry-run",
1533
+ help="Print the command instead of executing it.",
1534
+ ),
1535
+ ) -> None:
1536
+ r"""Calibrate a camera sensor using the ROS 2 camera_calibration package.
1537
+
1538
+ Builds and optionally runs::
1539
+
1540
+ ros2 run camera_calibration cameracalibrator \
1541
+ --size COLSxROWS --square SIZE \
1542
+ --ros-args -r image:=TOPIC -r camera_info:=INFO_TOPIC
1543
+
1544
+ Requires ``ros2_camera_calibration`` to be installed and ROS 2 sourced.
1545
+
1546
+ Example:
1547
+ >>> # openral calibrate camera --sensor head_color --chessboard-size 8x6 --square-size 0.025
1548
+ >>> # openral calibrate camera --sensor head_color --dry-run
1549
+ """
1550
+ try:
1551
+ cols_str, rows_str = chessboard_size.lower().split("x")
1552
+ cols, rows = int(cols_str), int(rows_str)
1553
+ except ValueError:
1554
+ console.print(
1555
+ f"[red]Invalid --chessboard-size '{chessboard_size}'. "
1556
+ "Expected format: COLSxROWS, e.g. 8x6[/red]"
1557
+ )
1558
+ raise typer.Exit(code=1) # noqa: B904
1559
+
1560
+ image_topic = topic or f"/{sensor}/image_raw"
1561
+ info_topic = image_topic.replace("/image_raw", "/camera_info").replace(
1562
+ "/image_rect_raw", "/camera_info"
1563
+ )
1564
+
1565
+ cmd = [
1566
+ "ros2",
1567
+ "run",
1568
+ "camera_calibration",
1569
+ "cameracalibrator",
1570
+ "--size",
1571
+ f"{cols}x{rows}",
1572
+ "--square",
1573
+ str(square_size),
1574
+ "--ros-args",
1575
+ "-r",
1576
+ f"image:={image_topic}",
1577
+ "-r",
1578
+ f"camera_info:={info_topic}",
1579
+ ]
1580
+
1581
+ if dry_run:
1582
+ console.print("[bold]openral calibrate camera[/bold] — dry run:")
1583
+ console.print(" ".join(cmd))
1584
+ return
1585
+
1586
+ ros2_bin = shutil.which("ros2")
1587
+ if ros2_bin is None:
1588
+ console.print(
1589
+ "[red]ros2 not found. Source your ROS 2 installation first:[/red]\n"
1590
+ " source /opt/ros/<distro>/setup.bash"
1591
+ )
1592
+ raise typer.Exit(code=1)
1593
+
1594
+ console.print(f"[bold]openral calibrate camera[/bold] — sensor: [cyan]{sensor}[/cyan]")
1595
+ console.print(f" image topic : [dim]{image_topic}[/dim]")
1596
+ console.print(f" camera info : [dim]{info_topic}[/dim]")
1597
+ console.print(f" target size : [dim]{cols}x{rows} squares @ {square_size} m[/dim]")
1598
+ console.print("Running camera_calibration … (Ctrl+C to abort)")
1599
+
1600
+ result = subprocess.run(cmd, check=False)
1601
+ if result.returncode != 0:
1602
+ console.print(f"[red]cameracalibrator exited with code {result.returncode}.[/red]")
1603
+ raise typer.Exit(code=result.returncode)
1604
+
1605
+
1606
+ if __name__ == "__main__":
1607
+ app()
1608
+
1609
+
1610
+ # ── rskill sub-app ────────────────────────────────────────────────────────────
1611
+
1612
+ rskill_app = typer.Typer(
1613
+ name="rskill",
1614
+ help="rSkill package management — install and list robot skills from the HF Hub.",
1615
+ no_args_is_help=True,
1616
+ )
1617
+ app.add_typer(rskill_app, name="rskill")
1618
+
1619
+ #: Canonical HF Hub org for first-party rSkills. Used to suggest a
1620
+ #: repair when ``rskill install`` is handed an org-less id, and as the ``author``
1621
+ #: filter for ``rskill search``.
1622
+ _DEFAULT_RSKILL_ORG: Final[str] = "OpenRAL"
1623
+
1624
+
1625
+ @rskill_app.command("install")
1626
+ def rskill_install(
1627
+ hub_id: str = typer.Argument(
1628
+ ...,
1629
+ metavar="HUB_ID",
1630
+ help="HF Hub repository, e.g. OpenRAL/rskill-smolvla-libero",
1631
+ ),
1632
+ revision: str = typer.Option(
1633
+ "",
1634
+ "--revision",
1635
+ "-r",
1636
+ help="Git commit SHA or branch to pin (recommended for reproducibility).",
1637
+ ),
1638
+ force: bool = typer.Option(
1639
+ False,
1640
+ "--force",
1641
+ "-f",
1642
+ help="Re-download even if cached files already exist.",
1643
+ ),
1644
+ non_commercial: bool = typer.Option(
1645
+ False,
1646
+ "--non-commercial",
1647
+ help="Declare non-commercial research intent (relaxes NVIDIA non-commercial guard).",
1648
+ ),
1649
+ yes: bool = typer.Option(
1650
+ False,
1651
+ "--yes",
1652
+ "-y",
1653
+ help="Skip confirmation prompt for proprietary or non-commercial licenses.",
1654
+ ),
1655
+ ) -> None:
1656
+ """Download an rSkill from the HF Hub, validate it, and register it locally.
1657
+
1658
+ Fetches ``rskill.yaml`` from the repository, validates the manifest,
1659
+ surfaces the license to the terminal, then downloads the weights snapshot
1660
+ into the local HF Hub cache (``~/.cache/openral/rskills/``).
1661
+
1662
+ The rSkill is registered in ``~/.local/share/openral/rskills.json`` and
1663
+ can be listed with ``openral rskill list``.
1664
+
1665
+ Example:
1666
+ >>> # openral rskill install OpenRAL/rskill-smolvla-libero
1667
+ >>> # openral rskill install OpenRAL/rskill-smolvla-libero --revision abc1234
1668
+ """
1669
+ from openral_rskill.loader import rSkill
1670
+
1671
+ # ── Step 0: a HF repo id needs an `org/name` shape. A bare name (the most
1672
+ # common paste mistake) otherwise 404s against a non-existent top-level repo
1673
+ # — fail fast with the canonical suggestion instead of a raw Hub stack trace.
1674
+ if "/" not in hub_id:
1675
+ suggestion = f"{_DEFAULT_RSKILL_ORG}/{hub_id}"
1676
+ console.print(
1677
+ f"[red]Not a Hub repo id:[/red] '{hub_id}' has no org prefix (expected `org/name`)."
1678
+ )
1679
+ console.print(f" Did you mean: [cyan]openral rskill install {suggestion}[/cyan]")
1680
+ console.print(f" Or find it: [cyan]openral rskill search {hub_id}[/cyan]")
1681
+ raise typer.Exit(code=1)
1682
+
1683
+ console.print(f"[bold]openral rskill install[/bold] — fetching [cyan]{hub_id}[/cyan] …")
1684
+
1685
+ # ── Step 1: fetch manifest only (to surface license before downloading weights)
1686
+ try:
1687
+ from huggingface_hub import hf_hub_download
1688
+ from openral_core.schemas import RSkillManifest
1689
+ except ImportError as exc:
1690
+ console.print(f"[red]Missing dependency:[/red] {exc}")
1691
+ raise typer.Exit(code=1) # noqa: B904
1692
+
1693
+ try:
1694
+ manifest_path = hf_hub_download(
1695
+ repo_id=hub_id,
1696
+ filename="rskill.yaml",
1697
+ revision=revision or None,
1698
+ )
1699
+ manifest = RSkillManifest.from_yaml(manifest_path)
1700
+ except Exception as exc: # reason: surface any download/parse error to user
1701
+ console.print(f"[red]Failed to fetch manifest:[/red] {exc}")
1702
+ if "404" in str(exc) or "Repository Not Found" in str(exc):
1703
+ bare = hub_id.rsplit("/", 1)[-1]
1704
+ console.print(f" Browse available skills: [cyan]openral rskill search {bare}[/cyan]")
1705
+ raise typer.Exit(code=1) # noqa: B904
1706
+
1707
+ # ── Step 2: display license + confirm if non-permissive
1708
+ _display_license_banner(manifest.name, manifest.license.value, manifest.version, console)
1709
+
1710
+ _permissive = {"apache-2.0", "mit", "bsd"}
1711
+ if manifest.license.value not in _permissive and not yes:
1712
+ confirmed = typer.confirm("Proceed with installation?", default=False)
1713
+ if not confirmed:
1714
+ console.print("[yellow]Aborted.[/yellow]")
1715
+ raise typer.Exit(code=0)
1716
+
1717
+ # ── Step 3: full install (snapshot download + registry)
1718
+ console.print(" Downloading weights snapshot …")
1719
+ try:
1720
+ pkg = rSkill.from_pretrained(
1721
+ hub_id,
1722
+ revision=revision or None,
1723
+ force_download=force,
1724
+ commercial_use=not non_commercial,
1725
+ )
1726
+ except Exception as exc: # reason: surface ROSConfigError + network errors
1727
+ console.print(f"[red]Install failed:[/red] {exc}")
1728
+ raise typer.Exit(code=1) # noqa: B904
1729
+
1730
+ console.print(
1731
+ f"[green]Installed[/green] [bold]{pkg.manifest.name}[/bold] "
1732
+ f"v{pkg.manifest.version} → {pkg.local_dir}"
1733
+ )
1734
+ if not revision:
1735
+ console.print(
1736
+ "[yellow]Tip:[/yellow] Pin a revision for reproducibility: "
1737
+ f"openral rskill install {hub_id} --revision <sha>"
1738
+ )
1739
+
1740
+
1741
+ #: Max description chars rendered in the `rskill search` table before eliding.
1742
+ _RSKILL_SEARCH_DESC_MAX: Final[int] = 60
1743
+
1744
+
1745
+ def _load_hub_rskill_manifest(repo_id: str) -> RSkillManifest | None:
1746
+ """Fetch + validate one Hub repo's ``rskill.yaml``; ``None`` if absent/invalid.
1747
+
1748
+ A repo with no manifest (or one that fails schema validation) is not an
1749
+ rSkill — the caller counts and surfaces these rather than failing the search.
1750
+ """
1751
+ from huggingface_hub import hf_hub_download
1752
+ from openral_core.schemas import RSkillManifest
1753
+
1754
+ try:
1755
+ path = hf_hub_download(repo_id=repo_id, filename="rskill.yaml")
1756
+ return RSkillManifest.from_yaml(path)
1757
+ except Exception: # reason: not an rSkill repo / invalid manifest — skip (counted by caller)
1758
+ return None
1759
+
1760
+
1761
+ def _rskill_matches_filters(
1762
+ m: RSkillManifest, *, kind: str, role: str, embodiment: str, license_: str
1763
+ ) -> bool:
1764
+ """Return whether a manifest passes every non-empty facet filter."""
1765
+ if kind and m.kind != kind:
1766
+ return False
1767
+ if role and m.role != role:
1768
+ return False
1769
+ if embodiment and embodiment not in m.embodiment_tags:
1770
+ return False
1771
+ return not (license_ and m.license.value != license_)
1772
+
1773
+
1774
+ def _render_rskill_search_results(
1775
+ rows: list[tuple[str, RSkillManifest]], skipped: int, query: str
1776
+ ) -> None:
1777
+ """Print the human-readable `rskill search` table (or a no-results notice)."""
1778
+ if not rows:
1779
+ suffix = f" ({skipped} repo(s) skipped — no valid manifest)" if skipped else ""
1780
+ console.print(
1781
+ f"[dim]No rSkills found in {_DEFAULT_RSKILL_ORG}/ for query "
1782
+ f"{query!r} with the given filters.{suffix}[/dim]"
1783
+ )
1784
+ return
1785
+
1786
+ _permissive = {"apache-2.0", "mit", "bsd"}
1787
+ table = Table(title=f"rSkills on the Hub — {_DEFAULT_RSKILL_ORG}/")
1788
+ for col in ("repo_id", "kind", "role", "license", "embodiment_tags", "description"):
1789
+ table.add_column(col, style="cyan bold" if col == "repo_id" else None)
1790
+ for repo_id, m in rows:
1791
+ lic = m.license.value
1792
+ lic_color = "green" if lic in _permissive else "yellow"
1793
+ desc = (
1794
+ m.description
1795
+ if len(m.description) <= _RSKILL_SEARCH_DESC_MAX + 1
1796
+ else m.description[:_RSKILL_SEARCH_DESC_MAX] + "…"
1797
+ )
1798
+ table.add_row(
1799
+ repo_id,
1800
+ m.kind,
1801
+ m.role,
1802
+ f"[{lic_color}]{lic}[/{lic_color}]",
1803
+ ", ".join(m.embodiment_tags) or "—",
1804
+ desc,
1805
+ )
1806
+ console.print(table)
1807
+ if skipped:
1808
+ console.print(
1809
+ f"[dim]{skipped} {_DEFAULT_RSKILL_ORG} repo(s) skipped — no valid rskill.yaml.[/dim]"
1810
+ )
1811
+ console.print("[dim]Install one with:[/dim] [cyan]openral rskill install <repo_id>[/cyan]")
1812
+
1813
+
1814
+ @rskill_app.command("search")
1815
+ def rskill_search(
1816
+ query: str = typer.Argument(
1817
+ "",
1818
+ metavar="[QUERY]",
1819
+ help="Free-text query matched against rSkill repo ids on the Hub.",
1820
+ ),
1821
+ kind: str = typer.Option(
1822
+ "", "--kind", help="Filter by manifest kind (vla, ros_action, detector, …)."
1823
+ ),
1824
+ role: str = typer.Option("", "--role", help="Filter by control role (s0, s1, s2)."),
1825
+ embodiment: str = typer.Option(
1826
+ "", "--embodiment", help="Filter by embodiment tag (e.g. franka_panda)."
1827
+ ),
1828
+ license_: str = typer.Option(
1829
+ "", "--license", help="Filter by license posture (e.g. apache-2.0)."
1830
+ ),
1831
+ limit: int = typer.Option(50, "--limit", help="Max OpenRAL repos to inspect."),
1832
+ json: bool = typer.Option(False, "--json", help="Output machine-readable JSON."),
1833
+ ) -> None:
1834
+ """Search the OpenRAL HF Hub org for installable rSkills.
1835
+
1836
+ Lists every ``OpenRAL/*`` repo whose ``rskill.yaml`` manifest validates and
1837
+ matches the optional facet filters, so the printed ids are paste-able into
1838
+ ``openral rskill install <repo_id>``. The HF Hub is the index — there is no
1839
+ bespoke catalog service. Repos without a valid manifest are skipped and the
1840
+ count surfaced.
1841
+
1842
+ Example:
1843
+ >>> # openral rskill search aloha
1844
+ >>> # openral rskill search --kind detector --license apache-2.0
1845
+ """
1846
+ import json as _json_mod
1847
+
1848
+ try:
1849
+ from huggingface_hub import HfApi
1850
+ except ImportError as exc:
1851
+ console.print(f"[red]Missing dependency:[/red] {exc}")
1852
+ raise typer.Exit(code=1) # noqa: B904
1853
+
1854
+ try:
1855
+ models = list(
1856
+ HfApi().list_models(author=_DEFAULT_RSKILL_ORG, search=query or None, limit=limit)
1857
+ )
1858
+ except Exception as exc: # reason: surface Hub/network errors to the user
1859
+ console.print(f"[red]Search failed:[/red] {exc}")
1860
+ raise typer.Exit(code=1) # noqa: B904
1861
+
1862
+ rows: list[tuple[str, RSkillManifest]] = []
1863
+ skipped = 0
1864
+ for model in models:
1865
+ manifest = _load_hub_rskill_manifest(model.id)
1866
+ if manifest is None:
1867
+ skipped += 1
1868
+ elif _rskill_matches_filters(
1869
+ manifest, kind=kind, role=role, embodiment=embodiment, license_=license_
1870
+ ):
1871
+ rows.append((model.id, manifest))
1872
+
1873
+ if json:
1874
+ console.print_json(
1875
+ _json_mod.dumps(
1876
+ [
1877
+ {
1878
+ "repo_id": repo_id,
1879
+ "name": m.name,
1880
+ "version": m.version,
1881
+ "kind": m.kind,
1882
+ "role": m.role,
1883
+ "license": m.license.value,
1884
+ "embodiment_tags": list(m.embodiment_tags),
1885
+ "description": m.description,
1886
+ }
1887
+ for repo_id, m in rows
1888
+ ]
1889
+ )
1890
+ )
1891
+ return
1892
+
1893
+ _render_rskill_search_results(rows, skipped, query)
1894
+
1895
+
1896
+ @rskill_app.command("list")
1897
+ def rskill_list(
1898
+ json: bool = typer.Option(False, "--json", help="Output machine-readable JSON"),
1899
+ ) -> None:
1900
+ """List every available rSkill — in-tree (``rskills/``) + HF-Hub-installed.
1901
+
1902
+ Each row shows the source so users can tell at a glance which entries
1903
+ are paste-able as ``--rskill <name>`` (in-tree) versus which
1904
+ need a HF Hub install first. JSON output keeps the same fields.
1905
+ """
1906
+ import json as _json_mod
1907
+
1908
+ from openral_rskill.loader import discover_intree_rskills, rSkill
1909
+
1910
+ intree = discover_intree_rskills()
1911
+ try:
1912
+ installed = rSkill.list_installed()
1913
+ except Exception as exc: # reason: surface corrupt registry error
1914
+ console.print(f"[red]Failed to read installed registry:[/red] {exc}")
1915
+ raise typer.Exit(code=1) # noqa: B904
1916
+
1917
+ def _bare_name_from_repo_id(repo_id: str) -> str:
1918
+ """Recover the bare rskill name that the loader would resolve to.
1919
+
1920
+ Mirrors `_candidate_local_paths`: strip the org prefix and the
1921
+ `rskill-`/`rskill_` prefix so the URI matches the in-tree form.
1922
+ """
1923
+ tail = repo_id.rsplit("/", 1)[-1]
1924
+ return tail.removeprefix("rskill-").removeprefix("rskill_") or repo_id
1925
+
1926
+ if json:
1927
+ data = [
1928
+ {
1929
+ "source": "in-tree",
1930
+ "name": name,
1931
+ "repo_id": m.name,
1932
+ "version": m.version,
1933
+ "model_family": m.model_family,
1934
+ "role": str(m.role),
1935
+ "license": m.license.value,
1936
+ "embodiment_tags": list(m.embodiment_tags),
1937
+ "uri": name,
1938
+ }
1939
+ for name, m in intree
1940
+ ] + [
1941
+ {
1942
+ "source": "installed",
1943
+ "name": _bare_name_from_repo_id(e.repo_id),
1944
+ "repo_id": e.repo_id,
1945
+ "version": e.version,
1946
+ "model_family": None,
1947
+ "role": e.role,
1948
+ "license": e.license,
1949
+ "embodiment_tags": list(e.embodiment_tags),
1950
+ "uri": _bare_name_from_repo_id(e.repo_id),
1951
+ "installed_at": e.installed_at,
1952
+ }
1953
+ for e in installed
1954
+ ]
1955
+ console.print_json(_json_mod.dumps(data))
1956
+ return
1957
+
1958
+ if not intree and not installed:
1959
+ console.print(
1960
+ "[dim]No rSkills available. Drop one under rskills/<name>/ or run: "
1961
+ "openral rskill install <hub-id>[/dim]"
1962
+ )
1963
+ return
1964
+
1965
+ table = Table(title="Available rSkills")
1966
+ table.add_column("source", style="dim")
1967
+ table.add_column("name / repo_id", style="cyan bold")
1968
+ table.add_column("version")
1969
+ table.add_column("family")
1970
+ table.add_column("license")
1971
+ table.add_column("embodiment_tags")
1972
+ table.add_column("paste-able --rskill")
1973
+
1974
+ _permissive = {"apache-2.0", "mit", "bsd"}
1975
+ for name, m in intree:
1976
+ lic = m.license.value
1977
+ lic_color = "green" if lic in _permissive else "yellow"
1978
+ table.add_row(
1979
+ "in-tree",
1980
+ name,
1981
+ m.version,
1982
+ m.model_family or "—",
1983
+ f"[{lic_color}]{lic}[/{lic_color}]",
1984
+ ", ".join(m.embodiment_tags) or "—",
1985
+ name,
1986
+ )
1987
+ for entry in installed:
1988
+ lic_color = "green" if entry.license in _permissive else "yellow"
1989
+ bare = _bare_name_from_repo_id(entry.repo_id)
1990
+ table.add_row(
1991
+ "installed",
1992
+ entry.repo_id,
1993
+ entry.version,
1994
+ "—",
1995
+ f"[{lic_color}]{entry.license}[/{lic_color}]",
1996
+ ", ".join(entry.embodiment_tags) or "—",
1997
+ bare,
1998
+ )
1999
+ console.print(table)
2000
+
2001
+
2002
+ _SECTION_DISPLAY: dict[str, str] = {
2003
+ "embodiment": "Embodiment",
2004
+ "capability_flags": "Capability flags",
2005
+ "gpu_runtime": "GPU runtime",
2006
+ "gpu_dtype": "GPU dtype",
2007
+ "sensors": "Sensors",
2008
+ "actuators": "Actuators",
2009
+ }
2010
+
2011
+
2012
+ def _render_single_rskill_table(row: RSkillCompatRow, robot_name: str) -> None:
2013
+ """Print the per-section table for `openral rskill check <rskill_id>`."""
2014
+ console.print(f"[bold]rSkill compatibility[/bold] for [cyan]{robot_name}[/cyan]")
2015
+ header = f"rSkill: [cyan bold]{row.repo_id}[/cyan bold]"
2016
+ if row.version:
2017
+ header += f" v{row.version}"
2018
+ if row.role:
2019
+ header += f" role={row.role}"
2020
+ console.print(header)
2021
+
2022
+ if not row.sections:
2023
+ console.print(f"[red]✗ {row.failure_kind}[/red] {row.reason or ''}")
2024
+ return
2025
+
2026
+ section_table = Table(show_header=True, header_style="bold")
2027
+ section_table.add_column("Section")
2028
+ section_table.add_column("Status")
2029
+ section_table.add_column("Reason")
2030
+ for section in row.sections:
2031
+ label = _SECTION_DISPLAY.get(section.label, section.label)
2032
+ if section.informational:
2033
+ status = "[dim]· informational[/dim]"
2034
+ elif section.compatible:
2035
+ status = "[green]✓[/green]"
2036
+ else:
2037
+ status = f"[red]✗ {section.failure_kind or 'fail'}[/red]"
2038
+ section_table.add_row(label, status, section.reason or "")
2039
+ console.print(section_table)
2040
+
2041
+ blocking = [s for s in row.sections if not s.informational and not s.compatible]
2042
+ if blocking:
2043
+ plural = "s" if len(blocking) != 1 else ""
2044
+ console.print(
2045
+ f"[red]Overall: ✗ incompatible ({len(blocking)} failing section{plural})[/red]"
2046
+ )
2047
+ else:
2048
+ console.print("[green]Overall: ✓ compatible[/green]")
2049
+
2050
+
2051
+ def _render_walk_all_table(report: CompatibilityReport, robot_name: str) -> None:
2052
+ """Print the walk-all (no-arg) table for `openral rskill check`."""
2053
+ if not report.rows:
2054
+ console.print(
2055
+ "[dim]No rSkills evaluated. Install some with `openral rskill install <hub-id>` "
2056
+ "or pass `--rskills-dir rskills/`.[/dim]"
2057
+ )
2058
+ else:
2059
+ table = Table(title=f"rSkill compatibility for {robot_name}")
2060
+ table.add_column("repo_id", style="cyan bold")
2061
+ table.add_column("role")
2062
+ table.add_column("status")
2063
+ table.add_column("reason")
2064
+ for row in report.rows:
2065
+ if row.compatible:
2066
+ status = "[green]✓ compatible[/green]"
2067
+ reason = ""
2068
+ else:
2069
+ status = f"[red]✗ {row.failure_kind or 'fail'}[/red]"
2070
+ reason = row.reason or ""
2071
+ table.add_row(row.repo_id, row.role, status, reason)
2072
+ console.print(table)
2073
+ if report.incompatible:
2074
+ console.print(
2075
+ f"[yellow]{len(report.incompatible)} of {len(report.rows)} "
2076
+ "installed rSkill(s) cannot run on this host.[/yellow]"
2077
+ )
2078
+
2079
+
2080
+ @rskill_app.command("check")
2081
+ def rskill_check(
2082
+ rskill_id: str | None = typer.Argument(
2083
+ None,
2084
+ metavar="RSKILL_ID",
2085
+ help=(
2086
+ "rSkill to check — bare in-tree name, path (rskills/<name>), "
2087
+ "or HF Hub repo id (e.g. OpenRAL/rskill-smolvla-libero). "
2088
+ "Omit to walk every installed / in-tree rSkill (walk-all mode)."
2089
+ ),
2090
+ ),
2091
+ robot: Path = typer.Option(
2092
+ Path("robot.yaml"),
2093
+ "--robot",
2094
+ help="Path to a RobotDescription yaml (typically the output of `openral detect`).",
2095
+ ),
2096
+ rskills_dir: Path = typer.Option(
2097
+ Path("rskills"),
2098
+ "--rskills-dir",
2099
+ help=(
2100
+ "(Walk-all mode) in-tree rSkills directory to scan in addition to "
2101
+ "the installed registry. Skipped if it does not exist."
2102
+ ),
2103
+ ),
2104
+ json_output: bool = typer.Option(
2105
+ False, "--json", help="Output the CompatibilityReport as JSON."
2106
+ ),
2107
+ ) -> None:
2108
+ """Report whether one (or every) rSkill will run on the current host.
2109
+
2110
+ Two modes:
2111
+
2112
+ * ``openral rskill check <rskill_id>`` resolves the id the same way as
2113
+ ``openral rskill list`` / ``openral benchmark run --rskill <id>``
2114
+ (in-tree, installed registry, or HF Hub) and prints a per-section
2115
+ breakdown — embodiment, capability flags, GPU runtime, GPU dtype,
2116
+ sensors, actuators.
2117
+ * ``openral rskill check`` (no arg) walks every installed / in-tree
2118
+ rSkill via `openral_detect.check_installed_rskills` and prints a
2119
+ one-row-per-rSkill compatibility table.
2120
+
2121
+ Exits 1 if any non-informational section fails (single-rSkill mode)
2122
+ or if any installed rSkill is incompatible (walk-all mode); exits 0
2123
+ otherwise.
2124
+
2125
+ Example:
2126
+ >>> # openral rskill check smolvla-libero
2127
+ >>> # openral rskill check OpenRAL/rskill-smolvla-libero --robot /tmp/robot.yaml --json
2128
+ >>> # openral rskill check # walk-all
2129
+ """
2130
+ import json as _json_mod
2131
+
2132
+ from openral_core.schemas import RobotDescription
2133
+ from openral_detect import check_installed_rskills, check_single_rskill
2134
+
2135
+ if not robot.exists():
2136
+ console.print(
2137
+ f"[red]Robot description not found:[/red] {robot}\n"
2138
+ "Run [bold]openral detect[/bold] first."
2139
+ )
2140
+ raise typer.Exit(code=1)
2141
+
2142
+ description = RobotDescription.from_yaml(str(robot))
2143
+
2144
+ if rskill_id is not None:
2145
+ single_report = check_single_rskill(rskill_id, description)
2146
+ single_row = single_report.rows[0]
2147
+ if json_output:
2148
+ console.print_json(_json_mod.dumps(single_report.model_dump(mode="json")))
2149
+ else:
2150
+ _render_single_rskill_table(single_row, description.name)
2151
+ if not single_row.compatible:
2152
+ raise typer.Exit(code=1)
2153
+ return
2154
+
2155
+ # ── Walk-all (no-arg) ─────────────────────────────────────────────────────
2156
+ walk_dir = rskills_dir if rskills_dir.is_dir() else None
2157
+ report = check_installed_rskills(description, rskills_dir=walk_dir)
2158
+ if json_output:
2159
+ console.print_json(_json_mod.dumps(report.model_dump(mode="json")))
2160
+ else:
2161
+ _render_walk_all_table(report, description.name)
2162
+
2163
+ if report.incompatible:
2164
+ raise typer.Exit(code=1)
2165
+
2166
+
2167
+ _DEFAULT_OWNER = "your-org"
2168
+ _DEFAULT_LICENSE = "apache-2.0"
2169
+ _DEFAULT_EMBODIMENT = "franka_panda"
2170
+
2171
+
2172
+ @rskill_app.command("new")
2173
+ def rskill_new(
2174
+ rskill_id: str = typer.Argument(
2175
+ ...,
2176
+ metavar="ID",
2177
+ help="Local rSkill id, convention <policy>-<task> e.g. pi05-pick-cube.",
2178
+ ),
2179
+ out_dir: Path | None = typer.Option(
2180
+ None,
2181
+ "--out-dir",
2182
+ help="Destination directory. Defaults to rskills/<ID>/ under the cwd.",
2183
+ ),
2184
+ owner: str | None = typer.Option(
2185
+ None,
2186
+ "--owner",
2187
+ help="HF Hub owner segment for the manifest 'name' field.",
2188
+ ),
2189
+ license_: str | None = typer.Option(
2190
+ None,
2191
+ "--license",
2192
+ help=(
2193
+ "One of: apache-2.0 | mit | bsd | permissive_research | "
2194
+ "nvidia_non_commercial | proprietary | unknown."
2195
+ ),
2196
+ ),
2197
+ embodiment_tag: str | None = typer.Option(
2198
+ None,
2199
+ "--embodiment-tag",
2200
+ help="One of the canonical EmbodimentTag literals (see CLAUDE.md §6.4).",
2201
+ ),
2202
+ family: str | None = typer.Option(
2203
+ None,
2204
+ "--family",
2205
+ "-f",
2206
+ help=(
2207
+ "Policy family — one of act | smolvla | pi05 | xvla | diffusion. "
2208
+ "Sets model_family / chunk_size / dtype / latency budget from the "
2209
+ "matching in-tree reference manifest so a fresh ACT scaffold "
2210
+ "doesn't ship pi0.5 numbers. Inferred from --from-hf when set; "
2211
+ "interactively prompted otherwise."
2212
+ ),
2213
+ ),
2214
+ from_hf: str | None = typer.Option(
2215
+ None,
2216
+ "--from-hf",
2217
+ help=(
2218
+ "HF Hub repo id (e.g. 'Deepkar/libero-test-act' or "
2219
+ "'hf://Deepkar/libero-test-act'). Fetches the checkpoint's "
2220
+ "config.json, infers the family, and pre-fills chunk_size, "
2221
+ "sensors_required, state_contract.dim, image_preprocessing aliases, "
2222
+ "and weights_uri. Eliminates manual rewriting after scaffold."
2223
+ ),
2224
+ ),
2225
+ yes: bool = typer.Option(
2226
+ False,
2227
+ "--yes",
2228
+ "-y",
2229
+ help="Skip interactive prompts and accept the defaults (for scripting / CI).",
2230
+ ),
2231
+ overwrite: bool = typer.Option(
2232
+ False,
2233
+ "--overwrite",
2234
+ help="Replace an existing destination directory instead of refusing.",
2235
+ ),
2236
+ ) -> None:
2237
+ """Scaffold a new local rSkill from ``rskills/template/``.
2238
+
2239
+ Three modes:
2240
+
2241
+ - ``--from-hf <owner/repo>`` — most intuitive. Fetches the
2242
+ checkpoint's ``config.json`` and pre-fills ``model_family`` /
2243
+ ``chunk_size`` / ``sensors_required`` / ``state_contract`` /
2244
+ ``image_preprocessing.aliases`` / ``weights_uri`` from the real
2245
+ Hub-side contract. No more hand-rewriting after scaffold.
2246
+ - ``--family <act|smolvla|pi05|xvla|diffusion>`` — family-aware
2247
+ defaults without Hub introspection. Use when you know the family
2248
+ but the weights live somewhere else (private repo, local mirror).
2249
+ - Neither — interactive. Prompts for ``--owner`` / ``--license`` /
2250
+ ``--embodiment-tag`` / ``--family`` (and offers ``--from-hf`` as
2251
+ a one-shot alternative). Pass ``--yes`` to skip all prompts and
2252
+ accept the defaults (your-org / apache-2.0 / franka_panda /
2253
+ no-family).
2254
+
2255
+ The generated manifest is round-tripped through
2256
+ `RSkillManifest.from_yaml` and `rSkill.from_yaml`
2257
+ so a malformed scaffold fails at scaffold-time, not on first load.
2258
+
2259
+ Example:
2260
+ >>> # openral rskill new act-libero --from-hf Deepkar/libero-test-act
2261
+ >>> # openral rskill new pi05-pick-cube --family pi05 --embodiment-tag franka_panda
2262
+ >>> # openral rskill new act-aloha-insertion --owner foo --embodiment-tag aloha
2263
+ """
2264
+ from typing import get_args
2265
+
2266
+ from openral_core.schemas import EmbodimentTag, RSkillLicensePosture
2267
+
2268
+ from openral_cli._rskill_scaffolder import scaffold_rskill
2269
+
2270
+ valid_tags = list(get_args(EmbodimentTag))
2271
+ valid_licenses = [v.value for v in RSkillLicensePosture]
2272
+
2273
+ resolved_owner = _resolve_or_prompt(
2274
+ owner,
2275
+ prompt=f"HF Hub owner (e.g. your username or org) [{_DEFAULT_OWNER}]",
2276
+ default=_DEFAULT_OWNER,
2277
+ skip_prompt=yes,
2278
+ )
2279
+ resolved_license = _resolve_or_prompt(
2280
+ license_,
2281
+ prompt=f"License posture [{_DEFAULT_LICENSE}]",
2282
+ default=_DEFAULT_LICENSE,
2283
+ skip_prompt=yes,
2284
+ )
2285
+ resolved_embodiment = _resolve_or_prompt(
2286
+ embodiment_tag,
2287
+ prompt=f"Embodiment tag (canonical robot id) [{_DEFAULT_EMBODIMENT}]",
2288
+ default=_DEFAULT_EMBODIMENT,
2289
+ skip_prompt=yes,
2290
+ )
2291
+
2292
+ try:
2293
+ license_enum = RSkillLicensePosture(resolved_license)
2294
+ except ValueError as exc:
2295
+ console.print(
2296
+ f"[red]Invalid license:[/red] {resolved_license!r}. "
2297
+ f"Valid values: {', '.join(valid_licenses)}"
2298
+ )
2299
+ raise typer.Exit(code=1) from exc
2300
+
2301
+ if resolved_embodiment not in valid_tags:
2302
+ console.print(
2303
+ f"[red]Invalid embodiment_tag:[/red] {resolved_embodiment!r}. "
2304
+ f"Valid values: {', '.join(valid_tags)}"
2305
+ )
2306
+ raise typer.Exit(code=1)
2307
+
2308
+ resolved_family, intel_patch = _resolve_family_and_patch(
2309
+ family=family, from_hf=from_hf, yes=yes
2310
+ )
2311
+
2312
+ resolved_out = out_dir if out_dir is not None else Path("rskills") / rskill_id
2313
+
2314
+ try:
2315
+ result = scaffold_rskill(
2316
+ rskill_id,
2317
+ out_dir=resolved_out,
2318
+ owner=resolved_owner,
2319
+ license_=license_enum,
2320
+ embodiment_tag=cast(EmbodimentTag, resolved_embodiment),
2321
+ family=resolved_family,
2322
+ patch=intel_patch,
2323
+ overwrite=overwrite,
2324
+ )
2325
+ except ROSConfigError as exc:
2326
+ console.print(f"[red]Scaffold failed:[/red] {exc}")
2327
+ raise typer.Exit(code=1) from exc
2328
+
2329
+ console.print(f"[green]Scaffolded[/green] [cyan]{rskill_id}[/cyan] → {result}")
2330
+ if intel_patch is not None:
2331
+ console.print(
2332
+ "[dim]Next steps: edit description / README.md, optionally adjust "
2333
+ "image_preprocessing.flip_180 for your scene, add eval/<benchmark>.json "
2334
+ "results, then publish with tools/rskill_publisher.py.[/dim]"
2335
+ )
2336
+ else:
2337
+ console.print(
2338
+ "[dim]Next steps: edit rskill.yaml (weights_uri / chunk_size / "
2339
+ "sensors_required / image_preprocessing), update README.md, add "
2340
+ "eval/<benchmark>.json results, then publish with "
2341
+ "tools/rskill_publisher.py. Tip: pass `--from-hf <owner/repo>` next "
2342
+ "time to auto-fill the manifest from a published checkpoint.[/dim]"
2343
+ )
2344
+
2345
+
2346
+ def _resolve_family_and_patch(
2347
+ *,
2348
+ family: str | None,
2349
+ from_hf: str | None,
2350
+ yes: bool,
2351
+ ) -> tuple[RSkillFamily | None, RSkillPatch | None]:
2352
+ """Resolve ``--family`` / ``--from-hf`` for ``openral rskill new``.
2353
+
2354
+ Three paths in order of priority:
2355
+
2356
+ 1. ``--from-hf`` set → introspect the Hub config and derive both the
2357
+ family and a manifest patch carrying real chunk_size / sensors /
2358
+ state_contract / aliases / weights_uri.
2359
+ 2. ``--family`` set → take its family defaults, no Hub call.
2360
+ 3. neither + interactive → offer the menu. Skipped under ``--yes``
2361
+ so scripted callers keep the historical "no-family, template
2362
+ baseline" behaviour.
2363
+
2364
+ Returns ``(resolved_family, patch)``; either or both may be ``None``.
2365
+ """
2366
+ from openral_cli._rskill_intel import (
2367
+ RSKILL_FAMILIES,
2368
+ introspect_hf,
2369
+ )
2370
+
2371
+ typed_family: RSkillFamily | None = (
2372
+ cast("RSkillFamily", family) if family in RSKILL_FAMILIES else None
2373
+ )
2374
+
2375
+ if from_hf is not None:
2376
+ try:
2377
+ resolved_family, intel_patch = introspect_hf(from_hf, default_family=typed_family)
2378
+ except ValueError as exc:
2379
+ console.print(f"[red]--from-hf failed:[/red] {exc}")
2380
+ raise typer.Exit(code=1) from exc
2381
+ console.print(
2382
+ f"[green]Auto-detected[/green] family=[cyan]{resolved_family}[/cyan] "
2383
+ f"from [dim]{from_hf}[/dim]"
2384
+ )
2385
+ return resolved_family, intel_patch
2386
+
2387
+ if family is not None:
2388
+ if family not in RSKILL_FAMILIES:
2389
+ console.print(
2390
+ f"[red]Invalid --family:[/red] {family!r}. "
2391
+ f"Valid values: {', '.join(RSKILL_FAMILIES)}"
2392
+ )
2393
+ raise typer.Exit(code=1)
2394
+ return family, None
2395
+
2396
+ if yes:
2397
+ return None, None
2398
+
2399
+ menu = " | ".join(RSKILL_FAMILIES)
2400
+ response = typer.prompt(
2401
+ f"Policy family [{menu}, empty for template baseline]",
2402
+ default="",
2403
+ show_default=False,
2404
+ ).strip()
2405
+ if not response:
2406
+ return None, None
2407
+ if response not in RSKILL_FAMILIES:
2408
+ console.print(
2409
+ f"[red]Invalid family:[/red] {response!r}. Valid values: {', '.join(RSKILL_FAMILIES)}"
2410
+ )
2411
+ raise typer.Exit(code=1)
2412
+ return cast("RSkillFamily", response), None
2413
+
2414
+
2415
+ def _resolve_or_prompt(value: str | None, *, prompt: str, default: str, skip_prompt: bool) -> str:
2416
+ """Return ``value`` if provided, else prompt (or fall back to ``default``).
2417
+
2418
+ Used by ``openral rskill new`` to drive the interactive prompts only when
2419
+ the user didn't pass the flag AND didn't request non-interactive
2420
+ mode with ``--yes``.
2421
+ """
2422
+ if value is not None:
2423
+ return value
2424
+ if skip_prompt:
2425
+ return default
2426
+ response: str = typer.prompt(prompt, default=default, show_default=False)
2427
+ return response
2428
+
2429
+
2430
+ def _display_license_banner(
2431
+ name: str,
2432
+ license_value: str,
2433
+ version: str,
2434
+ con: Console,
2435
+ ) -> None:
2436
+ """Print a colour-coded license banner to the console.
2437
+
2438
+ Args:
2439
+ name: rSkill name from the manifest.
2440
+ license_value: License posture value string.
2441
+ version: SemVer version string.
2442
+ con: Rich Console instance.
2443
+ """
2444
+ _permissive = {"apache-2.0", "mit", "bsd"}
2445
+ _warn = {"permissive_research", "unknown"}
2446
+ if license_value in _permissive:
2447
+ color, icon = "green", "✓"
2448
+ elif license_value in _warn:
2449
+ color, icon = "yellow", "!"
2450
+ else:
2451
+ color, icon = "red", "⚠"
2452
+
2453
+ con.print(
2454
+ f" [{color}]{icon} License:[/{color}] [bold]{license_value}[/bold] ({name} v{version})"
2455
+ )
2456
+
2457
+
2458
+ # ── sensor sub-app ────────────────────────────────────────────────────────────
2459
+
2460
+ sensor_app = typer.Typer(
2461
+ name="sensor",
2462
+ help="Sensor catalog browsing — list and inspect registered sensor specs.",
2463
+ no_args_is_help=True,
2464
+ )
2465
+ app.add_typer(sensor_app, name="sensor")
2466
+
2467
+
2468
+ @sensor_app.command("list")
2469
+ def sensor_list(
2470
+ vendor: str = typer.Option(
2471
+ "",
2472
+ "--vendor",
2473
+ help="Filter by vendor (lowercase, e.g. intel, orbbec, livox).",
2474
+ ),
2475
+ modality: str = typer.Option(
2476
+ "",
2477
+ "--modality",
2478
+ help="Filter by sensor modality (e.g. rgb, depth, lidar_2d, point_cloud).",
2479
+ ),
2480
+ kind: str = typer.Option(
2481
+ "",
2482
+ "--kind",
2483
+ help="Filter by kind: 'sensor' or 'bundle'.",
2484
+ ),
2485
+ json_output: bool = typer.Option(
2486
+ False,
2487
+ "--json",
2488
+ help="Emit machine-readable JSON instead of a table.",
2489
+ ),
2490
+ ) -> None:
2491
+ """List every sensor registered in the openral sensor catalog.
2492
+
2493
+ Example:
2494
+ >>> # openral sensor list
2495
+ >>> # openral sensor list --vendor intel
2496
+ >>> # openral sensor list --modality lidar_2d --json
2497
+ """
2498
+ # Imported lazily so `openral --help` doesn't pay the side-effect import cost.
2499
+ from openral_core.schemas import SensorModality
2500
+ from openral_sensors import CATALOG
2501
+
2502
+ modality_enum: SensorModality | None = None
2503
+ if modality:
2504
+ try:
2505
+ modality_enum = SensorModality(modality)
2506
+ except ValueError:
2507
+ valid = ", ".join(m.value for m in SensorModality)
2508
+ console.print(f"[red]Unknown --modality {modality!r}. Valid: {valid}[/red]")
2509
+ raise typer.Exit(code=1) from None
2510
+
2511
+ kind_filter: str | None = None
2512
+ if kind:
2513
+ if kind not in ("sensor", "bundle"):
2514
+ console.print(f"[red]--kind must be 'sensor' or 'bundle', got {kind!r}.[/red]")
2515
+ raise typer.Exit(code=1)
2516
+ kind_filter = kind
2517
+
2518
+ entries = CATALOG.filter(
2519
+ vendor=vendor or None,
2520
+ modality=modality_enum,
2521
+ kind=kind_filter, # type: ignore[arg-type] # reason: narrowed above
2522
+ )
2523
+
2524
+ if json_output:
2525
+ payload = [
2526
+ {
2527
+ "id": e.id,
2528
+ "vendor": e.vendor,
2529
+ "model": e.model,
2530
+ "kind": e.kind,
2531
+ "modalities": [m.value for m in e.modalities],
2532
+ "description": e.description,
2533
+ "docs_url": e.docs_url,
2534
+ }
2535
+ for e in entries
2536
+ ]
2537
+ console.print_json(_json.dumps(payload))
2538
+ return
2539
+
2540
+ if not entries:
2541
+ console.print("[yellow]No sensors match the requested filters.[/yellow]")
2542
+ return
2543
+
2544
+ table = Table(title=f"openral sensor catalog ({len(entries)} entries)")
2545
+ table.add_column("id", style="cyan", no_wrap=True)
2546
+ table.add_column("kind")
2547
+ table.add_column("modalities", style="magenta")
2548
+ table.add_column("description")
2549
+ for e in entries:
2550
+ table.add_row(
2551
+ e.id,
2552
+ e.kind,
2553
+ ",".join(m.value for m in e.modalities),
2554
+ e.description,
2555
+ )
2556
+ console.print(table)
2557
+
2558
+
2559
+ @sensor_app.command("show")
2560
+ def sensor_show(
2561
+ sensor_id: str = typer.Argument(
2562
+ ...,
2563
+ metavar="SENSOR_ID",
2564
+ help="Catalog id, e.g. intel/realsense_d435i or slamtec/rplidar_a2",
2565
+ ),
2566
+ name: str = typer.Option(
2567
+ "sensor",
2568
+ "--name",
2569
+ help="Instance name passed to the factory (used as topic / frame prefix).",
2570
+ ),
2571
+ parent_frame: str = typer.Option(
2572
+ "base_link",
2573
+ "--parent-frame",
2574
+ help="tf2 parent frame for the static transform.",
2575
+ ),
2576
+ json_output: bool = typer.Option(
2577
+ False,
2578
+ "--json",
2579
+ help="Emit the resolved SensorSpec / SensorBundle as JSON.",
2580
+ ),
2581
+ ) -> None:
2582
+ """Resolve a catalog entry to a concrete ``SensorSpec`` / ``SensorBundle``.
2583
+
2584
+ Example:
2585
+ >>> # openral sensor show intel/realsense_d435i --name head
2586
+ >>> # openral sensor show slamtec/rplidar_a2 --json
2587
+ """
2588
+ from openral_sensors import CATALOG
2589
+
2590
+ try:
2591
+ entry = CATALOG.get(sensor_id)
2592
+ except KeyError as exc:
2593
+ console.print(f"[red]{exc.args[0]}[/red]")
2594
+ raise typer.Exit(code=1) from None
2595
+
2596
+ resolved = entry.factory(name=name, parent_frame=parent_frame)
2597
+
2598
+ if json_output:
2599
+ console.print_json(resolved.model_dump_json(indent=2))
2600
+ return
2601
+
2602
+ console.print(f"[bold cyan]{entry.id}[/bold cyan] ({entry.kind})")
2603
+ console.print(f" vendor : [magenta]{entry.vendor}[/magenta]")
2604
+ console.print(f" model : [magenta]{entry.model}[/magenta]")
2605
+ console.print(f" modalities : {', '.join(m.value for m in entry.modalities)}")
2606
+ console.print(f" description : [dim]{entry.description}[/dim]")
2607
+ if entry.docs_url:
2608
+ console.print(f" docs_url : [dim]{entry.docs_url}[/dim]")
2609
+ console.print()
2610
+ console.print("[bold]Resolved:[/bold]")
2611
+ console.print_json(resolved.model_dump_json(indent=2))
2612
+
2613
+
2614
+ # ── openral benchmark ────────────────────────────────────────────────────────────
2615
+
2616
+ benchmark_app = typer.Typer(
2617
+ name="benchmark",
2618
+ help=(
2619
+ "Run a benchmark suite end-to-end (`openral benchmark run`), list available "
2620
+ "suites (`openral benchmark list`), or aggregate per-rSkill JSON results "
2621
+ "(`openral benchmark report`)."
2622
+ ),
2623
+ no_args_is_help=True,
2624
+ )
2625
+ app.add_typer(benchmark_app, name="benchmark")
2626
+
2627
+
2628
+ @benchmark_app.command("list")
2629
+ def benchmark_list(
2630
+ benchmarks_dir: Path = typer.Option(
2631
+ Path("benchmarks"),
2632
+ "--benchmarks-dir",
2633
+ help="Search directory for benchmark suite YAMLs.",
2634
+ ),
2635
+ ) -> None:
2636
+ """List every benchmark suite id available under ``benchmarks/*.yaml``.
2637
+
2638
+ Each entry is a paste-able ``--suite`` value for ``openral benchmark run``.
2639
+ No rollout, no GPU.
2640
+ """
2641
+ if not benchmarks_dir.is_dir():
2642
+ console.print(f"[red]No benchmarks dir at {benchmarks_dir}[/red]")
2643
+ raise typer.Exit(code=1)
2644
+ suites = sorted(p.stem for p in benchmarks_dir.glob("*.yaml") if p.is_file())
2645
+ if not suites:
2646
+ print("<none>")
2647
+ return
2648
+ for suite in suites:
2649
+ print(suite)
2650
+
2651
+
2652
+ @benchmark_app.command("run")
2653
+ def benchmark_run(
2654
+ suite: str = typer.Option(
2655
+ ...,
2656
+ "--suite",
2657
+ help=(
2658
+ "Benchmark suite to evaluate — a bare ``list[BenchmarkScene]`` YAML. "
2659
+ "Either a built-in id (resolved to "
2660
+ "`benchmarks/<id>.yaml`) or a direct YAML path."
2661
+ ),
2662
+ ),
2663
+ rskill: str = typer.Option(
2664
+ ...,
2665
+ "--rskill",
2666
+ help=(
2667
+ "rSkill reference — a bare name ('smolvla-libero'), a "
2668
+ "path ('rskills/smolvla-libero'), or an HF Hub repo id "
2669
+ "('OpenRAL/rskill-smolvla-libero'). "
2670
+ "Raw hf:// is rejected (weights must come from a manifest). "
2671
+ "The policy adapter id is read from the manifest's `model_family` "
2672
+ "field."
2673
+ ),
2674
+ ),
2675
+ out: Path | None = typer.Option(
2676
+ None,
2677
+ "--out",
2678
+ help=(
2679
+ "Output path for the RSkillEvalResult JSON. Defaults to "
2680
+ "rskills/<dir>/eval/<suite_id>.json derived from the rSkill ref."
2681
+ ),
2682
+ ),
2683
+ device: str | None = typer.Option(
2684
+ None,
2685
+ "--device",
2686
+ help="Torch device override for the policy (cpu, cuda:0, mps, auto).",
2687
+ ),
2688
+ save_dir: Path | None = typer.Option(
2689
+ None,
2690
+ "--save-dir",
2691
+ help="Optional adapter-side artefact directory (videos, traces).",
2692
+ ),
2693
+ benchmarks_dir: Path = typer.Option(
2694
+ Path("benchmarks"),
2695
+ "--benchmarks-dir",
2696
+ help="Search directory for built-in benchmark suite YAMLs.",
2697
+ ),
2698
+ task: str | None = typer.Option(
2699
+ None,
2700
+ "--task",
2701
+ help=(
2702
+ "Run only this single task id from the suite (e.g. "
2703
+ "'libero_spatial/3' or 'maniskill3/PushCube-v1'). Omit to run "
2704
+ "every task the rSkill supports (the suite is auto-filtered to "
2705
+ "the rSkill's evaluated_tasks)."
2706
+ ),
2707
+ ),
2708
+ n_episodes: int | None = typer.Option(
2709
+ None,
2710
+ "--n-episodes",
2711
+ help=(
2712
+ "Override ``BenchmarkScene.n_episodes`` for every scene in the "
2713
+ "suite (lower for quick smoke runs). The published-protocol value "
2714
+ "lives in the suite YAML; this flag is for fast iteration, not "
2715
+ "for paper-comparison numbers."
2716
+ ),
2717
+ ),
2718
+ dry_run: bool = typer.Option(
2719
+ False,
2720
+ "--dry-run",
2721
+ help=(
2722
+ "Resolve the suite + VLA and print the planned (task x seed) "
2723
+ "matrix without running any rollouts. Useful in CI to "
2724
+ "validate config wiring."
2725
+ ),
2726
+ ),
2727
+ update_manifest: bool = typer.Option(
2728
+ True,
2729
+ "--update-manifest/--no-update-manifest",
2730
+ help=(
2731
+ "On success, write the avg_success_rate back into the rSkill "
2732
+ "manifest at `benchmarks.<suite_id>`. Surgical edit — "
2733
+ "preserves comments. Only fires for locally-resolvable rSkills. "
2734
+ "Disable for read-only paper-number runs."
2735
+ ),
2736
+ ),
2737
+ dashboard: bool = typer.Option(
2738
+ False,
2739
+ "--dashboard",
2740
+ help=(
2741
+ "Boot `openral dashboard` as a child process, point OTel at it, "
2742
+ "and shut it down on exit (same semantics as `openral sim run "
2743
+ "--dashboard`)."
2744
+ ),
2745
+ ),
2746
+ dashboard_port: int = typer.Option(
2747
+ 4318,
2748
+ "--dashboard-port",
2749
+ help="Port for the spawned dashboard when --dashboard is set.",
2750
+ ),
2751
+ ) -> None:
2752
+ r"""Run a benchmark suite and write a validated `RSkillEvalResult` JSON.
2753
+
2754
+ The runner iterates ``scenes x range(seed, seed + n_episodes)``,
2755
+ delegating each rollout to ``openral_sim.SimRunner`` so the
2756
+ rSkill compatibility check, OTel spans, and latency-budget reporting
2757
+ are identical to ``openral sim run``. Each :class:`BenchmarkScene`
2758
+ carries its own scene + task + robot; the ``BenchmarkSpec`` wrapper
2759
+ class was removed so the suite is a bare list of scenes whose id is
2760
+ the YAML filename stem.
2761
+
2762
+ Example:
2763
+ >>> # openral benchmark run --suite libero_spatial \\
2764
+ >>> # --rskill smolvla-libero
2765
+ """
2766
+ scenes, suite_id = _resolve_benchmark_suite(suite, benchmarks_dir)
2767
+ vla_spec = _parse_rskill_cli_arg(rskill)
2768
+
2769
+ # --task selects a single explicit task from the suite. The rSkill's
2770
+ # evaluated_tasks auto-filter (in run_benchmark) still applies, so an
2771
+ # explicitly-picked task the rSkill was not trained for is rejected — same
2772
+ # contract as `openral benchmark scene`.
2773
+ if task is not None:
2774
+ matched = [s for s in scenes if s.task.id == task]
2775
+ if not matched:
2776
+ available = [s.task.id for s in scenes]
2777
+ console.print(
2778
+ f"[red]--task {task!r} is not in suite {suite_id!r}.[/red] "
2779
+ f"Available tasks: {available}"
2780
+ )
2781
+ raise typer.Exit(1)
2782
+ scenes = matched
2783
+
2784
+ # Apply --n-episodes override to every scene before dry-run or real run.
2785
+ if n_episodes is not None:
2786
+ scenes = [s.model_copy(update={"n_episodes": n_episodes}) for s in scenes]
2787
+
2788
+ if dry_run:
2789
+ # Suite invariants (openral_core.raise_on_invalid_suite) guarantee
2790
+ # every BenchmarkScene shares robot_id / n_episodes / seed; read
2791
+ # from scenes[0] for the summary.
2792
+ first = scenes[0]
2793
+ eff_episodes = first.n_episodes
2794
+ # ``robot_id`` is non-None per raise_on_invalid_suite; coerce for printing.
2795
+ robot_id = first.robot_id or "<unset>"
2796
+ # When every scene shares one scene.id we print it; otherwise
2797
+ # show how many distinct scenes the suite covers.
2798
+ scene_ids = {scene.scene.id for scene in scenes}
2799
+ scene_summary = next(iter(scene_ids)) if len(scene_ids) == 1 else f"{len(scene_ids)} scenes"
2800
+ console.print(
2801
+ f"[cyan]suite[/cyan] {suite_id} — robot={robot_id} "
2802
+ f"scene={scene_summary} tasks={len(scenes)} "
2803
+ f"n_episodes={eff_episodes}"
2804
+ )
2805
+ console.print(f"[cyan]vla[/cyan] id={vla_spec.id} weights={vla_spec.weights_uri}")
2806
+ console.print(
2807
+ f"[cyan]plan[/cyan] {len(scenes) * eff_episodes} "
2808
+ f"episodes ({len(scenes)} tasks x {eff_episodes} reps)"
2809
+ )
2810
+ return
2811
+
2812
+ out_path = out if out is not None else _default_benchmark_out_path(vla_spec, suite_id)
2813
+ out_path.parent.mkdir(parents=True, exist_ok=True)
2814
+
2815
+ from openral_observability.dashboard import attached_dashboard
2816
+ from openral_sim.benchmark import run_benchmark
2817
+
2818
+ # attached_dashboard is a no-op when enabled=False — wrap
2819
+ # unconditionally so the run_benchmark call is un-duplicated.
2820
+ with attached_dashboard(enabled=dashboard, port=dashboard_port):
2821
+ result, episodes = run_benchmark(
2822
+ scenes,
2823
+ suite_id=suite_id,
2824
+ vla=vla_spec,
2825
+ device=device,
2826
+ save_dir=str(save_dir) if save_dir is not None else None,
2827
+ )
2828
+
2829
+ out_path.write_text(result.model_dump_json(indent=2))
2830
+ avg = result.results.get("avg_success_rate", 0.0)
2831
+ console.print(
2832
+ f"[green]wrote {out_path}[/green] — avg success "
2833
+ f"= {float(avg) if isinstance(avg, (int, float)) else avg:.3f} "
2834
+ f"over {len(episodes)} episodes"
2835
+ )
2836
+
2837
+ if update_manifest:
2838
+ from openral_rskill.loader import resolve_rskill_local_dir
2839
+ from openral_sim.benchmark import update_rskill_benchmarks
2840
+
2841
+ # Resolve to the in-tree dir so the surgical write hits
2842
+ # rskills/<name>/rskill.yaml even when the user supplied a bare
2843
+ # name or a Hub-style repo id. Falls through to a cwd-relative
2844
+ # path for Hub-only references with no in-tree shim — the
2845
+ # update is then a no-op (FileNotFoundError handled below).
2846
+ local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
2847
+ skill_dir = str(local_dir) if local_dir is not None else vla_spec.weights_uri
2848
+
2849
+ try:
2850
+ manifest_path = update_rskill_benchmarks(
2851
+ skill_dir,
2852
+ suite_id,
2853
+ float(avg) if isinstance(avg, (int, float)) else 0.0,
2854
+ )
2855
+ console.print(
2856
+ f"[green]updated {manifest_path}[/green] — "
2857
+ f"benchmarks.{suite_id} = "
2858
+ f"{float(avg) if isinstance(avg, (int, float)) else avg:.3f}"
2859
+ )
2860
+ except FileNotFoundError as exc:
2861
+ console.print(
2862
+ f"[yellow]skipped manifest update:[/yellow] {exc} (eval JSON was still written)"
2863
+ )
2864
+
2865
+
2866
+ def _resolve_benchmark_suite(
2867
+ suite: str,
2868
+ benchmarks_dir: Path,
2869
+ ) -> tuple[list[BenchmarkScene], str]:
2870
+ """Map a ``--suite`` argument to a validated ``(scenes, suite_id)`` tuple.
2871
+
2872
+ A benchmark suite is a bare ``list[BenchmarkScene]`` YAML;
2873
+ the suite id is the filename stem. Accepts either a built-in id
2874
+ (resolved to ``benchmarks/<id>.yaml``) or a direct path. Bare ids
2875
+ that don't resolve raise ``typer.BadParameter`` listing the catalogue
2876
+ entries that ARE present so typos are easy to fix. Per-scene Pydantic
2877
+ validation and suite-level invariant checks (uniformity, uniqueness,
2878
+ non-empty) run here; any failure surfaces as a ``typer.BadParameter``
2879
+ so the CLI exit code stays informative.
2880
+ """
2881
+ from openral_core import load_benchmark_suite, raise_on_invalid_suite
2882
+ from openral_core.exceptions import ROSConfigError
2883
+
2884
+ candidate = Path(suite)
2885
+ if candidate.suffix in {".yaml", ".yml"} or candidate.exists():
2886
+ if not candidate.exists():
2887
+ raise typer.BadParameter(f"benchmark suite YAML not found: {candidate}")
2888
+ resolved_path = candidate
2889
+ else:
2890
+ resolved_path = benchmarks_dir / f"{suite}.yaml"
2891
+ if not resolved_path.exists():
2892
+ available = (
2893
+ sorted(p.stem for p in benchmarks_dir.glob("*.yaml") if p.is_file())
2894
+ if benchmarks_dir.is_dir()
2895
+ else []
2896
+ )
2897
+ raise typer.BadParameter(
2898
+ f"unknown benchmark suite {suite!r}; "
2899
+ f"available in {benchmarks_dir}/: {available if available else '<empty>'}"
2900
+ )
2901
+
2902
+ suite_id = resolved_path.stem
2903
+ try:
2904
+ scenes = load_benchmark_suite(str(resolved_path))
2905
+ raise_on_invalid_suite(scenes, suite_id=suite_id)
2906
+ except ROSConfigError as exc:
2907
+ raise typer.BadParameter(str(exc)) from exc
2908
+ return scenes, suite_id
2909
+
2910
+
2911
+ def _parse_rskill_cli_arg(raw: str) -> VLASpec:
2912
+ """Parse ``--rskill <reference>`` into a `VLASpec`.
2913
+
2914
+ Accepts a bare rSkill reference — a name (``smolvla-libero``),
2915
+ a path (``rskills/smolvla-libero``), or an HF repo id
2916
+ (``OpenRAL/rskill-smolvla-libero``). The adapter id is read from
2917
+ the loaded manifest's ``model_family`` field.
2918
+ """
2919
+ from openral_core import VLASpec
2920
+ from openral_core.exceptions import ROSConfigError
2921
+ from openral_rskill.loader import _validate_skill_ref, load_rskill_manifest
2922
+
2923
+ try:
2924
+ uri = _validate_skill_ref(raw)
2925
+ except ROSConfigError as exc:
2926
+ raise typer.BadParameter(str(exc)) from exc
2927
+ manifest = load_rskill_manifest(uri)
2928
+ if manifest.model_family is None:
2929
+ # Only `kind='vla'` skills carry a model_family; a detector/reward skill
2930
+ # has none and cannot drive a VLASpec.
2931
+ raise typer.BadParameter(
2932
+ f"rSkill {raw!r} has no model_family (kind={manifest.kind!r}); "
2933
+ f"--rskill expects a VLA skill."
2934
+ )
2935
+ return VLASpec(
2936
+ id=manifest.model_family,
2937
+ weights_uri=uri,
2938
+ extra=dict(manifest.policy_extras),
2939
+ )
2940
+
2941
+
2942
+ def _default_benchmark_out_path(vla_spec: VLASpec, suite_id: str) -> Path:
2943
+ """Derive ``rskills/<vla>/eval/<suite_id>.json`` from a VLASpec + suite id.
2944
+
2945
+ Resolves the rSkill to its in-tree directory via
2946
+ :func:`openral_rskill.loader.resolve_rskill_local_dir` so the JSON
2947
+ lands in the right place regardless of which URI form the user typed
2948
+ (bare name, ``rskills/<name>``, Hub repo id). Falls back to the
2949
+ library's :func:`openral_sim.benchmark.default_output_path` when no
2950
+ in-tree shim exists (Hub-only references).
2951
+ """
2952
+ from openral_rskill.loader import resolve_rskill_local_dir
2953
+ from openral_sim.benchmark import default_output_path
2954
+
2955
+ local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
2956
+ if local_dir is not None:
2957
+ return local_dir / "eval" / f"{suite_id}.json"
2958
+ return Path(default_output_path(vla_spec.weights_uri, suite_id))
2959
+
2960
+
2961
+ @benchmark_app.command("scene")
2962
+ def benchmark_scene(
2963
+ config: Path = typer.Option(
2964
+ ...,
2965
+ "--config",
2966
+ help=(
2967
+ "Path to a BenchmarkScene YAML "
2968
+ "(`scenes/benchmark/<id>.yaml`). DeployScene and SimScene "
2969
+ "YAMLs are rejected with a redirect — `openral benchmark "
2970
+ "scene` accepts BenchmarkScene only (scene + task + "
2971
+ "`n_episodes` + `seed` + `metadata.paper` + "
2972
+ "`metadata.honest_scope`)."
2973
+ ),
2974
+ ),
2975
+ rskill: str = typer.Option(
2976
+ ...,
2977
+ "--rskill",
2978
+ help=(
2979
+ "rSkill reference — a bare name ('smolvla-libero'), a "
2980
+ "path ('rskills/smolvla-libero'), or an HF Hub repo id "
2981
+ "('OpenRAL/rskill-smolvla-libero'). "
2982
+ "Raw hf:// is rejected (weights must come from a manifest). "
2983
+ "The policy adapter id is read from the manifest's `model_family` "
2984
+ "field."
2985
+ ),
2986
+ ),
2987
+ out: Path | None = typer.Option(
2988
+ None,
2989
+ "--out",
2990
+ help=(
2991
+ "Output path for the RSkillEvalResult JSON. Defaults to "
2992
+ "rskills/<dir>/eval/scene_<scene_id>.json derived from the "
2993
+ "rSkill ref."
2994
+ ),
2995
+ ),
2996
+ device: str | None = typer.Option(
2997
+ None,
2998
+ "--device",
2999
+ help="Torch device override for the policy (cpu, cuda:0, mps, auto).",
3000
+ ),
3001
+ save_dir: Path | None = typer.Option(
3002
+ None,
3003
+ "--save-dir",
3004
+ help="Optional adapter-side artefact directory (videos, traces).",
3005
+ ),
3006
+ save_video: Path | None = typer.Option(
3007
+ None,
3008
+ "--save-video",
3009
+ help=(
3010
+ "Write a clean single-view world MP4 per episode to this "
3011
+ "directory, named <task>_<rskill>_<success|fail>.mp4, plus a "
3012
+ "videos.json manifest — for website hero clips (overlays are "
3013
+ "rendered by the page, not burned into pixels). The task slug "
3014
+ "keeps benchmark scenes sharing one backend from overwriting each "
3015
+ "other. Enables per-step frame capture. Pair with --n-episodes 1 "
3016
+ "for a single demo clip."
3017
+ ),
3018
+ ),
3019
+ video_size: int = typer.Option(
3020
+ 1024,
3021
+ "--video-size",
3022
+ help=(
3023
+ "Square edge (px) for --save-video output. Frames are "
3024
+ "center-cropped to a square and resized to this size. Source "
3025
+ "sharpness is bounded by the scene's native render resolution."
3026
+ ),
3027
+ ),
3028
+ n_episodes: int | None = typer.Option(
3029
+ None,
3030
+ "--n-episodes",
3031
+ help=(
3032
+ "Override `BenchmarkScene.n_episodes` (lower for quick smoke "
3033
+ "runs). The published-protocol value lives in the YAML; this "
3034
+ "flag is for fast iteration, not for paper-comparison numbers."
3035
+ ),
3036
+ ),
3037
+ view: bool | None = typer.Option(
3038
+ None,
3039
+ "--view/--no-view",
3040
+ help=(
3041
+ "Open a passive mujoco.viewer window and stream the rollout in "
3042
+ "real time (parity with `openral sim run --view`). Default "
3043
+ "(unset): headless — benchmark eval artefacts and CI/deploy "
3044
+ "runs are unaffected. Pass --view to require a window (errors "
3045
+ "loud if unsupported), or --no-view to force offscreen. "
3046
+ "Incompatible with MUJOCO_GL=egl."
3047
+ ),
3048
+ ),
3049
+ dry_run: bool = typer.Option(
3050
+ False,
3051
+ "--dry-run",
3052
+ help=(
3053
+ "Resolve the scene + rSkill and print the planned (task x "
3054
+ "seed) matrix without running any rollouts. Useful in CI to "
3055
+ "validate config wiring."
3056
+ ),
3057
+ ),
3058
+ update_manifest: bool = typer.Option(
3059
+ True,
3060
+ "--update-manifest/--no-update-manifest",
3061
+ help=(
3062
+ "On success, write the avg_success_rate back into the rSkill "
3063
+ "manifest at `benchmarks.<scene_id>`. Surgical edit — "
3064
+ "preserves comments. Only fires for locally-resolvable rSkills."
3065
+ ),
3066
+ ),
3067
+ write_eval: bool = typer.Option(
3068
+ True,
3069
+ "--write-eval/--no-write-eval",
3070
+ help=(
3071
+ "Persist the RSkillEvalResult JSON to --out (default "
3072
+ "rskills/<dir>/eval/scene_<scene_id>.json, a tracked path). "
3073
+ "Pass --no-write-eval for a fully non-mutating smoke run: the "
3074
+ "rollout still executes and prints its score, but nothing is "
3075
+ "written to the rSkill package (implies --no-update-manifest)."
3076
+ ),
3077
+ ),
3078
+ dashboard: bool = typer.Option(
3079
+ False,
3080
+ "--dashboard",
3081
+ help=(
3082
+ "Boot `openral dashboard` as a child process, point OTel at it, "
3083
+ "and shut it down on exit (same semantics as `openral sim run "
3084
+ "--dashboard`)."
3085
+ ),
3086
+ ),
3087
+ dashboard_port: int = typer.Option(
3088
+ 4318,
3089
+ "--dashboard-port",
3090
+ help="Port for the spawned dashboard when --dashboard is set.",
3091
+ ),
3092
+ ) -> None:
3093
+ r"""Run a single-scene benchmark and write a validated `RSkillEvalResult` JSON.
3094
+
3095
+ Single-scene sibling of ``openral benchmark run --suite`` — accepts
3096
+ exactly one :class:`BenchmarkScene` YAML and emits the same eval JSON
3097
+ shape so ``openral benchmark report`` does not need to distinguish
3098
+ the two entrypoints.
3099
+
3100
+ Example:
3101
+ >>> # openral benchmark scene \\
3102
+ >>> # --config scenes/benchmark/pusht.yaml \\
3103
+ >>> # --rskill diffusion-pusht
3104
+ """
3105
+ from openral_core import BenchmarkScene, load_scene_strict
3106
+
3107
+ scene = load_scene_strict(str(config), BenchmarkScene)
3108
+ if n_episodes is not None:
3109
+ scene = scene.model_copy(update={"n_episodes": n_episodes})
3110
+
3111
+ if dry_run:
3112
+ # Dry-run validates config wiring only — do not touch the Hub or
3113
+ # load weights. Print the raw --rskill argument as-typed.
3114
+ console.print(
3115
+ f"[cyan]scene[/cyan] {scene.scene.id} — robot={scene.robot_id} "
3116
+ f"task={scene.task.id} n_episodes={scene.n_episodes} "
3117
+ f"seed={scene.seed}"
3118
+ )
3119
+ console.print(f"[cyan]vla[/cyan] rskill={rskill}")
3120
+ console.print(
3121
+ f"[cyan]plan[/cyan] {scene.n_episodes} episodes (seeds "
3122
+ f"{scene.seed}..{scene.seed + scene.n_episodes - 1})"
3123
+ )
3124
+ return
3125
+
3126
+ vla_spec = _parse_rskill_cli_arg(rskill)
3127
+ out_path = out if out is not None else _default_benchmark_scene_out_path(vla_spec, scene)
3128
+
3129
+ from openral_observability.dashboard import attached_dashboard
3130
+ from openral_sim.benchmark import run_benchmark_scene
3131
+
3132
+ with attached_dashboard(enabled=dashboard, port=dashboard_port):
3133
+ result, episodes = run_benchmark_scene(
3134
+ scene,
3135
+ vla_spec,
3136
+ device=device,
3137
+ save_dir=str(save_dir) if save_dir is not None else None,
3138
+ config_path=str(config),
3139
+ view=view,
3140
+ record_video=save_video is not None,
3141
+ )
3142
+
3143
+ if save_video is not None:
3144
+ from openral_sim._website_video import write_world_videos
3145
+
3146
+ write_world_videos(
3147
+ episodes,
3148
+ save_video,
3149
+ scene=scene.task.id,
3150
+ rskill=Path(rskill).name,
3151
+ section=Path(config).parent.name,
3152
+ size=video_size,
3153
+ )
3154
+
3155
+ avg = result.results.get("avg_success_rate", 0.0)
3156
+ avg_f = float(avg) if isinstance(avg, (int, float)) else 0.0
3157
+ if _persist_scene_eval(result, out_path, write_eval=write_eval):
3158
+ console.print(
3159
+ f"[green]wrote {out_path}[/green] — avg success "
3160
+ f"= {avg_f:.3f} over {len(episodes)} episodes"
3161
+ )
3162
+ else:
3163
+ console.print(
3164
+ f"[yellow]--no-write-eval:[/yellow] not persisting result — avg success "
3165
+ f"= {avg_f:.3f} over {len(episodes)} episodes (nothing written to the rSkill)"
3166
+ )
3167
+
3168
+ if not write_eval:
3169
+ # Non-mutating smoke run: skip the manifest writeback too.
3170
+ return
3171
+
3172
+ if update_manifest and not _scene_id_is_benchmark_suite(scene.scene.id):
3173
+ # The rskill.yaml `benchmarks:` block holds canonical SUITE headlines
3174
+ # (RSkillManifest.benchmarks is keyed by the BenchmarkName literal).
3175
+ # A single scene whose id is not itself a suite id (e.g. 'metaworld',
3176
+ # 'robocasa/PickPlaceCounterToCabinet') has no headline slot — writing
3177
+ # it would raise ROSConfigError. The per-scene result is already
3178
+ # captured in the eval JSON, so we skip the manifest write rather than
3179
+ # crash. (Scenes whose id IS a suite id — pusht, libero_spatial — still
3180
+ # update the headline below.)
3181
+ console.print(
3182
+ f"[yellow]skipped manifest update:[/yellow] scene id {scene.scene.id!r} "
3183
+ f"is not a canonical benchmark suite id; per-scene result written to "
3184
+ f"{out_path} only (rskill.yaml benchmarks: holds suite headlines)."
3185
+ )
3186
+ elif update_manifest:
3187
+ from openral_rskill.loader import resolve_rskill_local_dir
3188
+ from openral_sim.benchmark import update_rskill_benchmarks
3189
+
3190
+ local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
3191
+ skill_dir = str(local_dir) if local_dir is not None else vla_spec.weights_uri
3192
+ try:
3193
+ manifest_path = update_rskill_benchmarks(
3194
+ skill_dir,
3195
+ scene.scene.id,
3196
+ float(avg) if isinstance(avg, (int, float)) else 0.0,
3197
+ )
3198
+ console.print(
3199
+ f"[green]updated {manifest_path}[/green] — "
3200
+ f"benchmarks.{scene.scene.id} = "
3201
+ f"{float(avg) if isinstance(avg, (int, float)) else avg:.3f}"
3202
+ )
3203
+ except FileNotFoundError as exc:
3204
+ console.print(
3205
+ f"[yellow]skipped manifest update:[/yellow] {exc} (eval JSON was still written)"
3206
+ )
3207
+
3208
+
3209
+ def _scene_id_is_benchmark_suite(scene_id: str) -> bool:
3210
+ """True iff ``scene_id`` is a canonical ``BenchmarkName`` suite id.
3211
+
3212
+ ``openral benchmark scene`` only writes ``rskill.yaml``'s ``benchmarks:``
3213
+ block (the suite-headline map keyed by the ``BenchmarkName`` literal) when
3214
+ the scene's id IS one of those suite ids — e.g. ``"pusht"``,
3215
+ ``"libero_spatial"``. Arbitrary single-scene ids such as ``"metaworld"``
3216
+ (suite is ``"metaworld_mt50"``) or ``"robocasa/PickPlaceCounterToCabinet"``
3217
+ have no headline slot, so the manifest write is skipped (the per-scene
3218
+ eval JSON still records the result).
3219
+ """
3220
+ from typing import get_args
3221
+
3222
+ from openral_core import BenchmarkName
3223
+
3224
+ return scene_id in set(get_args(BenchmarkName))
3225
+
3226
+
3227
+ def _persist_scene_eval(result: RSkillEvalResult, out_path: Path, *, write_eval: bool) -> bool:
3228
+ """Persist a benchmark-scene ``RSkillEvalResult`` to ``out_path``.
3229
+
3230
+ Returns ``True`` if the file was written, ``False`` when ``write_eval``
3231
+ is ``False`` (the ``--no-write-eval`` non-mutating smoke-run mode — the
3232
+ rollout still runs, but nothing touches the tracked rSkill package).
3233
+ """
3234
+ if not write_eval:
3235
+ return False
3236
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3237
+ out_path.write_text(result.model_dump_json(indent=2))
3238
+ return True
3239
+
3240
+
3241
+ def _default_benchmark_scene_out_path(vla_spec: VLASpec, scene: BenchmarkScene) -> Path:
3242
+ """Derive ``rskills/<vla>/eval/scene_<scene_id>.json`` from a VLASpec.
3243
+
3244
+ Mirrors :func:`_default_benchmark_out_path` for the single-scene
3245
+ entrypoint. The ``scene_`` prefix distinguishes per-scene JSONs from
3246
+ multi-task suite JSONs so both can coexist under the same rSkill.
3247
+ """
3248
+ from openral_rskill.loader import resolve_rskill_local_dir
3249
+ from openral_sim.benchmark import default_output_path
3250
+
3251
+ local_dir = resolve_rskill_local_dir(vla_spec.weights_uri)
3252
+ if local_dir is not None:
3253
+ return local_dir / "eval" / f"scene_{scene.scene.id}.json"
3254
+ return Path(default_output_path(vla_spec.weights_uri, f"scene_{scene.scene.id}"))
3255
+
3256
+
3257
+ @benchmark_app.command("report")
3258
+ def benchmark_report(
3259
+ rskills_dir: Path = typer.Option(
3260
+ Path("rskills"),
3261
+ "--rskills-dir",
3262
+ help="Directory containing rSkill packages (each with optional eval/*.json).",
3263
+ ),
3264
+ json_output: bool = typer.Option(
3265
+ False,
3266
+ "--json",
3267
+ help="Emit a JSON dump instead of the rich-table summary.",
3268
+ ),
3269
+ ) -> None:
3270
+ """Walk every ``<skill>/eval/*.json`` and print a benchmark roll-up.
3271
+
3272
+ Validates each JSON against `openral_core.RSkillEvalResult`
3273
+ (the same schema the rSkill loader uses at install time) so a rotted
3274
+ file fails loudly instead of silently being skipped.
3275
+
3276
+ Example:
3277
+ >>> # openral benchmark report
3278
+ >>> # openral benchmark report --json > /tmp/report.json
3279
+ """
3280
+ from openral_core import RSkillEvalResult
3281
+ from pydantic import ValidationError
3282
+
3283
+ if not rskills_dir.is_dir():
3284
+ console.print(f"[red]rskills directory not found: {rskills_dir}[/red]")
3285
+ raise typer.Exit(code=1)
3286
+
3287
+ rows: list[dict[str, object]] = []
3288
+ for skill_dir in sorted(p for p in rskills_dir.iterdir() if p.is_dir()):
3289
+ eval_dir = skill_dir / "eval"
3290
+ if not eval_dir.is_dir():
3291
+ continue
3292
+ for json_path in sorted(eval_dir.glob("*.json")):
3293
+ try:
3294
+ result = RSkillEvalResult.from_json(str(json_path))
3295
+ except (ValidationError, _json.JSONDecodeError) as exc:
3296
+ console.print(f"[red]invalid {json_path}:[/red] {exc}")
3297
+ raise typer.Exit(code=1) from exc
3298
+ rows.append(
3299
+ {
3300
+ "rskill": skill_dir.name,
3301
+ "benchmark": result.benchmark.name,
3302
+ "robot": result.benchmark.robot,
3303
+ "simulator": result.benchmark.simulator,
3304
+ "reproduced_locally": result.source.reproduced_locally,
3305
+ "model_variant": result.source.model_variant,
3306
+ "status": result.source.status,
3307
+ "results": result.results,
3308
+ "path": (
3309
+ str(json_path.relative_to(Path.cwd()))
3310
+ if json_path.is_relative_to(Path.cwd())
3311
+ else str(json_path)
3312
+ ),
3313
+ }
3314
+ )
3315
+
3316
+ if json_output:
3317
+ console.print_json(_json.dumps(rows, indent=2, default=str))
3318
+ return
3319
+
3320
+ if not rows:
3321
+ console.print(f"[yellow]no rskills/<id>/eval/*.json files under {rskills_dir}[/yellow]")
3322
+ return
3323
+
3324
+ rows.sort(key=lambda r: (str(r["benchmark"]), str(r["rskill"])))
3325
+ table = Table(title=f"rSkill benchmark report — {len(rows)} entries")
3326
+ table.add_column("Benchmark", style="cyan")
3327
+ table.add_column("rSkill", style="magenta")
3328
+ table.add_column("Variant", style="dim")
3329
+ table.add_column("Robot", style="dim")
3330
+ table.add_column("Repro local?", justify="center")
3331
+ table.add_column("Headline result", style="green")
3332
+ table.add_column("Status", style="dim")
3333
+ for row in rows:
3334
+ results = row["results"]
3335
+ headline = _summarize_results(results) if isinstance(results, dict) else "—"
3336
+ table.add_row(
3337
+ str(row["benchmark"]),
3338
+ str(row["rskill"]),
3339
+ str(row["model_variant"]),
3340
+ str(row["robot"]),
3341
+ "✓" if row["reproduced_locally"] else "✗",
3342
+ headline,
3343
+ str(row["status"] or ""),
3344
+ )
3345
+ console.print(table)
3346
+
3347
+
3348
+ def _summarize_results(results: dict[str, object]) -> str:
3349
+ """Produce a one-line headline from a freeform ``results`` block.
3350
+
3351
+ Picks ``*_avg`` keys first, then falls back to a single-numeric value
3352
+ or a status string. Returns ``"—"`` when nothing summarisable is found.
3353
+ """
3354
+ avg_keys = [k for k in results if k.endswith("_avg") or k == "avg"]
3355
+ if avg_keys:
3356
+ v = results[avg_keys[0]]
3357
+ if isinstance(v, (int, float)):
3358
+ return f"avg = {v:.3f}"
3359
+ if isinstance(v, dict) and "success_rate" in v:
3360
+ sr = v["success_rate"]
3361
+ if isinstance(sr, (int, float)):
3362
+ return f"avg success = {sr:.3f}"
3363
+ numeric_keys = [
3364
+ k for k, v in results.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
3365
+ ]
3366
+ if len(numeric_keys) == 1:
3367
+ v = results[numeric_keys[0]]
3368
+ return f"{numeric_keys[0]} = {v:.3f}" if isinstance(v, (int, float)) else "—"
3369
+ if "status" in results and isinstance(results["status"], str):
3370
+ return f"status: {results['status']}"
3371
+ return "—"
3372
+
3373
+
3374
+ # ── sim sub-app ───────────────────────────────────────────────────────────────
3375
+ #
3376
+ # Mounts the ``openral sim`` Typer group exported by ``openral_sim.cli`` so
3377
+ # users can invoke the sim eval runner as ``openral sim run …``.
3378
+ #
3379
+ # Lazy-import discipline: importing `openral_sim.cli` at module load is
3380
+ # light (only the Typer option metadata + a couple of pydantic / structlog
3381
+ # imports). The heavy sim dependencies (torch, mujoco, gymnasium, lerobot)
3382
+ # load inside `openral_sim.runner` and the per-adapter modules under
3383
+ # `openral_sim.policies/backends`, which `_run()` imports lazily.
3384
+ # `tests/unit/test_cli_eval.py::test_bh_cli_import_is_light` guards this.
3385
+ app.add_typer(sim_app, name="sim")
3386
+
3387
+ # `openral install <group>` — post-install escape hatch for the
3388
+ # Tier-0 curl-bash installer (`scripts/install.sh`). The base install puts
3389
+ # `openral` on $PATH with the CLI's own thin runtime; sim physics, LIBERO,
3390
+ # MetaWorld, RoboCasa, and the sudo+apt ROS 2 bootstrap layer in on demand.
3391
+ app.add_typer(install_app, name="install")
3392
+
3393
+ # `openral dataset push` — publish a LeRobotDataset v3 to the HF Hub.
3394
+ # Importing `dataset` at module top is cheap; the `push` command itself lazy-
3395
+ # imports huggingface_hub only when actually publishing so `openral --help` stays
3396
+ # sub-second.
3397
+ app.add_typer(dataset_app, name="dataset")
3398
+
3399
+ # `openral collision lower|check` — offline URDF/SRDF → manifest
3400
+ # self-collision model. The `lower_robot` import is deferred inside the commands
3401
+ # (it pulls yourdfpy/trimesh) so `openral --help` stays fast.
3402
+ app.add_typer(collision_app, name="collision")
3403
+
3404
+ # `openral check` — static, host-independent validation of the declarative
3405
+ # robot/skill/scene set (manifests parse, asset refs resolve, scene robot_ids and
3406
+ # rSkill embodiment tags resolve). Complements `openral rskill check`. Manifest
3407
+ # JSON-Schema emission lives in `tools/schema_export.py` (CI-gated), not here.
3408
+ app.command("check")(check_command)
3409
+
3410
+ # `openral robot vendor-urdf <id>` — expand an upstream xacro to a
3411
+ # flat, committed URDF so end users need no xacro tooling at runtime. The
3412
+ # `vendor_urdf` import is deferred inside the command (it pulls robot_descriptions/
3413
+ # xacrodoc/yourdfpy) so `openral --help` stays fast.
3414
+ robot_app = typer.Typer(
3415
+ name="robot",
3416
+ help="Robot description assets — vendor a flat URDF from an upstream xacro.",
3417
+ no_args_is_help=True,
3418
+ )
3419
+ app.add_typer(robot_app, name="robot")
3420
+
3421
+
3422
+ @robot_app.command("vendor-urdf")
3423
+ def robot_vendor_urdf(
3424
+ robot_id: str = typer.Argument(
3425
+ ...,
3426
+ help="OpenRAL robot id; names the output file (e.g. ur5e → ur5e.urdf).",
3427
+ ),
3428
+ upstream: str = typer.Option(
3429
+ ...,
3430
+ "--upstream",
3431
+ help=(
3432
+ "Upstream source: 'rd:<robot_descriptions module>' (xacro, expanded "
3433
+ "via xacrodoc) or 'file:<path>' to an already-flat URDF."
3434
+ ),
3435
+ ),
3436
+ out: Path = typer.Option(
3437
+ ...,
3438
+ "--out",
3439
+ help="Output directory; '<robot_id>.urdf' is written here.",
3440
+ ),
3441
+ rename: list[str] | None = typer.Option(
3442
+ None,
3443
+ "--rename",
3444
+ help=(
3445
+ "Joint-name rename as 'PATTERN=>REPL' (regex re.sub). Repeatable — "
3446
+ "applied in order (so100/so101 take 6, gr1/h1 take 1). Defaults to "
3447
+ "the per-robot rule (openarm strips its 'openarm_' prefix)."
3448
+ ),
3449
+ ),
3450
+ raw_text: bool = typer.Option(
3451
+ False,
3452
+ "--raw-text/--no-raw-text",
3453
+ help=(
3454
+ "Copy an already-flat upstream URDF verbatim and apply --rename to "
3455
+ "the raw XML (no yourdfpy round-trip), preserving package:// mesh "
3456
+ "paths byte-for-byte (so100/so101/gr1/h1)."
3457
+ ),
3458
+ ),
3459
+ ) -> None:
3460
+ """Expand an upstream description to a flat, committed URDF."""
3461
+ from openral_cli.robot import vendor_urdf
3462
+
3463
+ rename_pairs: list[tuple[str, str]] | None = None
3464
+ if rename:
3465
+ rename_pairs = []
3466
+ for spec in rename:
3467
+ if "=>" not in spec:
3468
+ raise typer.BadParameter("--rename must be 'PATTERN=>REPL'", param_hint="--rename")
3469
+ pat, _, repl = spec.partition("=>")
3470
+ rename_pairs.append((pat, repl))
3471
+ written = vendor_urdf(
3472
+ robot_id, upstream=upstream, out_dir=out, rename=rename_pairs, raw_text=raw_text
3473
+ )
3474
+ typer.echo(f"Wrote {written}")
3475
+
3476
+
3477
+ # `openral prompt "do X"` publishes a one-shot PromptStamped
3478
+ # onto /openral/prompt_in/cli; the prompt_router_node fans it out to
3479
+ # /openral/prompt for the reasoner. rclpy import is deferred inside
3480
+ # the command body so `openral --help` stays sub-second.
3481
+ app.command(
3482
+ name="prompt",
3483
+ help=(
3484
+ "Publish a one-shot operator prompt to the prompt-router. Requires a sourced ROS 2 install."
3485
+ ),
3486
+ )(prompt_command)
3487
+
3488
+
3489
+ # ── openral dashboard — live debugging UI over the OTel stream (issue #44) ──────
3490
+
3491
+
3492
+ @app.command(
3493
+ "dashboard",
3494
+ help=(
3495
+ "Serve a live debugging dashboard (read-only) at the given port. "
3496
+ "The same port also acts as an OTLP/HTTP receiver, so any "
3497
+ "`openral sim run` / `openral deploy run` pointed at "
3498
+ "OTEL_EXPORTER_OTLP_ENDPOINT=http://<host>:<port> + "
3499
+ "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf streams in live. "
3500
+ "Works without Jaeger/Tempo running."
3501
+ ),
3502
+ )
3503
+ def dashboard(
3504
+ host: str = typer.Option(
3505
+ "127.0.0.1",
3506
+ "--host",
3507
+ help="Bind address. Loopback by default; no auth.",
3508
+ ),
3509
+ port: int = typer.Option(
3510
+ 4318,
3511
+ "--port",
3512
+ help=(
3513
+ "HTTP port; serves UI, /api/state, /api/stream, and OTLP/HTTP "
3514
+ "receiver. Defaults to 4318 (the OTLP/HTTP standard port) "
3515
+ "rather than 8000 (issue #132) — `mkdocs serve` and most "
3516
+ "FastAPI demos already squat on 8000."
3517
+ ),
3518
+ ),
3519
+ log_level: str = typer.Option(
3520
+ "warning",
3521
+ "--log-level",
3522
+ help="uvicorn log level (debug | info | warning | error).",
3523
+ ),
3524
+ inprocess: str | None = typer.Option(
3525
+ None,
3526
+ "--inprocess",
3527
+ help=(
3528
+ "Optional shell-quoted command to spawn as a child process with "
3529
+ "OTEL_EXPORTER_OTLP_ENDPOINT pointed at this dashboard. Pass the "
3530
+ "whole command as one string (shlex-tokenised), e.g. "
3531
+ "`--inprocess 'openral sim run --config scenes/benchmark/pusht.yaml"
3532
+ " --rskill diffusion-pusht'`."
3533
+ ),
3534
+ ),
3535
+ ) -> None:
3536
+ """Serve the OpenRAL live dashboard."""
3537
+ import shlex
3538
+
3539
+ from openral_observability.dashboard import run_dashboard
3540
+
3541
+ inprocess_cmd = shlex.split(inprocess) if inprocess else None
3542
+ run_dashboard(
3543
+ host=host,
3544
+ port=port,
3545
+ inprocess_cmd=inprocess_cmd,
3546
+ log_level=log_level,
3547
+ )
3548
+
3549
+
3550
+ # ── openral deploy {run, list} ───────────────────────────────────────────────────
3551
+
3552
+ deploy_app = typer.Typer(
3553
+ name="deploy",
3554
+ help=(
3555
+ "Hardware deploy — run the ROS graph against a real robot from a "
3556
+ "DeployScene YAML (`openral deploy run`) or list deploy scenes."
3557
+ ),
3558
+ no_args_is_help=True,
3559
+ )
3560
+ app.add_typer(deploy_app, name="deploy")
3561
+
3562
+ deploy_app.command(
3563
+ "sim",
3564
+ help=(
3565
+ "Boot the full ROS graph (dashboard + safety_kernel + reasoner + "
3566
+ "prompt_router + runtime + HAL) against a digital-twin HAL, driven "
3567
+ "by a DeployScene YAML + rSkill. Sibling of ``deploy run``."
3568
+ ),
3569
+ )(deploy_sim_command)
3570
+
3571
+
3572
+ @deploy_app.command("list")
3573
+ def deploy_list() -> None:
3574
+ """List every deploy scene under `scenes/deploy/*.yaml`.
3575
+
3576
+ Each entry is a paste-able `--config` path for `openral deploy run` or `deploy sim`.
3577
+ No hardware touch, no GPU.
3578
+ """
3579
+ from openral_rskill.loader import _find_repo_root_from
3580
+
3581
+ repo_root = _find_repo_root_from(Path(__file__))
3582
+ if repo_root is None:
3583
+ console.print("[red]Could not locate repo root.[/red]")
3584
+ raise typer.Exit(code=1)
3585
+ deploy_scenes = repo_root / "scenes" / "deploy"
3586
+ if not deploy_scenes.is_dir():
3587
+ print("<none>")
3588
+ return
3589
+ configs = sorted(deploy_scenes.rglob("*.yaml"))
3590
+ if not configs:
3591
+ print("<none>")
3592
+ return
3593
+ for cfg in configs:
3594
+ print(cfg.relative_to(repo_root))
3595
+
3596
+
3597
+ @deploy_app.command("run")
3598
+ def deploy_run(
3599
+ config: Path = typer.Option( # reason: typer Option idiom
3600
+ ...,
3601
+ "--config",
3602
+ "-c",
3603
+ exists=True,
3604
+ readable=True,
3605
+ dir_okay=False,
3606
+ help="Path to a DeployScene YAML; its robot_id selects the real robot workcell.",
3607
+ ),
3608
+ robot: str | None = typer.Option(
3609
+ None,
3610
+ "--robot",
3611
+ help="Override the robot_id resolved from --config.",
3612
+ ),
3613
+ hal: list[str] | None = typer.Option(
3614
+ None,
3615
+ "--hal",
3616
+ help="Override HAL node params, key=value (repeatable), e.g. --hal port=/dev/ttyUSB1.",
3617
+ ),
3618
+ dashboard: bool = typer.Option(
3619
+ True,
3620
+ "--dashboard/--no-dashboard",
3621
+ help="Spawn the live dashboard (default on).",
3622
+ ),
3623
+ dashboard_port: int = typer.Option(
3624
+ 4318,
3625
+ "--dashboard-port",
3626
+ help="Dashboard OTLP port.",
3627
+ ),
3628
+ enable_reward_monitor: bool | None = typer.Option(
3629
+ None,
3630
+ "--enable-reward-monitor/--no-enable-reward-monitor",
3631
+ help=(
3632
+ "Bring up the Robometer reward monitor parallel to the "
3633
+ "VLA (same leg `deploy sim` exposes): it scores the robot's first RGB "
3634
+ "camera topic and serves /openral/perception/query_task_progress. The "
3635
+ "manifest is auto-paired from the VLA palette's reward_rskill_name; "
3636
+ "override with --reward-monitor-manifest. Unset = the "
3637
+ "scene's runtime.enable_reward_monitor, else off."
3638
+ ),
3639
+ ),
3640
+ reward_monitor_manifest: str | None = typer.Option(
3641
+ None,
3642
+ "--reward-monitor-manifest",
3643
+ help=(
3644
+ "Path to a kind:reward rSkill manifest. Empty auto-pairs "
3645
+ "from the VLA palette, falling back to rskills/robometer-4b. Ignored "
3646
+ "unless --enable-reward-monitor."
3647
+ ),
3648
+ ),
3649
+ dry_run: bool = typer.Option(
3650
+ False,
3651
+ "--dry-run",
3652
+ help="Print the resolved real-mode launch argv and exit without shelling out.",
3653
+ ),
3654
+ ) -> None:
3655
+ """Run an rSkill on REAL hardware via the production ROS graph.
3656
+
3657
+ Unlike `openral deploy sim`, this drives the **real** hardware HAL: it
3658
+ resolves the robot from `--config` (a DeployScene) and shells the SAME
3659
+ `sim_e2e.launch.py` graph with `hal_mode:=real` — the HAL lifecycle node +
3660
+ C++ safety kernel + reasoner + world state (+ SLAM/Nav2 when the robot
3661
+ declares a lidar). The HAL's `connect()` fails loudly if no hardware is
3662
+ attached; a simulation-only robot raises ROSCapabilityMismatch (use
3663
+ `openral deploy sim`). Robot HAL defaults come from robot.yaml; `--hal` wins.
3664
+ """
3665
+ from openral_core import DeployScene # reason: defer schema import
3666
+ from openral_core.exceptions import ROSCapabilityMismatch # reason: defer
3667
+ from pydantic import ValidationError # reason: defer CLI import
3668
+
3669
+ from openral_cli.deploy_sim import ( # reason: defer heavy CLI import
3670
+ _parse_hal_overrides,
3671
+ resolve_launch_invocation,
3672
+ run_launch_invocation,
3673
+ )
3674
+
3675
+ try:
3676
+ deploy_scene = DeployScene.from_yaml(str(config))
3677
+ except (FileNotFoundError, ROSConfigError, ValidationError) as exc:
3678
+ console.print(f"[red]config error:[/red] {exc}")
3679
+ raise typer.Exit(code=1) from exc
3680
+
3681
+ overrides = _parse_hal_overrides(hal)
3682
+
3683
+ # A committed, config-relative `calibration_dir` (deploy owns its calibration
3684
+ # instead of the ambient HF cache) is resolved against THIS config's
3685
+ # directory so it works regardless of the CWD `deploy run` is invoked from.
3686
+ cal_dir = overrides.get("calibration_dir")
3687
+ if isinstance(cal_dir, str) and cal_dir and not Path(cal_dir).is_absolute():
3688
+ overrides["calibration_dir"] = str((config.parent / cal_dir).resolve())
3689
+
3690
+ try:
3691
+ invocation = resolve_launch_invocation(
3692
+ config=config,
3693
+ robot_override=robot or deploy_scene.robot_id,
3694
+ dashboard_port=dashboard_port,
3695
+ reset_to_pose_service=None,
3696
+ deploy_config=config,
3697
+ hal_param_overrides=overrides,
3698
+ hal_mode="real",
3699
+ enable_dashboard=dashboard,
3700
+ enable_reward_monitor=enable_reward_monitor,
3701
+ reward_monitor_manifest=reward_monitor_manifest,
3702
+ )
3703
+ except (ROSConfigError, ROSCapabilityMismatch) as exc:
3704
+ console.print(f"[red]deploy run:[/red] {exc}")
3705
+ raise typer.Exit(code=1) from exc
3706
+
3707
+ console.print(
3708
+ f"[cyan]deploy run[/cyan] {invocation.robot_id} → real HAL "
3709
+ f"(hal_mode=real); the HAL's connect() requires the robot to be attached."
3710
+ )
3711
+ if dry_run:
3712
+ printed = [
3713
+ arg.replace("HAL_PARAMS_FILE_PLACEHOLDER", "<hal-params-tmp>")
3714
+ for arg in invocation.argv_template
3715
+ ]
3716
+ console.print(f" hal_params: {invocation.hal_params}")
3717
+ console.print(f" argv: {shlex.join(printed)}")
3718
+ return
3719
+
3720
+ returncode = run_launch_invocation(invocation)
3721
+ raise typer.Exit(code=returncode)
3722
+
3723
+
3724
+ @deploy_app.command("validate")
3725
+ def deploy_validate(
3726
+ config: Path = typer.Option( # reason: typer Option idiom
3727
+ ...,
3728
+ "--config",
3729
+ "-c",
3730
+ exists=True,
3731
+ readable=True,
3732
+ dir_okay=False,
3733
+ help="DeployScene YAML to check for real-run readiness.",
3734
+ ),
3735
+ robot: str | None = typer.Option(
3736
+ None,
3737
+ "--robot",
3738
+ help="Override the robot_id resolved from --config.",
3739
+ ),
3740
+ hal: list[str] | None = typer.Option(
3741
+ None,
3742
+ "--hal",
3743
+ help="HAL overrides applied before validation (same precedence as deploy run).",
3744
+ ),
3745
+ ) -> None:
3746
+ """Pre-run readiness check for `openral deploy run` — no hardware, no ROS launch.
3747
+
3748
+ Validates the DeployScene + robot manifest resolve, then checks the
3749
+ runtime-required inputs a real run needs are present *before* the launch —
3750
+ the exact gaps that otherwise fail late at HAL configure / sensor leg:
3751
+
3752
+ * **HAL transport** — a serial `port` is declared, and its device exists now.
3753
+ * **Calibration** — a serial HAL with `calibrate_on_connect=false` has an
3754
+ `id` + `calibration_dir`, and the `<calibration_dir>/<id>.json` file exists
3755
+ (missing → "has no calibration registered" at every send_action).
3756
+ * **Camera bindings** — each scene sensor has a `deploy_binding` (else it is
3757
+ never published and a camera VLA gets an empty observation), and any
3758
+ `/dev/*` device path exists now.
3759
+
3760
+ Reports ERROR (missing committed data — exits non-zero) vs WARN (device just
3761
+ not attached right now). HAL param precedence matches `deploy run`
3762
+ (`--hal` > scene `hal` > `robot.yaml`).
3763
+ """
3764
+ from openral_core import DeployScene # reason: defer schema import
3765
+ from openral_core.exceptions import ROSCapabilityMismatch # reason: defer
3766
+ from pydantic import ValidationError # reason: defer CLI import
3767
+
3768
+ from openral_cli.deploy_sim import ( # reason: defer heavy CLI import
3769
+ _parse_hal_overrides,
3770
+ resolve_launch_invocation,
3771
+ )
3772
+
3773
+ try:
3774
+ deploy_scene = DeployScene.from_yaml(str(config))
3775
+ except (FileNotFoundError, ROSConfigError, ValidationError) as exc:
3776
+ console.print(f"[red]✗ config:[/red] {exc}")
3777
+ raise typer.Exit(code=1) from exc
3778
+
3779
+ overrides = _parse_hal_overrides(hal)
3780
+ cal_dir_override = overrides.get("calibration_dir")
3781
+ if (
3782
+ isinstance(cal_dir_override, str)
3783
+ and cal_dir_override
3784
+ and not Path(cal_dir_override).is_absolute()
3785
+ ):
3786
+ overrides["calibration_dir"] = str((config.parent / cal_dir_override).resolve())
3787
+
3788
+ # Reuse the deploy-run resolver: raises on sim-only robot, name mismatch,
3789
+ # unknown HAL, missing manifest — and produces the merged hal_params
3790
+ # (registry → scene hal → --hal) we then inspect for readiness.
3791
+ try:
3792
+ invocation = resolve_launch_invocation(
3793
+ config=config,
3794
+ robot_override=robot or deploy_scene.robot_id,
3795
+ dashboard_port=4318,
3796
+ reset_to_pose_service=None,
3797
+ deploy_config=config,
3798
+ hal_param_overrides=overrides,
3799
+ hal_mode="real",
3800
+ enable_dashboard=False,
3801
+ )
3802
+ except (ROSConfigError, ROSCapabilityMismatch) as exc:
3803
+ console.print(f"[red]✗ resolve:[/red] {exc}")
3804
+ raise typer.Exit(code=1) from exc
3805
+
3806
+ errors: list[str] = []
3807
+ warns: list[str] = []
3808
+ hp = invocation.hal_params
3809
+
3810
+ port = hp.get("port")
3811
+ if isinstance(port, str) and port and not Path(port).exists():
3812
+ warns.append(f"serial port {port!r} does not exist now (arm not attached?)")
3813
+
3814
+ if isinstance(port, str) and port and not bool(hp.get("calibrate_on_connect", False)):
3815
+ cal_id = hp.get("id")
3816
+ cal_dir = hp.get("calibration_dir")
3817
+ if not cal_id or not cal_dir:
3818
+ errors.append(
3819
+ "serial HAL with calibrate_on_connect=false but no id/calibration_dir "
3820
+ "→ every send_action/read_state raises 'has no calibration registered'. "
3821
+ "Add a scene `hal:` binding with id + calibration_dir."
3822
+ )
3823
+ else:
3824
+ cal_file = Path(str(cal_dir)) / f"{cal_id}.json"
3825
+ if not cal_file.exists():
3826
+ errors.append(f"calibration file {cal_file} does not exist (id={cal_id!r}).")
3827
+
3828
+ if not deploy_scene.sensors:
3829
+ warns.append("scene declares no sensors → a camera VLA will get an empty observation")
3830
+ for sensor in deploy_scene.sensors:
3831
+ binding = sensor.deploy_binding
3832
+ if binding is None:
3833
+ warns.append(
3834
+ f"sensor {sensor.name!r} has no deploy_binding → not published, VLA won't see it"
3835
+ )
3836
+ continue
3837
+ dev = binding.backend_params.get("device")
3838
+ if isinstance(dev, str) and dev.startswith("/dev/") and not Path(dev).exists():
3839
+ warns.append(f"sensor {sensor.name!r} device {dev!r} does not exist now")
3840
+
3841
+ console.print(f"[cyan]deploy validate[/cyan] {invocation.robot_id} ← {config}")
3842
+ for warn in warns:
3843
+ console.print(f" [yellow]⚠ {warn}[/yellow]")
3844
+ for err in errors:
3845
+ console.print(f" [red]✗ {err}[/red]")
3846
+ if errors:
3847
+ console.print(
3848
+ f"[red]{len(errors)} error(s), {len(warns)} warning(s) — "
3849
+ "not ready for `deploy run`.[/red]"
3850
+ )
3851
+ raise typer.Exit(code=1)
3852
+ console.print(f"[green]✓ ready for `deploy run` ({len(warns)} warning(s)).[/green]")
3853
+
3854
+
3855
+ # ── openral replay — bag↔OTel correlator ──────────────────────────
3856
+
3857
+
3858
+ def _resolve_frame_trace_id(frame_spec: str, dataset_root: Path) -> str:
3859
+ """Resolve a ``<repo_id>/<episode>/<frame>`` spec to its stored trace_id.
3860
+
3861
+ ``repo_id`` itself contains a slash (``org/name``); the episode and
3862
+ frame indices are the last two ``/``-separated fields, so we split
3863
+ from the right. Exits non-zero with a typed message on a malformed
3864
+ spec, a missing frame, or a frame that carries no trace.
3865
+ """
3866
+ from openral_dataset import read_frame_trace
3867
+
3868
+ try:
3869
+ _repo_id, ep_str, frame_str = frame_spec.rsplit("/", 2)
3870
+ episode_idx = int(ep_str)
3871
+ frame_idx = int(frame_str)
3872
+ except ValueError:
3873
+ console.print(
3874
+ f"[red]openral replay:[/red] malformed --frame {frame_spec!r}; "
3875
+ "expected '<repo_id>/<episode>/<frame>' (e.g. 'openral/dataset-pick/0/12')"
3876
+ )
3877
+ raise typer.Exit(code=2) from None
3878
+
3879
+ try:
3880
+ trace_id, _span_id = read_frame_trace(
3881
+ root=dataset_root, episode_idx=episode_idx, frame_idx=frame_idx
3882
+ )
3883
+ except ROSConfigError as exc:
3884
+ console.print(f"[red]openral replay:[/red] {exc}")
3885
+ raise typer.Exit(code=2) from exc
3886
+
3887
+ if not trace_id:
3888
+ console.print(
3889
+ f"[red]openral replay:[/red] frame {frame_spec} carries no trace_id "
3890
+ "(its producing tick had no active OTel span); nothing to pivot to"
3891
+ )
3892
+ raise typer.Exit(code=2)
3893
+ return trace_id
3894
+
3895
+
3896
+ @app.command(
3897
+ "replay",
3898
+ help=(
3899
+ "Join a rosbag2/.mcap file with OTel spans from the live dashboard. "
3900
+ "Prints a chronological JSON timeline keyed by trace_id; "
3901
+ "writes to `--out` when given. `--dashboard` may be omitted for a "
3902
+ "bag-only timeline."
3903
+ ),
3904
+ )
3905
+ def replay(
3906
+ bag: Path = typer.Argument( # reason: typer Argument idiom
3907
+ ...,
3908
+ exists=True,
3909
+ readable=True,
3910
+ help="Path to a rosbag2 directory or a bare .mcap file.",
3911
+ ),
3912
+ trace: str | None = typer.Option(
3913
+ None,
3914
+ "--trace",
3915
+ help="32-hex-char trace_id to filter on. Defaults to the busiest one in the bag.",
3916
+ ),
3917
+ frame: str | None = typer.Option(
3918
+ None,
3919
+ "--frame",
3920
+ help=(
3921
+ "Pivot from a written LeRobotDataset frame: '<repo_id>/<episode>/<frame>' "
3922
+ "(e.g. 'openral/dataset-pick/0/12'). Reads that frame's trace_id and uses "
3923
+ "it as the join key. Requires --dataset-root; mutually exclusive with --trace."
3924
+ ),
3925
+ ),
3926
+ dataset_root: Path | None = typer.Option(
3927
+ None,
3928
+ "--dataset-root",
3929
+ help="Root directory of the LeRobotDataset that --frame refers to.",
3930
+ ),
3931
+ dashboard: str | None = typer.Option(
3932
+ None,
3933
+ "--dashboard",
3934
+ help="Dashboard base URL (e.g. http://127.0.0.1:8000). Omit for bag-only.",
3935
+ ),
3936
+ out: Path | None = typer.Option(
3937
+ None,
3938
+ "--out",
3939
+ "-o",
3940
+ help="Write the timeline JSON to this file; print to stdout when omitted.",
3941
+ ),
3942
+ ) -> None:
3943
+ """Read a bag, join it with spans by trace_id, emit a JSON timeline."""
3944
+ from openral_observability.replay.cli import run_replay, write_timeline
3945
+
3946
+ # ISSUE-109 pivot — resolve --frame into a concrete trace_id off the
3947
+ # dataset before the join. Done here (not in run_replay) so the
3948
+ # openral_observability replay module stays free of the lerobot dep.
3949
+ if frame is not None:
3950
+ if trace is not None:
3951
+ console.print("[red]openral replay:[/red] --frame and --trace are mutually exclusive")
3952
+ raise typer.Exit(code=2)
3953
+ if dataset_root is None:
3954
+ console.print("[red]openral replay:[/red] --frame requires --dataset-root")
3955
+ raise typer.Exit(code=2)
3956
+ trace = _resolve_frame_trace_id(frame, dataset_root)
3957
+
3958
+ result = run_replay(bag_path=bag, trace_id=trace, dashboard_url=dashboard)
3959
+ if out is not None:
3960
+ write_timeline(result, out)
3961
+ console.print(
3962
+ f"[green]openral replay:[/green] wrote {len(result.timeline)} entries to {out}"
3963
+ )
3964
+ if result.trace_id:
3965
+ console.print(f"trace_id: {result.trace_id}")
3966
+ return
3967
+ print(_json.dumps(result.to_json(), indent=2, sort_keys=False))
3968
+
3969
+
3970
+ # ── openral record — wrap `ros2 bag record` with profile presets ──
3971
+
3972
+
3973
+ @app.command(
3974
+ "record",
3975
+ help=(
3976
+ "Spawn `ros2 bag record` for the OpenRAL ROS graph with a slim/full profile. "
3977
+ "Requires a sourced ROS 2 install. Use `--dry-run` to print the argv "
3978
+ "instead of executing."
3979
+ ),
3980
+ )
3981
+ def record(
3982
+ out: Path = typer.Option( # reason: typer Option idiom
3983
+ ...,
3984
+ "--out",
3985
+ "-o",
3986
+ help="Output directory passed to `ros2 bag record -o`.",
3987
+ ),
3988
+ profile: str = typer.Option(
3989
+ "slim",
3990
+ "--profile",
3991
+ help="Recording profile: 'slim' (default) or 'full'.",
3992
+ ),
3993
+ storage: str = typer.Option(
3994
+ "mcap",
3995
+ "--storage",
3996
+ help="rosbag2 storage backend; mcap is the openral default.",
3997
+ ),
3998
+ extra_topic: list[str] = typer.Option(
3999
+ [],
4000
+ "--extra-topic",
4001
+ help="Additional topic to record verbatim. Repeatable.",
4002
+ ),
4003
+ extra_regex: list[str] = typer.Option(
4004
+ [],
4005
+ "--extra-regex",
4006
+ help="Additional regex to OR into --regex. Repeatable.",
4007
+ ),
4008
+ dry_run: bool = typer.Option(
4009
+ False,
4010
+ "--dry-run",
4011
+ help="Print the composed argv instead of executing.",
4012
+ ),
4013
+ ) -> None:
4014
+ """Wrap `ros2 bag record` with slim/full topic presets."""
4015
+ from openral_observability.replay.cli import run_record
4016
+
4017
+ if profile not in {"slim", "full"}:
4018
+ console.print(f"[red]openral record:[/red] unknown profile {profile!r}; expected slim|full")
4019
+ raise typer.Exit(code=2)
4020
+ try:
4021
+ argv, completed = run_record(
4022
+ profile=profile, # type: ignore[arg-type] # reason: validated above against the literal set
4023
+ output_dir=out,
4024
+ storage=storage,
4025
+ extra_topics=extra_topic,
4026
+ extra_regex=extra_regex,
4027
+ dry_run=dry_run,
4028
+ )
4029
+ except FileNotFoundError as exc:
4030
+ console.print(f"[red]openral record:[/red] {exc}")
4031
+ raise typer.Exit(code=1) from exc
4032
+ if dry_run:
4033
+ print(" ".join(argv))
4034
+ return
4035
+ assert completed is not None
4036
+ if completed.returncode != 0:
4037
+ raise typer.Exit(code=completed.returncode)
4038
+
4039
+
4040
+ # ── openral profile session — LTTng opt-in profiling ──────────────
4041
+
4042
+ profile_app = typer.Typer(
4043
+ name="profile",
4044
+ help="Microsecond-accurate profiling via ros2_tracing / LTTng.",
4045
+ no_args_is_help=True,
4046
+ )
4047
+ app.add_typer(profile_app, name="profile")
4048
+
4049
+
4050
+ @profile_app.command(
4051
+ "session",
4052
+ help=(
4053
+ "Start, stop, or view an LTTng session for the realtime hot path. "
4054
+ "Requires lttng-tools on PATH. Set OPENRAL_ROS2_TRACING=1 on the "
4055
+ "agent process to emit tracepoints; the env var is the runtime gate."
4056
+ ),
4057
+ )
4058
+ def profile_session(
4059
+ action: str = typer.Argument( # reason: typer Argument idiom
4060
+ ...,
4061
+ help="One of: start | stop | view.",
4062
+ ),
4063
+ output: Path = typer.Option( # reason: typer Option idiom
4064
+ Path("./lttng-traces"),
4065
+ "--output",
4066
+ "-o",
4067
+ help="LTTng output directory. Used by start (write here) and view (read from here).",
4068
+ ),
4069
+ name: str = typer.Option(
4070
+ "openral",
4071
+ "--name",
4072
+ "-n",
4073
+ help="LTTng session name.",
4074
+ ),
4075
+ ) -> None:
4076
+ """Drive an LTTng session — start, stop, view."""
4077
+ from openral_observability.tracing_lttng import (
4078
+ LttngSessionError,
4079
+ start_session,
4080
+ stop_session,
4081
+ view_session,
4082
+ )
4083
+
4084
+ try:
4085
+ if action == "start":
4086
+ session = start_session(name=name, output_dir=output)
4087
+ console.print(
4088
+ f"[green]openral profile session start:[/green] "
4089
+ f"{session.name} → {session.output_dir}"
4090
+ )
4091
+ console.print(
4092
+ "Run your workload with OPENRAL_ROS2_TRACING=1, then "
4093
+ "`openral profile session stop` to flush."
4094
+ )
4095
+ elif action == "stop":
4096
+ stop_session(name=name)
4097
+ console.print(f"[green]openral profile session stop:[/green] {name}")
4098
+ elif action == "view":
4099
+ view_session(output_dir=output)
4100
+ else:
4101
+ console.print(
4102
+ f"[red]openral profile session:[/red] unknown action {action!r}; "
4103
+ "expected start | stop | view"
4104
+ )
4105
+ raise typer.Exit(code=2)
4106
+ except LttngSessionError as exc:
4107
+ console.print(f"[red]openral profile session:[/red] {exc}")
4108
+ raise typer.Exit(code=1) from exc