"""Tests for the community plugin index (#64181). Covers: index parsing, fuzzy search, cache TTL + fallback chain (remote → cache → seed), `hermes plugins search --json`, and install-time name resolution (unique / ambiguous / passthrough of owner/repo). No live network — every remote fetch is mocked. """ from __future__ import annotations import json import time from pathlib import Path import pytest from hermes_cli import plugin_index from hermes_cli.plugin_index import ( PluginIndexEntry, _parse_entries, load_index, resolve_name, search_index, ) def _entry(name, repo="owner/repo", **kw): return PluginIndexEntry(name=name, repo=repo, **kw) def _index_doc(entries): return {"schema_version": 1, "plugins": entries} SAMPLE = _index_doc( [ { "name": "hermes-media-studio", "description": "Generative media workspace plugin.", "author": "NousResearch", "tags": ["media", "image-gen"], "repo": "NousResearch/hermes-media-studio", "ref": "e" * 40, }, { "name": "hermes-telegram-business", "description": "Telegram secretary bot with owner approval.", "author": "NousResearch", "tags": ["telegram", "gateway"], "repo": "NousResearch/hermes-telegram-business", "ref": "f" * 40, "capabilities": ["platform"], }, { "name": "plugin-llm-example", "description": "Reference plugin for structured LLM access.", "author": "NousResearch", "tags": ["example", "llm"], "repo": "NousResearch/hermes-example-plugins", "subdir": "plugin-llm-example", "ref": "a" * 40, "capabilities": ["commands", "llm"], }, ] ) @pytest.fixture() def hermes_home(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setattr(plugin_index, "get_hermes_home", lambda: tmp_path) return tmp_path def _write_cache(home: Path, doc, *, age_seconds: float = 0) -> Path: cache = home / "cache" / "plugin_index.json" cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(doc), encoding="utf-8") if age_seconds: stamp = time.time() - age_seconds import os os.utime(cache, (stamp, stamp)) return cache # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- class TestParsing: def test_parses_object_form(self): entries = _parse_entries(SAMPLE) assert [e.name for e in entries] == [ "hermes-media-studio", "hermes-telegram-business", "plugin-llm-example", ] assert entries[2].subdir == "plugin-llm-example" assert entries[2].install_identifier == ( "NousResearch/hermes-example-plugins/plugin-llm-example" ) assert entries[0].install_identifier == "NousResearch/hermes-media-studio" def test_parses_bare_list_form(self): entries = _parse_entries(SAMPLE["plugins"]) assert len(entries) == 3 def test_skips_malformed_entries(self): doc = _index_doc( [ {"name": "good", "repo": "o/r"}, {"name": "", "repo": "o/r"}, # empty name {"name": "norepo"}, # missing repo {"name": "badrepo", "repo": "not-a-repo"}, # no slash {"name": "deep", "repo": "a/b/c"}, # too many slashes "not-a-dict", ] ) entries = _parse_entries(doc) assert [e.name for e in entries] == ["good"] def test_rejects_non_container(self): with pytest.raises(ValueError): _parse_entries("nope") def test_bundled_seed_parses(self): raw = json.loads(plugin_index.SEED_INDEX_PATH.read_text(encoding="utf-8")) entries = _parse_entries(raw) assert len(entries) >= 3 for e in entries: assert e.repo.count("/") == 1 assert e.ref, f"seed entry {e.name} must pin a ref" assert len(e.ref) == 40, f"seed entry {e.name} must pin a commit SHA" # --------------------------------------------------------------------------- # Search # --------------------------------------------------------------------------- class TestSearch: entries = _parse_entries(SAMPLE) def test_exact_name_ranks_first(self): results = search_index(self.entries, "hermes-media-studio") assert results[0].name == "hermes-media-studio" def test_matches_tags(self): results = search_index(self.entries, "telegram") assert results and results[0].name == "hermes-telegram-business" def test_matches_description(self): results = search_index(self.entries, "secretary") assert [e.name for e in results] == ["hermes-telegram-business"] def test_fuzzy_typo_tolerance(self): results = search_index(self.entries, "hermes-media-studo") assert results and results[0].name == "hermes-media-studio" def test_no_match(self): assert search_index(self.entries, "zzzzqqqq") == [] def test_empty_term_browses_all_sorted(self): results = search_index(self.entries, "") assert [e.name for e in results] == sorted(e.name for e in self.entries) def test_capability_filter(self): results = search_index(self.entries, "", capability="platform") assert [e.name for e in results] == ["hermes-telegram-business"] def test_capability_filter_with_term(self): results = search_index(self.entries, "llm", capability="commands") assert [e.name for e in results] == ["plugin-llm-example"] # --------------------------------------------------------------------------- # Fallback chain: remote → cache → seed # --------------------------------------------------------------------------- class TestLoadIndex: def test_fresh_cache_wins_without_network(self, hermes_home, monkeypatch): _write_cache(hermes_home, SAMPLE) def boom(): # pragma: no cover - must not be called raise AssertionError("network hit despite fresh cache") monkeypatch.setattr(plugin_index, "_fetch_remote", boom) entries, source = load_index() assert source == "cache" assert len(entries) == 3 def test_expired_cache_triggers_remote(self, hermes_home, monkeypatch): _write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60) remote_doc = _index_doc([{"name": "fresh-plugin", "repo": "o/r", "ref": "b" * 40}]) monkeypatch.setattr( plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc) ) entries, source = load_index() assert source == "remote" assert [e.name for e in entries] == ["fresh-plugin"] def test_remote_failure_falls_back_to_stale_cache(self, hermes_home, monkeypatch): _write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60) monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None) entries, source = load_index() assert source == "cache" assert len(entries) == 3 def test_no_cache_no_remote_falls_back_to_seed(self, hermes_home, monkeypatch): monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None) entries, source = load_index() assert source == "seed" assert len(entries) >= 3 def test_refresh_bypasses_fresh_cache(self, hermes_home, monkeypatch): _write_cache(hermes_home, SAMPLE) remote_doc = _index_doc([{"name": "newer", "repo": "o/r", "ref": "c" * 40}]) monkeypatch.setattr( plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc) ) entries, source = load_index(refresh=True) assert source == "remote" assert [e.name for e in entries] == ["newer"] def test_offline_skips_network(self, hermes_home, monkeypatch): def boom(): # pragma: no cover raise AssertionError("network hit in offline mode") monkeypatch.setattr(plugin_index, "_fetch_remote", boom) entries, source = load_index(offline=True) assert source == "seed" def test_corrupt_cache_ignored(self, hermes_home, monkeypatch): cache = hermes_home / "cache" / "plugin_index.json" cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text("{not json", encoding="utf-8") monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None) entries, source = load_index() assert source == "seed" def test_remote_fetch_writes_cache(self, hermes_home, monkeypatch): payload = json.dumps(SAMPLE) class FakeResponse: text = payload def raise_for_status(self): return None import httpx monkeypatch.setattr(httpx, "get", lambda *a, **k: FakeResponse()) entries, source = load_index() assert source == "remote" cache = hermes_home / "cache" / "plugin_index.json" assert cache.is_file() assert json.loads(cache.read_text(encoding="utf-8")) == SAMPLE def test_index_url_config_override(self, monkeypatch): monkeypatch.setattr( plugin_index, "get_index_url", plugin_index.get_index_url, # keep real fn, patch config below ) from hermes_cli import config as config_mod monkeypatch.setattr( config_mod, "load_config_readonly", lambda: {"plugins": {"index_url": "https://example.com/custom.json"}}, ) assert plugin_index.get_index_url() == "https://example.com/custom.json" # --------------------------------------------------------------------------- # Name resolution # --------------------------------------------------------------------------- class TestResolveName: entries = _parse_entries(SAMPLE) def test_exact_unique(self): entry, candidates = resolve_name(self.entries, "hermes-media-studio") assert entry is not None and entry.repo == "NousResearch/hermes-media-studio" def test_case_insensitive(self): entry, _ = resolve_name(self.entries, "Hermes-Media-Studio") assert entry is not None def test_unique_partial(self): entry, _ = resolve_name(self.entries, "telegram") assert entry is not None and entry.name == "hermes-telegram-business" def test_ambiguous_partial(self): entry, candidates = resolve_name(self.entries, "hermes") assert entry is None assert len(candidates) == 2 def test_unknown(self): entry, candidates = resolve_name(self.entries, "nonexistent-thing") assert entry is None and candidates == [] # --------------------------------------------------------------------------- # Install wiring # --------------------------------------------------------------------------- class TestInstallResolution: def test_bare_name_detection(self): from hermes_cli.plugins_cmd import _looks_like_bare_index_name assert _looks_like_bare_index_name("hermes-media-studio") assert not _looks_like_bare_index_name("owner/repo") assert not _looks_like_bare_index_name("https://github.com/o/r.git") assert not _looks_like_bare_index_name("git@github.com:o/r.git") assert not _looks_like_bare_index_name("ssh://git@github.com/o/r.git") assert not _looks_like_bare_index_name("file:///tmp/x") def test_install_resolves_name_and_pins_ref(self, hermes_home, monkeypatch): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) captured = {} def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): captured["identifier"] = identifier captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core) with pytest.raises(SystemExit): plugins_cmd.cmd_install("hermes-media-studio", enable=False) assert captured["identifier"] == "NousResearch/hermes-media-studio" assert captured["ref"] == "e" * 40 def test_install_explicit_ref_beats_index_pin(self, hermes_home, monkeypatch): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) captured = {} def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core) with pytest.raises(SystemExit): plugins_cmd.cmd_install("hermes-media-studio", enable=False, ref="d" * 40) assert captured["ref"] == "d" * 40 def test_install_ambiguous_name_lists_candidates_and_exits( self, hermes_home, monkeypatch, capsys ): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) called = [] monkeypatch.setattr( plugins_cmd, "_install_plugin_core", lambda *a, **k: called.append(1), ) with pytest.raises(SystemExit) as exc: plugins_cmd.cmd_install("hermes", enable=False) assert exc.value.code == 1 assert not called out = capsys.readouterr().out assert "ambiguous" in out assert "hermes-media-studio" in out assert "hermes-telegram-business" in out def test_install_unknown_name_exits(self, hermes_home, monkeypatch, capsys): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) with pytest.raises(SystemExit) as exc: plugins_cmd.cmd_install("totally-unknown", enable=False) assert exc.value.code == 1 assert "not found" in capsys.readouterr().out def test_owner_repo_passthrough_skips_index(self, hermes_home, monkeypatch): """Explicit owner/repo installs never consult the index.""" from hermes_cli import plugins_cmd def boom(**kw): # pragma: no cover raise AssertionError("index consulted for owner/repo identifier") monkeypatch.setattr(plugin_index, "load_index", boom) captured = {} def fake_core(identifier, *, force, ref=None, scan_decision_cb=None): captured["identifier"] = identifier captured["ref"] = ref raise plugins_cmd.PluginOperationError("stop here") monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core) with pytest.raises(SystemExit): plugins_cmd.cmd_install("someowner/somerepo", enable=False) assert captured["identifier"] == "someowner/somerepo" assert captured["ref"] is None # --------------------------------------------------------------------------- # CLI search command # --------------------------------------------------------------------------- class TestCmdSearch: def test_json_output(self, hermes_home, monkeypatch, capsys): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) plugins_cmd.cmd_search("telegram", json_output=True) payload = json.loads(capsys.readouterr().out) assert payload["source"] == "seed" assert payload["query"] == "telegram" assert payload["results"][0]["name"] == "hermes-telegram-business" assert payload["results"][0]["repo"] == "NousResearch/hermes-telegram-business" assert payload["results"][0]["ref"] == "f" * 40 assert "audited" in payload["note"] def test_table_output_includes_security_footer( self, hermes_home, monkeypatch, capsys ): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) plugins_cmd.cmd_search("media") out = capsys.readouterr().out assert "hermes-media-studio" in out assert "audited" in out def test_no_results_message(self, hermes_home, monkeypatch, capsys): from hermes_cli import plugins_cmd monkeypatch.setattr( plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed") ) plugins_cmd.cmd_search("zzzznope") assert "No plugins matched" in capsys.readouterr().out def test_parser_accepts_search(self): import argparse from hermes_cli.subcommands.plugins import build_plugins_parser parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command") build_plugins_parser(sub, cmd_plugins=lambda args: None) args = parser.parse_args( ["plugins", "search", "media", "--json", "--capability", "tools", "--refresh"] ) assert args.plugins_action == "search" assert args.term == "media" assert args.json is True assert args.capability == "tools" assert args.refresh is True def test_dispatch_routes_search(self, hermes_home, monkeypatch): from hermes_cli import plugins_cmd captured = {} def fake_search(term, *, json_output, capability, refresh): captured.update( term=term, json_output=json_output, capability=capability, refresh=refresh ) monkeypatch.setattr(plugins_cmd, "cmd_search", fake_search) import argparse args = argparse.Namespace( plugins_action="search", term="llm", json=True, capability=None, refresh=False ) plugins_cmd.plugins_command(args) assert captured == { "term": "llm", "json_output": True, "capability": None, "refresh": False, }