mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-26 06:01:49 +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:
|
def _discover_all_plugins() -> list:
|
||||||
"""Return a list of (name, version, description, source, dir_path) for
|
"""Return a list of (key, version, description, source, dir_path) for
|
||||||
every plugin the loader can see — user + bundled + project.
|
every plugin the loader can see — user + bundled.
|
||||||
|
|
||||||
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
|
Mirrors :meth:`PluginManager._scan_directory_level` so category-namespaced
|
||||||
bundled first, then user, then project; user overrides bundled on
|
plugins (``observability/langfuse``, ``image_gen/openai``) surface here
|
||||||
name collision.
|
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:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
except ImportError:
|
except ImportError:
|
||||||
yaml = None
|
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/
|
def _scan(base: Path, source: str, prefix: str, depth: int) -> None:
|
||||||
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")):
|
|
||||||
if not base.is_dir():
|
if not base.is_dir():
|
||||||
continue
|
return
|
||||||
for d in sorted(base.iterdir()):
|
for d in sorted(base.iterdir()):
|
||||||
if not d.is_dir():
|
if not d.is_dir():
|
||||||
continue
|
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
|
continue
|
||||||
manifest_file = d / "plugin.yaml"
|
manifest_file = d / "plugin.yaml"
|
||||||
if not manifest_file.exists():
|
if not manifest_file.exists():
|
||||||
manifest_file = d / "plugin.yml"
|
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
|
continue
|
||||||
name = d.name
|
|
||||||
version = ""
|
# No manifest at this level — treat as a category namespace and
|
||||||
description = ""
|
# recurse one level deeper. Cap at depth 2 (same as the loader).
|
||||||
if yaml:
|
if depth >= 1:
|
||||||
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":
|
|
||||||
continue
|
continue
|
||||||
src_label = source
|
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
|
||||||
if source == "user" and (d / ".git").exists():
|
_scan(d, source, sub_prefix, depth + 1)
|
||||||
src_label = "git"
|
|
||||||
seen[name] = (name, version, description, src_label, d)
|
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())
|
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.
|
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)
|
## 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.
|
**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
|
## Managing plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes plugins # unified interactive UI
|
hermes plugins # unified interactive UI
|
||||||
hermes plugins list # table: enabled / disabled / not enabled
|
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 # install from Git, then prompt Enable? [y/N]
|
||||||
hermes plugins install user/repo --enable # install AND enable (no prompt)
|
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 install user/repo --no-enable # install but leave disabled (no prompt)
|
||||||
hermes plugins update my-plugin # pull latest
|
hermes plugins update my-plugin # pull latest
|
||||||
hermes plugins remove my-plugin # uninstall
|
hermes plugins remove my-plugin # uninstall
|
||||||
hermes plugins enable my-plugin # add to allow-list
|
hermes plugins enable my-plugin # add to allow-list (flat plugin)
|
||||||
hermes plugins disable my-plugin # remove from allow-list + add to disabled
|
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
|
### Interactive UI
|
||||||
|
|
||||||
Running `hermes plugins` with no arguments opens a composite interactive screen:
|
Running `hermes plugins` with no arguments opens a composite interactive screen:
|
||||||
|
|
@ -286,6 +291,7 @@ Plugins
|
||||||
→ [✓] my-tool-plugin — Custom search tool
|
→ [✓] my-tool-plugin — Custom search tool
|
||||||
[ ] webhook-notifier — Event hooks
|
[ ] webhook-notifier — Event hooks
|
||||||
[ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled]
|
[ ] disk-cleanup — Auto-cleanup of ephemeral files [bundled]
|
||||||
|
[ ] observability/langfuse — Trace turns / LLM calls / tools to Langfuse [bundled]
|
||||||
|
|
||||||
Provider Plugins
|
Provider Plugins
|
||||||
Memory Provider ▸ honcho
|
Memory Provider ▸ honcho
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue