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.
@@ -0,0 +1,267 @@
1
+ """Scaffolder for new rSkill packages — backs ``ral skill new``.
2
+
3
+ Copies ``rskills/template/`` to a target directory and rewrites a small,
4
+ fixed set of placeholders (manifest ``name`` / ``license`` /
5
+ ``embodiment_tags``), then re-validates the result through
6
+ `RSkillManifest.from_yaml` and `rSkill.from_yaml` so any
7
+ malformed scaffold fails at scaffold-time rather than lazily on first
8
+ load.
9
+
10
+ Used by:
11
+
12
+ - ``ral skill new <id>`` (Typer command in :mod:`openral_cli.main`).
13
+ - ``tools/rskill_scaffolder.py`` (standalone argparse wrapper).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ from pathlib import Path
20
+
21
+ import structlog
22
+ import yaml
23
+ from openral_core.exceptions import ROSConfigError
24
+ from openral_core.schemas import (
25
+ EmbodimentTag,
26
+ RSkillLicensePosture,
27
+ RSkillManifest,
28
+ )
29
+ from openral_rskill.loader import rSkill
30
+ from pydantic import ValidationError
31
+
32
+ from openral_cli._rskill_intel import RSkillFamily, RSkillPatch, family_defaults
33
+
34
+ log = structlog.get_logger(__name__)
35
+
36
+ _TEMPLATE_DIR_NAME = "template"
37
+ _MANIFEST_FILENAME = "rskill.yaml"
38
+ _README_FILENAME = "README.md"
39
+
40
+
41
+ def _default_template_dir() -> Path:
42
+ """Resolve the in-tree ``rskills/template/`` directory.
43
+
44
+ Walks upward from this file's location until it finds a ``rskills/``
45
+ sibling, then returns ``rskills/template``. This makes the helper
46
+ work both when invoked from inside the installed CLI wheel and when
47
+ invoked via ``tools/rskill_scaffolder.py`` against an editable
48
+ workspace.
49
+ """
50
+ for parent in Path(__file__).resolve().parents:
51
+ candidate = parent / "rskills" / _TEMPLATE_DIR_NAME
52
+ if candidate.is_dir():
53
+ return candidate
54
+ raise ROSConfigError(
55
+ "Could not locate rskills/template/ relative to "
56
+ f"{Path(__file__).resolve()}; pass template_dir= explicitly."
57
+ )
58
+
59
+
60
+ def scaffold_rskill(
61
+ rskill_id: str,
62
+ *,
63
+ out_dir: Path,
64
+ owner: str,
65
+ license_: RSkillLicensePosture,
66
+ embodiment_tag: EmbodimentTag,
67
+ family: RSkillFamily | None = None,
68
+ patch: RSkillPatch | None = None,
69
+ template_dir: Path | None = None,
70
+ overwrite: bool = False,
71
+ ) -> Path:
72
+ """Scaffold a new local rSkill package from ``rskills/template/``.
73
+
74
+ Args:
75
+ rskill_id: Local skill id, convention ``<policy>-<task>`` (e.g.
76
+ ``pi05-pick-cube``). Becomes the ``<repo>`` segment of the
77
+ generated manifest ``name`` field.
78
+ out_dir: Destination directory. The scaffold lands here directly
79
+ (no extra ``rskill_id`` segment is appended).
80
+ owner: HF Hub owner segment for the manifest ``name`` field
81
+ (e.g. ``"your-org"``).
82
+ license_: License posture to write into the manifest.
83
+ embodiment_tag: Canonical embodiment tag to declare in
84
+ ``embodiment_tags``.
85
+ family: Optional :data:`RSkillFamily`. When set,
86
+ `openral_cli._rskill_intel.family_defaults` provides
87
+ the manifest baseline (``model_family``, ``chunk_size``,
88
+ ``quantization``, latency budget, …) so a fresh ACT scaffold
89
+ doesn't ship pi0.5 numbers. ``None`` leaves the template
90
+ (currently pi0.5-shaped) as the baseline.
91
+ patch: Optional manifest patch to overlay on top of the family
92
+ defaults — typically built by
93
+ `openral_cli._rskill_intel.introspect_hf` from a
94
+ checkpoint's ``config.json``. Wins over both ``family``
95
+ defaults and template values, but loses to the rename /
96
+ license rewrite.
97
+ template_dir: Override the in-tree template location. Defaults
98
+ to ``rskills/template/`` relative to this file.
99
+ overwrite: If ``True``, an existing ``out_dir`` is removed
100
+ before scaffolding. If ``False`` (default), a pre-existing
101
+ ``out_dir`` raises `ROSConfigError`.
102
+
103
+ Returns:
104
+ The resolved ``out_dir`` path.
105
+
106
+ Raises:
107
+ ROSConfigError: ``out_dir`` already exists and ``overwrite`` is
108
+ ``False``; or the template directory cannot be located; or
109
+ the generated manifest fails Pydantic / loader validation
110
+ (a partial scaffold is removed before re-raising).
111
+
112
+ Example:
113
+ >>> # from openral_cli._rskill_scaffolder import scaffold_rskill
114
+ >>> # scaffold_rskill(
115
+ >>> # "pi05-pick-cube",
116
+ >>> # out_dir=Path("rskills/pi05-pick-cube"),
117
+ >>> # owner="your-org",
118
+ >>> # license_=RSkillLicensePosture.APACHE_2_0,
119
+ >>> # embodiment_tag="franka_panda",
120
+ >>> # )
121
+ """
122
+ resolved_out = out_dir.resolve()
123
+ src = (template_dir or _default_template_dir()).resolve()
124
+
125
+ if not (src / _MANIFEST_FILENAME).is_file():
126
+ raise ROSConfigError(f"template directory {src} is missing {_MANIFEST_FILENAME}")
127
+
128
+ if resolved_out.exists():
129
+ if not overwrite:
130
+ raise ROSConfigError(
131
+ f"refusing to overwrite existing path {resolved_out}; "
132
+ "pass overwrite=True (or --overwrite on the CLI) to replace it."
133
+ )
134
+ shutil.rmtree(resolved_out)
135
+
136
+ shutil.copytree(src, resolved_out)
137
+
138
+ try:
139
+ _rewrite_manifest(
140
+ resolved_out / _MANIFEST_FILENAME,
141
+ rskill_id=rskill_id,
142
+ owner=owner,
143
+ license_=license_,
144
+ embodiment_tag=embodiment_tag,
145
+ family=family,
146
+ patch=patch,
147
+ )
148
+ _rewrite_readme(
149
+ resolved_out / _README_FILENAME,
150
+ rskill_id=rskill_id,
151
+ owner=owner,
152
+ )
153
+ _validate_scaffold(resolved_out)
154
+ except (ROSConfigError, ValidationError):
155
+ # Clean up the partial scaffold so a re-run doesn't trip the
156
+ # "out_dir already exists" guard above.
157
+ shutil.rmtree(resolved_out, ignore_errors=True)
158
+ raise
159
+
160
+ log.info(
161
+ "rskill_scaffolder.created",
162
+ rskill_id=rskill_id,
163
+ out_dir=str(resolved_out),
164
+ owner=owner,
165
+ license=license_.value,
166
+ embodiment_tag=embodiment_tag,
167
+ family=family,
168
+ from_hf=(patch or {}).get("weights_uri"),
169
+ )
170
+ return resolved_out
171
+
172
+
173
+ def _rewrite_manifest(
174
+ manifest_path: Path,
175
+ *,
176
+ rskill_id: str,
177
+ owner: str,
178
+ license_: RSkillLicensePosture,
179
+ embodiment_tag: EmbodimentTag,
180
+ family: RSkillFamily | None,
181
+ patch: RSkillPatch | None,
182
+ ) -> None:
183
+ """Rewrite the scaffolded fields in ``rskill.yaml`` in place.
184
+
185
+ Application order (lowest → highest precedence):
186
+
187
+ 1. The on-disk template values (currently a pi0.5-shaped baseline).
188
+ 2. Family-aware defaults from
189
+ `openral_cli._rskill_intel.family_defaults` when
190
+ ``family`` is set — overrides ``model_family`` /
191
+ ``chunk_size`` / ``quantization`` / latency budget / etc.
192
+ 3. The explicit ``patch`` (typically from ``--from-hf``
193
+ introspection) — overrides family defaults with checkpoint-side
194
+ facts (real chunk_size, real image-feature names, real state dim).
195
+ 4. CLI-driven rename / license rewrite — always authoritative.
196
+
197
+ Round-trips through ``yaml.safe_load`` → mutate → ``yaml.safe_dump``
198
+ so inline template comments are stripped (documented in the
199
+ template header).
200
+ """
201
+ raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
202
+ if not isinstance(raw, dict):
203
+ raise ROSConfigError(f"template manifest {manifest_path} did not parse as a mapping")
204
+
205
+ # Layer (2): family defaults.
206
+ if family is not None:
207
+ _apply_patch(raw, dict(family_defaults(family)))
208
+
209
+ # Layer (3): introspection patch.
210
+ if patch:
211
+ _apply_patch(raw, dict(patch))
212
+
213
+ # Layer (4): the always-authoritative CLI rewrite. Re-apply *after*
214
+ # the patch so a stale ``weights_uri`` left in the patch (e.g.
215
+ # placeholder template URL) never wins.
216
+ raw["name"] = f"{owner}/rskill-{rskill_id}"
217
+ raw["license"] = license_.value
218
+ raw["embodiment_tags"] = [embodiment_tag]
219
+ if not raw.get("weights_uri", "").startswith("hf://") or "TEMPLATE" in str(raw["weights_uri"]):
220
+ raw["weights_uri"] = f"hf://{owner}/{rskill_id}"
221
+ if "source_repo" in raw and (
222
+ not str(raw["source_repo"]).startswith("hf://") or "TEMPLATE" in str(raw["source_repo"])
223
+ ):
224
+ raw["source_repo"] = f"hf://{owner}/{rskill_id}"
225
+
226
+ manifest_path.write_text(
227
+ yaml.safe_dump(raw, sort_keys=False, default_flow_style=False),
228
+ encoding="utf-8",
229
+ )
230
+
231
+
232
+ def _apply_patch(raw: dict[str, object], patch: dict[str, object]) -> None:
233
+ """Overlay ``patch`` keys onto ``raw``, dropping keys explicitly set to ``None``.
234
+
235
+ Setting a manifest key to ``None`` in a patch means "remove this
236
+ optional block from the template" (e.g. the ACT family clears
237
+ ``min_vram_gb`` / ``n_action_steps`` since neither applies). Any
238
+ other value (including empty dicts / lists) overwrites in place.
239
+ """
240
+ for key, value in patch.items():
241
+ if value is None:
242
+ raw.pop(key, None)
243
+ else:
244
+ raw[key] = value
245
+
246
+
247
+ def _rewrite_readme(readme_path: Path, *, rskill_id: str, owner: str) -> None:
248
+ """Replace ``TEMPLATE_ORG`` / ``TEMPLATE_ID`` sentinels in the README."""
249
+ if not readme_path.is_file():
250
+ return
251
+ text = readme_path.read_text(encoding="utf-8")
252
+ text = text.replace("TEMPLATE_ORG/rskill-TEMPLATE_ID", f"{owner}/rskill-{rskill_id}")
253
+ text = text.replace("TEMPLATE_ORG", owner)
254
+ text = text.replace("TEMPLATE_ID", rskill_id)
255
+ readme_path.write_text(text, encoding="utf-8")
256
+
257
+
258
+ def _validate_scaffold(scaffold_dir: Path) -> None:
259
+ """Re-validate the generated manifest through the full loader path."""
260
+ manifest_path = scaffold_dir / _MANIFEST_FILENAME
261
+ # Pydantic-level validation first — gives the cleanest error message
262
+ # when the rewrite produced an invalid YAML shape.
263
+ RSkillManifest.from_yaml(str(manifest_path))
264
+ # Full rSkill loader path — runs the license guard + walks eval/*.json.
265
+ # An empty eval/ directory is OK (loader's _validate_eval_jsons
266
+ # early-returns on an empty glob).
267
+ rSkill.from_yaml(manifest_path)