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),