feat(skills): fix browse cap, add source links + copy buttons + category cleanup (#37143)

Skills discovery surfaced ~136 of 88k skills in the CLI and gave community
skills no clickable source on the docs page. Three coupled fixes:

CLI browse:
- hermes skills browse capped at 50 because the per-source limit dict had no
  'hermes-index' key — when the centralized index is available the router
  skips external APIs and serves only the index, so the default-50 fallthrough
  silently truncated the whole hub. Add hermes-index: 5000. Browse now loads
  5367 (269 pages) instead of 136.
- Add an Identifier column + install/inspect hint to the browse table so users
  can act on what they see without a second 'search'.
- Route the TUI browse_skills() helper through parallel_search_sources so it
  inherits the same index-aware source-skip (was double-counting); expose
  identifier in its output.

Docs Skills Hub page:
- Synthesize a sourceUrl for every community skill (github tree URL, clawhub /
  skills.sh / lobehub / browse.sh detail pages), preferring the adapter's
  explicit extra.detail_url/source_url/repo_url. Expanded cards now show
  'View source' for community skills (was nothing) and keep 'View full
  documentation' for built-in/optional. 99% coverage.
- Add a Copy button on the install command.
- Add a loading state instead of flashing '0 skills / No skills found' while
  the 45MB catalog fetches.

Category cleanup:
- _guess_category fell back to tags[0] verbatim, producing ~430 junk one-off
  categories (version strings, brand names: '0.10.7 Dev', 'Doramagic Crystal').
  Now only curated buckets are accepted; unknowns fold into 'Other'. Widen the
  tag->category map so common community tags route to real buckets. 430 -> 173
  categories, top 20 all meaningful.

Tests: tests/website/test_extract_skills.py covers _source_url synthesis +
precedence and _guess_category curation (13 tests). All 27 skills-hub CLI
tests still pass. Docusaurus build verified; expanded cards confirmed in
browser for both community (View source) and built-in (View full docs).
This commit is contained in:
Teknium
2026-06-01 19:52:28 -07:00
committed by GitHub
parent 0cd5867bbb
commit 59510d7b44
5 changed files with 399 additions and 43 deletions
+109 -15
View File
@@ -48,7 +48,7 @@ CATEGORY_LABELS = {
"data-science": "Data Science",
"devops": "DevOps",
"dogfood": "Dogfood",
"domain": "Domain",
"domain": "Business & Finance",
"email": "Email",
"gaming": "Gaming",
"gifs": "GIFs",
@@ -193,6 +193,60 @@ def _install_command(source: str, identifier: str, name: str) -> str:
return f"hermes skills install {identifier}"
def _source_url(source: str, identifier: str, extra: dict) -> str:
"""Best-effort clickable URL to the skill's origin (repo / detail page).
Community skills have no generated docs page, so without this the
expanded card on the Skills Hub gives users nowhere to go to read the
actual SKILL.md before installing. We prefer an explicit URL the source
adapter already collected (``extra.detail_url`` / ``extra.repo_url``),
then fall back to synthesizing one from the identifier shape.
"""
extra = extra or {}
for key in ("detail_url", "source_url", "repo_url", "url", "index_url"):
val = extra.get(key)
if isinstance(val, str) and val.startswith("http"):
return val
if not identifier:
return ""
src = (source or "").lower()
# GitHub-backed taps (openai/anthropic/nvidia/hf/gstack/VoltAgent/...):
# identifier is "owner/repo/<path...>" — link to the directory on GitHub.
if src in {"github", "openai", "anthropic", "huggingface", "nvidia",
"gstack", "voltagent", "minimax", "claude marketplace",
"claude-marketplace"}:
parts = [p for p in identifier.split("/") if p]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
sub = "/".join(parts[2:])
base = f"https://github.com/{owner}/{repo}"
return f"{base}/tree/main/{sub}" if sub else base
return ""
if src == "clawhub":
# identifier is a bare slug (the "clawhub/" prefix is added at install time)
slug = identifier[len("clawhub/"):] if identifier.startswith("clawhub/") else identifier
return f"https://clawhub.ai/skills/{slug}"
if src in {"skills.sh", "skills-sh"}:
# "skills-sh/owner/repo/skill" -> the skills.sh detail page
rest = identifier[len("skills-sh/"):] if identifier.startswith("skills-sh/") else identifier
return f"https://skills.sh/skills/{rest}"
if src == "lobehub":
slug = identifier[len("lobehub/"):] if identifier.startswith("lobehub/") else identifier
return f"https://lobehub.com/agent/{slug}"
if src in {"browse.sh", "browse-sh"}:
# "browse-sh/<hostname>/<task-id>" -> browse.sh task page
rest = identifier[len("browse-sh/"):] if identifier.startswith("browse-sh/") else identifier
return f"https://browse.sh/skills/{rest}"
return ""
def extract_local_skills():
skills = []
@@ -361,6 +415,7 @@ def extract_unified_index_skills():
author = repo.split("/")[0]
install_cmd = _install_command(source_id, identifier, name)
source_url = _source_url(source_id, identifier, extra)
out.append({
"name": name,
@@ -380,6 +435,7 @@ def extract_unified_index_skills():
"docsPath": "",
"identifier": identifier,
"installCmd": install_cmd,
"sourceUrl": source_url,
})
return out, meta
@@ -460,26 +516,60 @@ for _cat, _tags in {
"software-development": [
"programming", "code", "coding", "software-development",
"frontend-development", "backend-development", "web-development",
"react", "python", "typescript", "java", "rust",
"react", "python", "typescript", "java", "rust", "cli",
"developer-tools", "development", "api", "database", "debugging",
"documentation", "testing", "test", "architecture",
],
"creative": ["writing", "design", "creative", "art", "image-generation"],
"research": ["education", "academic", "research"],
"social-media": ["marketing", "seo", "social-media"],
"productivity": ["productivity", "business"],
"data-science": ["data", "data-science"],
"mlops": ["machine-learning", "deep-learning"],
"devops": ["devops"],
"autonomous-ai-agents": [
"ai", "agent", "agents", "ai-agent", "ai-agents", "agentic",
"agentic-ai", "ai-assistant", "assistant", "multi-agent",
"autonomous", "llm", "rag", "prompt", "prompts", "a2a", "acp",
],
"creative": [
"writing", "design", "creative", "art", "image-generation",
"image", "content", "video-editing", "content-creation",
],
"research": ["education", "academic", "academic-writing", "research", "knowledge"],
"social-media": ["marketing", "seo", "social-media", "advertising", "creator"],
"productivity": [
"productivity", "business", "automation", "calendar", "email",
"document", "documents", "office", "notes", "note-taking",
"collaboration", "workflow", "crm",
],
"data-science": ["data", "data-science", "analytics", "analysis", "visualization"],
"mlops": ["machine-learning", "deep-learning", "mlops", "training", "fine-tuning"],
"devops": ["devops", "docker", "kubernetes", "infrastructure", "deployment", "monitoring", "ci-cd"],
"gaming": ["gaming", "game", "game-development"],
"media": ["music", "media", "video"],
"health": ["health", "fitness"],
"translation": ["translation", "language-learning"],
"security": ["security", "cybersecurity"],
"media": ["music", "media", "video", "audio", "podcast", "youtube"],
"health": ["health", "fitness", "medical", "wellness"],
"translation": ["translation", "language-learning", "i18n", "localization"],
"security": ["security", "cybersecurity", "auth", "compliance", "audit", "privacy"],
"blockchain": [
"blockchain", "crypto", "cryptocurrency", "defi", "web3",
"bitcoin", "ethereum", "nft", "trading", "arbitrage",
],
"communication": ["communication", "chat", "messaging", "slack", "discord"],
"domain": [
"finance", "accounting", "banking", "ecommerce", "e-commerce",
"shopping", "travel", "booking", "real-estate", "legal",
"government", "b2b", "b2b-sales", "entrepreneur", "budget",
],
}.items():
for _t in _tags:
TAG_TO_CATEGORY[_t] = _cat
def _guess_category(tags: list) -> str:
"""Map a skill's tags to a curated category, or 'uncategorized'.
Previously this fell back to ``tags[0]`` verbatim, which produced
hundreds of junk one-off "categories" in the sidebar (e.g.
"Doramagic Crystal", "0.10.7 Dev", "Ap2") — version strings, brand
names, and tag noise. We now ONLY accept categories that map to a
known curated bucket; everything else becomes "uncategorized", which
_consolidate_small_categories folds into "Other". Sidecar-declared
categories (skills.sh groupings) bypass this entirely via fixedCategory.
"""
if not tags:
return "uncategorized"
for tag in tags:
@@ -488,8 +578,12 @@ def _guess_category(tags: list) -> str:
cat = TAG_TO_CATEGORY.get(tag.lower())
if cat:
return cat
first = tags[0] if isinstance(tags[0], str) else ""
return first.lower().replace(" ", "-") if first else "uncategorized"
# Also accept a tag that's already a known curated category key
# (e.g. a skill tagged literally "security" or "devops").
normalized = tag.lower().replace(" ", "-")
if normalized in CATEGORY_LABELS and normalized != "other":
return normalized
return "uncategorized"
MIN_CATEGORY_SIZE = 4