diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8b211280b..3564feea9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2682,7 +2682,7 @@ For more help on a command: skills_parser = subparsers.add_parser( "skills", help="Search, install, configure, and manage skills", - description="Search, install, inspect, audit, configure, and manage skills from GitHub, ClawHub, and other registries." + description="Search, install, inspect, audit, configure, and manage skills from skills.sh, GitHub, ClawHub, and other registries." ) skills_subparsers = skills_parser.add_subparsers(dest="skills_action") @@ -2690,12 +2690,12 @@ For more help on a command: skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)") skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)") skills_browse.add_argument("--source", default="all", - choices=["all", "official", "github", "clawhub", "lobehub"], + choices=["all", "official", "skills-sh", "github", "clawhub", "lobehub"], help="Filter by source (default: all)") skills_search = skills_subparsers.add_parser("search", help="Search skill registries") skills_search.add_argument("query", help="Search query") - skills_search.add_argument("--source", default="all", choices=["all", "official", "github", "clawhub", "lobehub"]) + skills_search.add_argument("--source", default="all", choices=["all", "official", "skills-sh", "github", "clawhub", "lobehub"]) skills_search.add_argument("--limit", type=int, default=10, help="Max results") skills_install = skills_subparsers.add_parser("install", help="Install a skill") diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index e39b098a2..c4ede777c 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -136,7 +136,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", # Collect results from all (or filtered) sources # Use empty query to get everything; per-source limits prevent overload _TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1} - _PER_SOURCE_LIMIT = {"official": 100, "github": 100, "clawhub": 50, + _PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "github": 100, "clawhub": 50, "claude-marketplace": 50, "lobehub": 50} all_results: list = [] diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index c907e9db1..9f0729212 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -8,10 +8,12 @@ from tools.skills_hub import ( GitHubAuth, GitHubSource, LobeHubSource, + SkillsShSource, SkillMeta, SkillBundle, HubLockFile, TapsManager, + create_source_router, unified_search, append_audit_log, _skill_meta_to_dict, @@ -93,6 +95,129 @@ class TestTrustLevelFor: assert result in ("trusted", "community") +# --------------------------------------------------------------------------- +# SkillsShSource +# --------------------------------------------------------------------------- + + +class TestSkillsShSource: + def _source(self): + auth = MagicMock(spec=GitHubAuth) + return SkillsShSource(auth=auth) + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_search_maps_skills_sh_results_to_prefixed_identifiers(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: { + "skills": [ + { + "id": "vercel-labs/agent-skills/vercel-react-best-practices", + "skillId": "vercel-react-best-practices", + "name": "vercel-react-best-practices", + "installs": 207679, + "source": "vercel-labs/agent-skills", + } + ] + }, + ) + + results = self._source().search("react", limit=5) + + assert len(results) == 1 + assert results[0].source == "skills.sh" + assert results[0].identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + assert "skills.sh" in results[0].description + assert results[0].repo == "vercel-labs/agent-skills" + assert results[0].path == "vercel-react-best-practices" + + @patch("tools.skills_hub._write_index_cache") + @patch("tools.skills_hub._read_index_cache", return_value=None) + @patch("tools.skills_hub.httpx.get") + def test_empty_search_uses_featured_homepage_links(self, mock_get, _mock_read_cache, _mock_write_cache): + mock_get.return_value = MagicMock( + status_code=200, + text=''' + React + PDF + React again + ''', + ) + + results = self._source().search("", limit=10) + + assert [r.identifier for r in results] == [ + "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices", + "skills-sh/anthropics/skills/pdf", + ] + assert all(r.source == "skills.sh" for r in results) + + @patch.object(GitHubSource, "fetch") + def test_fetch_delegates_to_github_source_and_relabels_bundle(self, mock_fetch): + mock_fetch.return_value = SkillBundle( + name="vercel-react-best-practices", + files={"SKILL.md": "# Test"}, + source="github", + identifier="vercel-labs/agent-skills/vercel-react-best-practices", + trust_level="community", + ) + + bundle = self._source().fetch("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert bundle is not None + assert bundle.source == "skills.sh" + assert bundle.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + mock_fetch.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices") + + @patch.object(GitHubSource, "inspect") + def test_inspect_delegates_to_github_source_and_relabels_meta(self, mock_inspect): + mock_inspect.return_value = SkillMeta( + name="vercel-react-best-practices", + description="React rules", + source="github", + identifier="vercel-labs/agent-skills/vercel-react-best-practices", + trust_level="community", + repo="vercel-labs/agent-skills", + path="vercel-react-best-practices", + ) + + meta = self._source().inspect("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert meta is not None + assert meta.source == "skills.sh" + assert meta.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + mock_inspect.assert_called_once_with("vercel-labs/agent-skills/vercel-react-best-practices") + + @patch.object(GitHubSource, "_list_skills_in_repo") + @patch.object(GitHubSource, "inspect") + def test_inspect_falls_back_to_repo_skill_catalog_when_slug_differs(self, mock_inspect, mock_list_skills): + resolved = SkillMeta( + name="vercel-react-best-practices", + description="React rules", + source="github", + identifier="vercel-labs/agent-skills/skills/react-best-practices", + trust_level="community", + repo="vercel-labs/agent-skills", + path="skills/react-best-practices", + ) + mock_inspect.side_effect = lambda identifier: resolved if identifier == resolved.identifier else None + mock_list_skills.return_value = [resolved] + + meta = self._source().inspect("skills-sh/vercel-labs/agent-skills/vercel-react-best-practices") + + assert meta is not None + assert meta.identifier == "skills-sh/vercel-labs/agent-skills/vercel-react-best-practices" + assert mock_list_skills.called + + +class TestCreateSourceRouter: + def test_includes_skills_sh_source(self): + sources = create_source_router(auth=MagicMock(spec=GitHubAuth)) + assert any(isinstance(src, SkillsShSource) for src in sources) + + # --------------------------------------------------------------------------- # HubLockFile # --------------------------------------------------------------------------- diff --git a/tools/skills_hub.py b/tools/skills_hub.py index eab880023..be702bf4d 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -497,6 +497,246 @@ class GitHubSource(SkillSource): return {} +# --------------------------------------------------------------------------- +# skills.sh source adapter +# --------------------------------------------------------------------------- + +class SkillsShSource(SkillSource): + """Discover skills via skills.sh and fetch content from the underlying GitHub repo.""" + + BASE_URL = "https://skills.sh" + SEARCH_URL = f"{BASE_URL}/api/search" + _SKILL_LINK_RE = re.compile(r'href=["\']/(?P(?!agents/|_next/|api/)[^"\'/]+/[^"\'/]+/[^"\'/]+)["\']') + + def __init__(self, auth: GitHubAuth): + self.auth = auth + self.github = GitHubSource(auth=auth) + + def source_id(self) -> str: + return "skills-sh" + + def trust_level_for(self, identifier: str) -> str: + return self.github.trust_level_for(self._normalize_identifier(identifier)) + + def search(self, query: str, limit: int = 10) -> List[SkillMeta]: + if not query.strip(): + return self._featured_skills(limit) + + cache_key = f"skills_sh_search_{hashlib.md5(f'{query}|{limit}'.encode()).hexdigest()}" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**item) for item in cached][:limit] + + try: + resp = httpx.get( + self.SEARCH_URL, + params={"q": query, "limit": limit}, + timeout=20, + ) + if resp.status_code != 200: + return [] + data = resp.json() + except (httpx.HTTPError, json.JSONDecodeError): + return [] + + items = data.get("skills", []) if isinstance(data, dict) else [] + if not isinstance(items, list): + return [] + + results: List[SkillMeta] = [] + for item in items[:limit]: + meta = self._meta_from_search_item(item) + if meta: + results.append(meta) + + _write_index_cache(cache_key, [_skill_meta_to_dict(item) for item in results]) + return results + + def fetch(self, identifier: str) -> Optional[SkillBundle]: + canonical = self._normalize_identifier(identifier) + for candidate in self._candidate_identifiers(canonical): + bundle = self.github.fetch(candidate) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + return bundle + + resolved = self._discover_identifier(canonical) + if resolved: + bundle = self.github.fetch(resolved) + if bundle: + bundle.source = "skills.sh" + bundle.identifier = self._wrap_identifier(canonical) + return bundle + return None + + def inspect(self, identifier: str) -> Optional[SkillMeta]: + canonical = self._normalize_identifier(identifier) + for candidate in self._candidate_identifiers(canonical): + meta = self.github.inspect(candidate) + if meta: + meta.source = "skills.sh" + meta.identifier = self._wrap_identifier(canonical) + meta.trust_level = self.trust_level_for(canonical) + return meta + + resolved = self._discover_identifier(canonical) + if resolved: + meta = self.github.inspect(resolved) + if meta: + meta.source = "skills.sh" + meta.identifier = self._wrap_identifier(canonical) + meta.trust_level = self.trust_level_for(canonical) + return meta + return None + + def _featured_skills(self, limit: int) -> List[SkillMeta]: + cache_key = "skills_sh_featured" + cached = _read_index_cache(cache_key) + if cached is not None: + return [SkillMeta(**item) for item in cached][:limit] + + try: + resp = httpx.get(self.BASE_URL, timeout=20) + if resp.status_code != 200: + return [] + except httpx.HTTPError: + return [] + + seen: set[str] = set() + results: List[SkillMeta] = [] + for match in self._SKILL_LINK_RE.finditer(resp.text): + canonical = match.group("id") + if canonical in seen: + continue + seen.add(canonical) + parts = canonical.split("/", 2) + if len(parts) < 3: + continue + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + results.append(SkillMeta( + name=skill_path.split("/")[-1], + description=f"Featured on skills.sh from {repo}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + trust_level=self.github.trust_level_for(canonical), + repo=repo, + path=skill_path, + )) + if len(results) >= limit: + break + + _write_index_cache(cache_key, [_skill_meta_to_dict(item) for item in results]) + return results + + def _meta_from_search_item(self, item: dict) -> Optional[SkillMeta]: + if not isinstance(item, dict): + return None + + canonical = item.get("id") + repo = item.get("source") + skill_path = item.get("skillId") + if not isinstance(canonical, str) or canonical.count("/") < 2: + if not (isinstance(repo, str) and isinstance(skill_path, str)): + return None + canonical = f"{repo}/{skill_path}" + + parts = canonical.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2] + installs = item.get("installs") + installs_label = f" ยท {int(installs):,} installs" if isinstance(installs, int) else "" + + return SkillMeta( + name=str(item.get("name") or skill_path.split("/")[-1]), + description=f"Indexed by skills.sh from {repo}{installs_label}", + source="skills.sh", + identifier=self._wrap_identifier(canonical), + trust_level=self.github.trust_level_for(canonical), + repo=repo, + path=skill_path, + ) + + def _discover_identifier(self, identifier: str) -> Optional[str]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return None + + repo = f"{parts[0]}/{parts[1]}" + skill_token = parts[2] + for base_path in ("skills/", ".agents/skills/", ".claude/skills/"): + try: + skills = self.github._list_skills_in_repo(repo, base_path) + except Exception: + continue + for meta in skills: + if self._matches_skill_token(meta, skill_token): + return meta.identifier + return None + + @staticmethod + def _matches_skill_token(meta: SkillMeta, skill_token: str) -> bool: + target = skill_token.strip("/").lower() + target_base = target.split("/")[-1] + + def variants(value: Optional[str]) -> set[str]: + if not value: + return set() + normalized = value.strip("/").lower() + base = normalized.split("/")[-1] + return { + normalized, + base, + normalized.replace("_", "-"), + base.replace("_", "-"), + } + + candidates = set() + candidates.update(variants(meta.name)) + candidates.update(variants(meta.path)) + candidates.update(variants(meta.identifier.split("/", 2)[-1] if meta.identifier else None)) + return target in candidates or target_base in candidates + + @staticmethod + def _normalize_identifier(identifier: str) -> str: + if identifier.startswith("skills-sh/"): + return identifier[len("skills-sh/"):] + if identifier.startswith("skills.sh/"): + return identifier[len("skills.sh/"):] + return identifier + + @staticmethod + def _candidate_identifiers(identifier: str) -> List[str]: + parts = identifier.split("/", 2) + if len(parts) < 3: + return [identifier] + + repo = f"{parts[0]}/{parts[1]}" + skill_path = parts[2].lstrip("/") + candidates = [ + f"{repo}/{skill_path}", + f"{repo}/skills/{skill_path}", + f"{repo}/.agents/skills/{skill_path}", + f"{repo}/.claude/skills/{skill_path}", + ] + + seen = set() + deduped: List[str] = [] + for candidate in candidates: + if candidate not in seen: + seen.add(candidate) + deduped.append(candidate) + return deduped + + @staticmethod + def _wrap_identifier(identifier: str) -> str: + return f"skills-sh/{identifier}" + + # --------------------------------------------------------------------------- # ClawHub source adapter # --------------------------------------------------------------------------- @@ -1453,6 +1693,7 @@ def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource] sources: List[SkillSource] = [ OptionalSkillSource(), # Official optional skills (highest priority) + SkillsShSource(auth=auth), GitHubSource(auth=auth, extra_taps=extra_taps), ClawHubSource(), ClaudeMarketplaceSource(auth=auth),