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,6 @@
1
+ """openral CLI — the `ros` command entry point."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["app"]
5
+
6
+ from openral_cli.main import app
@@ -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 ``![alt](src)``, 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 ![alt](media/...) "
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)