fix(plugins): surface category-namespaced plugins in hermes plugins list

`_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 `<category>/<plugin>` 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) <noreply@anthropic.com>
This commit is contained in:
Guillaume Meyer 2026-05-16 22:55:28 +00:00 committed by Teknium
parent 29b1bd0e20
commit 9b82586c6b
2 changed files with 70 additions and 40 deletions

View file

@ -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 <key>``. For category-namespaced
plugins that's ``<category>/<dirname>``; 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 (<repo>/plugins/<name>/), 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())