nanobot/nanobot/webui/skills_marketplace.py

939 lines
32 KiB
Python

"""Search and install skills from public Agent Skills catalogs."""
from __future__ import annotations
import asyncio
import hashlib
import os
import re
import shutil
import stat
import tempfile
import time
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any, cast
from urllib.parse import quote, urlparse
import httpx
from nanobot.agent.skills import SkillsLoader
from nanobot.security.network import PinnedDNSAsyncTransport
from nanobot.security.workspace_policy import WorkspaceBoundaryError, require_path_within
_PROVIDER_ALL = "all"
_PROVIDER_SKILLS_SH = "skills_sh"
_PROVIDER_SKILLHUB = "skillhub"
_PROVIDERS = {_PROVIDER_ALL, _PROVIDER_SKILLS_SH, _PROVIDER_SKILLHUB}
_SEARCH_URL = "https://skills.sh/api/search"
_TRENDING_URL = "https://skills.sh/api/skills/trending/0"
_SKILL_PAGE_BASE_URL = "https://www.skills.sh"
_SKILLHUB_API_BASE_URL = "https://api.skillhub.cn"
_SKILLHUB_SEARCH_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/search"
_SKILLHUB_TRENDING_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/showcase/trending"
_SKILLHUB_DOWNLOAD_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/download"
_SKILLHUB_PAGE_BASE_URL = "https://skillhub.cn"
_ALL_TIME_URLS = (
"https://skills.sh/api/skills/all-time/0",
"https://skills.sh/api/skills/all-time/1",
)
_TREND_VALUES_RE = re.compile(r'\\"values\\":\s*\[([0-9,\s]+)\]')
_SOURCE_RE = re.compile(
r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/"
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$"
)
_SKILL_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._+-]{0,63})$")
_ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
_INSTALL_TIMEOUT_SECONDS = 120
_WEEKLY_CACHE_TTL_SECONDS = 300
_SKILLHUB_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024
_SKILLHUB_MAX_UNPACKED_BYTES = 100 * 1024 * 1024
_SKILLHUB_MAX_FILES = 1_000
# The skills CLI's OpenClaw adapter copies into <workspace>/skills, nanobot's layout too.
_CLI_AGENT = "openclaw"
_weekly_cache: dict[tuple[str, str], list[int]] = {}
_weekly_cache_expires_at = 0.0
def _response_json_object(response: httpx.Response) -> dict[str, Any] | None:
"""Narrow an untyped HTTP JSON response at the external-data boundary."""
payload = cast(object, response.json())
return cast(dict[str, Any], payload) if isinstance(payload, dict) else None
class SkillsMarketplaceError(Exception):
"""A safe error that can be returned to the WebUI."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def skills_install_supported() -> bool:
"""Return whether the official skills CLI can be launched."""
return shutil.which("npx") is not None
async def trending_marketplace_skills(
workspace_path: Path,
*,
limit: int = 8,
provider: str = _PROVIDER_ALL,
) -> dict[str, Any]:
"""Return provider-aware marketplace rankings without mixing metric semantics."""
selected = _valid_provider(provider)
if selected == _PROVIDER_SKILLHUB:
return await _trending_skillhub_skills(workspace_path, limit=limit)
if selected == _PROVIDER_SKILLS_SH:
return await _trending_skills_sh_skills(workspace_path, limit=limit)
results = await asyncio.gather(
_trending_skills_sh_skills(workspace_path, limit=limit),
_trending_skillhub_skills(workspace_path, limit=limit),
return_exceptions=True,
)
payloads = [result for result in results if isinstance(result, dict)]
if not payloads:
raise SkillsMarketplaceError(
"skill marketplaces are temporarily unavailable",
status=502,
)
return {
"skills": [
skill
for payload in payloads
for skill in payload.get("skills", [])
if isinstance(skill, dict)
],
"period": "mixed",
"provider": _PROVIDER_ALL,
"install_supported": any(bool(payload.get("install_supported")) for payload in payloads),
}
async def _trending_skills_sh_skills(
workspace_path: Path,
*,
limit: int,
) -> dict[str, Any]:
"""Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard."""
try:
async with _skills_client() as client:
response = await client.get(_TRENDING_URL)
response.raise_for_status()
payload = _response_json_object(response) or {}
except (httpx.HTTPError, ValueError) as exc:
raise SkillsMarketplaceError(
"skills.sh trending skills are temporarily unavailable",
status=502,
) from exc
installed = _installed_skill_names(workspace_path)
rows = payload.get("skills", [])
skills: list[dict[str, Any]] = []
seen_sources: set[str] = set()
for rank, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
row_payload = cast(dict[str, Any], row)
source = row_payload.get("source")
if not isinstance(source, str) or source in seen_sources:
continue
skill = _marketplace_skill(row_payload, installed, rank=rank)
if skill is None:
continue
seen_sources.add(source)
skills.append(skill)
if len(skills) >= min(max(limit, 1), 20):
break
return {
"skills": skills,
"period": "24h",
"provider": _PROVIDER_SKILLS_SH,
"install_supported": skills_install_supported(),
}
async def search_marketplace_skills(
query: str,
workspace_path: Path,
*,
limit: int = 20,
provider: str = _PROVIDER_ALL,
) -> dict[str, Any]:
"""Search one or all catalogs and annotate locally installed results."""
normalized = " ".join(query.split())
if len(normalized) < 2:
raise SkillsMarketplaceError("search query must contain at least 2 characters")
if len(normalized) > 100:
raise SkillsMarketplaceError("search query is too long")
selected = _valid_provider(provider)
if selected == _PROVIDER_SKILLHUB:
return await _search_skillhub_skills(normalized, workspace_path, limit=limit)
if selected == _PROVIDER_SKILLS_SH:
return await _search_skills_sh_skills(normalized, workspace_path, limit=limit)
results = await asyncio.gather(
_search_skills_sh_skills(normalized, workspace_path, limit=limit),
_search_skillhub_skills(normalized, workspace_path, limit=limit),
return_exceptions=True,
)
payloads = [result for result in results if isinstance(result, dict)]
if not payloads:
raise SkillsMarketplaceError(
"skill marketplaces are temporarily unavailable",
status=502,
)
return {
"query": normalized,
"skills": [
skill
for payload in payloads
for skill in payload.get("skills", [])
if isinstance(skill, dict)
],
"provider": _PROVIDER_ALL,
"install_supported": any(bool(payload.get("install_supported")) for payload in payloads),
}
async def _search_skills_sh_skills(
normalized: str,
workspace_path: Path,
*,
limit: int,
) -> dict[str, Any]:
try:
async with _skills_client() as client:
response = await client.get(
_SEARCH_URL,
params={"q": normalized, "limit": min(max(limit, 1), 50)},
)
response.raise_for_status()
payload = _response_json_object(response) or {}
except (httpx.HTTPError, ValueError) as exc:
raise SkillsMarketplaceError(
"skills.sh search is temporarily unavailable",
status=502,
) from exc
installed = _installed_skill_names(workspace_path)
rows = payload.get("skills", [])
skills: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
skill = _marketplace_skill(cast(dict[str, Any], row), installed)
if skill is not None:
skills.append(skill)
return {
"query": normalized,
"skills": skills,
"provider": _PROVIDER_SKILLS_SH,
"install_supported": skills_install_supported(),
}
async def _search_skillhub_skills(
normalized: str,
workspace_path: Path,
*,
limit: int,
) -> dict[str, Any]:
try:
async with _skillhub_client() as client:
response = await client.get(
_SKILLHUB_SEARCH_URL,
params={"q": normalized, "limit": min(max(limit, 1), 50)},
)
response.raise_for_status()
payload = _response_json_object(response) or {}
except (httpx.HTTPError, ValueError) as exc:
raise SkillsMarketplaceError(
"SkillHub search is temporarily unavailable",
status=502,
) from exc
installed = _installed_skill_names(workspace_path)
rows = payload.get("results", [])
skills = [
skill
for row in rows
if isinstance(row, dict)
if (skill := _skillhub_skill(cast(dict[str, Any], row), installed)) is not None
]
return {
"query": normalized,
"skills": skills,
"provider": _PROVIDER_SKILLHUB,
"install_supported": True,
}
async def _trending_skillhub_skills(
workspace_path: Path,
*,
limit: int,
) -> dict[str, Any]:
try:
async with _skillhub_client() as client:
response = await client.get(_SKILLHUB_TRENDING_URL)
response.raise_for_status()
payload = _response_json_object(response) or {}
except (httpx.HTTPError, ValueError) as exc:
raise SkillsMarketplaceError(
"SkillHub trending skills are temporarily unavailable",
status=502,
) from exc
installed = _installed_skill_names(workspace_path)
rows = payload.get("skills", [])
skills: list[dict[str, Any]] = []
for rank, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
skill = _skillhub_skill(cast(dict[str, Any], row), installed, rank=rank)
if skill is not None:
skills.append(skill)
if len(skills) >= min(max(limit, 1), 20):
break
return {
"skills": skills,
"period": "trending",
"provider": _PROVIDER_SKILLHUB,
"install_supported": True,
}
async def marketplace_skill_trends(
skill_ids: list[str] | None = None,
) -> dict[str, dict[str, list[int]]]:
"""Return install history independently, filling requested cache misses."""
requested = _valid_skill_refs(skill_ids or [])
async with _skills_client() as client:
weekly_installs = await _load_weekly_installs(client)
missing = [ref for ref in requested if ref not in weekly_installs]
if missing:
weekly_installs.update(await _load_skill_page_trends(client, missing))
selected = requested or list(weekly_installs)
return {
"trends": {
f"{source}/{skill_id}": values
for source, skill_id in selected
if (values := weekly_installs.get((source, skill_id))) is not None
}
}
async def install_marketplace_skill(
source: str,
skill_id: str,
workspace_path: Path,
*,
provider: str = _PROVIDER_SKILLS_SH,
version: str = "",
) -> dict[str, Any]:
"""Install one normalized marketplace result into ``<workspace>/skills``."""
selected = _valid_provider(provider, allow_all=False)
if selected == _PROVIDER_SKILLHUB:
return await _install_skillhub_skill(skill_id, version, workspace_path)
return await _install_skills_sh_skill(source, skill_id, workspace_path)
async def _install_skills_sh_skill(
source: str,
skill_id: str,
workspace_path: Path,
) -> dict[str, Any]:
if not _SOURCE_RE.fullmatch(source):
raise SkillsMarketplaceError("invalid skill source")
if not _valid_skill_id(skill_id):
raise SkillsMarketplaceError("invalid skill name")
loader = SkillsLoader(workspace_path)
existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)}
if skill_id in existing:
return {"installed": True, "already_installed": True, "name": skill_id}
workspace = workspace_path.expanduser().resolve()
workspace.mkdir(parents=True, exist_ok=True)
try:
require_path_within(
workspace / "skills",
workspace,
message="skills directory must stay inside the workspace",
)
except WorkspaceBoundaryError as exc:
raise SkillsMarketplaceError(str(exc), status=403) from exc
npx = shutil.which("npx")
if npx is None:
raise SkillsMarketplaceError(
"Node.js with npx is required to install skills",
status=503,
)
env = os.environ.copy()
env["DISABLE_TELEMETRY"] = "1"
command = (
npx,
"--yes",
"skills@latest",
"add",
source,
"--skill",
skill_id,
"--agent",
_CLI_AGENT,
"--copy",
"--yes",
)
process = await asyncio.create_subprocess_exec(
*command,
cwd=str(workspace),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
output, _ = await asyncio.wait_for(
process.communicate(),
timeout=_INSTALL_TIMEOUT_SECONDS,
)
except TimeoutError as exc:
process.kill()
await process.communicate()
raise SkillsMarketplaceError("skill installation timed out", status=504) from exc
if process.returncode != 0:
detail = _safe_output_tail(output)
message = "skill installation failed"
if detail:
message = f"{message}: {detail}"
raise SkillsMarketplaceError(message, status=502)
installed = next(
(
entry
for entry in loader.list_skills(filter_unavailable=False)
if entry["source"] == "workspace" and entry["name"] == skill_id
),
None,
)
if installed is None:
raise SkillsMarketplaceError(
"installer completed but the skill was not found in this workspace",
status=502,
)
return {"installed": True, "already_installed": False, "name": skill_id}
async def _install_skillhub_skill(
skill_id: str,
requested_version: str,
workspace_path: Path,
) -> dict[str, Any]:
if not _valid_skill_id(skill_id):
raise SkillsMarketplaceError("invalid SkillHub skill name")
if requested_version and _VERSION_RE.fullmatch(requested_version) is None:
raise SkillsMarketplaceError("invalid SkillHub skill version")
loader = SkillsLoader(workspace_path)
existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)}
if skill_id in existing:
return {
"installed": True,
"already_installed": True,
"name": skill_id,
"provider": _PROVIDER_SKILLHUB,
}
workspace = workspace_path.expanduser().resolve()
workspace.mkdir(parents=True, exist_ok=True)
try:
skills_root = require_path_within(
workspace / "skills",
workspace,
message="skills directory must stay inside the workspace",
)
except WorkspaceBoundaryError as exc:
raise SkillsMarketplaceError(str(exc), status=403) from exc
skills_root.mkdir(parents=True, exist_ok=True)
target = skills_root / skill_id
try:
async with _skillhub_client() as client:
version = requested_version or await _skillhub_latest_version(client, skill_id)
signature = await _skillhub_signature(client, skill_id, version)
expected_hash = signature.get("content_hash")
if not isinstance(expected_hash, str) or not re.fullmatch(
r"[0-9a-fA-F]{64}",
expected_hash,
):
raise SkillsMarketplaceError(
"SkillHub did not provide a valid package fingerprint",
status=502,
)
with tempfile.TemporaryDirectory(
prefix=".skillhub-install-",
dir=skills_root,
) as temporary:
temporary_path = Path(temporary)
archive_path = temporary_path / f"{skill_id}.zip"
stage_path = temporary_path / "stage"
await _download_skillhub_archive(
client,
skill_id,
version,
archive_path,
)
actual_hash = _validate_skillhub_archive(archive_path)
if actual_hash.lower() != expected_hash.lower():
raise SkillsMarketplaceError(
"SkillHub package fingerprint did not match",
status=502,
)
_extract_skillhub_archive(archive_path, stage_path)
if target.exists():
return {
"installed": True,
"already_installed": True,
"name": skill_id,
"provider": _PROVIDER_SKILLHUB,
}
os.replace(stage_path, target)
except SkillsMarketplaceError:
raise
except (httpx.HTTPError, OSError, zipfile.BadZipFile) as exc:
raise SkillsMarketplaceError(
"SkillHub skill installation failed",
status=502,
) from exc
installed = next(
(
entry
for entry in loader.list_skills(filter_unavailable=False)
if entry["source"] == "workspace" and entry["name"] == skill_id
),
None,
)
if installed is None:
raise SkillsMarketplaceError(
"installer completed but the skill was not found in this workspace",
status=502,
)
return {
"installed": True,
"already_installed": False,
"name": skill_id,
"provider": _PROVIDER_SKILLHUB,
"version": version,
}
async def _skillhub_latest_version(client: httpx.AsyncClient, skill_id: str) -> str:
response = await client.get(
f"{_SKILLHUB_API_BASE_URL}/api/v1/skills/{quote(skill_id, safe='')}"
)
response.raise_for_status()
payload = _response_json_object(response) or {}
raw_latest = payload.get("latestVersion", {})
latest = cast(dict[str, Any], raw_latest) if isinstance(raw_latest, dict) else {}
version = latest.get("version")
if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None:
raise SkillsMarketplaceError(
"SkillHub did not return a valid skill version",
status=502,
)
return version
async def _skillhub_signature(
client: httpx.AsyncClient,
skill_id: str,
version: str,
) -> dict[str, Any]:
response = await client.get(
f"{_SKILLHUB_API_BASE_URL}/api/v1/open/skills/"
f"{quote(skill_id, safe='')}/versions/{quote(version, safe='')}/signature"
)
response.raise_for_status()
payload = _response_json_object(response)
if payload is None:
raise SkillsMarketplaceError(
"SkillHub returned an invalid package fingerprint",
status=502,
)
return payload
async def _download_skillhub_archive(
client: httpx.AsyncClient,
skill_id: str,
version: str,
destination: Path,
) -> None:
redirect = await client.get(
_SKILLHUB_DOWNLOAD_URL,
params={"slug": skill_id, "version": version},
)
if redirect.status_code not in {301, 302, 303, 307, 308}:
redirect.raise_for_status()
raise SkillsMarketplaceError(
"SkillHub returned an unexpected download response",
status=502,
)
location = redirect.headers.get("location", "")
if not _valid_skillhub_download_url(location):
raise SkillsMarketplaceError(
"SkillHub returned an unsafe download location",
status=502,
)
received = 0
async with client.stream(
"GET",
location,
headers={"Accept": "application/zip,application/octet-stream"},
) as response:
response.raise_for_status()
declared = response.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > _SKILLHUB_MAX_DOWNLOAD_BYTES:
raise SkillsMarketplaceError("SkillHub package is too large", status=413)
with destination.open("wb") as output:
async for chunk in response.aiter_bytes():
received += len(chunk)
if received > _SKILLHUB_MAX_DOWNLOAD_BYTES:
raise SkillsMarketplaceError("SkillHub package is too large", status=413)
output.write(chunk)
def _valid_skillhub_download_url(value: str) -> bool:
try:
parsed = urlparse(value)
hostname = (parsed.hostname or "").lower()
port = parsed.port
except ValueError:
return False
return (
parsed.scheme == "https"
and parsed.username is None
and parsed.password is None
and port in {None, 443}
and hostname.endswith(".myqcloud.com")
)
def _validated_skillhub_entries(
archive: zipfile.ZipFile,
) -> list[tuple[zipfile.ZipInfo, str]]:
entries: list[tuple[zipfile.ZipInfo, str]] = []
seen: set[str] = set()
unpacked = 0
for info in archive.infolist():
raw_name = info.filename.replace("\\", "/")
path = PurePosixPath(raw_name)
normalized = path.as_posix()
mode = info.external_attr >> 16
kind = stat.S_IFMT(mode)
if (
not normalized
or "\x00" in normalized
or path.is_absolute()
or ".." in path.parts
or (path.parts and ":" in path.parts[0])
or kind == stat.S_IFLNK
or kind not in {0, stat.S_IFREG, stat.S_IFDIR}
):
raise SkillsMarketplaceError(
f"SkillHub package contains an unsafe path: {raw_name}",
status=422,
)
if info.is_dir():
continue
if normalized in seen:
raise SkillsMarketplaceError(
f"SkillHub package contains a duplicate path: {normalized}",
status=422,
)
seen.add(normalized)
unpacked += info.file_size
if len(entries) >= _SKILLHUB_MAX_FILES:
raise SkillsMarketplaceError("SkillHub package contains too many files", status=413)
if unpacked > _SKILLHUB_MAX_UNPACKED_BYTES:
raise SkillsMarketplaceError(
"SkillHub package expands beyond the size limit", status=413
)
entries.append((info, normalized))
if "SKILL.md" not in seen:
raise SkillsMarketplaceError(
"SkillHub package does not contain a root SKILL.md",
status=422,
)
return entries
def _validate_skillhub_archive(archive_path: Path) -> str:
hashed: list[tuple[str, str]] = []
with zipfile.ZipFile(archive_path, "r") as archive:
for info, normalized in _validated_skillhub_entries(archive):
if _skillhub_hash_ignored(normalized):
continue
digest = hashlib.sha256()
with archive.open(info, "r") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
hashed.append((normalized, digest.hexdigest()))
combined = hashlib.sha256()
for normalized, digest in sorted(hashed):
combined.update(f"{normalized}:{digest}\n".encode())
return combined.hexdigest()
def _skillhub_hash_ignored(path: str) -> bool:
parts = PurePosixPath(path).parts
basename = parts[-1] if parts else ""
return (
path == "_meta.json"
or "__MACOSX" in parts
or basename == ".DS_Store"
or basename.startswith("._")
or basename.lower() == "thumbs.db"
)
def _extract_skillhub_archive(archive_path: Path, destination: Path) -> None:
destination.mkdir()
with zipfile.ZipFile(archive_path, "r") as archive:
for info, normalized in _validated_skillhub_entries(archive):
target = destination.joinpath(*PurePosixPath(normalized).parts)
target.parent.mkdir(parents=True, exist_ok=True)
with archive.open(info, "r") as source, target.open("wb") as output:
shutil.copyfileobj(source, output)
mode = (info.external_attr >> 16) & 0o777
if mode:
target.chmod(mode & 0o755)
def _skills_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=PinnedDNSAsyncTransport(),
timeout=10.0,
follow_redirects=False,
)
def _skillhub_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=PinnedDNSAsyncTransport(),
timeout=httpx.Timeout(30.0, connect=10.0),
follow_redirects=False,
)
def _installed_skill_names(workspace_path: Path) -> set[str]:
return {
entry["name"]
for entry in SkillsLoader(workspace_path).list_skills(filter_unavailable=False)
}
def _marketplace_skill(
row: dict[str, Any],
installed: set[str],
*,
rank: int | None = None,
) -> dict[str, Any] | None:
source = row.get("source")
skill_id = row.get("skillId")
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
return None
if not isinstance(skill_id, str) or not _valid_skill_id(skill_id):
return None
display_name = row.get("name")
if not isinstance(display_name, str) or not display_name.strip():
display_name = skill_id
installs = row.get("installs")
skill: dict[str, Any] = {
"id": f"{source}/{skill_id}",
"skill_id": skill_id,
"name": display_name.strip(),
"source": source,
"provider": _PROVIDER_SKILLS_SH,
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
"url": f"https://skills.sh/{source}/{skill_id}",
"installed": skill_id in installed,
"install_supported": skills_install_supported(),
"metric": "installs_24h" if rank is not None else "installs_total",
}
if rank is not None:
skill["rank"] = rank
return skill
def _skillhub_skill(
row: dict[str, Any],
installed: set[str],
*,
rank: int | None = None,
) -> dict[str, Any] | None:
skill_id = row.get("slug")
if not isinstance(skill_id, str) or not _valid_skill_id(skill_id):
return None
display_name = row.get("displayName") or row.get("name") or skill_id
if not isinstance(display_name, str) or not display_name.strip():
display_name = skill_id
namespace = row.get("namespace")
namespace_payload = cast(dict[str, Any], namespace) if isinstance(namespace, dict) else {}
handle = namespace_payload.get("handle")
if not isinstance(handle, str) or not handle.strip():
owner = row.get("owner_name") or row.get("ownerName")
handle = owner if isinstance(owner, str) and owner.strip() else "community"
source = f"@{handle.strip()}/{skill_id}"
installs = row.get("installs")
downloads = row.get("downloads")
publisher = row.get("publisher")
publisher_payload = cast(dict[str, Any], publisher) if isinstance(publisher, dict) else {}
verified = publisher_payload.get("verified") is True
labels = row.get("labels")
labels_payload = cast(dict[str, Any], labels) if isinstance(labels, dict) else {}
requires_api_key = str(labels_payload.get("requires_api_key", "")).lower() == "true"
version = row.get("version")
if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None:
version = ""
skill: dict[str, Any] = {
"id": f"{_PROVIDER_SKILLHUB}:{skill_id}",
"skill_id": skill_id,
"name": display_name.strip(),
"source": source,
"provider": _PROVIDER_SKILLHUB,
"installs": installs if isinstance(installs, int) and installs >= 0 else 0,
"downloads": downloads if isinstance(downloads, int) and downloads >= 0 else 0,
"url": f"{_SKILLHUB_PAGE_BASE_URL}/{quote(handle.strip(), safe='')}/"
f"{quote(skill_id, safe='')}",
"installed": skill_id in installed,
"install_supported": True,
"metric": "installs_total",
"version": version,
"verified": verified,
"requires_api_key": requires_api_key,
}
if rank is not None:
skill["rank"] = rank
return skill
async def _load_weekly_installs(
client: httpx.AsyncClient,
) -> dict[tuple[str, str], list[int]]:
global _weekly_cache, _weekly_cache_expires_at
now = time.monotonic()
if now < _weekly_cache_expires_at:
return _weekly_cache
responses = await asyncio.gather(
*(client.get(url) for url in _ALL_TIME_URLS),
return_exceptions=True,
)
history: dict[tuple[str, str], list[int]] = {}
successful = False
for response in responses:
if isinstance(response, BaseException):
continue
try:
response.raise_for_status()
payload = _response_json_object(response) or {}
except (httpx.HTTPError, ValueError):
continue
successful = True
rows = payload.get("skills", [])
for row in rows:
if not isinstance(row, dict):
continue
row_payload = cast(dict[str, Any], row)
source = row_payload.get("source")
skill_id = row_payload.get("skillId")
values = row_payload.get("weeklyInstalls")
if isinstance(source, str) and isinstance(skill_id, str) and isinstance(values, list):
clean = [
value
for value in cast(list[object], values)
if isinstance(value, int) and not isinstance(value, bool) and value >= 0
]
if len(clean) >= 2:
history[(source, skill_id)] = clean
if successful:
_weekly_cache = history
_weekly_cache_expires_at = now + _WEEKLY_CACHE_TTL_SECONDS
return history
def _valid_skill_refs(skill_ids: list[str]) -> list[tuple[str, str]]:
refs: list[tuple[str, str]] = []
for value in skill_ids[:20]:
if "/" not in value:
continue
source, skill_id = value.rsplit("/", 1)
ref = (source, skill_id)
if _SOURCE_RE.fullmatch(source) and _valid_skill_id(skill_id) and ref not in refs:
refs.append(ref)
return refs
async def _load_skill_page_trends(
client: httpx.AsyncClient,
refs: list[tuple[str, str]],
) -> dict[tuple[str, str], list[int]]:
semaphore = asyncio.Semaphore(6)
async def fetch(ref: tuple[str, str]) -> tuple[tuple[str, str], list[int]]:
source, skill_id = ref
try:
async with semaphore:
response = await client.get(f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}")
response.raise_for_status()
except httpx.HTTPError:
return ref, []
match = _TREND_VALUES_RE.search(response.text)
if match is None:
return ref, []
values = [int(value) for value in match.group(1).split(",") if value.strip()]
return ref, values if len(values) >= 2 else []
return dict(await asyncio.gather(*(fetch(ref) for ref in refs)))
def _valid_skill_id(value: str) -> bool:
return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None
def _valid_provider(value: str, *, allow_all: bool = True) -> str:
normalized = value.strip().lower() or _PROVIDER_ALL
allowed = _PROVIDERS if allow_all else _PROVIDERS - {_PROVIDER_ALL}
if normalized not in allowed:
raise SkillsMarketplaceError("invalid skill marketplace provider")
return normalized
def _safe_output_tail(output: bytes | None) -> str:
if not output:
return ""
text = _ANSI_RE.sub("", output.decode("utf-8", errors="replace"))
lines = [line.strip() for line in text.splitlines() if line.strip()]
return " · ".join(lines[-3:])[-600:]