* fix(skills): reduce skills.sh resolution churn and preserve trust for wrapped identifiers - Accept common skills.sh prefix typos (skils-sh/, skils.sh/) - Strip skills-sh/ prefix in _resolve_trust_level() so trusted repos stay trusted when installed through skills.sh - Use resolved identifier (from bundle/meta) for scan_skill source - Prefer tree search before root scan in _discover_identifier() - Add _resolve_github_meta() consolidation for inspect flow Cherry-picked from PR #3001 by kshitijk4poor. * fix: restore candidate loop in SkillsShSource.fetch() for consistency The cherry-picked PR only tried the first candidate identifier in fetch() while inspect() (via _resolve_github_meta) tried all four. This meant skills at repo/skills/path would be found by inspect but missed by fetch, forcing it through the heavier _discover_identifier flow. Restore the candidate loop so both paths behave identically. Updated the test assertion to match. --------- Co-authored-by: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
234 lines
8.0 KiB
Python
234 lines
8.0 KiB
Python
from io import StringIO
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from hermes_cli.skills_hub import do_check, do_install, do_list, do_update, handle_skills_slash
|
|
|
|
|
|
class _DummyLockFile:
|
|
def __init__(self, installed):
|
|
self._installed = installed
|
|
|
|
def list_installed(self):
|
|
return self._installed
|
|
|
|
|
|
@pytest.fixture()
|
|
def hub_env(monkeypatch, tmp_path):
|
|
"""Set up isolated hub directory paths and return (monkeypatch, tmp_path)."""
|
|
import tools.skills_hub as hub
|
|
|
|
hub_dir = tmp_path / "skills" / ".hub"
|
|
monkeypatch.setattr(hub, "SKILLS_DIR", tmp_path / "skills")
|
|
monkeypatch.setattr(hub, "HUB_DIR", hub_dir)
|
|
monkeypatch.setattr(hub, "LOCK_FILE", hub_dir / "lock.json")
|
|
monkeypatch.setattr(hub, "QUARANTINE_DIR", hub_dir / "quarantine")
|
|
monkeypatch.setattr(hub, "AUDIT_LOG", hub_dir / "audit.log")
|
|
monkeypatch.setattr(hub, "TAPS_FILE", hub_dir / "taps.json")
|
|
monkeypatch.setattr(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache")
|
|
|
|
return hub_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures for common skill setups
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HUB_ENTRY = {"name": "hub-skill", "source": "github", "trust_level": "community"}
|
|
|
|
_ALL_THREE_SKILLS = [
|
|
{"name": "hub-skill", "category": "x", "description": "hub"},
|
|
{"name": "builtin-skill", "category": "x", "description": "builtin"},
|
|
{"name": "local-skill", "category": "x", "description": "local"},
|
|
]
|
|
|
|
_BUILTIN_MANIFEST = {"builtin-skill": "abc123"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def three_source_env(monkeypatch, hub_env):
|
|
"""Populate hub/builtin/local skills for source-classification tests."""
|
|
import tools.skills_hub as hub
|
|
import tools.skills_sync as skills_sync
|
|
import tools.skills_tool as skills_tool
|
|
|
|
monkeypatch.setattr(hub, "HubLockFile", lambda: _DummyLockFile([_HUB_ENTRY]))
|
|
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: list(_ALL_THREE_SKILLS))
|
|
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: dict(_BUILTIN_MANIFEST))
|
|
|
|
return hub_env
|
|
|
|
|
|
def _capture(source_filter: str = "all") -> str:
|
|
"""Run do_list into a string buffer and return the output."""
|
|
sink = StringIO()
|
|
console = Console(file=sink, force_terminal=False, color_system=None)
|
|
do_list(source_filter=source_filter, console=console)
|
|
return sink.getvalue()
|
|
|
|
|
|
def _capture_check(monkeypatch, results, name=None) -> str:
|
|
import tools.skills_hub as hub
|
|
|
|
sink = StringIO()
|
|
console = Console(file=sink, force_terminal=False, color_system=None)
|
|
monkeypatch.setattr(hub, "check_for_skill_updates", lambda **_kwargs: results)
|
|
do_check(name=name, console=console)
|
|
return sink.getvalue()
|
|
|
|
|
|
def _capture_update(monkeypatch, results) -> tuple[str, list[tuple[str, str, bool]]]:
|
|
import tools.skills_hub as hub
|
|
import hermes_cli.skills_hub as cli_hub
|
|
|
|
sink = StringIO()
|
|
console = Console(file=sink, force_terminal=False, color_system=None)
|
|
installs = []
|
|
|
|
monkeypatch.setattr(hub, "check_for_skill_updates", lambda **_kwargs: results)
|
|
monkeypatch.setattr(hub, "HubLockFile", lambda: type("L", (), {
|
|
"get_installed": lambda self, name: {"install_path": "category/" + name}
|
|
})())
|
|
monkeypatch.setattr(cli_hub, "do_install", lambda identifier, category="", force=False, console=None: installs.append((identifier, category, force)))
|
|
|
|
do_update(console=console)
|
|
return sink.getvalue(), installs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_do_list_initializes_hub_dir(monkeypatch, hub_env):
|
|
import tools.skills_sync as skills_sync
|
|
import tools.skills_tool as skills_tool
|
|
|
|
monkeypatch.setattr(skills_tool, "_find_all_skills", lambda: [])
|
|
monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {})
|
|
|
|
hub_dir = hub_env
|
|
assert not hub_dir.exists()
|
|
|
|
_capture()
|
|
|
|
assert hub_dir.exists()
|
|
assert (hub_dir / "lock.json").exists()
|
|
assert (hub_dir / "quarantine").is_dir()
|
|
assert (hub_dir / "index-cache").is_dir()
|
|
|
|
|
|
def test_do_list_distinguishes_hub_builtin_and_local(three_source_env):
|
|
output = _capture()
|
|
|
|
assert "hub-skill" in output
|
|
assert "builtin-skill" in output
|
|
assert "local-skill" in output
|
|
assert "1 hub-installed, 1 builtin, 1 local" in output
|
|
|
|
|
|
def test_do_list_filter_local(three_source_env):
|
|
output = _capture(source_filter="local")
|
|
|
|
assert "local-skill" in output
|
|
assert "builtin-skill" not in output
|
|
assert "hub-skill" not in output
|
|
|
|
|
|
def test_do_list_filter_hub(three_source_env):
|
|
output = _capture(source_filter="hub")
|
|
|
|
assert "hub-skill" in output
|
|
assert "builtin-skill" not in output
|
|
assert "local-skill" not in output
|
|
|
|
|
|
def test_do_list_filter_builtin(three_source_env):
|
|
output = _capture(source_filter="builtin")
|
|
|
|
assert "builtin-skill" in output
|
|
assert "hub-skill" not in output
|
|
assert "local-skill" not in output
|
|
|
|
|
|
def test_do_check_reports_available_updates(monkeypatch):
|
|
output = _capture_check(monkeypatch, [
|
|
{"name": "hub-skill", "source": "skills.sh", "status": "update_available"},
|
|
{"name": "other-skill", "source": "github", "status": "up_to_date"},
|
|
])
|
|
|
|
assert "hub-skill" in output
|
|
assert "update_available" in output
|
|
assert "up_to_date" in output
|
|
|
|
|
|
def test_do_check_handles_no_installed_updates(monkeypatch):
|
|
output = _capture_check(monkeypatch, [])
|
|
|
|
assert "No hub-installed skills to check" in output
|
|
|
|
|
|
def test_do_update_reinstalls_outdated_skills(monkeypatch):
|
|
output, installs = _capture_update(monkeypatch, [
|
|
{"name": "hub-skill", "identifier": "skills-sh/example/repo/hub-skill", "status": "update_available"},
|
|
{"name": "other-skill", "identifier": "github/example/other-skill", "status": "up_to_date"},
|
|
])
|
|
|
|
assert installs == [("skills-sh/example/repo/hub-skill", "category", True)]
|
|
assert "Updated 1 skill" in output
|
|
|
|
|
|
def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_env):
|
|
import tools.skills_guard as guard
|
|
import tools.skills_hub as hub
|
|
|
|
canonical_identifier = "skills-sh/anthropics/skills/frontend-design"
|
|
|
|
class _ResolvedSource:
|
|
def inspect(self, identifier):
|
|
return type("Meta", (), {
|
|
"extra": {},
|
|
"identifier": canonical_identifier,
|
|
})()
|
|
|
|
def fetch(self, identifier):
|
|
return type("Bundle", (), {
|
|
"name": "frontend-design",
|
|
"files": {"SKILL.md": "# Frontend Design"},
|
|
"source": "skills.sh",
|
|
"identifier": canonical_identifier,
|
|
"trust_level": "trusted",
|
|
"metadata": {},
|
|
})()
|
|
|
|
q_path = tmp_path / "skills" / ".hub" / "quarantine" / "frontend-design"
|
|
q_path.mkdir(parents=True)
|
|
(q_path / "SKILL.md").write_text("# Frontend Design")
|
|
|
|
scanned = {}
|
|
|
|
def _scan_skill(skill_path, source="community"):
|
|
scanned["source"] = source
|
|
return guard.ScanResult(
|
|
skill_name="frontend-design",
|
|
source=source,
|
|
trust_level="trusted",
|
|
verdict="safe",
|
|
)
|
|
|
|
monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None)
|
|
monkeypatch.setattr(hub, "create_source_router", lambda auth: [_ResolvedSource()])
|
|
monkeypatch.setattr(hub, "quarantine_bundle", lambda bundle: q_path)
|
|
monkeypatch.setattr(hub, "HubLockFile", lambda: type("Lock", (), {"get_installed": lambda self, name: None})())
|
|
monkeypatch.setattr(guard, "scan_skill", _scan_skill)
|
|
monkeypatch.setattr(guard, "format_scan_report", lambda result: "scan ok")
|
|
monkeypatch.setattr(guard, "should_allow_install", lambda result, force=False: (False, "stop after scan"))
|
|
|
|
sink = StringIO()
|
|
console = Console(file=sink, force_terminal=False, color_system=None)
|
|
|
|
do_install("skils-sh/anthropics/skills/frontend-design", console=console, skip_confirm=True)
|
|
|
|
assert scanned["source"] == canonical_identifier
|