Bug Description
hermes_cli/plugins_cmd.py:_discover_all_plugins() only iterates immediate subdirectories of ~/.hermes/plugins/ and the bundled plugins/ directory. It does not recurse into nested category directories (e.g. plugins/web/<name>/, plugins/image_gen/<name>/, plugins/browser/<name>/).
This means:
- All bundled category plugins (
web/tavily, web/exa, web-parallel, image_gen/openai, browser/browser_use, video_gen/fal, etc.) are invisible to hermes plugins list — they do not appear in the table, plain, or JSON output.
- User-installed category plugins (e.g.
~/.hermes/plugins/web/tinyfish/) load correctly at runtime via PluginManager._scan_directory() but are invisible to the CLI listing.
hermes plugins enable/disable <name> uses a different code path (_plugin_exists) that does iterate all subdirs, so enabling works — but the plugin still never appears in the list.
The root cause is that _discover_all_plugins() does a flat iterdir() + continue on dirs without plugin.yaml, while the runtime scanner (PluginManager._scan_directory_level in hermes_cli/plugins.py:1227) correctly recurses into category directories up to 2 levels deep.
Impact
- 12 bundled plugins invisible to
hermes plugins list: all web/* (brave-free, ddgs, exa, firecrawl, parallel, searxng, tavily, xai), image_gen/* (openai, fal, krea, openai-codex, xai), browser/* (browser-use, browserbase, firecrawl), video_gen/* (fal, xai).
- Any user plugin installed into a category namespace (the recommended layout matching bundled plugins) will load at runtime but be invisible to
hermes plugins list.
- Status reporting (
_plugin_status) only checks the manifest name against plugins.enabled, not the path-derived key, so even if a user manually adds web/tinyfish to plugins.enabled, the CLI shows "not enabled" because it compares against the manifest name web-tinyfish.
Steps to Reproduce
# 1. Create a user plugin in category layout
mkdir -p ~/.hermes/plugins/web/tinyfish
cat > ~/.hermes/plugins/web/tinyfish/plugin.yaml <<'EOF'
name: web-tinyfish
version: 1.0.0
description: "Test plugin"
author: test
kind: backend
provides_web_providers:
- tinyfish
requires_env: []
EOF
cat > ~/.hermes/plugins/web/tinyfish/__init__.py <<'EOF'
def register(ctx): pass
EOF
# 2. Enable it
hermes plugins enable "web/tinyfish"
# 3. Check the CLI listing — plugin does NOT appear
hermes plugins list
hermes plugins list --user
hermes plugins list --json
# 4. But runtime discovery finds it fine
python3 -c "
import sys; sys.path.insert(0, '.')
from hermes_cli.plugins import PluginManager
pm = PluginManager()
pm.discover_and_load(force=True)
for k, p in pm._plugins.items():
if 'tinyfish' in k.lower():
print(f'{k}: enabled={p.enabled}')
"
# → web/tinyfish: enabled=True
Expected Behavior
hermes plugins list should discover plugins in nested category directories (up to 2 segments deep), matching the behavior of PluginManager._scan_directory_level(). The CLI listing and the runtime scanner should be consistent.
Actual Behavior
hermes plugins list shows only flat plugins (e.g. disk-cleanup, google_meet) and misses all category-namespaced plugins.
Root Cause Analysis
The discrepancy exists because two separate code paths implement plugin discovery:
| Component |
Function |
Recurses into categories? |
| Runtime |
PluginManager._scan_directory_level() (hermes_cli/plugins.py:1227) |
Yes (depth <= 2) |
| CLI list |
_discover_all_plugins() (hermes_cli/plugins_cmd.py:731) |
No (flat only) |
The runtime scanner correctly handles both layouts:
# Flat: plugins/disk-cleanup/plugin.yaml → key = "disk-cleanup"
# Category: plugins/web/tavily/plugin.yaml → key = "web/tavily"
The CLI listing only handles flat, causing category plugins to be silently dropped.
Additionally, _plugin_status() in plugins_cmd.py checks entry[0] (the manifest name) against the enabled/disabled sets, but for category plugins the config key is path-derived (e.g. web/tinyfish) while the manifest name differs (e.g. web-tinyfish). This means even manually enabling via config doesn't fix the display.
Suggested Fix
1. Make _discover_all_plugins() recurse into category directories
Replace the flat iterdir() loop with a recursive _scan_level() helper that mirrors PluginManager._scan_directory_level() — recurse into directories without plugin.yaml up to 2 levels deep, computing the path-derived key with the accumulated prefix.
2. Fix _plugin_status() to check both name and key
Add the path-derived key to the entry tuple (element 5) and have _plugin_status() accept an optional key parameter, checking both name and key against the enabled/disabled sets. This ensures category plugins like web/tinyfish show as "enabled" when web/tinyfish appears in plugins.enabled.
Proposed Patch
# In _discover_all_plugins():
def _scan_level(path, source, skip_names, prefix, depth):
"""Recursive directory scan matching PluginManager._scan_directory_level."""
if not path.is_dir():
return
for d in sorted(path.iterdir()):
if not d.is_dir():
continue
if depth == 0 and skip_names and d.name in skip_names:
continue
info = _read_manifest_info(d, prefix)
if info is not None:
name, version, description, key = info
if key in seen and source == "bundled":
continue
src_label = source
if source == "user" and (d / ".git").exists():
src_label = "git"
seen[key] = (name, version, description, src_label, d, key)
continue
if depth >= 1:
continue
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
_scan_level(d, source, skip_names=None, prefix=sub_prefix, depth=depth + 1)
# In _plugin_status():
def _plugin_status(name, enabled, disabled, key=""):
if name in disabled or (key and key in disabled):
return "disabled"
if name in enabled or (key and key in enabled):
return "enabled"
return "not enabled"
Scope
This bug affects:
hermes plugins list (table, plain, JSON, --user, --enabled filters)
hermes plugins list --user (user category plugins invisible)
hermes plugins list --enabled (enabled category plugins invisible)
- Status display for category plugins (shows "not enabled" even when enabled via config)
It does not affect:
hermes plugins enable/disable (uses _plugin_exists which does iterate all subdirs)
- Runtime plugin loading (
PluginManager._scan_directory_level already handles categories)
- Gateway/platform adapters (loaded by the runtime scanner, not the CLI listing)
Related Issues
Environment
- Hermes Agent: v0.13.0+ (commit
e3ae03592)
- OS: Linux (WSL)
- Python: 3.14.4
- Reproduced with both bundled plugins (
web/*, image_gen/*, browser/*, video_gen/*) and user-installed plugins (web/tinyfish)
Bug Description
hermes_cli/plugins_cmd.py:_discover_all_plugins()only iterates immediate subdirectories of~/.hermes/plugins/and the bundledplugins/directory. It does not recurse into nested category directories (e.g.plugins/web/<name>/,plugins/image_gen/<name>/,plugins/browser/<name>/).This means:
web/tavily,web/exa,web-parallel,image_gen/openai,browser/browser_use,video_gen/fal, etc.) are invisible tohermes plugins list— they do not appear in the table, plain, or JSON output.~/.hermes/plugins/web/tinyfish/) load correctly at runtime viaPluginManager._scan_directory()but are invisible to the CLI listing.hermes plugins enable/disable <name>uses a different code path (_plugin_exists) that does iterate all subdirs, so enabling works — but the plugin still never appears in the list.The root cause is that
_discover_all_plugins()does a flatiterdir()+continueon dirs withoutplugin.yaml, while the runtime scanner (PluginManager._scan_directory_levelinhermes_cli/plugins.py:1227) correctly recurses into category directories up to 2 levels deep.Impact
hermes plugins list: allweb/*(brave-free, ddgs, exa, firecrawl, parallel, searxng, tavily, xai),image_gen/*(openai, fal, krea, openai-codex, xai),browser/*(browser-use, browserbase, firecrawl),video_gen/*(fal, xai).hermes plugins list._plugin_status) only checks the manifestnameagainstplugins.enabled, not the path-derived key, so even if a user manually addsweb/tinyfishtoplugins.enabled, the CLI shows "not enabled" because it compares against the manifest nameweb-tinyfish.Steps to Reproduce
Expected Behavior
hermes plugins listshould discover plugins in nested category directories (up to 2 segments deep), matching the behavior ofPluginManager._scan_directory_level(). The CLI listing and the runtime scanner should be consistent.Actual Behavior
hermes plugins listshows only flat plugins (e.g.disk-cleanup,google_meet) and misses all category-namespaced plugins.Root Cause Analysis
The discrepancy exists because two separate code paths implement plugin discovery:
PluginManager._scan_directory_level()(hermes_cli/plugins.py:1227)_discover_all_plugins()(hermes_cli/plugins_cmd.py:731)The runtime scanner correctly handles both layouts:
The CLI listing only handles flat, causing category plugins to be silently dropped.
Additionally,
_plugin_status()inplugins_cmd.pychecksentry[0](the manifestname) against the enabled/disabled sets, but for category plugins the config key is path-derived (e.g.web/tinyfish) while the manifest name differs (e.g.web-tinyfish). This means even manually enabling via config doesn't fix the display.Suggested Fix
1. Make
_discover_all_plugins()recurse into category directoriesReplace the flat
iterdir()loop with a recursive_scan_level()helper that mirrorsPluginManager._scan_directory_level()— recurse into directories withoutplugin.yamlup to 2 levels deep, computing the path-derived key with the accumulated prefix.2. Fix
_plugin_status()to check both name and keyAdd the path-derived key to the entry tuple (element 5) and have
_plugin_status()accept an optionalkeyparameter, checking bothnameandkeyagainst the enabled/disabled sets. This ensures category plugins likeweb/tinyfishshow as "enabled" whenweb/tinyfishappears inplugins.enabled.Proposed Patch
Scope
This bug affects:
hermes plugins list(table, plain, JSON,--user,--enabledfilters)hermes plugins list --user(user category plugins invisible)hermes plugins list --enabled(enabled category plugins invisible)It does not affect:
hermes plugins enable/disable(uses_plugin_existswhich does iterate all subdirs)PluginManager._scan_directory_levelalready handles categories)Related Issues
platforms/namespace from keys (same root cause family; different aspect)hermes plugins enable/listfilters out entry-point-discovered plugins (related gap in same function, different source)Environment
e3ae03592)web/*,image_gen/*,browser/*,video_gen/*) and user-installed plugins (web/tinyfish)