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/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
openral_cli/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Shared HF Hub publishing helpers — token resolution, scope check, ignore patterns.
|
|
2
|
+
|
|
3
|
+
Lifted from :mod:`tools.rskill_publisher` so that both ``openral dataset push``
|
|
4
|
+
and the existing skill publisher share one canonical path
|
|
5
|
+
for token discovery, scope verification, and ignore-pattern filtering. Per
|
|
6
|
+
CLAUDE.md §1.13, this de-duplication preempts the next "add another HF
|
|
7
|
+
uploader" PR from copying the rSkill version verbatim.
|
|
8
|
+
|
|
9
|
+
The helpers are conservative by design:
|
|
10
|
+
|
|
11
|
+
* :func:`resolve_token` accepts an explicit token argument, then falls back
|
|
12
|
+
to ``HF_TOKEN`` / ``HUGGINGFACE_HUB_TOKEN`` environment variables. It
|
|
13
|
+
raises :class:`openral_core.exceptions.ROSConfigError` (with an actionable
|
|
14
|
+
hint) when nothing is found, rather than handing the API a ``None`` and
|
|
15
|
+
watching it produce a generic 401.
|
|
16
|
+
* :func:`ensure_private` re-fetches repo metadata after creation and aborts
|
|
17
|
+
if the API reports the repo as public. This catches the edge case where
|
|
18
|
+
``create_repo(private=True)`` silently fails to apply on a pre-existing
|
|
19
|
+
public repo.
|
|
20
|
+
* :data:`IGNORE_PATTERNS` excludes the same secrets / build-artifact glob
|
|
21
|
+
as ``rskill_publisher`` so dataset uploads can't accidentally publish
|
|
22
|
+
``.env`` files or ``__pycache__/`` directories.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
29
|
+
|
|
30
|
+
from openral_core.exceptions import ROSConfigError
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from huggingface_hub import HfApi
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"IGNORE_PATTERNS",
|
|
37
|
+
"ensure_private",
|
|
38
|
+
"resolve_token",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Files / directories excluded from every HF Hub upload. Same set the
|
|
42
|
+
# rSkill publisher uses — keeps secrets out of public-by-accident repos.
|
|
43
|
+
IGNORE_PATTERNS: Final[list[str]] = [
|
|
44
|
+
"*.pyc",
|
|
45
|
+
"__pycache__",
|
|
46
|
+
".env",
|
|
47
|
+
".env.*",
|
|
48
|
+
"*.key",
|
|
49
|
+
"*.pem",
|
|
50
|
+
".DS_Store",
|
|
51
|
+
"Thumbs.db",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_token(token_arg: str | None = None) -> str:
|
|
56
|
+
"""Return the HF token from the argument or the environment.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
token_arg: Value passed via a ``--token`` CLI flag (may be None).
|
|
60
|
+
When provided, takes precedence over the environment.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
The resolved token string.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ROSConfigError: When no token is available (with a hint listing
|
|
67
|
+
the env vars and the CLI flag).
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
>>> import os
|
|
71
|
+
>>> os.environ["HF_TOKEN"] = "hf_test_token"
|
|
72
|
+
>>> resolve_token()
|
|
73
|
+
'hf_test_token'
|
|
74
|
+
>>> resolve_token("hf_override")
|
|
75
|
+
'hf_override'
|
|
76
|
+
>>> del os.environ["HF_TOKEN"]
|
|
77
|
+
"""
|
|
78
|
+
token = token_arg or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
|
79
|
+
if not token:
|
|
80
|
+
raise ROSConfigError(
|
|
81
|
+
"no HF token found; set HF_TOKEN (or HUGGINGFACE_HUB_TOKEN) env var, "
|
|
82
|
+
"or pass --token <hf_xxx>. Token must have 'repo.write' scope. "
|
|
83
|
+
"Generate at https://huggingface.co/settings/tokens."
|
|
84
|
+
)
|
|
85
|
+
return token
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ensure_private(api: HfApi, repo_id: str, *, repo_type: str = "model") -> None:
|
|
89
|
+
"""Re-fetch repo metadata and raise if it is not private.
|
|
90
|
+
|
|
91
|
+
Critical safety gate: even when ``create_repo`` was called with
|
|
92
|
+
``private=True``, an existing public repo at the same id will NOT
|
|
93
|
+
be auto-flipped to private. This function verifies the live state
|
|
94
|
+
on the Hub.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
api: Authenticated :class:`huggingface_hub.HfApi` client.
|
|
98
|
+
repo_id: The repository to verify (e.g. ``"openral/dataset-foo"``).
|
|
99
|
+
repo_type: ``"model"`` (default — used by rSkills), ``"dataset"``
|
|
100
|
+
(used by dataset uploads), or ``"space"``.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ROSConfigError: When the repo is not private or metadata cannot
|
|
104
|
+
be fetched. The caller must abort the upload and let the user
|
|
105
|
+
either delete the existing public repo or rename.
|
|
106
|
+
"""
|
|
107
|
+
# HF Hub returns different concrete info types per repo_type, but all
|
|
108
|
+
# carry the .private attribute. Use Any to side-step the union plumbing.
|
|
109
|
+
info: Any
|
|
110
|
+
try:
|
|
111
|
+
if repo_type == "dataset":
|
|
112
|
+
info = api.dataset_info(repo_id)
|
|
113
|
+
elif repo_type == "space":
|
|
114
|
+
info = api.space_info(repo_id)
|
|
115
|
+
else:
|
|
116
|
+
info = api.model_info(repo_id)
|
|
117
|
+
except Exception as exc: # HF SDK raises many specific types
|
|
118
|
+
raise ROSConfigError(f"privacy check failed for {repo_type} '{repo_id}': {exc!s}") from exc
|
|
119
|
+
|
|
120
|
+
if not info.private:
|
|
121
|
+
raise ROSConfigError(
|
|
122
|
+
f"ABORT: {repo_type} '{repo_id}' is public on the HF Hub. "
|
|
123
|
+
"OpenRAL never publishes a public repo automatically. "
|
|
124
|
+
"Delete the existing public repo manually before retrying."
|
|
125
|
+
)
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""rSkill README + manifest documentation validator.
|
|
2
|
+
|
|
3
|
+
Enforces that an rSkill directory ships with a *useful* README and a
|
|
4
|
+
filled-in manifest before it can be published to the HF Hub. The
|
|
5
|
+
schema-level Pydantic validators on
|
|
6
|
+
:class:`openral_core.schemas.RSkillManifest` answer "is this YAML
|
|
7
|
+
well-formed"; this validator answers "is this rSkill ready for someone
|
|
8
|
+
who isn't the author to install and run".
|
|
9
|
+
|
|
10
|
+
CLAUDE.md §6.4 lists ``README.md with a runnable example`` as required
|
|
11
|
+
packaging for every rSkill, alongside ``rskill.yaml``, weights, and
|
|
12
|
+
``eval/``. Until this module existed the README requirement was only a
|
|
13
|
+
prose obligation — ``tools/rskill_publisher.py`` happily uploaded a
|
|
14
|
+
manifest with the template's ``"Scaffold template generated by …"``
|
|
15
|
+
description and no README at all. The publisher now imports
|
|
16
|
+
:func:`validate_rskill_docs` as a hard gate (the dry-run path emits the
|
|
17
|
+
same report so authors see the issues before they ever pass
|
|
18
|
+
``--publish``).
|
|
19
|
+
|
|
20
|
+
What the validator enforces
|
|
21
|
+
---------------------------
|
|
22
|
+
|
|
23
|
+
README presence and shape:
|
|
24
|
+
|
|
25
|
+
* ``README.md`` exists at the top of the rSkill directory.
|
|
26
|
+
* Body is non-trivial (``>= _MIN_README_BODY_LENGTH`` bytes).
|
|
27
|
+
* Headings cover the canonical sections that answer the three questions
|
|
28
|
+
every consumer asks: **what** the skill does, **how** it works, and
|
|
29
|
+
**how it was trained**. See :data:`README_REQUIRED_SECTIONS`.
|
|
30
|
+
* No unresolved template sentinels (``TEMPLATE_ORG`` / ``TEMPLATE_ID``)
|
|
31
|
+
or generic placeholder strings (``TODO:``, ``FIXME``, ``<fill-in>``).
|
|
32
|
+
* A preview image or video is present (a ```` markdown image, an
|
|
33
|
+
``<img>``, or a ``<video>``) — emitted as a **warning**, not an error,
|
|
34
|
+
so the pre-existing in-tree skills that lack a preview keep passing
|
|
35
|
+
while new skills get prompted via the template's ``## Preview`` section.
|
|
36
|
+
|
|
37
|
+
Manifest content (beyond what the Pydantic schema already catches):
|
|
38
|
+
|
|
39
|
+
* ``description`` is not the template scaffold placeholder and is at
|
|
40
|
+
least :data:`_MIN_DESCRIPTION_LENGTH` characters.
|
|
41
|
+
* At least one of ``paper_url`` / ``source_repo`` is set — both are
|
|
42
|
+
schema-optional but publish requires one or the other so consumers
|
|
43
|
+
always have a citation or upstream-model link to follow.
|
|
44
|
+
* ``weights_uri`` / ``source_repo`` do not contain ``TEMPLATE`` (the
|
|
45
|
+
scaffolder leaves those URIs alone in some paths and a published skill
|
|
46
|
+
pointing at a sentinel URL is broken on the consumer end).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import re
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Final, Literal
|
|
54
|
+
|
|
55
|
+
from openral_core.schemas import RSKILL_TEMPLATE_SENTINELS, RSkillManifest
|
|
56
|
+
from pydantic import BaseModel, Field
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DELEGATION_MARKER_NAME",
|
|
60
|
+
"PLACEHOLDER_MANIFEST_DESCRIPTION_MARKERS",
|
|
61
|
+
"PLACEHOLDER_SENTINELS",
|
|
62
|
+
"README_REQUIRED_SECTIONS",
|
|
63
|
+
"DocValidationIssue",
|
|
64
|
+
"DocValidationReport",
|
|
65
|
+
"format_report",
|
|
66
|
+
"validate_rskill_docs",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
_README_FILENAME: Final[str] = "README.md"
|
|
73
|
+
_MIN_README_BODY_LENGTH: Final[int] = 200
|
|
74
|
+
_MIN_DESCRIPTION_LENGTH: Final[int] = 30
|
|
75
|
+
|
|
76
|
+
_MEDIA_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
77
|
+
r"!\[[^\]]*\]\([^)]+\)|<img\b|<video\b", re.IGNORECASE
|
|
78
|
+
)
|
|
79
|
+
"""Matches a markdown image ````, an ``<img>``, or a ``<video>`` tag.
|
|
80
|
+
|
|
81
|
+
A published rSkill README should show *what the skill does* — a preview image or
|
|
82
|
+
a demo clip. Enforced as a **warning** (not a publish-blocking error) so the 39
|
|
83
|
+
in-tree skills that predate this requirement keep passing; new skills scaffolded
|
|
84
|
+
from ``rskills/template/README.md`` get the ``## Preview`` section for free.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
README_REQUIRED_SECTIONS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = (
|
|
88
|
+
# (display_label, lowercase substrings that satisfy this section)
|
|
89
|
+
(
|
|
90
|
+
"Upstream model / training",
|
|
91
|
+
("upstream", "model", "training", "provenance", "architecture"),
|
|
92
|
+
),
|
|
93
|
+
(
|
|
94
|
+
"Supported robots / embodiments",
|
|
95
|
+
("robot", "embodiment", "supported"),
|
|
96
|
+
),
|
|
97
|
+
(
|
|
98
|
+
"Sensors / observation contract",
|
|
99
|
+
("sensor", "observation", "input"),
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"Manifest summary",
|
|
103
|
+
("manifest",),
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
"License",
|
|
107
|
+
("license",),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
"""Canonical README section coverage (display label → acceptable substrings).
|
|
111
|
+
|
|
112
|
+
For each tuple, the README is acceptable if **any** of the substrings
|
|
113
|
+
appears (case-insensitive) in **any** heading line. The set is
|
|
114
|
+
deliberately permissive — ``## Model``, ``## Upstream model``,
|
|
115
|
+
``## Architecture``, and ``## Provenance`` all satisfy the first slot —
|
|
116
|
+
so the diverse in-tree READMEs (smolvla-libero, pi05-libero-int8,
|
|
117
|
+
diffusion-pusht, …) pass without rewrites.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
PLACEHOLDER_SENTINELS: Final[tuple[str, ...]] = (
|
|
121
|
+
# Canonical unresolved-scaffold sentinels (shared with the reasoner palette
|
|
122
|
+
# gate via RSkillManifest.is_scaffold_placeholder), plus README-only markers.
|
|
123
|
+
*RSKILL_TEMPLATE_SENTINELS,
|
|
124
|
+
"TODO:",
|
|
125
|
+
"<TODO>",
|
|
126
|
+
"FIXME:",
|
|
127
|
+
"<FIXME>",
|
|
128
|
+
"<fill-in>",
|
|
129
|
+
"<your-org>",
|
|
130
|
+
"<your-skill>",
|
|
131
|
+
"TBD:",
|
|
132
|
+
"XXX:",
|
|
133
|
+
)
|
|
134
|
+
"""Strings that must NOT survive into a published README or manifest."""
|
|
135
|
+
|
|
136
|
+
PLACEHOLDER_MANIFEST_DESCRIPTION_MARKERS: Final[tuple[str, ...]] = (
|
|
137
|
+
"Scaffold template generated by",
|
|
138
|
+
"Edit this description",
|
|
139
|
+
)
|
|
140
|
+
"""Substrings that flag a manifest ``description`` left at the template default."""
|
|
141
|
+
|
|
142
|
+
DELEGATION_MARKER_NAME: Final[str] = "openral:rskill-readme-delegates-to"
|
|
143
|
+
"""HTML-comment marker name that lets a stub README delegate to a sibling.
|
|
144
|
+
|
|
145
|
+
A README containing
|
|
146
|
+
``<!-- openral:rskill-readme-delegates-to: <relative-path> -->``
|
|
147
|
+
is treated as a stub: the section checks are run against the resolved
|
|
148
|
+
sibling instead of the stub itself. Placeholder and length checks still
|
|
149
|
+
apply to the stub. Delegation is single-hop — a delegated-to README
|
|
150
|
+
cannot itself delegate further.
|
|
151
|
+
|
|
152
|
+
This handles families like the RLDX-1 ports (``rldx1-ft-rc365-nf4``
|
|
153
|
+
delegates shared architecture / license docs to
|
|
154
|
+
``rldx1-ft-libero-nf4``) without forcing every sibling skill to
|
|
155
|
+
duplicate the same content.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
_DELEGATION_MARKER_PATTERN: Final[re.Pattern[str]] = re.compile(
|
|
159
|
+
r"<!--\s*" + re.escape(DELEGATION_MARKER_NAME) + r"\s*:\s*(?P<path>[^\s>]+)\s*-->",
|
|
160
|
+
re.IGNORECASE,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Report types ─────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class DocValidationIssue(BaseModel):
|
|
168
|
+
"""One problem surfaced by :func:`validate_rskill_docs`.
|
|
169
|
+
|
|
170
|
+
Attributes:
|
|
171
|
+
severity: ``"error"`` blocks publishing; ``"warning"`` is
|
|
172
|
+
advisory (shown but does not exit 1).
|
|
173
|
+
field: Dot-path of the field that failed
|
|
174
|
+
(``"readme.body"`` / ``"readme.section.License"`` /
|
|
175
|
+
``"manifest.description"`` / …).
|
|
176
|
+
message: Human-readable explanation including remediation hint.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
severity: Literal["error", "warning"]
|
|
180
|
+
field: str
|
|
181
|
+
message: str
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class DocValidationReport(BaseModel):
|
|
185
|
+
"""Result of validating one rSkill's README + manifest.
|
|
186
|
+
|
|
187
|
+
Attributes:
|
|
188
|
+
skill_dir: Absolute path to the validated rSkill directory.
|
|
189
|
+
manifest_name: ``RSkillManifest.name`` of the validated manifest.
|
|
190
|
+
issues: All issues found, in the order they were detected.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
>>> # report = validate_rskill_docs(Path("rskills/smolvla-libero"), manifest)
|
|
194
|
+
>>> # if not report.is_valid:
|
|
195
|
+
>>> # for issue in report.errors:
|
|
196
|
+
>>> # print(f"{issue.field}: {issue.message}")
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
skill_dir: str
|
|
200
|
+
manifest_name: str
|
|
201
|
+
issues: list[DocValidationIssue] = Field(default_factory=list)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def is_valid(self) -> bool:
|
|
205
|
+
"""True iff no ``error``-severity issues were found."""
|
|
206
|
+
return not any(i.severity == "error" for i in self.issues)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def errors(self) -> list[DocValidationIssue]:
|
|
210
|
+
"""The subset of :attr:`issues` with ``severity == "error"``."""
|
|
211
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def warnings(self) -> list[DocValidationIssue]:
|
|
215
|
+
"""The subset of :attr:`issues` with ``severity == "warning"``."""
|
|
216
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── Validator entry point ────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate_rskill_docs(skill_dir: Path, manifest: RSkillManifest) -> DocValidationReport:
|
|
223
|
+
"""Validate an rSkill directory's README and manifest documentation.
|
|
224
|
+
|
|
225
|
+
Composes :func:`_validate_readme` and :func:`_validate_manifest_content`
|
|
226
|
+
into one report so the publisher can render a single block of issues
|
|
227
|
+
rather than failing piecemeal.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
skill_dir: Path to the rSkill directory (the parent of
|
|
231
|
+
``rskill.yaml`` and ``README.md``).
|
|
232
|
+
manifest: Already-loaded, schema-valid manifest. Callers obtain
|
|
233
|
+
this via :meth:`RSkillManifest.from_yaml` before calling here.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
:class:`DocValidationReport` with one issue per problem found.
|
|
237
|
+
Iterate :attr:`DocValidationReport.errors` to decide whether to
|
|
238
|
+
block publishing.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
>>> # from pathlib import Path
|
|
242
|
+
>>> # from openral_core.schemas import RSkillManifest
|
|
243
|
+
>>> # m = RSkillManifest.from_yaml("rskills/smolvla-libero/rskill.yaml")
|
|
244
|
+
>>> # rep = validate_rskill_docs(Path("rskills/smolvla-libero"), m)
|
|
245
|
+
>>> # rep.is_valid
|
|
246
|
+
True
|
|
247
|
+
"""
|
|
248
|
+
issues: list[DocValidationIssue] = []
|
|
249
|
+
issues.extend(_validate_readme(skill_dir))
|
|
250
|
+
issues.extend(_validate_manifest_content(manifest))
|
|
251
|
+
return DocValidationReport(
|
|
252
|
+
skill_dir=str(skill_dir.resolve()),
|
|
253
|
+
manifest_name=manifest.name,
|
|
254
|
+
issues=issues,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── README checks ────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _validate_readme(skill_dir: Path) -> list[DocValidationIssue]:
|
|
262
|
+
"""Check README presence, length, required sections, and sentinels.
|
|
263
|
+
|
|
264
|
+
Honors single-hop delegation via the
|
|
265
|
+
``<!-- openral:rskill-readme-delegates-to: <path> -->`` marker (see
|
|
266
|
+
:data:`DELEGATION_MARKER_NAME`): when present, the section checks run
|
|
267
|
+
against the resolved sibling README instead of this one. Placeholder
|
|
268
|
+
and length checks still apply to the stub itself.
|
|
269
|
+
"""
|
|
270
|
+
readme_path = skill_dir / _README_FILENAME
|
|
271
|
+
if not readme_path.is_file():
|
|
272
|
+
return [
|
|
273
|
+
DocValidationIssue(
|
|
274
|
+
severity="error",
|
|
275
|
+
field="readme",
|
|
276
|
+
message=(
|
|
277
|
+
f"README.md is required at {readme_path}. Every rSkill "
|
|
278
|
+
"package must ship a README explaining what the skill "
|
|
279
|
+
"does, how it works, and how it was trained "
|
|
280
|
+
"(CLAUDE.md §6.4). Start from rskills/template/README.md."
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
body = readme_path.read_text(encoding="utf-8")
|
|
286
|
+
issues: list[DocValidationIssue] = []
|
|
287
|
+
|
|
288
|
+
if len(body.strip()) < _MIN_README_BODY_LENGTH:
|
|
289
|
+
issues.append(
|
|
290
|
+
DocValidationIssue(
|
|
291
|
+
severity="error",
|
|
292
|
+
field="readme.body",
|
|
293
|
+
message=(
|
|
294
|
+
f"README.md is shorter than {_MIN_README_BODY_LENGTH} characters "
|
|
295
|
+
f"({len(body.strip())} found); too short to document what "
|
|
296
|
+
"the skill does, the upstream model, the embodiment, and the "
|
|
297
|
+
"license. Flesh it out using rskills/template/README.md."
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Single-hop delegation: a stub README points the section checks at a
|
|
303
|
+
# sibling. Used by the RLDX-1 family where one canonical README owns
|
|
304
|
+
# the shared architecture / license / sidecar docs.
|
|
305
|
+
delegated_headings, delegation_issues = _resolve_delegation(skill_dir, body)
|
|
306
|
+
issues.extend(delegation_issues)
|
|
307
|
+
headings_for_sections = (
|
|
308
|
+
delegated_headings if delegated_headings is not None else _extract_headings(body)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
for label, substrings in README_REQUIRED_SECTIONS:
|
|
312
|
+
if not _any_heading_matches(headings_for_sections, substrings):
|
|
313
|
+
field = f"readme.section.{label}"
|
|
314
|
+
message = (
|
|
315
|
+
f"README.md is missing a heading for '{label}'. "
|
|
316
|
+
f"Add a section whose heading contains one of: "
|
|
317
|
+
f"{', '.join(repr(s) for s in substrings)}. "
|
|
318
|
+
"See rskills/template/README.md for the canonical layout."
|
|
319
|
+
)
|
|
320
|
+
if delegated_headings is not None:
|
|
321
|
+
message = (
|
|
322
|
+
f"Delegated README is missing a heading for '{label}'. "
|
|
323
|
+
f"Add a section whose heading contains one of: "
|
|
324
|
+
f"{', '.join(repr(s) for s in substrings)} to the "
|
|
325
|
+
"sibling README this stub delegates to."
|
|
326
|
+
)
|
|
327
|
+
issues.append(DocValidationIssue(severity="error", field=field, message=message))
|
|
328
|
+
|
|
329
|
+
for sentinel in PLACEHOLDER_SENTINELS:
|
|
330
|
+
if sentinel in body:
|
|
331
|
+
issues.append(
|
|
332
|
+
DocValidationIssue(
|
|
333
|
+
severity="error",
|
|
334
|
+
field="readme.placeholder",
|
|
335
|
+
message=(
|
|
336
|
+
f"README.md still contains the placeholder {sentinel!r}. "
|
|
337
|
+
"Replace every template sentinel and TODO marker with "
|
|
338
|
+
"real content before publishing."
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if not _MEDIA_PATTERN.search(body):
|
|
344
|
+
issues.append(
|
|
345
|
+
DocValidationIssue(
|
|
346
|
+
severity="warning",
|
|
347
|
+
field="readme.media",
|
|
348
|
+
message=(
|
|
349
|
+
"README.md has no preview image or video. Add a '## Preview' "
|
|
350
|
+
"section with at least one image (markdown  "
|
|
351
|
+
"or an <img> tag) or a <video> — a still, a progress overlay, "
|
|
352
|
+
"or a demo clip showing what the skill does. HF model cards "
|
|
353
|
+
"render images but not <video>, so for a video, also commit "
|
|
354
|
+
"the .mp4 and show start/middle/end stills. See "
|
|
355
|
+
"rskills/template/README.md."
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return issues
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _resolve_delegation(
|
|
364
|
+
skill_dir: Path, body: str
|
|
365
|
+
) -> tuple[list[str] | None, list[DocValidationIssue]]:
|
|
366
|
+
"""Resolve a ``rskill-readme-delegates-to`` marker.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
skill_dir: Directory of the README being validated.
|
|
370
|
+
body: Text of the current README.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Tuple ``(delegated_headings, issues)``:
|
|
374
|
+
|
|
375
|
+
* ``delegated_headings is None`` if there is no delegation marker
|
|
376
|
+
(caller falls back to the current README's own headings).
|
|
377
|
+
* ``delegated_headings is []`` if the marker exists but cannot
|
|
378
|
+
be resolved (an issue is emitted into ``issues``); the caller
|
|
379
|
+
treats this as "no headings found" so every section reports as
|
|
380
|
+
missing on top of the delegation error.
|
|
381
|
+
* Otherwise the parsed heading lines of the sibling README.
|
|
382
|
+
|
|
383
|
+
``issues`` is empty when delegation resolves cleanly.
|
|
384
|
+
"""
|
|
385
|
+
match = _DELEGATION_MARKER_PATTERN.search(body)
|
|
386
|
+
if match is None:
|
|
387
|
+
return None, []
|
|
388
|
+
|
|
389
|
+
target_rel = match.group("path")
|
|
390
|
+
target_dir = (skill_dir / target_rel).resolve()
|
|
391
|
+
target_path = target_dir / _README_FILENAME if target_dir.is_dir() else target_dir
|
|
392
|
+
if not target_path.is_file():
|
|
393
|
+
return [], [
|
|
394
|
+
DocValidationIssue(
|
|
395
|
+
severity="error",
|
|
396
|
+
field="readme.delegation",
|
|
397
|
+
message=(
|
|
398
|
+
f"README.md delegates to {target_rel!r} via the "
|
|
399
|
+
f"{DELEGATION_MARKER_NAME} marker, but no README was found "
|
|
400
|
+
f"at {target_path}. Fix the relative path or remove the marker."
|
|
401
|
+
),
|
|
402
|
+
)
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
target_body = target_path.read_text(encoding="utf-8")
|
|
406
|
+
# Refuse double-hop delegation: a delegated-to README cannot itself
|
|
407
|
+
# delegate further. Single-hop keeps the check fast and the contract
|
|
408
|
+
# easy to reason about.
|
|
409
|
+
if _DELEGATION_MARKER_PATTERN.search(target_body):
|
|
410
|
+
return [], [
|
|
411
|
+
DocValidationIssue(
|
|
412
|
+
severity="error",
|
|
413
|
+
field="readme.delegation",
|
|
414
|
+
message=(
|
|
415
|
+
f"README.md delegates to {target_rel!r}, but that sibling "
|
|
416
|
+
f"also contains the {DELEGATION_MARKER_NAME} marker. "
|
|
417
|
+
"Delegation is single-hop only; resolve to the canonical "
|
|
418
|
+
"README directly."
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
]
|
|
422
|
+
|
|
423
|
+
return _extract_headings(target_body), []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _extract_headings(body: str) -> list[str]:
|
|
427
|
+
"""Return every ATX heading line (``#``/``##``/``###`` …) found in ``body``."""
|
|
428
|
+
return re.findall(r"^#{1,6}\s+.*$", body, flags=re.MULTILINE)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _any_heading_matches(headings: list[str], substrings: tuple[str, ...]) -> bool:
|
|
432
|
+
"""Return True if any heading contains any of ``substrings`` (case-insensitive)."""
|
|
433
|
+
lowered = [h.lower() for h in headings]
|
|
434
|
+
return any(s in h for h in lowered for s in substrings)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── Manifest checks ──────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _validate_manifest_content(manifest: RSkillManifest) -> list[DocValidationIssue]:
|
|
441
|
+
"""Check the manifest fields that the Pydantic schema can't fail on."""
|
|
442
|
+
issues: list[DocValidationIssue] = []
|
|
443
|
+
|
|
444
|
+
description = manifest.description.strip()
|
|
445
|
+
for marker in PLACEHOLDER_MANIFEST_DESCRIPTION_MARKERS:
|
|
446
|
+
if marker in description:
|
|
447
|
+
issues.append(
|
|
448
|
+
DocValidationIssue(
|
|
449
|
+
severity="error",
|
|
450
|
+
field="manifest.description",
|
|
451
|
+
message=(
|
|
452
|
+
"manifest.description still contains the template "
|
|
453
|
+
f"placeholder {marker!r}. Replace it with a real "
|
|
454
|
+
"summary of what the skill does (the LLM tool palette "
|
|
455
|
+
"uses this text — keep it specific to the task, "
|
|
456
|
+
"objects, and scene)."
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
break # one placeholder hit is enough; don't double-report
|
|
461
|
+
|
|
462
|
+
if len(description) < _MIN_DESCRIPTION_LENGTH:
|
|
463
|
+
issues.append(
|
|
464
|
+
DocValidationIssue(
|
|
465
|
+
severity="error",
|
|
466
|
+
field="manifest.description",
|
|
467
|
+
message=(
|
|
468
|
+
f"manifest.description is {len(description)} chars; "
|
|
469
|
+
f"publish requires >= {_MIN_DESCRIPTION_LENGTH}. The reasoner "
|
|
470
|
+
"LLM scores skills primarily on this text — make it specific."
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if manifest.paper_url is None and manifest.source_repo is None:
|
|
476
|
+
issues.append(
|
|
477
|
+
DocValidationIssue(
|
|
478
|
+
severity="error",
|
|
479
|
+
field="manifest.provenance",
|
|
480
|
+
message=(
|
|
481
|
+
"Publish requires at least one of manifest.paper_url or "
|
|
482
|
+
"manifest.source_repo to be set, so consumers have a "
|
|
483
|
+
"citation / upstream-model link to follow. The HF Hub model "
|
|
484
|
+
"card URL is acceptable for paper_url when no formal paper "
|
|
485
|
+
"exists."
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
_candidates = (
|
|
491
|
+
("weights_uri", manifest.weights_uri),
|
|
492
|
+
("source_repo", manifest.source_repo),
|
|
493
|
+
)
|
|
494
|
+
for field_name, value in _candidates:
|
|
495
|
+
if value and "TEMPLATE" in value:
|
|
496
|
+
issues.append(
|
|
497
|
+
DocValidationIssue(
|
|
498
|
+
severity="error",
|
|
499
|
+
field=f"manifest.{field_name}",
|
|
500
|
+
message=(
|
|
501
|
+
f"manifest.{field_name} still contains the template "
|
|
502
|
+
f"sentinel substring 'TEMPLATE' ({value!r}). Point it at "
|
|
503
|
+
"the real HF Hub repo before publishing."
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
for sentinel in RSKILL_TEMPLATE_SENTINELS:
|
|
509
|
+
if sentinel in manifest.name:
|
|
510
|
+
issues.append(
|
|
511
|
+
DocValidationIssue(
|
|
512
|
+
severity="error",
|
|
513
|
+
field="manifest.name",
|
|
514
|
+
message=(
|
|
515
|
+
f"manifest.name still contains the template sentinel "
|
|
516
|
+
f"{sentinel!r} ({manifest.name!r}). Rename via `openral rskill new` "
|
|
517
|
+
"or rewrite the field directly."
|
|
518
|
+
),
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
return issues
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ── Pretty-printer ───────────────────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def format_report(report: DocValidationReport) -> str:
|
|
529
|
+
"""Render a human-readable summary of a :class:`DocValidationReport`.
|
|
530
|
+
|
|
531
|
+
Used by ``tools/rskill_publisher.py`` and ``openral rskill new`` for the
|
|
532
|
+
publish-readiness banner. Errors are prefixed with ``error:`` and
|
|
533
|
+
warnings with ``warning:`` so the output greps cleanly.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
report: The report to render.
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Multi-line string with one issue per line plus a summary footer.
|
|
540
|
+
|
|
541
|
+
Example:
|
|
542
|
+
>>> # print(format_report(report))
|
|
543
|
+
"""
|
|
544
|
+
lines: list[str] = []
|
|
545
|
+
lines.append(f"rSkill docs report — {report.manifest_name}")
|
|
546
|
+
lines.append(f" dir: {report.skill_dir}")
|
|
547
|
+
if not report.issues:
|
|
548
|
+
lines.append(" ✓ README + manifest documentation are publish-ready.")
|
|
549
|
+
return "\n".join(lines)
|
|
550
|
+
|
|
551
|
+
for issue in report.issues:
|
|
552
|
+
lines.append(f" {issue.severity}: [{issue.field}] {issue.message}")
|
|
553
|
+
|
|
554
|
+
n_err = len(report.errors)
|
|
555
|
+
n_warn = len(report.warnings)
|
|
556
|
+
lines.append(f" → {n_err} error(s), {n_warn} warning(s).")
|
|
557
|
+
if n_err:
|
|
558
|
+
lines.append(
|
|
559
|
+
" Publishing is BLOCKED until every error is resolved "
|
|
560
|
+
"(see rskills/template/README.md for the canonical layout)."
|
|
561
|
+
)
|
|
562
|
+
return "\n".join(lines)
|