Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,21 @@ def _get_enabled_plugins() -> Optional[set]:

_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}

# Per-harness manifest directories that plugin repos ship for OTHER agent
# harnesses (Claude Code, Codex, Cursor, Devin, Kimi β€” e.g. obra/superpowers
# keeps one plugin.json per harness inside them). Their plugin.json is not an
# Agent Plugins v1 manifest and can never validate, so attempting it on every
# discovery pass only spams warnings (#101962). Directory discovery skips
# them; a plugin's real Hermes manifest (.hermes-plugin/plugin.yaml or a
# top-level plugin.yaml/plugin.json) is unaffected.
_FOREIGN_HARNESS_MANIFEST_DIRS = frozenset({
".claude-plugin",
".codex-plugin",
".cursor-plugin",
".devin-plugin",
".kimi-plugin",
})


def _portable_skill_namespace(key: str) -> str:
"""Return a readable, collision-resistant namespace for a portable plugin."""
Expand Down Expand Up @@ -4709,6 +4724,11 @@ def _scan_directory_level(
continue
if depth == 0 and skip_names and child.name in skip_names:
continue
if child.name in _FOREIGN_HARNESS_MANIFEST_DIRS:
logger.debug(
"Skipping %s (foreign-harness manifest convention)", child
)
continue
manifest_file = child / "plugin.yaml"
if not manifest_file.exists():
manifest_file = child / "plugin.yml"
Expand Down
69 changes: 69 additions & 0 deletions tests/hermes_cli/test_plugin_scanner_recursion.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict

Expand Down Expand Up @@ -113,6 +114,74 @@ def test_depth_cap_two(self, tmp_path, monkeypatch):
assert non_bundled == []


# ── Foreign-harness manifest dirs (#101962) ────────────────────────────────


class TestForeignHarnessManifestDirs:
def test_foreign_harness_dirs_skipped_without_warnings(
self, tmp_path, monkeypatch, caplog
):
"""Multi-harness plugin repos (e.g. obra/superpowers) ship one
``plugin.json`` per OTHER agent harness inside ``.claude-plugin/``,
``.codex-plugin/`` etc. Those manifests can never satisfy the Agent
Plugins v1 schema, so scanning them warned on every discovery pass.
They must be skipped silently; the plugin's real Hermes manifest
(``.hermes-plugin/plugin.yaml``) is still discovered."""
import os
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
sp = hermes_home / "plugins" / "superpowers"
(sp / ".hermes-plugin").mkdir(parents=True)
(sp / ".hermes-plugin" / "plugin.yaml").write_text(
yaml.dump(
{
"name": "superpowers",
"version": "6.3.0",
"description": "multi-harness plugin",
}
)
)
for harness in (
".claude-plugin",
".codex-plugin",
".cursor-plugin",
".devin-plugin",
".kimi-plugin",
):
harness_dir = sp / harness
harness_dir.mkdir(parents=True)
(harness_dir / "plugin.json").write_text(
json.dumps({"name": "superpowers", "version": "6.3.0"})
)

with caplog.at_level("WARNING", logger="hermes_cli.plugins"):
mgr = PluginManager()
mgr.discover_and_load()

assert "superpowers/.hermes-plugin" in mgr._plugins
parse_warnings = [
r for r in caplog.records if "Failed to parse" in r.getMessage()
]
assert parse_warnings == []

def test_broken_portable_plugin_still_warns(self, tmp_path, monkeypatch, caplog):
"""A genuinely broken portable plugin.json (not a foreign-harness
convention directory) must still surface its parse warning."""
import os
hermes_home = Path(os.environ["HERMES_HOME"]) # set by hermetic conftest fixture
broken = hermes_home / "plugins" / "broken-portable"
broken.mkdir(parents=True)
(broken / "plugin.json").write_text(json.dumps({"name": "broken"}))

with caplog.at_level("WARNING", logger="hermes_cli.plugins"):
mgr = PluginManager()
mgr.discover_and_load()

assert any(
"Failed to parse" in r.getMessage() and "broken-portable" in r.getMessage()
for r in caplog.records
)



# ── Kind parsing ───────────────────────────────────────────────────────────

Expand Down
Loading