feat(skills): categorize tap skills from skills.sh.json grouping sidecar
A GitHub tap can ship a repo-root skills.sh.json (the published skills.sh
schema) declaring category groupings. The Skills Hub now reads it at index
time and uses each grouping title as the skill's category label, instead of
the tag-derived guess. Generic: any tap that ships the file gets real
categorization — NVIDIA's groupings (Inference AI, Decision Optimization,
GPU Development, etc.) flow through automatically.
- GitHubSource: _get_skillsh_groupings() fetches+caches the sidecar per repo;
_parse_skillsh_groupings() flattens it to {skill_name: title};
_list_skills_in_repo() stamps meta.extra['category']; _meta_to_dict now
serializes extra so the category survives the index cache round-trip.
- extract-skills.py: prefers extra['category'] over the tag heuristic and
exempts sidecar categories from the small-category to Other collapse.
- Docs + 12 tests.
This commit is contained in:
@@ -420,6 +420,10 @@ class GitHubSource(SkillSource):
|
||||
# Per-instance cache: repo -> (default_branch, tree_entries)
|
||||
# Survives within a single search/install flow, avoiding redundant API calls.
|
||||
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
|
||||
# Per-repo cache of the optional skills.sh.json grouping sidecar,
|
||||
# mapping skill_name -> human-readable grouping title. ``None`` means
|
||||
# "fetched, no sidecar"; a missing key means "not fetched yet".
|
||||
self._skillsh_groupings: Dict[str, Optional[Dict[str, str]]] = {}
|
||||
# Set when GitHub returns 403 with rate limit exhausted
|
||||
self._rate_limited: bool = False
|
||||
|
||||
@@ -558,6 +562,7 @@ class GitHubSource(SkillSource):
|
||||
return []
|
||||
|
||||
skills: List[SkillMeta] = []
|
||||
groupings = self._get_skillsh_groupings(repo)
|
||||
for entry in entries:
|
||||
if entry.get("type") != "dir":
|
||||
continue
|
||||
@@ -570,6 +575,10 @@ class GitHubSource(SkillSource):
|
||||
skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}"
|
||||
meta = self.inspect(skill_identifier)
|
||||
if meta:
|
||||
if groupings:
|
||||
category = groupings.get(meta.name) or groupings.get(dir_name)
|
||||
if category:
|
||||
meta.extra["category"] = category
|
||||
skills.append(meta)
|
||||
|
||||
# Cache the results
|
||||
@@ -772,6 +781,61 @@ class GitHubSource(SkillSource):
|
||||
logger.debug("GitHub contents API fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
|
||||
"""Fetch and parse the repo-root ``skills.sh.json`` grouping sidecar.
|
||||
|
||||
``skills.sh.json`` is a published cross-ecosystem standard
|
||||
(``$schema: https://skills.sh/schemas/skills.sh.schema.json``) that
|
||||
lets a tap declare human-readable category groupings for its skills:
|
||||
|
||||
{"groupings": [{"title": "Inference AI", "skills": ["dynamo-..."]}]}
|
||||
|
||||
We flatten it into ``{skill_name: grouping_title}`` so the Skills Hub
|
||||
UI can show a real category pill instead of a tag-derived guess. Any
|
||||
tap that ships this file gets categorization for free — this is not
|
||||
NVIDIA-specific.
|
||||
|
||||
Returns the map (possibly empty) on success, or ``None`` when the repo
|
||||
has no sidecar / it couldn't be parsed. Cached per-repo on the instance.
|
||||
"""
|
||||
if repo in self._skillsh_groupings:
|
||||
return self._skillsh_groupings[repo]
|
||||
|
||||
content = self._fetch_file_content(repo, "skills.sh.json")
|
||||
groupings = self._parse_skillsh_groupings(content) if content else None
|
||||
self._skillsh_groupings[repo] = groupings
|
||||
return groupings
|
||||
|
||||
@staticmethod
|
||||
def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]:
|
||||
"""Flatten a ``skills.sh.json`` document into ``{skill_name: title}``.
|
||||
|
||||
Returns ``None`` when the content isn't a usable grouping document.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
groupings = data.get("groupings")
|
||||
if not isinstance(groupings, list):
|
||||
return None
|
||||
|
||||
mapping: Dict[str, str] = {}
|
||||
for group in groupings:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
title = group.get("title")
|
||||
members = group.get("skills")
|
||||
if not isinstance(title, str) or not isinstance(members, list):
|
||||
continue
|
||||
for member in members:
|
||||
if isinstance(member, str) and member:
|
||||
# First grouping wins if a skill is listed twice.
|
||||
mapping.setdefault(member, title)
|
||||
return mapping
|
||||
|
||||
def _read_cache(self, key: str) -> Optional[list]:
|
||||
"""Read cached index if not expired."""
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
@@ -805,6 +869,7 @@ class GitHubSource(SkillSource):
|
||||
"repo": meta.repo,
|
||||
"path": meta.path,
|
||||
"tags": meta.tags,
|
||||
"extra": meta.extra,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
Reference in New Issue
Block a user