diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 1e1c3282bee..8c002456787 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -761,6 +761,11 @@ def _discover_all_plugins() -> list: description = manifest.get("description", "") except Exception: pass + # Path-derived key, intentionally ignoring the manifest + # ``name:`` field for category-namespaced plugins — mirrors + # ``PluginManager._parse_manifest`` in plugins.py:1027-1028 + # so renaming a directory (without touching plugin.yaml) shifts + # the registry key in both places consistently. key = f"{prefix}/{d.name}" if prefix else manifest_name src_label = source if source == "user" and (d / ".git").exists(): diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 180646c935d..5a421f018f9 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -396,6 +396,117 @@ class TestCmdList: cmd_list() +# ── _discover_all_plugins tests ─────────────────────────────────────────────── + + +class TestDiscoverAllPlugins: + """Exercise the recursive scan that powers ``hermes plugins list``. + + Mirrors the layouts the runtime loader handles + (:meth:`PluginManager._scan_directory_level`): flat plugins at the root, + category-namespaced plugins one level deeper, and user-overrides-bundled + on key collision. + """ + + @staticmethod + def _write_plugin(root: Path, segments: list, manifest_name: str = None) -> None: + plugin_dir = root + for seg in segments: + plugin_dir = plugin_dir / seg + plugin_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "name": manifest_name or segments[-1], + "version": "0.1.0", + "description": f"Test plugin {'/'.join(segments)}", + } + (plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest)) + + def _entries_by_key(self, tmp_path, monkeypatch) -> dict: + from hermes_cli import plugins_cmd + bundled = tmp_path / "bundled" + user = tmp_path / "user" + bundled.mkdir() + user.mkdir() + monkeypatch.setattr( + "hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled + ) + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user) + return bundled, user, lambda: { + e[0]: e for e in plugins_cmd._discover_all_plugins() + } + + def test_flat_plugin_uses_manifest_name_as_key(self, tmp_path, monkeypatch): + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["disk-cleanup"]) + + entries = discover() + assert "disk-cleanup" in entries + assert entries["disk-cleanup"][3] == "bundled" + + def test_category_namespaced_plugin_uses_path_derived_key( + self, tmp_path, monkeypatch + ): + """Regression test for the original bug — ``observability/langfuse`` + and ``image_gen/openai`` must surface under their path-derived key, + not vanish because the category directory has no ``plugin.yaml``.""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + # langfuse's real manifest declares ``name: langfuse`` (bare), but it + # lives under ``observability/`` — the key must reflect the path. + self._write_plugin( + bundled, ["observability", "langfuse"], manifest_name="langfuse" + ) + self._write_plugin(bundled, ["image_gen", "openai"]) + + entries = discover() + assert "observability/langfuse" in entries + assert "image_gen/openai" in entries + # Bare manifest name must NOT leak through as a top-level key. + assert "langfuse" not in entries + assert "openai" not in entries + + def test_user_overrides_bundled_on_key_collision(self, tmp_path, monkeypatch): + bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["observability", "langfuse"]) + self._write_plugin(user, ["observability", "langfuse"]) + + entries = discover() + assert entries["observability/langfuse"][3] == "user" + + def test_depth_cap_skips_third_level(self, tmp_path, monkeypatch): + """Anything deeper than ``///`` is ignored, + matching the loader's depth cap.""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + # plugins/a/b/c/plugin.yaml — too deep, must NOT be discovered. + self._write_plugin(bundled, ["a", "b", "c"]) + + entries = discover() + assert not any(k.startswith("a/") for k in entries), entries + + def test_bundled_memory_and_context_engine_skipped(self, tmp_path, monkeypatch): + """``plugins/memory/`` and ``plugins/context_engine/`` use their own + loaders; bundled entries inside them must not appear in the general + list (matches the pre-refactor skip set).""" + bundled, _, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(bundled, ["memory", "honcho"]) + self._write_plugin(bundled, ["context_engine", "compressor"]) + self._write_plugin(bundled, ["observability", "langfuse"]) + + entries = discover() + assert "memory/honcho" not in entries + assert "context_engine/compressor" not in entries + assert "observability/langfuse" in entries + + def test_user_memory_subdir_is_still_scanned(self, tmp_path, monkeypatch): + """The memory/context_engine skip only applies to *bundled* — a user + plugin at ``~/.hermes/plugins/memory//`` should still be discovered + so the user can see what they installed.""" + bundled, user, discover = self._entries_by_key(tmp_path, monkeypatch) + self._write_plugin(user, ["memory", "my-custom-store"]) + + entries = discover() + assert "memory/my-custom-store" in entries + + # ── _copy_example_files tests ─────────────────────────────────────────────────