mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
29b1bd0e20
commit
9b82586c6b
2 changed files with 70 additions and 40 deletions
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -142,6 +142,8 @@ Within each source, Hermes also recognizes sub-category directories that route p
|
|||
|
||||
User plugins at `~/.hermes/plugins/model-providers/<name>/` and `~/.hermes/plugins/memory/<name>/` 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 `<category>/<plugin>` 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue