mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 01:48:53 +00:00
939 lines
32 KiB
Python
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:]
|