diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 31885269122ab..833e1fcf07933 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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.""" @@ -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" diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index 4a0614f959276..8ac876f43d251 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -8,6 +8,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any, Dict @@ -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 ───────────────────────────────────────────────────────────