"""Skills Hub official sources: repo-shipped optional skills and the centralized Hermes index.""" import logging from pathlib import Path, PurePosixPath from typing import Dict, List, Optional, Tuple, Union from agent.skill_utils import is_excluded_skill_path from tools.skills_hub_github import GitHubAuth, GitHubSource, _skip_bundle_file from tools.skills_hub_models import ( SkillBundle, SkillMeta, SkillSource, _hermes_tags, _matches_query, _parse_frontmatter, hub, ) logger = logging.getLogger("tools.skills_hub") # Identifier prefixes stripped when matching index entries loosely. _INDEX_ID_PREFIXES = ("skills-sh/", "skills.sh/", "official/", "github/", "clawhub/") def _strip_prefix(value: str, prefixes) -> str: for prefix in prefixes: if value.startswith(prefix): return value[len(prefix):] return value def _clean_rel_parts(path: str) -> Optional[List[str]]: """Split a relative path, dropping ``.``/empty parts; None on traversal or empty.""" parts = [p for p in path.split("/") if p not in ("", ".")] if not parts or any(p == ".." for p in parts): return None return parts # --------------------------------------------------------------------------- # Official optional skills source adapter # --------------------------------------------------------------------------- class OptionalSkillSource(SkillSource): """Skills from the repo's ``optional-skills/`` directory. Official (Nous-maintained) but not activated by default — absent from the system prompt and not copied to ~/.hermes/skills/ at setup. Discoverable via the Skills Hub as source "official" with "builtin" trust. """ OFFICIAL_REPO = "NousResearch/hermes-agent" OPTIONAL_SKILLS_PREFIX = "optional-skills" _parse_frontmatter = staticmethod(_parse_frontmatter) def __init__(self, auth: Optional[GitHubAuth] = None): from hermes_constants import get_optional_skills_dir self._optional_dir = get_optional_skills_dir( Path(__file__).parent.parent / "optional-skills" ) self._auth = auth # GitHubSource for the live-repo fallback, created only when a skill is # missing from the local checkout. self._github: Optional[GitHubSource] = None # "category/skill" -> True from the live repo tree; None = not fetched yet. self._remote_dirs: Optional[Dict[str, bool]] = None def source_id(self) -> str: return "official" def trust_level_for(self, identifier: str) -> str: return "builtin" @staticmethod def _rel(identifier: str) -> str: return identifier.split("/", 1)[-1] if identifier.startswith("official/") else identifier def _remote_meta(self, rel_dir: str) -> SkillMeta: """Placeholder meta for a skill that exists on live main but not locally.""" return SkillMeta( name=rel_dir.rsplit("/", 1)[-1], description="Official optional skill (from live repo; run install to fetch)", source="official", identifier=f"official/{rel_dir}", trust_level="builtin", repo=self.OFFICIAL_REPO, path=f"{self.OPTIONAL_SKILLS_PREFIX}/{rel_dir}", tags=[], ) # -- search ----------------------------------------------------------- def search(self, query: str, limit: int = 10) -> List[SkillMeta]: results: List[SkillMeta] = [] query_lower = query.lower() local_rels: set = set() for meta in self._scan_all(): local_rels.add(meta.identifier.split("/", 1)[-1] if meta.identifier else "") if _matches_query(query_lower, meta.name, meta.description, meta.tags): results.append(meta) if len(results) >= limit: break # Also surface skills that landed on live main after this install was cut. if len(results) < limit: for rel_dir in sorted(self._list_remote_skill_dirs()): if rel_dir in local_rels or (query_lower and query_lower not in rel_dir.lower()): continue results.append(self._remote_meta(rel_dir)) if len(results) >= limit: break return results # -- fetch ------------------------------------------------------------ def fetch(self, identifier: str) -> Optional[SkillBundle]: # identifier format: "official/category/skill" or "official/skill" rel = self._rel(identifier) skill_dir = self._optional_dir / rel # Guard against path traversal (e.g. "official/../../etc") try: resolved = skill_dir.resolve() optional_root = self._optional_dir.resolve() if not resolved.is_relative_to(optional_root): return None except (OSError, ValueError): return None if resolved.is_dir(): skill_dir = resolved else: # Try by bare skill name; if still absent, the skill may have # landed on main after this install was cut — use the live repo. skill_dir = self._find_skill_dir(rel.rsplit("/", 1)[-1]) if not skill_dir: return self._fetch_from_live_repo(rel) rel_id = skill_dir.resolve().relative_to(optional_root).as_posix() # Catalog stubs point at the real skill in an upstream-maintained repo # (metadata.hermes.upstream); install pulls the live content from there. upstream = self._upstream_pointer(skill_dir) if upstream is not None: return self._fetch_from_upstream(upstream, rel_id) files: Dict[str, Union[str, bytes]] = {} for f in skill_dir.rglob("*"): if f.is_file() and not _skip_bundle_file(f.relative_to(skill_dir).as_posix()): try: files[str(f.relative_to(skill_dir))] = f.read_bytes() except OSError: continue if not files: return None return SkillBundle( name=skill_dir.name, files=files, source="official", identifier=f"official/{rel_id}", trust_level="builtin", ) # -- inspect ---------------------------------------------------------- def inspect(self, identifier: str) -> Optional[SkillMeta]: skill_name = self._rel(identifier).rsplit("/", 1)[-1] for meta in self._scan_all(): if meta.name == skill_name: return meta # Not in the local checkout — check live main. matches = self._remote_matches(skill_name) if len(matches) == 1: return self._remote_meta(matches[0]) return None # -- catalog ---------------------------------------------------------- def list_local(self) -> List[SkillMeta]: """Every optional skill in the local checkout, with frontmatter metadata (backs the dashboard/desktop "built-in optional skills" catalog).""" return self._scan_all() # -- internal helpers ------------------------------------------------- def _get_github(self) -> GitHubSource: if self._github is None: self._github = GitHubSource(auth=self._auth or GitHubAuth()) return self._github def _remote_matches(self, name: str) -> List[str]: return [d for d in self._list_remote_skill_dirs() if d.rsplit("/", 1)[-1] == name] def _fetch_from_live_repo(self, rel: str) -> Optional[SkillBundle]: """Fetch an optional skill straight from the live default branch. Local installs lag ``main``; rather than demanding ``hermes update`` first, resolve against the live repo. ``rel`` is ``category/skill`` (used verbatim) or a bare skill name (located via the repo tree). """ parts = _clean_rel_parts(rel.strip("/")) if parts is None: return None rel = "/".join(parts) github = self._get_github() if rel not in self._list_remote_skill_dirs(): # Bare name (or stale category) — locate by final path segment. matches = self._remote_matches(parts[-1]) if len(matches) != 1: return None rel = matches[0] repo_path = f"{self.OPTIONAL_SKILLS_PREFIX}/{rel}" # Download the FULL directory byte-exact (root-level install scripts, # LICENSE, tests/). GitHubSource.fetch() would only pull SKILL.md + # referenced support dirs. tree = github._get_repo_tree(self.OFFICIAL_REPO) if tree is None: return None _branch, entries = tree prefix = f"{repo_path}/" files: Dict[str, Union[str, bytes]] = {} for item in entries: item_path = item.get("path", "") if ( item.get("type") != "blob" or item.get("mode") == "120000" or not item_path.startswith(prefix) ): continue rel_file = item_path[len(prefix):] if _skip_bundle_file(rel_file): continue content = github._fetch_file_bytes(self.OFFICIAL_REPO, item_path) if content is None: logger.warning("Live-repo optional skill fetch failed for %s", item_path) return None files[rel_file] = content if "SKILL.md" not in files: return None # Live-fetched catalog stubs redirect the same way local ones do. upstream = self._upstream_pointer_from_content(files["SKILL.md"]) if upstream is not None: return self._fetch_from_upstream(upstream, rel) logger.info("Optional skill '%s' fetched from live repo (not in local checkout)", rel) return SkillBundle( name=rel.rsplit("/", 1)[-1], files=files, source="official", identifier=f"official/{rel}", trust_level="builtin", ) def _list_remote_skill_dirs(self) -> Dict[str, bool]: """``category/skill`` dirs under optional-skills/ on live main. One repo-tree call (cached per-process by GitHubSource + the on-disk index cache). {} when the network/API is unavailable — callers degrade to local-only. """ if self._remote_dirs is not None: return self._remote_dirs cache_key = "official_optional_dirs" cached = hub()._read_index_cache(cache_key) if isinstance(cached, dict) and cached: self._remote_dirs = cached return cached dirs: Dict[str, bool] = {} tree = self._get_github()._get_repo_tree(self.OFFICIAL_REPO) if tree is not None: _branch, entries = tree prefix = f"{self.OPTIONAL_SKILLS_PREFIX}/" suffix = "/SKILL.md" for item in entries: path = item.get("path", "") if item.get("type") == "blob" and path.startswith(prefix) and path.endswith(suffix): rel_dir = path[len(prefix):-len(suffix)] if rel_dir and not is_excluded_skill_path(PurePosixPath(rel_dir + suffix)): dirs[rel_dir] = True if dirs: hub()._write_index_cache(cache_key, dirs) self._remote_dirs = dirs return dirs def _upstream_pointer(self, skill_dir: Path) -> Optional[Dict[str, str]]: """Upstream pointer for a catalog-stub skill dir, or None for vendored skills. A stub declares ``metadata.hermes.upstream: {repo: owner/name, path: ...}`` in its SKILL.md frontmatter. """ try: content = (skill_dir / "SKILL.md").read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return None return self._upstream_pointer_from_content(content) def _upstream_pointer_from_content(self, content: Union[str, bytes]) -> Optional[Dict[str, str]]: """Parse ``metadata.hermes.upstream`` out of SKILL.md content.""" if isinstance(content, bytes): try: content = content.decode("utf-8") except UnicodeDecodeError: return None meta_block = _parse_frontmatter(content).get("metadata") hermes_meta = meta_block.get("hermes") if isinstance(meta_block, dict) else None upstream = hermes_meta.get("upstream") if isinstance(hermes_meta, dict) else None if not isinstance(upstream, dict): return None repo = str(upstream.get("repo", "")).strip().strip("/") path = str(upstream.get("path", "")).strip().strip("/") # repo must be exactly owner/name; path must be a clean relative path. if not repo or repo.count("/") != 1 or not path: return None parts = _clean_rel_parts(path) if parts is None: return None return {"repo": repo, "path": "/".join(parts)} def _fetch_from_upstream(self, upstream: Dict[str, str], rel_id: str) -> Optional[SkillBundle]: """Fetch an upstream-maintained optional skill via GitHubSource.fetch() (full-tree download, symlink/unsafe-path rejection, quarantine + scan downstream) and re-label it as an official catalog entry.""" bundle = self._get_github().fetch(f"{upstream['repo']}/{upstream['path']}") if bundle is None: logger.warning( "Upstream fetch failed for optional skill %s (%s:%s)", rel_id, upstream["repo"], upstream["path"], ) return None return SkillBundle( name=bundle.name, files=bundle.files, source="official", identifier=f"official/{rel_id}", # Curated endorsement, but the content is live third-party: # "trusted", not "builtin", so a dangerous scan verdict still blocks. trust_level="trusted", metadata={ **bundle.metadata, "upstream_repo": upstream["repo"], "upstream_path": upstream["path"], }, ) def _local_skill_mds(self): if not self._optional_dir.is_dir(): return for skill_md in sorted(self._optional_dir.rglob("SKILL.md")): if not is_excluded_skill_path( skill_md.relative_to(self._optional_dir), root=self._optional_dir ): yield skill_md def _find_skill_dir(self, name: str) -> Optional[Path]: """Find a skill directory by name anywhere in optional-skills/.""" for skill_md in self._local_skill_mds(): if skill_md.parent.name == name: return skill_md.parent return None def _scan_all(self) -> List[SkillMeta]: """Enumerate all optional skills with metadata.""" results: List[SkillMeta] = [] for skill_md in self._local_skill_mds(): parent = skill_md.parent try: content = skill_md.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue fm = _parse_frontmatter(content) tags = _hermes_tags(fm) rel_path = parent.relative_to(self._optional_dir).as_posix() results.append(SkillMeta( name=fm.get("name", parent.name), description=fm.get("description", "")[:200], source="official", identifier=f"official/{rel_path}", trust_level="builtin", repo=self.OFFICIAL_REPO, # The centralized skills index consumes repo-root-relative paths. path=f"optional-skills/{rel_path}", tags=tags if isinstance(tags, list) else [], )) return results # --------------------------------------------------------------------------- # Hermes centralized index source # --------------------------------------------------------------------------- class HermesIndexSource(SkillSource): """Skill source backed by the centralized Hermes Skills Index. A JSON catalog on the docs site, rebuilt daily by CI, with metadata + resolved GitHub paths for every skill — search and path discovery cost zero GitHub API calls. When unavailable every method returns empty/None so downstream sources take over transparently. """ def __init__(self, auth: GitHubAuth): self._index: Optional[dict] = None self._loaded = False self.auth = auth self._github: Optional[GitHubSource] = None # only needed for fetch def _ensure_loaded(self) -> dict: if not self._loaded: self._index = hub()._load_hermes_index() self._loaded = True return self._index or {} def _skills(self) -> list: return self._ensure_loaded().get("skills", []) def _get_github(self) -> GitHubSource: if self._github is None: self._github = GitHubSource(auth=self.auth) return self._github def source_id(self) -> str: return "hermes-index" @property def is_available(self) -> bool: """Whether the index is loaded and has skills.""" return bool(self._skills()) def trust_level_for(self, identifier: str) -> str: for skill in self._skills(): if skill.get("identifier") == identifier: return skill.get("trust_level", "community") return "community" def search(self, query: str, limit: int = 10) -> List[SkillMeta]: """Search the cached index (zero API calls). Matches name, description, tags, identifier and ``extra.provider`` (so ``nvidia`` finds ``NVIDIA/skills/...`` entries stored as source "github"). Ranked exact name > name prefix > provider > whole-word > name substring > other, index order as tiebreaker — a raw break-at-limit slice buried the most relevant skills. """ skills = self._skills() if not skills: return [] if not query.strip(): return [self._to_meta(s) for s in skills[:limit]] # featured / index order query_lower = query.lower() scored: List[Tuple[int, int, dict]] = [] for i, s in enumerate(skills): name = str(s.get("name", "")).lower() provider = str((s.get("extra") or {}).get("provider", "")).lower() haystack = " ".join([ name, str(s.get("description", "")).lower(), " ".join(str(t).lower() for t in s.get("tags", [])), str(s.get("identifier", "")).lower(), provider, ]) if query_lower not in haystack: continue if name == query_lower: score = 0 elif name.startswith(query_lower): score = 1 elif provider == query_lower: score = 2 elif query_lower in name.split() or query_lower in provider.split(): score = 3 elif query_lower in name: score = 4 else: score = 5 scored.append((score, i, s)) scored.sort(key=lambda x: (x[0], x[1])) return [self._to_meta(s) for _, _, s in scored[:limit]] def fetch(self, identifier: str) -> Optional[SkillBundle]: """Fetch via the index's ``resolved_github_id`` (skipping the whole candidate/discovery chain), falling back to ``repo/path``.""" entry = self._find_entry(identifier, self._ensure_loaded()) if not entry: return None candidates = [entry.get("resolved_github_id")] repo, path = entry.get("repo", ""), entry.get("path", "") if repo and path: candidates.append(f"{repo}/{path}") for github_id in candidates: if not github_id: continue bundle = self._get_github().fetch(github_id) if bundle: bundle.source = entry.get("source", "hermes-index") bundle.identifier = identifier return bundle return None def inspect(self, identifier: str) -> Optional[SkillMeta]: """Return metadata from the index (zero API calls).""" entry = self._find_entry(identifier, self._ensure_loaded()) return self._to_meta(entry) if entry else None def _find_entry(self, identifier: str, index: dict) -> Optional[dict]: """Exact identifier match first, then match with source prefixes stripped.""" skills = index.get("skills", []) for s in skills: if s.get("identifier") == identifier: return s normalized = _strip_prefix(identifier, _INDEX_ID_PREFIXES) for s in skills: if _strip_prefix(s.get("identifier", ""), _INDEX_ID_PREFIXES) == normalized: return s return None @staticmethod def _to_meta(entry: dict) -> SkillMeta: return SkillMeta( name=entry.get("name", ""), description=entry.get("description", ""), source=entry.get("source", "hermes-index"), identifier=entry.get("identifier", ""), trust_level=entry.get("trust_level", "community"), repo=entry.get("repo"), path=entry.get("path"), tags=entry.get("tags", []), extra=entry.get("extra", {}), )