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:
Teknium
2026-05-29 12:24:39 -07:00
parent 4de8009ce4
commit b6ed3913d2
4 changed files with 240 additions and 2 deletions
+137
View File
@@ -70,6 +70,143 @@ class TestParseFrontmatterQuick:
assert fm == {}
# ---------------------------------------------------------------------------
# GitHubSource skills.sh.json grouping sidecar (category support)
# ---------------------------------------------------------------------------
class TestSkillsShGroupings:
"""Parsing + stamping of the skills.sh.json grouping sidecar.
A tap can ship a repo-root ``skills.sh.json`` declaring category
groupings; we flatten it to {skill_name: title} and stamp the title onto
each SkillMeta's ``extra["category"]``. This is the generic cross-ecosystem
mechanism behind NVIDIA-style categorization — not NVIDIA-specific.
"""
def test_parse_basic_groupings(self):
content = json.dumps({
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
"groupings": [
{"title": "Inference AI", "skills": ["dynamo-router", "dynamo-recipe"]},
{"title": "Decision Optimization", "skills": ["cuopt-developer"]},
],
})
mapping = GitHubSource._parse_skillsh_groupings(content)
assert mapping == {
"dynamo-router": "Inference AI",
"dynamo-recipe": "Inference AI",
"cuopt-developer": "Decision Optimization",
}
def test_parse_invalid_json_returns_none(self):
assert GitHubSource._parse_skillsh_groupings("not json{{") is None
def test_parse_non_dict_returns_none(self):
assert GitHubSource._parse_skillsh_groupings("[1, 2, 3]") is None
def test_parse_missing_groupings_returns_none(self):
assert GitHubSource._parse_skillsh_groupings('{"foo": 1}') is None
def test_parse_empty_groupings_returns_empty_map(self):
assert GitHubSource._parse_skillsh_groupings('{"groupings": []}') == {}
def test_parse_tolerates_malformed_group(self):
# A group missing its skills list is skipped; the valid one survives.
content = json.dumps({"groupings": [
{"title": "X"}, # no skills -> skipped
{"skills": ["a"]}, # no title -> skipped
{"title": "Y", "skills": ["b", 5, None]}, # only valid string members kept
]})
assert GitHubSource._parse_skillsh_groupings(content) == {"b": "Y"}
def test_parse_first_grouping_wins_on_duplicate(self):
content = json.dumps({"groupings": [
{"title": "First", "skills": ["dup"]},
{"title": "Second", "skills": ["dup"]},
]})
assert GitHubSource._parse_skillsh_groupings(content) == {"dup": "First"}
def test_get_groupings_caches_per_repo(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
content = json.dumps({"groupings": [{"title": "T", "skills": ["s"]}]})
with patch.object(src, "_fetch_file_content", return_value=content) as mock_fetch:
first = src._get_skillsh_groupings("acme/skills")
second = src._get_skillsh_groupings("acme/skills")
assert first == {"s": "T"}
assert second == {"s": "T"}
# Second call must hit the per-repo cache, not GitHub again.
mock_fetch.assert_called_once_with("acme/skills", "skills.sh.json")
def test_get_groupings_no_sidecar_returns_none_and_caches(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
with patch.object(src, "_fetch_file_content", return_value=None) as mock_fetch:
assert src._get_skillsh_groupings("acme/skills") is None
assert src._get_skillsh_groupings("acme/skills") is None
mock_fetch.assert_called_once()
def test_list_skills_stamps_category_from_sidecar(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
meta = SkillMeta(
name="cuopt-developer", description="d", source="github",
identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted",
)
contents = [{"type": "dir", "name": "cuopt-developer"}]
groupings = {"cuopt-developer": "Decision Optimization"}
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = contents
with patch.object(src, "_read_cache", return_value=None), \
patch.object(src, "_write_cache"), \
patch.object(src, "_get_skillsh_groupings", return_value=groupings), \
patch.object(src, "inspect", return_value=meta), \
patch("tools.skills_hub.httpx.get", return_value=resp):
skills = src._list_skills_in_repo("NVIDIA/skills", "skills/")
assert len(skills) == 1
assert skills[0].extra["category"] == "Decision Optimization"
def test_list_skills_no_sidecar_leaves_extra_empty(self):
auth = MagicMock()
src = GitHubSource(auth=auth)
meta = SkillMeta(
name="foo", description="d", source="github",
identifier="acme/skills/skills/foo", trust_level="community",
)
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = [{"type": "dir", "name": "foo"}]
with patch.object(src, "_read_cache", return_value=None), \
patch.object(src, "_write_cache"), \
patch.object(src, "_get_skillsh_groupings", return_value=None), \
patch.object(src, "inspect", return_value=meta), \
patch("tools.skills_hub.httpx.get", return_value=resp):
skills = src._list_skills_in_repo("acme/skills", "skills/")
assert len(skills) == 1
assert "category" not in skills[0].extra
def test_meta_to_dict_roundtrip_preserves_extra(self):
meta = SkillMeta(
name="x", description="d", source="github",
identifier="acme/skills/x", trust_level="trusted",
extra={"category": "Inference AI"},
)
d = GitHubSource._meta_to_dict(meta)
assert d["extra"] == {"category": "Inference AI"}
# Round-trips back through the cache deserialization path.
restored = SkillMeta(**d)
assert restored.extra == {"category": "Inference AI"}
# ---------------------------------------------------------------------------
# GitHubSource.trust_level_for
# ---------------------------------------------------------------------------