Files
hive/core/framework/skills/registry.py
T
Hundao 589c5b06fe fix: resolve all ruff lint and format errors across codebase (#7058)
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
  - E501: wrapped long lines in queen_profiles, catalog, routes_credentials
  - F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
    HookContext, HookResult; added runtime imports where needed
  - F811: removed duplicate method definitions in queen_lifecycle_tools
  - F841/B007: removed unused variables in discovery.py
  - W291: removed trailing whitespace in queen nodes
  - E402: moved import to top of queen_memory_v2.py
  - Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
2026-04-16 19:30:01 +08:00

206 lines
7.7 KiB
Python

"""Registry client for the Hive community skill registry.
Fetches the skill index from the hive-skill-registry GitHub repo, caches it
locally, and provides search and resolution utilities.
The registry repo (Phase 3) may not exist yet. All public methods degrade
gracefully — returning None or [] on any network or parse failure.
Configure a custom registry URL via the HIVE_REGISTRY_URL environment variable.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import UTC, datetime
from pathlib import Path
from urllib.error import URLError
from urllib.request import urlopen
logger = logging.getLogger(__name__)
# Default registry index URL (Phase 3 repo, may not exist yet)
_DEFAULT_REGISTRY_URL = (
"https://raw.githubusercontent.com/hive-skill-registry/hive-skill-registry/main/skill_index.json"
)
_CACHE_DIR = Path.home() / ".hive" / "registry_cache"
_CACHE_INDEX_PATH = _CACHE_DIR / "skill_index.json"
_CACHE_METADATA_PATH = _CACHE_DIR / "metadata.json"
_CACHE_TTL_SECONDS = 3600 # 1 hour
class RegistryClient:
"""Client for the Hive community skill registry.
All public methods return None / [] on any failure — never raise.
Network errors, parse failures, and missing registries are all
treated as graceful degradation.
"""
def __init__(
self,
registry_url: str | None = None,
cache_dir: Path | None = None,
) -> None:
self._url = registry_url or os.environ.get("HIVE_REGISTRY_URL", _DEFAULT_REGISTRY_URL)
cache_root = cache_dir or _CACHE_DIR
self._index_path = cache_root / "skill_index.json"
self._metadata_path = cache_root / "metadata.json"
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def fetch_index(self, force_refresh: bool = False) -> dict | None:
"""Return the registry index dict.
Uses the local cache if it is fresh (within TTL) unless
force_refresh=True. Returns None on any failure.
"""
if not force_refresh and self._is_cache_fresh():
cached = self._load_cache()
if cached is not None:
return cached
raw = self._http_fetch(self._url)
if raw is None:
# Network unavailable — fall back to stale cache if present
stale = self._load_cache()
if stale is not None:
logger.debug("registry: network unavailable, using stale cache")
return stale
try:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("registry: failed to parse index JSON: %s", exc)
return self._load_cache()
if not isinstance(data, dict):
logger.warning("registry: index is not a JSON object")
return self._load_cache()
self._save_cache(data)
return data
def search(self, query: str) -> list[dict]:
"""Search registry skills by name, description, or tags.
Case-insensitive substring match. Returns [] if index unavailable.
"""
index = self.fetch_index()
if not index:
return []
skills = index.get("skills", [])
if not isinstance(skills, list):
return []
q = query.lower()
results = []
for entry in skills:
if not isinstance(entry, dict):
continue
name = str(entry.get("name", "")).lower()
description = str(entry.get("description", "")).lower()
tags = " ".join(str(t) for t in entry.get("tags", [])).lower()
if q in name or q in description or q in tags:
results.append(entry)
return results
def get_skill_entry(self, name: str) -> dict | None:
"""Look up a single skill by exact name. Returns None if not found."""
index = self.fetch_index()
if not index:
return None
for entry in index.get("skills", []):
if isinstance(entry, dict) and entry.get("name") == name:
return entry
return None
def get_pack(self, pack_name: str) -> list[str] | None:
"""Return the list of skill names in a starter pack.
Returns None if the pack is not found or the index is unavailable.
"""
index = self.fetch_index()
if not index:
return None
for pack in index.get("packs", []):
if isinstance(pack, dict) and pack.get("name") == pack_name:
skills = pack.get("skills", [])
if isinstance(skills, list):
return [s for s in skills if isinstance(s, str)]
return None
def resolve_git_url(self, name: str) -> tuple[str, str | None] | None:
"""Return (git_url, subdirectory) for a skill name.
Returns None if the skill is not in the registry or the index
is unavailable.
"""
entry = self.get_skill_entry(name)
if not entry:
return None
git_url = entry.get("git_url")
if not git_url:
return None
subdirectory = entry.get("subdirectory") or None
return str(git_url), subdirectory
# ------------------------------------------------------------------
# Cache internals
# ------------------------------------------------------------------
def _load_cache(self) -> dict | None:
"""Read cached index from disk. Returns None if absent or unreadable."""
try:
data = json.loads(self._index_path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except FileNotFoundError:
return None
except Exception as exc:
logger.debug("registry: could not read cache: %s", exc)
return None
def _save_cache(self, data: dict) -> None:
"""Write index to disk atomically (.tmp then rename)."""
try:
self._index_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._index_path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._index_path)
# Update metadata
meta = {"last_fetched": datetime.now(tz=UTC).isoformat()}
meta_tmp = self._metadata_path.with_suffix(".tmp")
meta_tmp.write_text(json.dumps(meta, indent=2), encoding="utf-8")
meta_tmp.replace(self._metadata_path)
except Exception as exc:
logger.debug("registry: could not write cache: %s", exc)
def _is_cache_fresh(self) -> bool:
"""Return True if the cached index was fetched within the TTL."""
try:
meta = json.loads(self._metadata_path.read_text(encoding="utf-8"))
last_fetched = datetime.fromisoformat(meta["last_fetched"])
age = (datetime.now(tz=UTC) - last_fetched).total_seconds()
return age < _CACHE_TTL_SECONDS
except Exception:
return False
def _http_fetch(self, url: str, timeout: int = 10) -> bytes | None:
"""Fetch URL contents. Returns None on any network error — never raises."""
try:
with urlopen(url, timeout=timeout) as resp: # noqa: S310
return resp.read()
except URLError as exc:
logger.debug("registry: HTTP fetch failed for %s: %s", url, exc)
return None
except TimeoutError as exc:
logger.debug("registry: HTTP fetch timed out for %s: %s", url, exc)
return None
except Exception as exc:
logger.debug("registry: unexpected error fetching %s: %s", url, exc)
return None