Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1006 lines
38 KiB
Python

"""
Skill hub providers
===================
Import skills from external registries ("hubs") into the local skill layer.
A hub skill is the same artefact DeepTutor already speaks natively — a
directory with a ``SKILL.md`` (YAML frontmatter + markdown playbook) plus
optional support files — so importing is a fetch + adapt + install pipeline,
not a format translation:
1. **verify** — ask the hub for its security verdict on the package. A
``suspicious`` verdict aborts the install unless the caller explicitly
opts out (registries of this kind have shipped malware before).
2. **fetch** — download and safely extract the package into a temp dir
(zip-slip / zip-bomb guards live here).
3. **install** — :meth:`SkillService.install_tree` applies the import
policy (frontmatter adaptation, ``always`` stripping, suffix whitelist)
and records provenance in ``.hub-lock.json``.
Providers are addressed as ``<hub>:<slug>[@version]`` (e.g.
``eduhub:socratic-tutor@1.2.0``; the hub prefix defaults to ``eduhub``).
Two provider shapes ship in v1:
* :class:`ClawHubProvider` — speaks the ClawHub HTTP API directly (search /
download / verify), so no Node toolchain is required;
* :class:`CommandProvider` — wraps an arbitrary fetch command for registries
that only publish a CLI. The command must drop the package into the
directory given as ``{dest}``; everything after that goes through the same
verify-less install gate (verdict is ``unknown``).
Extra hubs are declared in ``settings/skill_hubs.json``::
{
"hubs": {
"myhub": {"type": "command", "fetch_cmd": "myhub pull {slug} --out {dest}"},
"mirror": {"type": "clawhub", "base_url": "https://mirror.example/api/v1"}
}
}
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tempfile
from typing import Any, Protocol
import zipfile
import httpx
from deeptutor.services.path_service import get_path_service
from .service import SkillImportError, SkillInstallResult, SkillService
logger = logging.getLogger(__name__)
# DeepTutor ships pointing at EduHub, its native open skill registry: a bare
# ``install <slug>`` with no ``<hub>:`` prefix resolves here.
DEFAULT_HUB = "eduhub"
_CLAWHUB_BASE_URL = "https://clawhub.ai/api/v1"
_EDUHUB_BASE_URL = "https://eduhub.deeptutor.info/api/v1"
_HUBS_SETTINGS_FILE = "skill_hubs"
# Hubs available out of the box, no settings file required. Both speak the
# ClawHub HTTP protocol. A user's ``skill_hubs.json`` is layered on top and may
# override any entry (e.g. point ``eduhub`` at a local dev server).
_BUILTIN_HUBS: dict[str, dict[str, str]] = {
"clawhub": {"type": "clawhub", "base_url": _CLAWHUB_BASE_URL},
"eduhub": {"type": "clawhub", "base_url": _EDUHUB_BASE_URL},
}
_REF_RE = re.compile(
r"^(?:(?P<hub>[a-z0-9][a-z0-9-]{0,31}):)?"
r"(?P<slug>[a-z0-9][a-z0-9._-]{0,127})"
r"(?:@(?P<version>[A-Za-z0-9][A-Za-z0-9._-]{0,63}))?$"
)
# Extraction bounds for a downloaded package archive. Structural safety only —
# the per-file suffix whitelist is applied later by ``install_tree``.
_ZIP_MAX_ENTRIES = 600
_ZIP_MAX_ENTRY_BYTES = 4_000_000
_ZIP_MAX_TOTAL_BYTES = 40_000_000
_ZIP_MAX_RATIO = 200.0
_HTTP_TIMEOUT = 30.0
_FETCH_CMD_TIMEOUT = 120.0
class HubError(Exception):
"""A hub interaction failed (network, protocol, or configuration)."""
@dataclass(slots=True)
class HubSkillRef:
"""One hub listing: enough to show a search row and stamp provenance."""
hub: str
slug: str
display_name: str = ""
summary: str = ""
version: str = ""
@dataclass(slots=True)
class HubVerdict:
"""Hub security verdict, collapsed to a tri-state.
``ok`` — the hub vouches for the package; ``suspicious`` — the hub
flags it (install is refused without an explicit override);
``unknown`` — the hub has no verdict surface or the call failed,
so the caller should warn rather than block.
"""
status: str # "ok" | "suspicious" | "unknown"
detail: str = ""
@dataclass(slots=True)
class FetchedSkill:
"""A downloaded package on local disk, pending install."""
ref: HubSkillRef
root: Path # directory containing SKILL.md
cleanup_dir: Path # temp tree to remove once installed
def cleanup(self) -> None:
shutil.rmtree(self.cleanup_dir, ignore_errors=True)
@dataclass(slots=True)
class HubInstallOutcome:
"""Everything the caller needs to report an install."""
result: SkillInstallResult
ref: HubSkillRef
verdict: HubVerdict
class SkillHubProvider(Protocol):
name: str
def search(self, query: str, *, limit: int = 10) -> list[HubSkillRef]: ...
def fetch(self, slug: str, *, version: str | None = None) -> FetchedSkill: ...
def verify(self, slug: str, *, version: str | None = None) -> HubVerdict: ...
# ── ref parsing ──────────────────────────────────────────────────────────
def parse_hub_ref(ref: str) -> tuple[str, str, str | None]:
"""Split ``<hub>:<slug>[@version]`` into its parts; hub defaults."""
match = _REF_RE.match((ref or "").strip())
if match is None:
raise HubError(f"Invalid skill reference `{ref}`. Expected <hub>:<slug>[@version].")
return (
match.group("hub") or default_hub(),
match.group("slug"),
match.group("version"),
)
# ── safe archive extraction (directory-preserving) ──────────────────────
def _extract_skill_zip(zip_path: Path, dest: Path) -> None:
"""Extract a package zip preserving subdirectories, defensively.
The KB upload extractor (:func:`safe_extract_zip`) flattens paths, which
would destroy a skill's ``references/`` layout — so this sibling keeps
relative paths and instead rejects traversal segments outright. Bomb
guards mirror the extractor's posture. Members are streamed through
``ZipFile.open``, so no permission bits or links materialise.
"""
dest.mkdir(parents=True, exist_ok=True)
dest_root = dest.resolve()
total = 0
try:
archive = zipfile.ZipFile(zip_path)
except zipfile.BadZipFile as exc:
raise SkillImportError(f"Downloaded package is not a valid zip: {exc}") from exc
with archive:
members = [info for info in archive.infolist() if not info.is_dir()]
if len(members) > _ZIP_MAX_ENTRIES:
raise SkillImportError("Package archive has too many entries.")
for info in members:
raw = info.filename.replace("\\", "/")
rel = Path(raw)
if rel.is_absolute() or ".." in rel.parts:
raise SkillImportError(f"Illegal path in package archive: {raw}")
if raw.startswith("__MACOSX/") or rel.name.startswith("."):
continue
if info.file_size > _ZIP_MAX_ENTRY_BYTES:
raise SkillImportError(f"Package entry too large: {raw}")
if info.compress_size > 0:
if info.file_size / info.compress_size > _ZIP_MAX_RATIO:
raise SkillImportError(f"Suspicious compression ratio: {raw}")
total += info.file_size
if total > _ZIP_MAX_TOTAL_BYTES:
raise SkillImportError("Package archive exceeds the size limit.")
target = (dest_root / rel).resolve()
if not target.is_relative_to(dest_root): # defense in depth
raise SkillImportError(f"Illegal path in package archive: {raw}")
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info) as source, open(target, "wb") as sink:
shutil.copyfileobj(source, sink, length=1 << 16)
if target.stat().st_size > info.file_size:
target.unlink(missing_ok=True)
raise SkillImportError(f"Entry decompressed past declared size: {raw}")
def _locate_package_root(extracted: Path) -> Path:
"""Find the directory holding ``SKILL.md`` (top level or one wrapper deep).
Hub zips routinely wrap the package in a single named folder; anything
deeper or ambiguous is rejected rather than guessed at.
"""
if (extracted / "SKILL.md").is_file():
return extracted
subdirs = [p for p in extracted.iterdir() if p.is_dir()]
if len(subdirs) == 1 and (subdirs[0] / "SKILL.md").is_file():
return subdirs[0]
raise SkillImportError("Package does not contain a SKILL.md.")
# ── providers ─────────────────────────────────────────────────────────────
class ClawHubProvider:
"""ClawHub registry over its public read-only HTTP API.
Speaking HTTP directly (instead of shelling out to the ``clawhub`` npm
CLI) keeps the pipeline free of a Node toolchain dependency and lets the
security verdict gate run inside our install path.
"""
def __init__(
self,
name: str = "clawhub",
*,
base_url: str = _CLAWHUB_BASE_URL,
client: httpx.Client | None = None,
) -> None:
self.name = name
self._base_url = base_url.rstrip("/")
self._client = client or httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=True)
@property
def base_url(self) -> str:
"""The configured ``/api/v1`` base URL (used to derive the web origin)."""
return self._base_url
@property
def web_origin(self) -> str:
"""The hub's website origin — ``base_url`` minus the ``/api/...`` suffix.
Used to build "view on EduHub" links for the in-app skill browser.
"""
marker = "/api/"
idx = self._base_url.find(marker)
return self._base_url[:idx] if idx != -1 else self._base_url
def _get(self, path: str, **params: Any) -> httpx.Response:
url = f"{self._base_url}{path}"
try:
response = self._client.get(
url, params={k: v for k, v in params.items() if v is not None}
)
except httpx.HTTPError as exc:
raise HubError(f"{self.name}: request failed: {exc}") from exc
if response.status_code == 404:
raise HubError(f"{self.name}: not found: {path}")
if response.status_code >= 400:
raise HubError(
f"{self.name}: HTTP {response.status_code} for {path}: {response.text[:200]}"
)
return response
def search(self, query: str, *, limit: int = 10) -> list[HubSkillRef]:
response = self._get("/search", q=query, limit=limit)
try:
payload = response.json()
except ValueError as exc:
raise HubError(f"{self.name}: search returned invalid JSON") from exc
rows = payload if isinstance(payload, list) else None
if rows is None and isinstance(payload, dict):
for key in ("results", "items", "skills"):
if isinstance(payload.get(key), list):
rows = payload[key]
break
if rows is None:
raise HubError(f"{self.name}: unrecognised search response shape")
refs: list[HubSkillRef] = []
for row in rows:
if not isinstance(row, dict):
continue
slug = str(row.get("slug") or "").strip()
if not slug:
continue
refs.append(
HubSkillRef(
hub=self.name,
slug=slug,
display_name=str(row.get("displayName") or row.get("name") or slug),
summary=str(row.get("summary") or row.get("description") or ""),
version=str(row.get("version") or ""),
)
)
return refs
def verify(self, slug: str, *, version: str | None = None) -> HubVerdict:
try:
response = self._get(
f"/skills/{slug}/verify", version=version, tag=None if version else "latest"
)
payload = response.json()
except (HubError, ValueError) as exc:
return HubVerdict(status="unknown", detail=str(exc))
if not isinstance(payload, dict):
return HubVerdict(status="unknown", detail="unrecognised verify response")
decision = str(payload.get("decision") or "").strip().lower()
if payload.get("ok") is True:
return HubVerdict(status="ok", detail=decision)
return HubVerdict(status="suspicious", detail=decision or "hub flagged this package")
def fetch(self, slug: str, *, version: str | None = None) -> FetchedSkill:
response = self._get(
"/download", slug=slug, version=version, tag=None if version else "latest"
)
tmp = Path(tempfile.mkdtemp(prefix="deeptutor-skill-"))
try:
zip_path = tmp / "package.zip"
zip_path.write_bytes(response.content)
extracted = tmp / "extracted"
_extract_skill_zip(zip_path, extracted)
root = _locate_package_root(extracted)
except Exception:
shutil.rmtree(tmp, ignore_errors=True)
raise
resolved_version = version or self._latest_version(slug)
return FetchedSkill(
ref=HubSkillRef(hub=self.name, slug=slug, version=resolved_version),
root=root,
cleanup_dir=tmp,
)
def _latest_version(self, slug: str) -> str:
"""Resolve the version actually served by an untagged download."""
try:
payload = self._get(f"/skills/{slug}").json()
except (HubError, ValueError):
return ""
if not isinstance(payload, dict):
return ""
latest = payload.get("latestVersion") or payload.get("latest_version")
if isinstance(latest, dict):
latest = latest.get("version")
if latest is None and isinstance(payload.get("skill"), dict):
latest = payload["skill"].get("latestVersion")
return str(latest or "").strip()
@staticmethod
def _listing_from_row(row: dict[str, Any]) -> dict[str, Any] | None:
"""Normalise one hub catalog/detail row into a display-ready dict."""
slug = str(row.get("slug") or "").strip()
if not slug:
return None
stats = row.get("stats") if isinstance(row.get("stats"), dict) else {}
owner = row.get("owner") if isinstance(row.get("owner"), dict) else {}
return {
"slug": slug,
"name": str(row.get("displayName") or row.get("name") or slug),
"summary": str(row.get("summary") or row.get("description") or ""),
"version": str(row.get("version") or ""),
"downloads": int(stats.get("downloads") or 0),
"stars": int(stats.get("stars") or 0),
"owner": str(owner.get("displayName") or row.get("ownerHandle") or ""),
"owner_url": str(owner.get("htmlUrl") or ""),
"updated_at": row.get("updatedAt"),
}
def catalog(
self, *, query: str = "", limit: int = 50, sort: str = "createdAt"
) -> list[dict[str, Any]]:
"""List skills published on the hub, for the in-app skill browser.
Uses ``/search`` when a query is given (server-side relevance) and the
plain ``/skills`` explore feed otherwise. Rows carry display name,
summary, version, download/star counts and owner — everything the
DeepTutor-native browser renders without a second round-trip.
"""
if query.strip():
response = self._get("/search", q=query.strip(), limit=limit)
else:
response = self._get("/skills", limit=limit, sort=sort)
try:
payload = response.json()
except ValueError as exc:
raise HubError(f"{self.name}: catalog returned invalid JSON") from exc
rows = payload if isinstance(payload, list) else None
if rows is None and isinstance(payload, dict):
for key in ("results", "skills", "items"):
if isinstance(payload.get(key), list):
rows = payload[key]
break
if rows is None:
raise HubError(f"{self.name}: unrecognised catalog response shape")
out: list[dict[str, Any]] = []
for row in rows:
if isinstance(row, dict):
listing = self._listing_from_row(row)
if listing is not None:
out.append(listing)
return out
def detail(self, slug: str) -> dict[str, Any]:
"""Full metadata + SKILL.md body for one hub skill (browser detail view)."""
try:
payload = self._get(f"/skills/{slug}").json()
except ValueError as exc:
raise HubError(f"{self.name}: skill detail returned invalid JSON") from exc
if not isinstance(payload, dict):
raise HubError(f"{self.name}: unrecognised skill detail shape")
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
listing = self._listing_from_row(skill) or {"slug": slug, "name": slug}
# Owner sometimes lives at the envelope top level, not inside ``skill``.
if not listing.get("owner") and isinstance(payload.get("owner"), dict):
owner = payload["owner"]
listing["owner"] = str(owner.get("displayName") or owner.get("handle") or "")
listing["owner_url"] = str(owner.get("htmlUrl") or "")
dist = payload.get("distTags") if isinstance(payload.get("distTags"), dict) else {}
listing["version"] = str(dist.get("latest") or listing.get("version") or "")
listing["content"] = str(skill.get("description") or "")
# EduHub's ``tags`` field is a dist-tags map; the topical labels live in
# ``keywords``. Fall back to a list-shaped ``tags`` for other hubs.
keywords = skill.get("keywords")
if not isinstance(keywords, list):
keywords = skill.get("tags") if isinstance(skill.get("tags"), list) else []
listing["tags"] = [str(t) for t in keywords if str(t).strip()]
return listing
def publish(
self,
*,
slug: str,
version: str,
zip_bytes: bytes,
token: str,
fields: dict[str, str],
) -> dict[str, Any]:
"""Publish a version via ``POST /skills`` (multipart + bearer token).
Read-only ClawHub mirrors will reject this; eduhub (same /api/v1 shape)
accepts it. Surfaces the API's JSON ``error`` message on failure.
"""
url = f"{self._base_url}/skills"
try:
response = self._client.post(
url,
data={"slug": slug, "version": version, **fields},
files={"zip": ("package.zip", zip_bytes, "application/zip")},
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise HubError(f"{self.name}: publish request failed: {exc}") from exc
if response.status_code >= 400:
detail = ""
try:
detail = str(response.json().get("error") or "")
except ValueError:
detail = response.text[:200]
raise HubError(f"{self.name}: publish failed (HTTP {response.status_code}): {detail}")
try:
return response.json()
except ValueError:
return {}
def list_my_skills(self, token: str) -> list[dict[str, Any]]:
"""List the authenticated user's skills via ``GET /skills?owner=me``.
Each row carries the slug, current ``version`` (latest), the full
``versions`` list (for rollback) and the current classification (for
pre-filling an upgrade's tagging) — see ``buildMySkills`` on the hub.
"""
url = f"{self._base_url}/skills"
try:
response = self._client.get(
url, params={"owner": "me"}, headers={"Authorization": f"Bearer {token}"}
)
except httpx.HTTPError as exc:
raise HubError(f"{self.name}: request failed: {exc}") from exc
if response.status_code in (401, 403):
raise HubError(
f"{self.name}: not authenticated — run `skill login` or pass a valid token."
)
if response.status_code >= 400:
raise HubError(f"{self.name}: HTTP {response.status_code}: {response.text[:200]}")
try:
payload = response.json()
except ValueError as exc:
raise HubError(f"{self.name}: invalid JSON listing skills") from exc
rows = payload.get("skills") if isinstance(payload, dict) else None
return [row for row in (rows or []) if isinstance(row, dict)]
def set_dist_tag(
self,
slug: str,
*,
version: str,
token: str,
tag: str = "latest",
) -> dict[str, Any]:
"""Move a dist-tag (default ``latest``) to an existing version (rollback)."""
url = f"{self._base_url}/skills/{slug}/dist-tags"
try:
response = self._client.post(
url,
json={"tag": tag, "version": version},
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise HubError(f"{self.name}: request failed: {exc}") from exc
if response.status_code >= 400:
detail = ""
try:
detail = str(response.json().get("error") or "")
except ValueError:
detail = response.text[:200]
raise HubError(
f"{self.name}: set dist-tag failed (HTTP {response.status_code}): {detail}"
)
try:
return response.json()
except ValueError:
return {}
class CommandProvider:
"""Generic fetch-by-command provider for registries without a public API.
The configured ``fetch_cmd`` template receives ``{slug}``, ``{version}``
(empty string when unpinned) and ``{dest}`` — it must leave the package
(a ``SKILL.md`` tree, or a zip we can extract) under ``{dest}``. The
command runs without a shell; pipes and substitutions won't work, which
is the point.
"""
def __init__(self, name: str, *, fetch_cmd: str) -> None:
self.name = name
self._fetch_cmd = fetch_cmd
def search(self, query: str, *, limit: int = 10) -> list[HubSkillRef]:
raise HubError(f"{self.name}: this hub does not support search.")
def verify(self, slug: str, *, version: str | None = None) -> HubVerdict:
return HubVerdict(status="unknown", detail="command hubs have no verdict API")
def fetch(self, slug: str, *, version: str | None = None) -> FetchedSkill:
tmp = Path(tempfile.mkdtemp(prefix="deeptutor-skill-"))
dest = tmp / "fetched"
dest.mkdir()
argv = [
part.format(slug=slug, version=version or "", dest=str(dest))
for part in shlex.split(self._fetch_cmd)
]
try:
completed = subprocess.run(
argv,
cwd=str(tmp),
capture_output=True,
text=True,
timeout=_FETCH_CMD_TIMEOUT,
check=False,
)
if completed.returncode != 0:
raise HubError(
f"{self.name}: fetch command failed "
f"({completed.returncode}): {(completed.stderr or completed.stdout)[:300]}"
)
root = self._resolve_fetched_root(dest, tmp)
except Exception:
shutil.rmtree(tmp, ignore_errors=True)
raise
return FetchedSkill(
ref=HubSkillRef(hub=self.name, slug=slug, version=version or ""),
root=root,
cleanup_dir=tmp,
)
@staticmethod
def _resolve_fetched_root(dest: Path, tmp: Path) -> Path:
"""Accept either an extracted tree or a single zip in ``dest``."""
zips = sorted(dest.glob("*.zip"))
if len(zips) == 1 and not (dest / "SKILL.md").is_file():
extracted = tmp / "extracted"
_extract_skill_zip(zips[0], extracted)
return _locate_package_root(extracted)
return _locate_package_root(dest)
# ── provider registry ─────────────────────────────────────────────────────
def _load_hub_config() -> dict[str, Any]:
"""Read the whole skill_hubs settings doc (``{}`` if absent/unreadable)."""
try:
path = get_path_service().get_settings_file(_HUBS_SETTINGS_FILE)
except Exception:
return {}
if not isinstance(path, Path) or not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("skill_hubs settings file is unreadable; ignoring it")
return {}
return data if isinstance(data, dict) else {}
def _load_hub_settings() -> dict[str, dict[str, Any]]:
hubs = _load_hub_config().get("hubs")
if not isinstance(hubs, dict):
return {}
return {str(name): value for name, value in hubs.items() if isinstance(value, dict)}
def default_hub() -> str:
"""The hub used when a ref carries no ``<hub>:`` prefix.
Settings-driven (``"default": "eduhub"`` in skill_hubs.json) so a
deployment can point the bare ``install <slug>`` at its own hub without
forking the code; falls back to the built-in ``eduhub``.
"""
chosen = str(_load_hub_config().get("default") or "").strip().lower()
return chosen or DEFAULT_HUB
def get_hub_provider(name: str) -> SkillHubProvider:
"""Resolve a hub name to a provider.
Built-in hubs (``eduhub``, ``clawhub``) work with no configuration; entries
in ``settings/skill_hubs.json`` are layered on top and override built-ins.
"""
hub = (name or DEFAULT_HUB).strip().lower()
configured = _load_hub_settings().get(hub) or _BUILTIN_HUBS.get(hub)
if configured is not None:
kind = str(configured.get("type") or "").strip().lower()
if kind == "clawhub":
return ClawHubProvider(
hub, base_url=str(configured.get("base_url") or _CLAWHUB_BASE_URL)
)
if kind == "command":
fetch_cmd = str(configured.get("fetch_cmd") or "").strip()
if not fetch_cmd:
raise HubError(f"Hub `{hub}` is missing fetch_cmd in skill_hubs settings.")
return CommandProvider(hub, fetch_cmd=fetch_cmd)
raise HubError(f"Hub `{hub}` has unknown type `{kind}` in skill_hubs settings.")
raise HubError(f"Unknown hub `{hub}`. Configure it in settings/skill_hubs.json.")
# ── orchestration ─────────────────────────────────────────────────────────
def install_from_hub(
ref: str,
*,
service: SkillService,
rename_to: str | None = None,
force: bool = False,
allow_unverified: bool = False,
provider: SkillHubProvider | None = None,
) -> HubInstallOutcome:
"""One-shot pipeline: verify → fetch → install → record provenance.
``suspicious`` verdicts abort unless ``allow_unverified`` is set;
``unknown`` verdicts install but are stamped into provenance so the
caller can warn. ``provider`` is injectable for tests.
"""
hub, slug, version = parse_hub_ref(ref)
resolved = provider or get_hub_provider(hub)
verdict = resolved.verify(slug, version=version)
if verdict.status == "suspicious" and not allow_unverified:
raise SkillImportError(
f"{hub} flags `{slug}` as suspicious"
+ (f" ({verdict.detail})" if verdict.detail else "")
+ ". Pass --allow-unverified to install anyway."
)
fetched = resolved.fetch(slug, version=version)
try:
result = service.install_tree(
fetched.root,
rename_to=rename_to,
fallback_description=fetched.ref.summary or None,
force=force,
# Stamp the source hub as a tag so imported skills are visibly
# grouped (e.g. an ``eduhub`` tag) in the user's Skills list.
extra_tags=[hub],
origin={
"hub": hub,
"slug": slug,
"version": fetched.ref.version or version or "",
"verdict": verdict.status,
"installed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
},
)
finally:
fetched.cleanup()
return HubInstallOutcome(result=result, ref=fetched.ref, verdict=verdict)
# ── publish ─────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class PublishOutcome:
hub: str
slug: str
version: str
response: dict[str, Any]
@dataclass(slots=True)
class SkillPreflight:
"""Result of the local pre-publish format check.
``errors`` would make the hub reject the upload (the CLI should stop);
``warnings`` are advisory. ``file_count``/``total_bytes`` are reported so
the caller can show what is about to be zipped.
"""
errors: list[str]
warnings: list[str]
file_count: int
total_bytes: int
@property
def ok(self) -> bool:
return not self.errors
# Native executables / installers have no place in a document-shaped skill;
# mirrors the hub scanner's DANGEROUS_BINARY_EXT so the CLI flags them locally
# before upload rather than after a server-side ``review`` verdict.
_DANGEROUS_BINARY_EXT = frozenset(
{
".exe",
".dll",
".so",
".dylib",
".bin",
".msi",
".dmg",
".pkg",
".app",
".bat",
".cmd",
".scr",
".com",
".jar",
".apk",
}
)
def preflight_skill_dir(directory: str | Path) -> SkillPreflight:
"""Check a skill directory against the hub's structural constraints.
Local mirror of ``normalizePackage`` / scanner bounds (SKILL.md present,
≤600 files, ≤4 MB/file, ≤40 MB total, no native executables) plus advisory
checks on the SKILL.md frontmatter. Catches the common rejections before a
round-trip; the authoritative scan still runs server-side on publish.
"""
root = Path(directory).expanduser().resolve()
errors: list[str] = []
warnings: list[str] = []
file_count = 0
total_bytes = 0
if not root.is_dir():
return SkillPreflight([f"Not a directory: {root}"], [], 0, 0)
skill_md = root / "SKILL.md"
if not skill_md.is_file():
errors.append("Missing SKILL.md at the package root.")
else:
fm = _read_frontmatter(skill_md)
if not str(fm.get("name") or "").strip():
warnings.append(
"SKILL.md frontmatter has no `name:` (display name falls back to slug)."
)
if not str(fm.get("description") or "").strip():
warnings.append(
"SKILL.md frontmatter has no `description:` — agents rely on it to decide when to use the skill."
)
for path in root.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(root)
if any(part.startswith(".") or part == "__MACOSX" for part in rel.parts):
continue # dotfiles / __MACOSX are dropped at zip time
file_count += 1
try:
size = path.stat().st_size
except OSError:
size = 0
total_bytes += size
if size > _ZIP_MAX_ENTRY_BYTES:
errors.append(f"File exceeds 4 MB: {rel.as_posix()} ({size // 1_000_000} MB).")
if rel.suffix.lower() in _DANGEROUS_BINARY_EXT:
errors.append(f"Native executable/installer not allowed: {rel.as_posix()}.")
if file_count == 0:
errors.append("No publishable files found (only dotfiles/__MACOSX present).")
if file_count > _ZIP_MAX_ENTRIES:
errors.append(f"Too many files: {file_count} (limit {_ZIP_MAX_ENTRIES}).")
if total_bytes > _ZIP_MAX_TOTAL_BYTES:
errors.append(f"Package too large: {total_bytes // 1_000_000} MB (limit 40 MB).")
return SkillPreflight(errors, warnings, file_count, total_bytes)
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
# frontmatter 字段直通到发布表单(track/name/description 另行处理)。
_PUBLISH_PASSTHROUGH = (
"language",
"domains",
"stages",
"forms",
"audiences",
"tags",
"changelog",
"license",
"longDescription",
)
def _slugify(text: str) -> str:
cleaned = re.sub(r"[^a-z0-9._-]+", "-", (text or "").lower()).strip("-")
return cleaned or "skill"
def _read_frontmatter(skill_md: Path) -> dict[str, Any]:
try:
text = skill_md.read_text(encoding="utf-8")
except OSError:
return {}
match = _FRONTMATTER_RE.match(text)
if not match:
return {}
try:
import yaml
data = yaml.safe_load(match.group(1))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def read_skill_metadata(directory: str | Path) -> dict[str, Any]:
"""Public reader for a skill dir's SKILL.md frontmatter (``{}`` if absent).
Lets the interactive CLI pre-fill prompts from frontmatter without
re-implementing the parser.
"""
return _read_frontmatter(Path(directory).expanduser().resolve() / "SKILL.md")
def resolve_publish_identity(
directory: str | Path,
*,
slug: str | None = None,
version: str | None = None,
) -> tuple[str, str]:
"""Resolve the (slug, version) a publish would use; version may be ``""``.
Same precedence as :func:`publish_to_hub` (explicit arg → frontmatter →
slug from name/dir), exposed so the CLI can show an accurate confirmation
summary before uploading.
"""
root = Path(directory).expanduser().resolve()
fm = _read_frontmatter(root / "SKILL.md")
eff_slug = slug or str(fm.get("slug") or "") or _slugify(str(fm.get("name") or root.name))
eff_version = (version or str(fm.get("version") or "")).strip()
return eff_slug, eff_version
def _zip_dir(root: Path) -> bytes:
"""Zip a skill directory (skip dotfiles and __MACOSX), paths relative to root."""
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root)
if any(part.startswith(".") or part == "__MACOSX" for part in rel.parts):
continue
archive.write(path, rel.as_posix())
return buf.getvalue()
def publish_to_hub(
directory: str | Path,
*,
token: str,
version: str | None = None,
slug: str | None = None,
track: str | None = None,
hub: str | None = None,
overrides: dict[str, str] | None = None,
provider: SkillHubProvider | None = None,
) -> PublishOutcome:
"""Zip a local skill dir and publish a version to a publish-capable hub.
Metadata defaults come from the SKILL.md frontmatter; explicit args win.
``slug``/``version``/``track`` are what eduhub requires (track is
mandatory there). ``overrides`` carries the classification an interactive
caller resolved (track / language / domains / stages / forms / audiences /
tags / changelog); each present key replaces the frontmatter-derived value
verbatim — including an empty string, which clears a multi-select facet.
Auth is a bearer ``token`` minted on the hub web UI.
"""
root = Path(directory).expanduser().resolve()
skill_md = root / "SKILL.md"
if not skill_md.is_file():
raise SkillImportError(f"No SKILL.md found in {root}")
fm = _read_frontmatter(skill_md)
hub = hub or default_hub()
resolved = provider or get_hub_provider(hub)
publish = getattr(resolved, "publish", None)
if not callable(publish):
raise HubError(f"Hub `{hub}` does not support publishing.")
eff_slug, eff_version = resolve_publish_identity(root, slug=slug, version=version)
if not eff_version:
raise SkillImportError(
"A version is required (pass --version or set `version:` in SKILL.md)."
)
fields: dict[str, str] = {}
if fm.get("name"):
fields["name"] = str(fm["name"])
if fm.get("description"):
fields["description"] = str(fm["description"])
eff_track = track or str(fm.get("track") or "")
if eff_track:
fields["track"] = eff_track
for key in _PUBLISH_PASSTHROUGH:
value = fm.get(key)
if isinstance(value, list):
fields[key] = ",".join(str(x) for x in value)
elif value is not None:
fields[key] = str(value)
# An interactive caller's resolved classification is authoritative: present
# keys replace the frontmatter-derived value as-is (empty string clears).
if overrides:
fields.update(overrides)
response = publish(
slug=eff_slug,
version=eff_version,
zip_bytes=_zip_dir(root),
token=token,
fields=fields,
)
return PublishOutcome(hub=hub, slug=eff_slug, version=eff_version, response=response)
__all__ = [
"DEFAULT_HUB",
"ClawHubProvider",
"CommandProvider",
"FetchedSkill",
"HubError",
"HubInstallOutcome",
"HubSkillRef",
"HubVerdict",
"PublishOutcome",
"SkillHubProvider",
"SkillPreflight",
"default_hub",
"get_hub_provider",
"install_from_hub",
"parse_hub_ref",
"preflight_skill_dir",
"publish_to_hub",
"read_skill_metadata",
"resolve_publish_identity",
]