Skip to content

fix(plugins): hermes plugins list does not discover nested category plugins #41066

@mis198314

Description

@mis198314

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Low — cosmetic, nice to havecomp/cliCLI entry point, hermes_cli/, setup wizardcomp/pluginsPlugin system and bundled pluginstype/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions