diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 687badff7d5fa..5470898fa0436 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1908,6 +1908,24 @@ display: # # Routing/delivery still uses the original values internally. # redact_pii: false +# ============================================================================= +# Plugin discovery +# ============================================================================= +# User-installed and external plugins are discovered for visibility, but their +# code only runs after the plugin key is listed in plugins.enabled. External +# paths may point to a plugin collection directory or to a direct checkout with +# a root plugin.yaml. Relative entries resolve from the active Hermes home. +# If enabled external roots provide the same plugin key, the later entry wins +# and Hermes logs the collision. +# +# plugins: +# extra_paths: +# - ~/src/hermes-private-plugin +# - ~/src/hermes-private-plugins +# enabled: +# - my-tool-plugin +# disabled: [] + # ============================================================================= # Shell-script hooks # ============================================================================= diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 31885269122ab..0334b02347fb4 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -2,7 +2,7 @@ Hermes Plugin System ==================== -Discovers, loads, and manages plugins from four sources: +Discovers, loads, and manages plugins from five sources: 1. **Bundled plugins** – ``/plugins//`` (shipped with hermes-agent; ``memory/`` and ``context_engine/`` subdirs are excluded — they have their @@ -10,11 +10,15 @@ 2. **User plugins** – ``~/.hermes/plugins//`` 3. **Project plugins** – ``./.hermes/plugins//`` (opt-in via ``HERMES_ENABLE_PROJECT_PLUGINS``) -4. **Pip plugins** – packages that expose the ``hermes_agent.plugins`` +4. **External plugin paths** – general PluginManager plugins listed in + ``plugins.extra_paths``. Specialized plugin categories keep their own + documented discovery locations. +5. **Pip plugins** – packages that expose the ``hermes_agent.plugins`` entry-point group. -Later sources override earlier ones on name collision, so a user or project -plugin with the same name as a bundled plugin replaces it. +Later sources can replace earlier discovered plugins only after the later +plugin is explicitly enabled, so discovering an external path cannot +suppress an active bundled/user plugin by itself. Each directory plugin must contain a ``plugin.yaml`` manifest **and** an ``__init__.py`` with a ``register(ctx)`` function. @@ -676,6 +680,59 @@ def _get_enabled_plugins() -> Optional[set]: return None +def _get_extra_plugin_paths() -> List[Path]: + """Read additional general-plugin discovery roots from config. + + ``plugins.extra_paths`` may be a path string or a YAML list of path + strings. Each entry may be either a collection directory containing + plugin subdirectories or a direct plugin checkout containing a root + ``plugin.yaml``. Relative entries resolve from the active Hermes home. + """ + paths: List[Path] = [] + + try: + from hermes_cli.config import load_config + config = load_config() + plugins_cfg = config.get("plugins") + if isinstance(plugins_cfg, dict): + raw_config = plugins_cfg.get("extra_paths", []) + if isinstance(raw_config, str): + if raw_config.strip(): + paths.append(Path(raw_config.strip()).expanduser()) + elif isinstance(raw_config, list): + for item in raw_config: + if isinstance(item, str) and item.strip(): + paths.append(Path(item).expanduser()) + elif raw_config not in (None, []): + logger.warning( + "plugins.extra_paths must be a path string or list of path strings; got %s", + type(raw_config).__name__, + ) + except Exception: + pass + + deduped: List[Path] = [] + seen: Set[str] = set() + for path in paths: + try: + if not path.is_absolute(): + path = get_hermes_home() / path + path = path.resolve(strict=False) + except (OSError, RuntimeError) as exc: + logger.warning( + "Could not resolve configured plugin extra path %s: %s", + path, + exc, + ) + continue + key = str(path) + if key in seen: + continue + seen.add(key) + deduped.append(path) + return deduped + + # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @@ -4359,13 +4416,6 @@ def _discover_and_load_inner(self) -> None: logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests)) manifests.extend(ep_manifests) - # Load each manifest (skip user-disabled plugins). - # Later sources override earlier ones on key collision — user - # plugins take precedence over bundled, project plugins take - # precedence over user. Dedup here so we only load the final - # winner. Keys are path-derived (``image_gen/openai``, - # ``disk-cleanup``) so ``tts/openai`` and ``image_gen/openai`` - # don't collide even when both manifests say ``name: openai``. disabled = _get_disabled_plugins() enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled) stale_relay_keys = legacy_relay_plugin_keys(enabled) @@ -4376,9 +4426,58 @@ def _discover_and_load_inner(self) -> None: ", ".join(stale_relay_keys), RELAY_PLUGINS_CONFIG_ENV, ) + + # Load each manifest (skip user-disabled plugins). Later sources can + # override earlier ones on key collision only after the later plugin is + # explicitly enabled. This preserves the opt-in execution boundary: + # merely discovering an external/user/project plugin must not hide or + # replace an earlier bundled backend/platform. Keys are path-derived + # (``image_gen/openai``, ``disk-cleanup``) so ``tts/openai`` and + # ``image_gen/openai`` do not collide even when both manifests say + # ``name: openai``. winners: Dict[str, PluginManifest] = {} for manifest in manifests: - winners[manifest.key or manifest.name] = manifest + lookup_key = manifest.key or manifest.name + is_disabled = lookup_key in disabled or manifest.name in disabled + is_enabled = ( + enabled is not None + and (lookup_key in enabled or manifest.name in enabled) + ) + requires_opt_in = not ( + manifest.kind in {"exclusive", "model-provider"} + or ( + manifest.source == "bundled" + and manifest.kind in {"backend", "platform"} + ) + ) + if ( + lookup_key in winners + and requires_opt_in + and not is_enabled + and not is_disabled + ): + logger.debug( + "Discovered plugin '%s' from %s but not replacing earlier " + "manifest because it is not enabled", + lookup_key, + manifest.source, + ) + continue + previous = winners.get(lookup_key) + if ( + previous is not None + and previous.source == "external" + and manifest.source == "external" + and is_enabled + ): + logger.warning( + "multiple enabled plugin sources provide key '%s'; " + "the later configured source wins: %s -> %s", + lookup_key, + previous.path, + manifest.path, + ) + winners[lookup_key] = manifest # Standalone/user plugins that pass the gates below are collected # here and loaded AFTER the sweep in dependency-respecting order # (requires_plugins topological sort, #64165). @@ -4598,6 +4697,20 @@ def _collect_directory_manifests(self) -> List[PluginManifest]: "Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)" ) + # 4. External general-plugin paths configured in config.yaml. These + # are part of general PluginManager discovery only; independent + # memory/context-engine/model-provider/dashboard loaders keep their + # own roots. + for external_path in _get_extra_plugin_paths(): + logger.debug("Scanning external plugin path: %s", external_path) + external_manifests = self._scan_external_path(external_path) + logger.debug( + " external %s: %d manifest(s)", + external_path, + len(external_manifests), + ) + manifests.extend(external_manifests) + return manifests def has_enabled_portable_mcp(self, raw_config: Mapping[str, Any]) -> bool: @@ -4685,6 +4798,53 @@ def _scan_directory( path, source, skip_names=skip_names, prefix="", depth=0 ) + def _scan_external_path(self, path: Path) -> List[PluginManifest]: + """Scan an externally configured general-plugin path. + + External paths intentionally accept two shapes: + + * ``/path/to/plugins/`` containing ``/plugin.yaml`` children. + * ``/path/to/plugin-checkout/`` containing a root ``plugin.yaml``. + """ + if not path.is_dir(): + logger.warning( + "Configured plugin extra path does not exist or is not a directory: %s", + path, + ) + return [] + try: + manifest_file = path / "plugin.yaml" + if not manifest_file.exists(): + manifest_file = path / "plugin.yml" + if manifest_file.exists(): + manifest = self._parse_manifest( + manifest_file, + path, + source="external", + prefix="", + ) + if manifest is not None and manifest.kind in {"exclusive", "model-provider"}: + logger.warning( + "Skipping external plugin '%s': kind '%s' uses an independent " + "discovery root and is not supported by plugins.extra_paths", + manifest.name, + manifest.kind, + ) + return [] + return [manifest] if manifest is not None else [] + return self._scan_directory( + path, + source="external", + skip_names={"memory", "context_engine", "model-providers"}, + ) + except OSError as exc: + logger.warning( + "Could not scan configured plugin extra path %s: %s", + path, + exc, + ) + return [] + def _scan_directory_level( self, path: Path, @@ -5280,7 +5440,7 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None: preloaded = self._predeclared_modules.pop(plugin_key, None) if preloaded is not None: module = preloaded - elif manifest.source in {"user", "project", "bundled"}: + elif manifest.source in {"user", "project", "bundled", "external"}: module = self._load_directory_module( manifest, module_name=_module_name ) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 912d93208d216..3189069f8ecda 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1880,7 +1880,7 @@ def _discover_all_plugins() -> list: # and model-providers/ — model providers load through the dedicated # provider registry (providers/__init__.py), not the general PluginManager # opt-in surface, so listing them as toggleable plugins is misleading. - from hermes_cli.plugins import get_bundled_plugins_dir + from hermes_cli.plugins import _env_enabled, get_bundled_plugins_dir repo_plugins = get_bundled_plugins_dir() for base, source, skip in ( (repo_plugins, "bundled", {"memory", "context_engine", "model-providers"}), @@ -1888,6 +1888,44 @@ def _discover_all_plugins() -> list: ): _scan_level(base, source, skip, "", 0, seen) + if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): + _scan_level( + Path.cwd() / ".hermes" / "plugins", + "project", + set(), + "", + 0, + seen, + ) + + # Configured external roots support either a collection directory or a + # direct checkout containing a root plugin.yaml. Keep this control-plane + # view aligned with PluginManager so list/enable/disable can address the + # same external plugins the runtime sees. + from hermes_cli.plugins import PluginManager, _get_extra_plugin_paths + external_scanner = PluginManager() + enabled = _get_enabled_set() + disabled = _get_disabled_set() + for external_root in _get_extra_plugin_paths(): + for manifest in external_scanner._scan_external_path(external_root): + key = manifest.key or manifest.name + selected = ( + key in enabled + or manifest.name in enabled + or key in disabled + or manifest.name in disabled + ) + if key in seen and not selected: + continue + seen[key] = ( + manifest.name, + manifest.version, + manifest.description, + "external", + Path(manifest.path) if manifest.path else external_root, + key, + ) + # Entry-point plugins (installed as Python packages; no plugin directory). for name, version, description, path in _discover_entrypoint_plugins(): seen[name] = (name, version, description, "entrypoint", path, name) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index d44abafb4f78c..a6eaaf04f43ab 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -18,6 +18,7 @@ PluginManager, PluginManifest, _dispatch_pre_tool_call_hooks, + _get_extra_plugin_paths, get_plugin_command_handler, get_plugin_commands, get_pre_tool_call_block_message, @@ -116,6 +117,25 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", return plugin_dir +def _write_plugins_config( + hermes_home: Path, + *, + enabled: list[str] | None = None, + disabled: list[str] | None = None, + extra_paths: list[str] | str | None = None, +) -> None: + cfg: dict = {"plugins": {}} + plugins_cfg = cfg["plugins"] + if enabled is not None: + plugins_cfg["enabled"] = enabled + if disabled is not None: + plugins_cfg["disabled"] = disabled + if extra_paths is not None: + plugins_cfg["extra_paths"] = extra_paths + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text(yaml.safe_dump(cfg)) + + # ── TestPluginDiscovery ──────────────────────────────────────────────────── @@ -351,6 +371,286 @@ def test_middleware_helpers_skip_no_listener_work(self, monkeypatch): assert run_tool_execution_middleware("terminal", args, lambda payload: payload) is args assert has_middleware("tool_request") is False + def test_discover_external_plugin_collection_from_config(self, tmp_path, monkeypatch): + """Configured external plugin collections are discovered but still opt-in.""" + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + external_root = tmp_path / "private_plugins" + _make_plugin_dir( + external_root, + "private-router", + register_body='ctx.register_command("private-router", lambda raw: "ok")', + auto_enable=False, + ) + _write_plugins_config(hermes_home, enabled=[], extra_paths=[str(external_root)]) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["private-router"] + assert loaded.manifest.source == "external" + assert loaded.enabled is False + assert "not enabled" in (loaded.error or "") + assert "private-router" not in mgr._plugin_commands + + _write_plugins_config( + hermes_home, + enabled=["private-router"], + extra_paths=[str(external_root)], + ) + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["private-router"] + assert loaded.manifest.source == "external" + assert loaded.enabled is True + assert "private-router" in mgr._plugin_commands + + def test_discover_external_direct_checkout_from_config(self, tmp_path, monkeypatch): + """A direct private plugin checkout with root plugin.yaml can be enabled.""" + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + checkout = tmp_path / "direct-plugin" + checkout.mkdir() + (checkout / "plugin.yaml").write_text( + yaml.safe_dump({"name": "direct-plugin", "version": "0.1.0", "kind": "backend"}) + ) + (checkout / "__init__.py").write_text( + "def register(ctx):\n" + " ctx.register_command('direct-plugin', lambda raw: 'ok')\n" + ) + _write_plugins_config( + hermes_home, + enabled=["direct-plugin"], + extra_paths=[str(checkout)], + ) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["direct-plugin"] + assert loaded.manifest.source == "external" + assert loaded.manifest.kind == "backend" + assert loaded.enabled is True + assert "direct-plugin" in mgr._plugin_commands + + def test_external_collection_excludes_independent_loader_categories(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes_test" + external_root = tmp_path / "private_plugins" + bundled_root = tmp_path / "bundled_plugins" + bundled_root.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled_root)) + _make_plugin_dir(external_root / "memory", "memory-x", auto_enable=False) + _make_plugin_dir(external_root / "context_engine", "context-x", auto_enable=False) + _make_plugin_dir(external_root / "model-providers", "model-x", auto_enable=False) + _write_plugins_config( + hermes_home, + enabled=["memory/memory-x", "context_engine/context-x", "model-providers/model-x"], + extra_paths=[str(external_root)], + ) + + mgr = PluginManager() + mgr.discover_and_load() + + assert "memory/memory-x" not in mgr._plugins + assert "context_engine/context-x" not in mgr._plugins + assert "model-providers/model-x" not in mgr._plugins + + def test_external_direct_checkout_excludes_independent_provider_kind(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes_test" + checkout = tmp_path / "model-provider-checkout" + checkout.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (checkout / "plugin.yaml").write_text( + yaml.safe_dump({"name": "model-x", "kind": "model-provider"}) + ) + (checkout / "__init__.py").write_text("def register(ctx): pass\n") + _write_plugins_config( + hermes_home, + enabled=["model-x"], + extra_paths=[str(checkout)], + ) + + mgr = PluginManager() + mgr.discover_and_load() + + assert "model-x" not in mgr._plugins + + def test_missing_external_path_logs_actionable_warning(self, tmp_path, monkeypatch, caplog): + hermes_home = tmp_path / "hermes_test" + missing = tmp_path / "missing-plugin-root" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _write_plugins_config(hermes_home, extra_paths=[str(missing)]) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + PluginManager().discover_and_load() + + assert "does not exist or is not a directory" in caplog.text + + def test_extra_paths_are_canonicalized_before_deduplication(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes_test" + external_root = tmp_path / "private_plugins" + external_root.mkdir() + alias = tmp_path / "private_plugins_alias" + alias.symlink_to(external_root, target_is_directory=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _write_plugins_config(hermes_home, extra_paths=[str(external_root), str(alias)]) + + assert _get_extra_plugin_paths() == [external_root.resolve()] + + def test_relative_extra_path_resolves_from_hermes_home(self, tmp_path, monkeypatch): + """Service launch cwd must not change config-relative plugin discovery.""" + hermes_home = tmp_path / "hermes_test" + external_root = hermes_home / "private_plugins" + external_root.mkdir(parents=True) + unrelated_cwd = tmp_path / "service_cwd" + unrelated_cwd.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.chdir(unrelated_cwd) + _write_plugins_config(hermes_home, extra_paths=["private_plugins"]) + + assert _get_extra_plugin_paths() == [external_root.resolve()] + + def test_unresolvable_extra_path_does_not_hide_later_valid_path( + self, tmp_path, monkeypatch, caplog + ): + hermes_home = tmp_path / "hermes_test" + unresolvable = tmp_path / "unresolvable-plugin-path" + valid = tmp_path / "valid-plugins" + valid.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + original_resolve = Path.resolve + + def _resolve(path, *, strict=False): + if path == unresolvable: + raise OSError("simulated resolution failure") + return original_resolve(path, strict=strict) + + monkeypatch.setattr(Path, "resolve", _resolve) + _write_plugins_config(hermes_home, extra_paths=[str(unresolvable), str(valid)]) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + paths = _get_extra_plugin_paths() + + assert paths == [original_resolve(valid)] + assert "could not resolve configured plugin extra path" in caplog.text.lower() + + def test_invalid_extra_paths_config_logs_actionable_warning(self, tmp_path, monkeypatch, caplog): + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + hermes_home.mkdir(exist_ok=True) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"extra_paths": {"bad": "shape"}}}) + ) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + paths = _get_extra_plugin_paths() + + assert paths == [] + assert "must be a path string or list of path strings" in caplog.text + + def test_external_scan_isolates_filesystem_errors(self, tmp_path, caplog): + external_root = tmp_path / "private_plugins" + external_root.mkdir() + mgr = PluginManager() + + with patch.object(mgr, "_scan_directory", side_effect=OSError("permission denied")): + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + manifests = mgr._scan_external_path(external_root) + + assert manifests == [] + assert "Could not scan configured plugin extra path" in caplog.text + + def test_unenabled_external_plugin_does_not_shadow_bundled_backend(self, tmp_path, monkeypatch): + """External discovery alone must not replace a bundled auto-loaded backend.""" + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + bundled_root = tmp_path / "bundled_plugins" + external_root = tmp_path / "private_plugins" + monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled_root)) + _make_plugin_dir( + bundled_root, + "shared-plugin", + register_body='ctx.register_command("bundled-shared", lambda raw: "bundled")', + manifest_extra={"kind": "backend"}, + auto_enable=False, + ) + _make_plugin_dir( + external_root, + "shared-plugin", + register_body='ctx.register_command("external-shared", lambda raw: "external")', + auto_enable=False, + ) + _write_plugins_config(hermes_home, enabled=[], extra_paths=[str(external_root)]) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["shared-plugin"] + assert loaded.manifest.source == "bundled" + assert loaded.enabled is True + assert "bundled-shared" in mgr._plugin_commands + assert "external-shared" not in mgr._plugin_commands + + def test_enabled_external_plugin_can_replace_bundled_backend(self, tmp_path, monkeypatch): + """An explicitly enabled external plugin can replace an earlier bundled key.""" + hermes_home = tmp_path / "hermes_test" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + bundled_root = tmp_path / "bundled_plugins" + external_root = tmp_path / "private_plugins" + monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", str(bundled_root)) + _make_plugin_dir( + bundled_root, + "shared-plugin", + register_body='ctx.register_command("bundled-shared", lambda raw: "bundled")', + manifest_extra={"kind": "backend"}, + auto_enable=False, + ) + _make_plugin_dir( + external_root, + "shared-plugin", + register_body='ctx.register_command("external-shared", lambda raw: "external")', + auto_enable=False, + ) + _write_plugins_config( + hermes_home, + enabled=["shared-plugin"], + extra_paths=[str(external_root)], + ) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["shared-plugin"] + assert loaded.manifest.source == "external" + assert loaded.enabled is True + assert "external-shared" in mgr._plugin_commands + assert "bundled-shared" not in mgr._plugin_commands + + def test_enabled_external_key_collision_warns_with_both_sources( + self, tmp_path, monkeypatch, caplog + ): + """An ordered override must not silently hide an external checkout.""" + hermes_home = tmp_path / "hermes_test" + first_root = tmp_path / "first_plugins" + second_root = tmp_path / "second_plugins" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _make_plugin_dir(first_root, "shared-plugin", auto_enable=False) + _make_plugin_dir(second_root, "shared-plugin", auto_enable=False) + _write_plugins_config( + hermes_home, + enabled=["shared-plugin"], + extra_paths=[str(first_root), str(second_root)], + ) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + PluginManager().discover_and_load() + + assert "multiple enabled plugin sources provide key 'shared-plugin'" in caplog.text + assert str(first_root / "shared-plugin") in caplog.text + assert str(second_root / "shared-plugin") in caplog.text + diff --git a/tests/hermes_cli/test_plugins_cmd_list.py b/tests/hermes_cli/test_plugins_cmd_list.py index 9c8ffe6042268..885dda2c1a7cc 100644 --- a/tests/hermes_cli/test_plugins_cmd_list.py +++ b/tests/hermes_cli/test_plugins_cmd_list.py @@ -79,6 +79,7 @@ def test_discover_all_plugins_includes_entrypoint_plugins(monkeypatch, tmp_path) "entry_points", lambda: [entry_point], ) + monkeypatch.setattr("hermes_cli.plugins._get_extra_plugin_paths", lambda: []) entries = plugins_cmd._discover_all_plugins() @@ -94,6 +95,145 @@ def test_discover_all_plugins_includes_entrypoint_plugins(monkeypatch, tmp_path) ] +def test_discover_all_plugins_includes_external_collection_and_checkout( + monkeypatch, tmp_path +): + bundled_dir = tmp_path / "bundled" + user_dir = tmp_path / "user" + collection = tmp_path / "external-collection" + direct = tmp_path / "external-direct" + bundled_dir.mkdir() + user_dir.mkdir() + plugin_dir = collection / "analytics" + plugin_dir.mkdir(parents=True) + direct.mkdir() + (plugin_dir / "plugin.yaml").write_text( + "name: analytics\nversion: 1.2.3\ndescription: External analytics\n" + ) + (direct / "plugin.yaml").write_text( + "name: direct-plugin\nversion: 2.0.0\ndescription: Direct checkout\n" + ) + + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user_dir) + monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled_dir) + monkeypatch.setattr( + "hermes_cli.plugins._get_extra_plugin_paths", + lambda: [collection, direct], + ) + monkeypatch.setattr(plugins_cmd.importlib.metadata, "entry_points", lambda: []) + + entries = plugins_cmd._discover_all_plugins() + + assert entries == [ + ( + "analytics", + "1.2.3", + "External analytics", + "external", + plugin_dir, + "analytics", + ), + ( + "direct-plugin", + "2.0.0", + "Direct checkout", + "external", + direct, + "direct-plugin", + ), + ] + + +def test_discover_all_plugins_unenabled_external_does_not_shadow_bundled( + monkeypatch, tmp_path +): + bundled_dir = tmp_path / "bundled" + user_dir = tmp_path / "user" + external_dir = tmp_path / "external" + bundled_plugin = bundled_dir / "shared-plugin" + external_plugin = external_dir / "shared-plugin" + user_dir.mkdir() + bundled_plugin.mkdir(parents=True) + external_plugin.mkdir(parents=True) + (bundled_plugin / "plugin.yaml").write_text( + "name: shared-plugin\nversion: 1.0.0\ndescription: Bundled\n" + ) + (external_plugin / "plugin.yaml").write_text( + "name: shared-plugin\nversion: 2.0.0\ndescription: External\n" + ) + + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user_dir) + monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled_dir) + monkeypatch.setattr("hermes_cli.plugins._get_extra_plugin_paths", lambda: [external_dir]) + monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: set()) + monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) + monkeypatch.setattr(plugins_cmd.importlib.metadata, "entry_points", lambda: []) + + entries = plugins_cmd._discover_all_plugins() + + assert entries == [ + ( + "shared-plugin", + "1.0.0", + "Bundled", + "bundled", + bundled_plugin, + "shared-plugin", + ) + ] + + +def test_discover_all_plugins_includes_enabled_project_plugins(monkeypatch, tmp_path): + bundled_dir = tmp_path / "bundled" + user_dir = tmp_path / "user" + project_plugin = tmp_path / ".hermes" / "plugins" / "project-tool" + bundled_dir.mkdir() + user_dir.mkdir() + project_plugin.mkdir(parents=True) + (project_plugin / "plugin.yaml").write_text( + "name: project-tool\nversion: 1.0.0\ndescription: Project tool\n" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "true") + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user_dir) + monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled_dir) + monkeypatch.setattr("hermes_cli.plugins._get_extra_plugin_paths", lambda: []) + monkeypatch.setattr(plugins_cmd.importlib.metadata, "entry_points", lambda: []) + + entries = plugins_cmd._discover_all_plugins() + + assert entries == [ + ( + "project-tool", + "1.0.0", + "Project tool", + "project", + project_plugin, + "project-tool", + ) + ] + + +def test_discover_all_plugins_warns_for_missing_external_root( + monkeypatch, tmp_path, caplog +): + bundled_dir = tmp_path / "bundled" + user_dir = tmp_path / "user" + missing = tmp_path / "missing-external" + bundled_dir.mkdir() + user_dir.mkdir() + monkeypatch.setattr(plugins_cmd, "_plugins_dir", lambda: user_dir) + monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: bundled_dir) + monkeypatch.setattr("hermes_cli.plugins._get_extra_plugin_paths", lambda: [missing]) + monkeypatch.setattr(plugins_cmd.importlib.metadata, "entry_points", lambda: []) + + with caplog.at_level("WARNING", logger="hermes_cli.plugins"): + entries = plugins_cmd._discover_all_plugins() + + assert entries == [] + assert "does not exist or is not a directory" in caplog.text + + def test_declared_capabilities_for_entrypoint_uses_distribution_metadata( monkeypatch, tmp_path ): @@ -121,9 +261,8 @@ def test_declared_capabilities_for_entrypoint_uses_distribution_metadata( "entry_points", lambda: [plugin_ep, capability_ep], ) + monkeypatch.setattr("hermes_cli.plugins._get_extra_plugin_paths", lambda: []) assert plugins_cmd._declared_capabilities_for_key("thread-namer") == [ "gateway.platform_actions" ] - - diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 417850c1c7b1c..9c1e2f20007a5 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -92,6 +92,8 @@ The model-facing tool description belongs in `schema["description"]`. The option Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable them only for trusted repositories by setting `HERMES_ENABLE_PROJECT_PLUGINS=true` before starting Hermes. +Private general-plugin repositories can also be discovered without copying them into `~/.hermes/plugins/`. Add either a directory that contains multiple plugin folders or a direct single-plugin checkout with a root `plugin.yaml` to `plugins.extra_paths` in `~/.hermes/config.yaml`. Relative entries resolve from `~/.hermes` (or the active profile home), never from the process working directory. External plugins appear in `hermes plugins list` and can be activated with `hermes plugins enable `, but they still require `plugins.enabled` before their code runs. When multiple external roots provide the same enabled plugin key, the later configured root wins and Hermes logs both sources. `extra_paths` is consumed only by the general `PluginManager`; it does not extend the independent memory, context-engine, model-provider, or dashboard-asset discovery roots. + ## What plugins can do Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. @@ -126,10 +128,11 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Bundled | `/plugins/` | Ships with Hermes — see [Built-in Plugins](/user-guide/features/built-in-plugins) | | User | `~/.hermes/plugins/` | Personal plugins | | Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | +| External | `plugins.extra_paths` | Private general-plugin repo checkouts or plugin collections | | pip | `hermes_agent.plugins` entry_points | Distributed packages | | Nix | `services.hermes-agent.extraPlugins` / `extraPythonPackages` | NixOS declarative installs — see [Nix Setup](/getting-started/nix-setup#plugins) | -Later sources override earlier ones on name collision, so a user plugin with the same name as a bundled plugin replaces it. +Later opt-in sources replace earlier plugins on key collision only when the later plugin is explicitly enabled. Merely discovering external code never suppresses an active bundled backend or platform. ### Plugin sub-categories @@ -152,6 +155,9 @@ User plugins at `~/.hermes/plugins/model-providers//` and `~/.hermes/plugi ```yaml plugins: + extra_paths: + - ~/src/hermes-private-plugin # direct checkout with plugin.yaml + - ~/src/hermes-private-plugins # collection containing /plugin.yaml enabled: - my-tool-plugin - disk-cleanup