diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index b1310e85359..e374f926c6c 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -198,6 +198,59 @@ class TestSkillsShGroupings: assert len(skills) == 1 assert "category" not in skills[0].extra + def test_list_skills_bucket_stamps_category_when_no_sidecar(self): + # A tap-level bucket labels every skill when the repo ships no + # skills.sh.json grouping — this is how several repos share one hub + # category (e.g. science). + auth = MagicMock() + src = GitHubSource(auth=auth) + + meta = SkillMeta( + name="rdkit", description="d", source="github", + identifier="K-Dense-AI/scientific-agent-skills/skills/rdkit", + trust_level="community", + ) + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = [{"type": "dir", "name": "rdkit"}] + + 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( + "K-Dense-AI/scientific-agent-skills", "skills/", "science" + ) + + assert len(skills) == 1 + assert skills[0].extra["category"] == "science" + + def test_list_skills_sidecar_grouping_wins_over_bucket(self): + # When a repo DOES publish a skills.sh.json grouping, that wins over + # the tap-level bucket fallback. + auth = MagicMock() + src = GitHubSource(auth=auth) + + meta = SkillMeta( + name="cuopt-developer", description="d", source="github", + identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted", + ) + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = [{"type": "dir", "name": "cuopt-developer"}] + + with patch.object(src, "_read_cache", return_value=None), \ + patch.object(src, "_write_cache"), \ + patch.object(src, "_get_skillsh_groupings", + return_value={"cuopt-developer": "Decision Optimization"}), \ + 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/", "science") + + assert len(skills) == 1 + assert skills[0].extra["category"] == "Decision Optimization" + def test_meta_to_dict_roundtrip_preserves_extra(self): meta = SkillMeta( name="x", description="d", source="github", diff --git a/tools/skills_hub.py b/tools/skills_hub.py index f81501712fa..e27ce7c4a3b 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -525,19 +525,36 @@ class GitHubSource(SkillSource): # https://github.com/NVIDIA/skills/tree/main/skills {"repo": "NVIDIA/skills", "path": "skills/"}, {"repo": "garrytan/gstack", "path": ""}, - # K-Dense-AI/scientific-agent-skills: ~150 scientific research skills - # (ML training/eval, computational biology, cheminformatics, physics, - # scientific databases). MIT-licensed skill content; the flat - # skills// layout matches the anthropic/huggingface taps. This is - # a community tap on purpose — it is deliberately NOT in - # tools/skills_guard.py::TRUSTED_REPOS, so it resolves at "community" - # trust: the security guard still scans every skill and INSTALL_POLICY - # only auto-installs the ones it rates "safe". Individual skills wrap - # third-party tools whose OWN licenses vary (some GPL, KEGG needs a - # commercial license for non-academic use); those terms are the - # installer's concern at use time and are surfaced in each skill's own - # frontmatter/Prerequisites, not vetted here. - {"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/"}, + # --- Science bucket ------------------------------------------------- + # Two upstream scientific-skill repos are tapped under one shared hub + # category ("science") via the tap-level "bucket" key, so their skills + # surface together instead of as two unrelated repos. Both are + # community trust on purpose — deliberately NOT in + # tools/skills_guard.py::TRUSTED_REPOS — so the security guard scans + # every skill and INSTALL_POLICY only auto-installs "safe" ones. + # Individual skills wrap third-party tools whose OWN licenses vary + # (some GPL; KEGG needs a commercial license for non-academic use); + # those terms are surfaced per-skill (frontmatter/Prerequisites) and + # are the installer's concern at use time, not vetted here. + # + # K-Dense-AI/scientific-agent-skills: ~150 skills, flat skills// + # layout (matches the anthropic/huggingface taps). MIT-licensed content. + {"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/", "bucket": "science"}, + # synthetic-sciences/openscience: ~290 skills, Apache-2.0. Nested + # backend/cli/skills/// layout — _list_skills_in_repo + # only walks ONE level under a tap path, so each category is tapped + # separately (same one-entry-per-inner-path pattern as openai above). + *( + {"repo": "synthetic-sciences/openscience", + "path": f"backend/cli/skills/{_cat}/", "bucket": "science"} + for _cat in ( + "biology", "chemistry", "cloud-compute", "coding", + "data-engineering", "databases", "document-parsing", + "llm-tools", "ml-inference", "ml-training", "other", + "physics", "quantum", "research", "scholar-evaluation", + "visualization", "writing", + ) + ), ] def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None): @@ -579,7 +596,7 @@ class GitHubSource(SkillSource): for tap in self.taps: try: - skills = self._list_skills_in_repo(tap["repo"], tap.get("path", "")) + skills = self._list_skills_in_repo(tap["repo"], tap.get("path", ""), tap.get("bucket")) for skill in skills: searchable = f"{skill.name} {skill.description} {' '.join(skill.tags)}".lower() if query_lower in searchable: @@ -676,9 +693,15 @@ class GitHubSource(SkillSource): # -- Internal helpers -- - def _list_skills_in_repo(self, repo: str, path: str) -> List[SkillMeta]: - """List skill directories in a GitHub repo path, using cached index.""" - cache_key = f"{repo}_{path}".replace("/", "_").replace(" ", "_") + def _list_skills_in_repo(self, repo: str, path: str, bucket: Optional[str] = None) -> List[SkillMeta]: + """List skill directories in a GitHub repo path, using cached index. + + ``bucket`` optionally forces a category label on every skill from this + tap (used to unify skills from several repos under one hub category, + e.g. multiple science repos → "science"). A repo's own + ``skills.sh.json`` grouping still wins when present. + """ + cache_key = f"{repo}_{path}_{bucket or ''}".replace("/", "_").replace(" ", "_") cached = self._read_cache(cache_key) if cached is not None: return [SkillMeta(**s) for s in cached] @@ -706,10 +729,16 @@ class GitHubSource(SkillSource): skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}" meta = self.inspect(skill_identifier) if meta: + category = None if groupings: category = groupings.get(meta.name) or groupings.get(dir_name) - if category: - meta.extra["category"] = category + # Tap-level bucket is the fallback when the repo ships no + # skills.sh.json grouping — lets several repos share one + # hub category (e.g. science) without a sidecar we can't edit. + if not category and bucket: + category = bucket + if category: + meta.extra["category"] = category skills.append(meta) # Cache the results