From 9b82586c6b6dd628af273b3c6875e0142f798089 Mon Sep 17 00:00:00 2001 From: Guillaume Meyer Date: Sat, 16 May 2026 22:55:28 +0000 Subject: [PATCH] fix(plugins): surface category-namespaced plugins in hermes plugins list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_discover_all_plugins()` in plugins_cmd.py did a flat scan of the bundled and user plugin directories — only direct children with a plugin.yaml were surfaced. Category directories like `observability/`, `image_gen/`, `platforms/`, `model-providers/`, `web/`, and `video_gen/` have no plugin.yaml of their own, so their nested plugins (`observability/langfuse`, `image_gen/openai`, etc.) never appeared in `hermes plugins list` or the interactive `hermes plugins` UI — even though the runtime loader (`PluginManager._scan_directory_level`) discovers them correctly and they do load at runtime. This broke the documented promise that bundled plugins appear in `hermes plugins list` and the interactive UI before being enabled, and made it look like `observability/langfuse` didn't exist. Refactor `_discover_all_plugins()` to mirror the loader's recursion (depth cap = 2, same skip set, user overrides bundled on key collision). Return the path-derived registry key (e.g. `observability/langfuse`) as the displayed name, matching what the user passes to `hermes plugins enable …` / writes under `plugins.enabled` in config.yaml. Also clarify the plugins docs: spell out that sub-category plugins surface by their `/` key in `hermes plugins list` / interactive UI, add an `observability/langfuse` example to the command reference, and include a nested entry in the interactive-UI mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/plugins_cmd.py | 86 +++++++++++++-------- website/docs/user-guide/features/plugins.md | 24 +++--- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 675989d170e..6fa3c59c7a3 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -708,55 +708,79 @@ def _plugin_exists(name: str) -> bool: def _discover_all_plugins() -> list: - """Return a list of (name, version, description, source, dir_path) for - every plugin the loader can see — user + bundled + project. + """Return a list of (key, version, description, source, dir_path) for + every plugin the loader can see — user + bundled. - Matches the ordering/dedup of ``PluginManager.discover_and_load``: - bundled first, then user, then project; user overrides bundled on - name collision. + Mirrors :meth:`PluginManager._scan_directory_level` so category-namespaced + plugins (``observability/langfuse``, ``image_gen/openai``) surface here + just like flat ones (``disk-cleanup``). A subdirectory with no + ``plugin.yaml`` of its own is treated as a category and recursed into + one level deeper (depth capped at 2, same as the loader). + + The returned ``key`` is the path-derived registry key — the value the + user types into ``hermes plugins enable ``. For category-namespaced + plugins that's ``/``; for flat plugins it's the + manifest's ``name`` (or the directory name if the manifest omits it). + + User entries override bundled on key collision, matching + ``PluginManager.discover_and_load``. """ try: import yaml except ImportError: yaml = None - seen: dict = {} # name -> (name, version, description, source, path) + seen: dict = {} # key -> (key, version, description, source, path) - # Bundled (/plugins//), excluding memory/ and context_engine/ - from hermes_cli.plugins import get_bundled_plugins_dir - repo_plugins = get_bundled_plugins_dir() - for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")): + def _scan(base: Path, source: str, prefix: str, depth: int) -> None: if not base.is_dir(): - continue + return for d in sorted(base.iterdir()): if not d.is_dir(): continue - if source == "bundled" and d.name in {"memory", "context_engine"}: + if ( + depth == 0 + and source == "bundled" + and d.name in {"memory", "context_engine"} + ): continue manifest_file = d / "plugin.yaml" if not manifest_file.exists(): manifest_file = d / "plugin.yml" - if not manifest_file.exists(): + + if manifest_file.exists(): + manifest_name = d.name + version = "" + description = "" + if yaml: + try: + with open(manifest_file, encoding="utf-8") as f: + manifest = yaml.safe_load(f) or {} + manifest_name = manifest.get("name", d.name) + version = manifest.get("version", "") + description = manifest.get("description", "") + except Exception: + pass + key = f"{prefix}/{d.name}" if prefix else manifest_name + if key in seen and source == "bundled": + continue + src_label = source + if source == "user" and (d / ".git").exists(): + src_label = "git" + seen[key] = (key, version, description, src_label, d) continue - name = d.name - version = "" - description = "" - if yaml: - try: - with open(manifest_file, encoding="utf-8") as f: - manifest = yaml.safe_load(f) or {} - name = manifest.get("name", d.name) - version = manifest.get("version", "") - description = manifest.get("description", "") - except Exception: - pass - # User plugins override bundled on name collision. - if name in seen and source == "bundled": + + # No manifest at this level — treat as a category namespace and + # recurse one level deeper. Cap at depth 2 (same as the loader). + if depth >= 1: continue - src_label = source - if source == "user" and (d / ".git").exists(): - src_label = "git" - seen[name] = (name, version, description, src_label, d) + sub_prefix = f"{prefix}/{d.name}" if prefix else d.name + _scan(d, source, sub_prefix, depth + 1) + + from hermes_cli.plugins import get_bundled_plugins_dir + _scan(get_bundled_plugins_dir(), "bundled", "", 0) + _scan(_plugins_dir(), "user", "", 0) + return list(seen.values()) diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index e9dc2910889..9572f3538a6 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -142,6 +142,8 @@ Within each source, Hermes also recognizes sub-category directories that route p User plugins at `~/.hermes/plugins/model-providers//` and `~/.hermes/plugins/memory//` override bundled plugins of the same name — last-writer-wins in `register_provider()` / `register_memory_provider()`. Drop a directory in, and it replaces the built-in without any repo edits. +Sub-category plugins surface in `hermes plugins list` and the interactive `hermes plugins` UI under their **path-derived key** — e.g. `observability/langfuse`, `image_gen/openai`, `platforms/teams`. That key (not the bare manifest `name:`) is the value you pass to `hermes plugins enable …` / `disable …` and the string to add under `plugins.enabled` in `config.yaml`. + ## Plugins are opt-in (with a few exceptions) **General plugins and user-installed backends are disabled by default** — discovery finds them (so they show up in `hermes plugins` and `/plugins`), but nothing with hooks or tools loads until you add the plugin's name to `plugins.enabled` in `~/.hermes/config.yaml`. This stops third-party code from running without your explicit consent. @@ -263,17 +265,20 @@ Declarative plugins are symlinked with a `nix-managed-` prefix — they coexist ## Managing plugins ```bash -hermes plugins # unified interactive UI -hermes plugins list # table: enabled / disabled / not enabled -hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] -hermes plugins install user/repo --enable # install AND enable (no prompt) -hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) -hermes plugins update my-plugin # pull latest -hermes plugins remove my-plugin # uninstall -hermes plugins enable my-plugin # add to allow-list -hermes plugins disable my-plugin # remove from allow-list + add to disabled +hermes plugins # unified interactive UI +hermes plugins list # table: enabled / disabled / not enabled +hermes plugins install user/repo # install from Git, then prompt Enable? [y/N] +hermes plugins install user/repo --enable # install AND enable (no prompt) +hermes plugins install user/repo --no-enable # install but leave disabled (no prompt) +hermes plugins update my-plugin # pull latest +hermes plugins remove my-plugin # uninstall +hermes plugins enable my-plugin # add to allow-list (flat plugin) +hermes plugins enable observability/langfuse # add to allow-list (sub-category plugin) +hermes plugins disable my-plugin # remove from allow-list + add to disabled ``` +For plugins under a sub-category directory (e.g. `plugins/observability/langfuse/`, `plugins/image_gen/openai/`), use the full `/` key — that's exactly what `hermes plugins list` shows in the **Name** column. + ### Interactive UI Running `hermes plugins` with no arguments opens a composite interactive screen: @@ -286,6 +291,7 @@ Plugins → [✓] my-tool-plugin — Custom search tool [ ] webhook-notifier — Event hooks [ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled] + [ ] observability/langfuse — Trace turns / LLM calls / tools to Langfuse [bundled] Provider Plugins Memory Provider ▸ honcho