mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(skills): integrate skills.sh as a hub source
Add a skills.sh-backed source adapter for the Hermes Skills Hub. The new adapter uses skills.sh search results for discovery, falls back to featured homepage links for browse-style queries, and resolves installs / inspects through the underlying GitHub repo using common Agent Skills layout conventions. Also expose skills-sh in CLI source filters and add regression coverage for search, alias resolution, and source routing.
This commit is contained in:
parent
a0f0f4fe52
commit
483a0b5233
4 changed files with 370 additions and 4 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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='''
|
||||
<a href="/vercel-labs/agent-skills/vercel-react-best-practices">React</a>
|
||||
<a href="/anthropics/skills/pdf">PDF</a>
|
||||
<a href="/vercel-labs/agent-skills/vercel-react-best-practices">React again</a>
|
||||
''',
|
||||
)
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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<id>(?!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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue