Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions hermes_cli/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Collect results from all (or filtered) sources in parallel.
# Per-source limits are generous — parallelism + 30s timeout cap prevents hangs.
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
# NOTE: when the centralized index is available, parallel_search_sources
# skips the external API sources and serves everything from "hermes-index".
# That source MUST therefore carry a high limit, or browse silently caps
# the entire hub at the default (50) — it shipped that way and surfaced
# ~136 of 88k skills. The external-source limits below only apply when the
# index is unavailable (offline / first run before the cache populates).
_PER_SOURCE_LIMIT = {
"hermes-index": 5000,
"official": 200, "skills-sh": 200, "well-known": 50,
"github": 200, "clawhub": 500, "claude-marketplace": 100,
"lobehub": 500, "browse-sh": 500,
Expand Down Expand Up @@ -396,18 +403,22 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
# Build table
table = Table(show_header=True, header_style="bold")
table.add_column("#", style="dim", width=4, justify="right")
table.add_column("Name", style="bold cyan", max_width=25)
table.add_column("Description", max_width=50)
table.add_column("Name", style="bold cyan", max_width=22)
table.add_column("Description", max_width=44)
table.add_column("Source", style="dim", width=12)
table.add_column("Trust", width=10)
# The identifier is what you pass to `hermes skills install`. Browse used
# to omit it entirely, so users couldn't act on what they saw without a
# second `search`. overflow="fold" keeps long slugs copy-pasteable.
table.add_column("Identifier", style="dim", overflow="fold", no_wrap=False)

for i, r in enumerate(page_items, start=start + 1):
trust_style = {"builtin": "bright_cyan", "trusted": "green",
"community": "yellow"}.get(r.trust_level, "dim")
trust_label = "★ official" if r.source == "official" else r.trust_level

desc = r.description[:50]
if len(r.description) > 50:
desc = r.description[:44]
if len(r.description) > 44:
desc += "..."

table.add_row(
Expand All @@ -416,6 +427,7 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
desc,
r.source,
f"[{trust_style}]{trust_label}[/]",
r.identifier,
)

c.print(table)
Expand All @@ -439,7 +451,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
c.print(f" [yellow]⚡ Slow sources skipped: {', '.join(timed_out)} "
f"— run again for cached results[/]")

c.print("[dim]Tip: 'hermes skills search <query>' searches deeper across all registries[/]\n")
c.print("[dim]Tip: 'hermes skills inspect <identifier>' to preview, "
"'hermes skills install <identifier>' to install, "
"'hermes skills search <query>' to search deeper[/]\n")


def do_install(identifier: str, category: str = "", force: bool = False,
Expand Down Expand Up @@ -725,24 +739,27 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di

Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
"""
from tools.skills_hub import GitHubAuth, create_source_router
from tools.skills_hub import (
GitHubAuth, create_source_router, parallel_search_sources,
)

page_size = max(1, min(page_size, 100))
_TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1}
_PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50,
# "hermes-index" must carry a high limit: when the index is available the
# router skips external API sources and serves everything from it, so a
# low cap here silently truncates the whole hub (see do_browse note).
_PER_SOURCE_LIMIT = {"hermes-index": 5000, "official": 100, "skills-sh": 100,
"well-known": 25, "github": 100, "clawhub": 50,
"claude-marketplace": 50, "lobehub": 50, "browse-sh": 500}
auth = GitHubAuth()
sources = create_source_router(auth)
all_results: list = []
for src in sources:
sid = src.source_id()
if source != "all" and sid != source and sid != "official":
continue
try:
limit = _PER_SOURCE_LIMIT.get(sid, 50)
all_results.extend(src.search("", limit=limit))
except Exception:
continue
# Delegate to the shared parallel walker so this inherits the index-aware
# source-skip logic — querying hermes-index AND the external APIs at once
# would double-count every skill.
all_results, _counts, _timed_out = parallel_search_sources(
sources, query="", per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source, overall_timeout=30,
)
if not all_results:
return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {}
Expand All @@ -759,7 +776,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
page_items = deduped[start : min(start + page_size, total)]
return {
"items": [{"name": r.name, "description": r.description, "source": r.source,
"trust": r.trust_level} for r in page_items],
"trust": r.trust_level, "identifier": r.identifier} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
Expand Down
116 changes: 116 additions & 0 deletions tests/website/test_extract_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tests for website/scripts/extract-skills.py helpers.

Covers the two behavioral contracts added when the Skills Hub page gained
per-skill source links and a cleaned-up category sidebar:

1. ``_source_url`` — every community skill must resolve to a clickable
origin URL (explicit ``extra`` URL preferred, else synthesized from the
identifier shape). Built-in/optional skills intentionally return "" —
they have a generated docs page (docsPath) instead.

2. ``_guess_category`` — tags only map to a curated category bucket;
unknown tags fall to ``uncategorized`` (folded into "Other" later) so the
sidebar doesn't fill with one-off junk like version strings or brand
names.
"""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
EXTRACT = REPO_ROOT / "website" / "scripts" / "extract-skills.py"


@pytest.fixture(scope="module")
def mod():
spec = importlib.util.spec_from_file_location("extract_skills", EXTRACT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


# --------------------------------------------------------------------------
# _source_url
# --------------------------------------------------------------------------

def test_source_url_prefers_explicit_detail_url(mod):
extra = {"detail_url": "https://skills.sh/owner/repo/skill"}
assert (
mod._source_url("skills.sh", "skills-sh/owner/repo/skill", extra)
== "https://skills.sh/owner/repo/skill"
)


def test_source_url_prefers_browse_sh_source_url(mod):
# browse.sh adapter carries its origin under extra["source_url"].
extra = {"source_url": "https://airbnb.com/host"}
assert (
mod._source_url("browse-sh", "browse-sh/airbnb.com/login-abc", extra)
== "https://airbnb.com/host"
)


def test_source_url_synthesizes_github_tree_url(mod):
url = mod._source_url("github", "anthropics/skills/skills/algorithmic-art", {})
assert url == "https://github.com/anthropics/skills/tree/main/skills/algorithmic-art"


def test_source_url_synthesizes_github_root_when_no_subpath(mod):
assert mod._source_url("github", "owner/repo", {}) == "https://github.com/owner/repo"


def test_source_url_synthesizes_clawhub(mod):
assert mod._source_url("clawhub", "go-music-skill", {}) == "https://clawhub.ai/skills/go-music-skill"


def test_source_url_synthesizes_clawhub_strips_prefix(mod):
# identifier may arrive already prefixed; we must not double-prefix.
assert (
mod._source_url("clawhub", "clawhub/go-music-skill", {})
== "https://clawhub.ai/skills/go-music-skill"
)


def test_source_url_synthesizes_lobehub(mod):
assert mod._source_url("lobehub", "lobehub/chinese-paper", {}) == "https://lobehub.com/agent/chinese-paper"


def test_source_url_empty_for_unknown_source_without_identifier(mod):
assert mod._source_url("mystery", "", {}) == ""


# --------------------------------------------------------------------------
# _guess_category
# --------------------------------------------------------------------------

def test_guess_category_maps_known_tag(mod):
assert mod._guess_category(["security"]) == "security"
assert mod._guess_category(["machine-learning"]) == "mlops"
assert mod._guess_category(["crypto"]) == "blockchain"


def test_guess_category_accepts_literal_curated_key(mod):
# A skill tagged literally with a curated category key should route there.
assert mod._guess_category(["devops"]) == "devops"


def test_guess_category_rejects_junk_tag(mod):
# This is the whole point: version strings / brand names must NOT become
# their own sidebar category. They land in "uncategorized" → "Other".
assert mod._guess_category(["0.10.7 Dev"]) == "uncategorized"
assert mod._guess_category(["Doramagic Crystal"]) == "uncategorized"
assert mod._guess_category(["Ap2"]) == "uncategorized"


def test_guess_category_empty_tags(mod):
assert mod._guess_category([]) == "uncategorized"


def test_guess_category_skips_first_junk_tag_for_later_known_tag(mod):
# First tag is junk, second is curated — we should still find the curated one.
assert mod._guess_category(["Some Brand", "security"]) == "security"
Loading
Loading