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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user