diff --git a/hermes_cli/plugins_loader.py b/hermes_cli/plugins_loader.py index e73a9c2ce7..788aeb744c 100644 --- a/hermes_cli/plugins_loader.py +++ b/hermes_cli/plugins_loader.py @@ -278,6 +278,14 @@ class PluginLoaderMixin: if manifest.portable: self._load_portable_plugin(manifest, loaded) return + # requires_hermes gate: skip cleanly (no import, no traceback) on a version mismatch. + from hermes_cli.plugins_manifest import requires_hermes_error + reason = requires_hermes_error(manifest) + if reason: + loaded.error = reason + logger.warning("Plugin '%s' skipped: %s", plugin_key, reason) + self._plugins[plugin_key] = loaded + return # After the compat-removal date an external plugin that still imports pre-decomposition paths is # skipped with a clear reason instead of dying on ImportError mid-register (hermes_cli.plugin_compat). from hermes_cli.plugin_compat import disable_reason diff --git a/hermes_cli/plugins_manifest.py b/hermes_cli/plugins_manifest.py index fec0acfbad..ce5c34e40a 100644 --- a/hermes_cli/plugins_manifest.py +++ b/hermes_cli/plugins_manifest.py @@ -6,8 +6,10 @@ Split out of :mod:`hermes_cli.plugins`; validation warns and never fails a load. from __future__ import annotations import hashlib +import importlib.metadata import importlib.util import logging +import re from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path @@ -34,6 +36,7 @@ _KNOWN_MANIFEST_FIELDS: Set[str] = { "pip_dependencies", "provides_browser_providers", "provides_web_providers", "manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema", "license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends", + "requires_hermes", } # Highest manifest schema version this Hermes understands. @@ -336,6 +339,9 @@ class PluginManifest: # Path-derived registry key used by plugins.enabled/disabled and `hermes plugins list`: ``disk-cleanup`` # for a flat plugin, ``image_gen/openai`` for a category plugin. Empty -> name. key: str = "" + # Hermes version requirement (``">=0.19"``, comma-separated clauses allowed). Unsatisfied plugins are + # recorded with an error and skipped before import — see ``requires_hermes_error``. + requires_hermes: str = "" portable: bool = False skill_namespace: str = "" # Declared capability ids, normalized to KNOWN ids. Declaration is consent metadata, NOT a grant: live @@ -364,6 +370,55 @@ class PluginManifest: listens: List[str] = field(default_factory=list) +# ── requires_hermes version gate ───────────────────────────────────────────── +_VERSION_COMPARATOR_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$") + + +def running_hermes_version() -> str: + """Installed ``hermes-agent`` distribution version, else ``hermes_cli.__version__`` (source checkout).""" + try: + return importlib.metadata.version("hermes-agent") + except Exception: + from hermes_cli import __version__ + return __version__ + + +def _version_tuple(v: str) -> Optional[tuple]: + """``v1.2.3-rc1`` → ``(1, 2, 3)``; ``None`` when a segment is non-numeric.""" + parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".") + parts += ["0"] * (3 - len(parts)) + try: + return tuple(int(x) for x in parts[:3]) + except ValueError: + return None + + +def version_satisfies(spec: str, current: str) -> bool: + """``>=``/``>``/``<=``/``<``/``==``/``!=`` clauses (comma = AND; bare version = ``>=``). Unparseable + versions are permissive — no PEP 440 dependency for a one-field manifest gate.""" + cur = _version_tuple(current) + if cur is None: + return True + ops = {">=": cur.__ge__, "<=": cur.__le__, "==": cur.__eq__, "!=": cur.__ne__, ">": cur.__gt__, "<": cur.__lt__} + for clause in filter(None, (c.strip() for c in spec.split(","))): + m = _VERSION_COMPARATOR_RE.match(clause) + op, target = (m.group(1), m.group(2)) if m else (">=", clause) + tgt = _version_tuple(target) + if tgt is not None and not ops[op](tgt): + return False + return True + + +def requires_hermes_error(manifest: "PluginManifest") -> Optional[str]: + """Load-blocking reason when the manifest's ``requires_hermes`` rejects the running version.""" + if not manifest.requires_hermes: + return None + current = running_hermes_version() + if version_satisfies(manifest.requires_hermes, current): + return None + return f"requires hermes {manifest.requires_hermes}, running {current}" + + def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest: """Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn.""" from hermes_cli.agent_plugins import read_agent_plugin_manifest @@ -420,7 +475,7 @@ def parse_manifest_file( requires_env=data.get("requires_env", []), provides_tools=data.get("provides_tools", []), provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir), - kind=kind, key=key, + kind=kind, key=key, requires_hermes=str(data.get("requires_hermes") or "").strip(), capabilities=_parse_declared_capabilities(data.get("capabilities"), name), **_parse_manifest_v2_fields(data, key), emits=data.get("emits") or [], listens=data.get("listens") or [], diff --git a/tests/hermes_cli/test_plugin_manifest_v2.py b/tests/hermes_cli/test_plugin_manifest_v2.py index 3e8a0d9d9f..a38e9a328e 100644 --- a/tests/hermes_cli/test_plugin_manifest_v2.py +++ b/tests/hermes_cli/test_plugin_manifest_v2.py @@ -409,3 +409,26 @@ class TestCtxHasPlugin: finally: if hasattr(sys, "_m2_probe"): del sys._m2_probe + + +class TestRequiresHermes: + def test_unsatisfied_requires_hermes_skips_without_importing(self, hermes_home, monkeypatch): + """A too-new ``requires_hermes`` records an error and never runs register(); a satisfied one loads.""" + import sys + from hermes_cli import plugins_manifest + monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: "1.2.3") + _write_plugin(hermes_home / "plugins", "future", manifest_extra={"requires_hermes": ">=99.0"}, + register_body="import sys; sys._rh_future = True") + _write_plugin(hermes_home / "plugins", "current", manifest_extra={"requires_hermes": ">=1.2,<2"}, + register_body="import sys; sys._rh_current = True") + _enable(hermes_home, ["future", "current"]) + try: + mgr = PluginManager() + mgr.discover_and_load() + assert not hasattr(sys, "_rh_future") + assert "requires hermes >=99.0" in (mgr._plugins["future"].error or "") + assert getattr(sys, "_rh_current", False) is True + finally: + for attr in ("_rh_future", "_rh_current"): + if hasattr(sys, attr): + delattr(sys, attr)