mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(plugins): enforce the catalog's requires_hermes at install and update
The catalog entry's requires_hermes was display-only: `hermes plugins install <name>` cloned and published a pin the catalog says needs a newer Hermes, and `hermes plugins update` re-pinned an installed plugin onto it. Only the plugin's own plugin.yaml floor was checked, and only at load. - install_catalog_entry (the one seam every install by catalog name goes through: CLI, dashboard/Desktop/TUI plugins.manage, `hermes setup`'s importer step, the left-core and memory-provider auto-installs) refuses with "<plugin> needs Hermes <spec>; this is <version>. Update Hermes (`hermes update`) and install again." before any clone. The connector card's pre-check uses the same function. - repin_catalog_plugin (every catalog update) keeps the installed pin and says the update is available after updating Hermes; `hermes plugins update` with no name lists such a plugin as held instead of applying it, and check-updates marks it. - `hermes plugins search` / `info` still list incompatible entries, marked as needing a newer Hermes. Portable packages (plugin.json) have no version field, so the catalog entry's requires_hermes is their only floor; the gate is kind-agnostic and covers them (tested), and the agent_plugins comment and docs say so. The verdict is plugins_manifest.requires_hermes_error itself (same running_hermes_version + version_satisfies), so a dev checkout, canary build or tagless checkout is judged exactly as the plugin.yaml gate judges it. No override flag.
This commit is contained in:
1 parent
e571027310
commit
0255dedd1a
12 files changed
+228
-18
No files matched your search
@@ -111,7 +111,9 @@ def _server_declarations(
|
||||
raise AgentPluginError(f"server declaration '{name}' must be an object")
|
||||
# A key added by a newer Hermes must not make the package uninstallable here: the older
|
||||
# release ignores it with a diagnostic, like unknown top-level fields. A key whose absence
|
||||
# would be unsafe needs the catalog's requires_hermes, not a hard failure on every release.
|
||||
# would be unsafe needs the catalog entry's requires_hermes, not a hard failure on every release:
|
||||
# plugin.json has no version floor, and install/update by catalog name enforce the entry's
|
||||
# (plugins_cmd_catalog.refuse_incompatible_catalog_entry / repin_catalog_plugin).
|
||||
unknown = set(raw) - _SERVER_DECLARATION_KEYS
|
||||
if unknown:
|
||||
if diagnostics is not None:
|
||||
|
||||
@@ -250,6 +250,29 @@ def _refuse_unsupported_catalog_platform(entry: PluginCatalogEntry) -> None:
|
||||
)
|
||||
|
||||
|
||||
def catalog_hermes_error(entry: PluginCatalogEntry) -> Optional[str]:
|
||||
"""``<name> needs Hermes <spec>; this is <version>.`` when the entry's ``requires_hermes`` rejects the
|
||||
running Hermes, else ``None``. The verdict IS the plugin.yaml gate's
|
||||
(:func:`~hermes_cli.plugins_manifest.requires_hermes_error`): same version source, same comparison, so
|
||||
a dev checkout or canary build is judged the same by both, and an unknown version never blocks."""
|
||||
from hermes_cli.plugins_manifest import requires_hermes_error, running_hermes_version
|
||||
if not requires_hermes_error({"requires_hermes": entry.requires_hermes}):
|
||||
return None
|
||||
return f"{entry.name} needs Hermes {entry.requires_hermes}; this is {running_hermes_version()}."
|
||||
|
||||
|
||||
def refuse_incompatible_catalog_entry(entry: PluginCatalogEntry) -> None:
|
||||
"""``PluginOperationError`` when the catalog says *entry* cannot run here: a platform it does not list,
|
||||
or a ``requires_hermes`` the running version fails. Every install by catalog name passes through here."""
|
||||
from hermes_cli.config import recommended_update_command
|
||||
from hermes_cli.plugins_cmd import PluginOperationError
|
||||
_refuse_unsupported_catalog_platform(entry)
|
||||
reason = catalog_hermes_error(entry)
|
||||
if reason:
|
||||
raise PluginOperationError(f"{reason} Update Hermes (`{recommended_update_command()}`) and install again.",
|
||||
failure_class="incompatible")
|
||||
|
||||
|
||||
def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Optional[str] = None,
|
||||
allow_removed: bool = False, scan_decision_cb=None, python_deps: bool = True,
|
||||
assume_deps_consent: bool = False, before_swap=None) -> tuple:
|
||||
@@ -259,7 +282,7 @@ def install_catalog_entry(entry: PluginCatalogEntry, *, force: bool, ref: Option
|
||||
from hermes_cli.plugins_cmd import _install_plugin_core
|
||||
if not allow_removed:
|
||||
raise_if_removed(entry.name, entry.repo)
|
||||
_refuse_unsupported_catalog_platform(entry)
|
||||
refuse_incompatible_catalog_entry(entry)
|
||||
target, manifest, installed_name = _install_plugin_core(
|
||||
entry.install_identifier, force=force, ref=ref or entry.sha, scan_decision_cb=scan_decision_cb,
|
||||
reviewed_pin=entry.sha, python_deps=python_deps, assume_deps_consent=assume_deps_consent,
|
||||
@@ -581,6 +604,14 @@ def repin_catalog_plugin(
|
||||
refuse_if_installed_removed(catalog_name, target)
|
||||
if at_catalog_pin(sidecar, entry.sha):
|
||||
return RepinResult(entry.sha, False, target.name, [])
|
||||
held = catalog_hermes_error(entry)
|
||||
if held:
|
||||
# The pin moved past what this Hermes can run: keep the installed tree, name the way forward.
|
||||
from hermes_cli.config import recommended_update_command
|
||||
raise PluginOperationError(
|
||||
f"Update of {catalog_name} to {entry.sha[:8]} not applied: {held} {catalog_name} stays at "
|
||||
f"{str(sidecar.get('sha') or '')[:8] or 'its installed version'}; the update is available after "
|
||||
f"you update Hermes (`{recommended_update_command()}`).", failure_class="incompatible")
|
||||
|
||||
local, modified = _local_changes(target)
|
||||
old_sha8 = str(sidecar.get("sha") or "old")[:8]
|
||||
@@ -718,7 +749,8 @@ def _render_entries(entries: list[PluginCatalogEntry], console) -> None:
|
||||
for e in sorted(entries, key=lambda e: (e.category, rank.get(e.tier, len(rank)), e.name)):
|
||||
tier = f"[{style.get(e.tier, 'magenta')}]{e.tier}[/{style.get(e.tier, 'magenta')}]"
|
||||
desc = e.description if len(e.description) <= 60 else e.description[:57] + "..."
|
||||
table.add_row(e.name, e.category, tier, desc, pin_label(e), _capability_counts(e))
|
||||
name = e.name + (f"\n[yellow]needs Hermes {e.requires_hermes}[/yellow]" if catalog_hermes_error(e) else "")
|
||||
table.add_row(name, e.category, tier, desc, pin_label(e), _capability_counts(e))
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
@@ -730,7 +762,8 @@ def cmd_search(term: str = "", *, json_output: bool = False) -> None:
|
||||
from hermes_cli.plugins_cmd import _console
|
||||
matches = filter_entries(load_catalog_live(), term)
|
||||
if json_output:
|
||||
print(json.dumps({"query": term, "results": [e.to_dict() for e in matches]}, indent=2))
|
||||
print(json.dumps({"query": term, "results": [
|
||||
{**e.to_dict(), "needs_newer_hermes": catalog_hermes_error(e)} for e in matches]}, indent=2))
|
||||
return
|
||||
console = _console()
|
||||
if not matches:
|
||||
@@ -771,6 +804,11 @@ def cmd_info(name: str) -> None:
|
||||
console.print(f"[red bold]✗ REMOVED from catalog: {removed.reason or 'no reason recorded'}"
|
||||
f"{f' ({removed.date})' if removed.date else ''}[/red bold]")
|
||||
console.print()
|
||||
held = catalog_hermes_error(entry)
|
||||
if held:
|
||||
from hermes_cli.config import recommended_update_command
|
||||
console.print(f"[yellow]⚠ {held} Update Hermes (`{recommended_update_command()}`) to install it.[/yellow]")
|
||||
console.print()
|
||||
console.print(f"[dim]Install:[/dim] hermes plugins install {entry.name}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ def cmd_check_updates(args: Any | None = None) -> None:
|
||||
console = Console()
|
||||
plugins_dir = _pc()._plugins_dir()
|
||||
|
||||
from hermes_cli.plugins_updates import run_checks
|
||||
from hermes_cli.plugins_updates import held_for_newer_hermes, run_checks
|
||||
|
||||
from hermes_cli.plugins_cadence import record_check_results, snapshot_installs
|
||||
|
||||
@@ -290,6 +290,8 @@ def cmd_check_updates(args: Any | None = None) -> None:
|
||||
for r in results:
|
||||
if r.needs_fixing:
|
||||
status = f"[red]needs fixing[/red]\n[dim]{r.needs_fixing}[/dim]"
|
||||
elif r.update_available is True and (held := held_for_newer_hermes(r)):
|
||||
status = f"[yellow]update available after updating Hermes[/yellow]\n[dim]{held}[/dim]"
|
||||
elif r.update_available is True:
|
||||
status = "[green]update available[/green]"
|
||||
elif r.update_available is False:
|
||||
|
||||
@@ -39,10 +39,13 @@ class UpdatePlan:
|
||||
|
||||
def plan_update_all(results: list[Any]) -> UpdatePlan:
|
||||
"""Split ``run_checks`` rows into the plugins ``update`` can move and the ones it cannot (with why)."""
|
||||
from hermes_cli.plugins_updates import held_for_newer_hermes
|
||||
plan = UpdatePlan()
|
||||
for row in results:
|
||||
if row.needs_fixing:
|
||||
plan.skipped.append((row, f"needs fixing: {row.needs_fixing}"))
|
||||
elif row.update_available is True and (held := held_for_newer_hermes(row)):
|
||||
plan.skipped.append((row, f"update available after updating Hermes ({held})"))
|
||||
elif row.update_available is True and row.klass in _UPDATE_KIND:
|
||||
plan.movable.append(row)
|
||||
elif row.update_available is True:
|
||||
|
||||
@@ -480,7 +480,7 @@ def version_satisfies(spec: str, current: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def requires_hermes_error(manifest: PluginManifest) -> Optional[str]:
|
||||
def requires_hermes_error(manifest: PluginManifest | Mapping[str, Any]) -> Optional[str]:
|
||||
"""Load-blocking reason when the manifest's ``requires_hermes`` rejects the running version."""
|
||||
spec = manifest.get("requires_hermes", "") if isinstance(manifest, Mapping) else manifest.requires_hermes
|
||||
if not spec:
|
||||
|
||||
@@ -51,7 +51,7 @@ class CheckResult:
|
||||
latest: Optional[str] = None
|
||||
update_available: Optional[bool] = None # None = unknown/uncheckable
|
||||
needs_fixing: Optional[str] = None # mismatch reason when set
|
||||
min_hermes: Optional[str] = None # feed's version floor, if any
|
||||
min_hermes: Optional[str] = None # feed floor / catalog requires_hermes, if any
|
||||
reason: str = ""
|
||||
|
||||
def to_json(self) -> dict:
|
||||
@@ -130,6 +130,18 @@ def check_local_provenance(prov: Provenance) -> CheckResult:
|
||||
return result
|
||||
|
||||
|
||||
def held_for_newer_hermes(result: CheckResult) -> Optional[str]:
|
||||
"""``needs Hermes <spec>; this is <version>`` when the update's ``min_hermes`` (a feed floor or the
|
||||
catalog entry's ``requires_hermes``) rejects the running Hermes, else ``None``. The update path refuses
|
||||
such an update, so listings name it as waiting on a Hermes update instead of offering it."""
|
||||
if not result.min_hermes:
|
||||
return None
|
||||
from hermes_cli.plugins_manifest import requires_hermes_error, running_hermes_version
|
||||
if not requires_hermes_error({"requires_hermes": result.min_hermes}):
|
||||
return None
|
||||
return f"needs Hermes {result.min_hermes}; this is {running_hermes_version()}"
|
||||
|
||||
|
||||
def check_provenanced(
|
||||
prov: Provenance,
|
||||
*,
|
||||
@@ -158,6 +170,7 @@ def check_provenanced(
|
||||
return result
|
||||
result.latest = entry.sha
|
||||
result.update_available = result.current != entry.sha
|
||||
result.min_hermes = entry.requires_hermes or None
|
||||
return result
|
||||
result = check_local_provenance(prov)
|
||||
if result.reason or result.needs_fixing:
|
||||
|
||||
@@ -114,9 +114,16 @@ meaningful:
|
||||
reporting are opt-in. Reviewers summarise these as disclosure lines on the
|
||||
entry PR; undisclosed behaviour found in review is a request for changes.
|
||||
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
|
||||
(the loader skips the plugin otherwise). `version` matches the pinned code,
|
||||
and Python dependencies resolve under Hermes's core constraints
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release.
|
||||
The entry's `requires_hermes` is enforced: an install by catalog name refuses
|
||||
on a Hermes it rejects, and `hermes plugins update` keeps an installed plugin
|
||||
at its current pin until Hermes is updated. Declare the same floor in
|
||||
`plugin.yaml` as well, because older Hermes versions enforce only that one
|
||||
(the loader skips the plugin when it does not match). A portable package
|
||||
(`plugin.json`) has no version field of its own, so the catalog entry's
|
||||
`requires_hermes` is its only floor: set one when the package needs a recent
|
||||
Hermes (a server declaration key an older release ignores). `version` matches the
|
||||
pinned code, and Python dependencies resolve under Hermes's core constraints
|
||||
(`hermes plugins validate --install-deps` is what CI runs).
|
||||
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
|
||||
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
|
||||
@@ -168,7 +175,7 @@ tier: community # official | verified | community (default community
|
||||
# official and verified are set by Nous maintainers only
|
||||
category: memory # desktop | memory | platform | web | tools | voice | automation | models | general
|
||||
# (default desktop) — the shelf the entry sits on at /docs/plugins
|
||||
requires_hermes: ">=0.19" # optional
|
||||
requires_hermes: ">=0.19" # optional; enforced at install and update (also declare it in plugin.yaml)
|
||||
docs_url: "" # optional
|
||||
version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234"
|
||||
image: "" # optional https image on a GitHub host, 2:1 (e.g. 1200x600), e.g.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""The catalog entry's ``requires_hermes`` is enforced, not just displayed: an install by catalog name
|
||||
refuses on a Hermes the spec rejects (CLI, dashboard/Desktop, TUI RPC and the left-core auto-install all
|
||||
go through one seam), and ``update`` keeps the installed pin rather than moving to one that needs a newer
|
||||
Hermes. The running version comes from the same source the plugin.yaml gate reads. Real git, file://
|
||||
repos, a seeded catalog, temp HERMES_HOME."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import left_core_migration as lcm
|
||||
from hermes_cli import plugin_catalog as pc_cat
|
||||
from hermes_cli import plugins_cmd as pc
|
||||
from hermes_cli import plugins_manifest
|
||||
from tests.hermes_cli.test_plugins_cmd_catalog import _commit, _head, world # world: the seeded catalog + plugins dir
|
||||
from tests.pm._fixtures import client, isolated_python # fixtures world needs
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git not available")
|
||||
|
||||
REFUSAL = "cat-plugin needs Hermes >=0.31; this is 0.30.0."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated(request, monkeypatch):
|
||||
"""*world* whose catalog entry carries ``state["requires"]`` as its requires_hermes, on Hermes 0.30.0.
|
||||
Console output is captured in ``printed``."""
|
||||
base = request.getfixturevalue("world")
|
||||
state = base["state"]
|
||||
state["requires"] = ""
|
||||
|
||||
def _entries():
|
||||
return [pc_cat.PluginCatalogEntry(name="cat-plugin", repo=state.get("repo_uri") or base["repo"].as_uri(),
|
||||
sha=state["pin"],
|
||||
description="d", maintainer="t", requires_hermes=state["requires"])]
|
||||
|
||||
monkeypatch.setattr(pc_cat, "load_catalog", lambda catalog_dir=None: _entries())
|
||||
state["running"] = "0.30.0"
|
||||
monkeypatch.setattr(plugins_manifest, "running_hermes_version", lambda: state["running"])
|
||||
printed: list[str] = []
|
||||
console = type("C", (), {"print": lambda _self, *a, **_k: printed.append(" ".join(map(str, a)))})()
|
||||
monkeypatch.setattr(pc, "_console", lambda: console)
|
||||
return {**base, "printed": printed}
|
||||
|
||||
|
||||
def _rpc(params: dict) -> dict:
|
||||
from tui_gateway import server
|
||||
return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})
|
||||
|
||||
|
||||
def test_install_by_catalog_name_refused_on_every_surface_when_hermes_is_too_old(gated):
|
||||
gated["state"]["requires"] = ">=0.31"
|
||||
|
||||
with pytest.raises(SystemExit): # `hermes plugins install <name>` (and `hermes setup`'s importer step)
|
||||
pc.cmd_install("cat-plugin", enable=False)
|
||||
assert any(REFUSAL in line and "and install again" in line for line in gated["printed"])
|
||||
|
||||
dashboard = pc.dashboard_install_plugin("", force=False, enable=True, catalog_name="cat-plugin")
|
||||
assert dashboard["ok"] is False and REFUSAL in dashboard["error"]
|
||||
|
||||
rpc = _rpc({"action": "install", "catalog_name": "cat-plugin"}) # Desktop / TUI
|
||||
assert REFUSAL in rpc["error"]["message"]
|
||||
|
||||
said: list[str] = []
|
||||
feature = lcm.LeftCoreFeature(plugin="cat-plugin", label="Cat", in_use=lambda *_a, **_k: True, unchanged="x")
|
||||
assert lcm._install_one(gated["plugins_dir"].parent, feature,
|
||||
install=lcm._install_into(gated["plugins_dir"].parent), say=said.append) is False
|
||||
assert REFUSAL.rstrip(".") in said[0]
|
||||
|
||||
assert not (gated["plugins_dir"] / "cat-plugin").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("running", "spec"), [("0.30.0", ">=0.30"), ("unknown", ">=99.0")])
|
||||
def test_install_proceeds_when_hermes_satisfies_the_spec_or_its_version_is_unknown(gated, running, spec):
|
||||
# "unknown" is a tagless checkout: the plugin.yaml gate lets it through, so the catalog gate must too.
|
||||
gated["state"].update(running=running, requires=spec)
|
||||
assert plugins_manifest.requires_hermes_error({"requires_hermes": spec}) is None
|
||||
|
||||
result = pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")
|
||||
|
||||
assert result["ok"] is True, result
|
||||
assert _head(gated["plugins_dir"] / "cat-plugin") == gated["sha1"]
|
||||
|
||||
|
||||
def test_update_keeps_the_installed_pin_when_the_new_pin_needs_a_newer_hermes(gated):
|
||||
from hermes_cli.plugins_cmd_update_all import plan_update_all
|
||||
from hermes_cli.plugins_updates import run_checks
|
||||
|
||||
assert pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")["ok"] is True
|
||||
target = gated["plugins_dir"] / "cat-plugin"
|
||||
gated["state"].update(pin=gated["sha2"], requires=">=0.31")
|
||||
|
||||
with pytest.raises(SystemExit): # `hermes plugins update cat-plugin`
|
||||
pc.cmd_update("cat-plugin", interactive=False)
|
||||
assert any(REFUSAL in line and "after you update Hermes" in line for line in gated["printed"])
|
||||
dashboard = pc.dashboard_update_user_plugin("cat-plugin")
|
||||
assert dashboard["ok"] is False and REFUSAL in dashboard["error"]
|
||||
assert REFUSAL in _rpc({"action": "update", "name": "cat-plugin"})["error"]["message"]
|
||||
# `hermes plugins update` (all) lists it as held, not as an update it will apply.
|
||||
plan = plan_update_all(run_checks(gated["plugins_dir"], include_pip=False))
|
||||
assert not plan.movable
|
||||
assert [(row.name, "after updating Hermes" in why) for row, why in plan.skipped] == [("cat-plugin", True)]
|
||||
assert _head(target) == gated["sha1"]
|
||||
|
||||
gated["state"]["running"] = "0.31.0"
|
||||
assert pc.dashboard_update_user_plugin("cat-plugin")["unchanged"] is False
|
||||
assert _head(target) == gated["sha2"]
|
||||
|
||||
|
||||
def test_portable_catalog_entry_gets_its_hermes_floor_from_the_catalog(gated, tmp_path):
|
||||
# A portable package (plugin.json) has no requires_hermes of its own: the catalog entry is its only floor.
|
||||
from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1
|
||||
repo = tmp_path / "portable-repo"
|
||||
repo.mkdir()
|
||||
sp.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
shas = []
|
||||
for version in ("1.0.0", "2.0.0"):
|
||||
(repo / "plugin.json").write_text(json.dumps(
|
||||
{"$schema": PLUGIN_SCHEMA_V1, "name": "cat-plugin", "version": version}), encoding="utf-8")
|
||||
shas.append(_commit(repo, version))
|
||||
gated["state"].update(repo_uri=repo.as_uri(), pin=shas[0], requires=">=0.31")
|
||||
|
||||
refused = pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")
|
||||
assert refused["ok"] is False and REFUSAL in refused["error"]
|
||||
assert not (gated["plugins_dir"] / "cat-plugin").exists()
|
||||
|
||||
gated["state"]["requires"] = ""
|
||||
assert pc.dashboard_install_plugin("", force=False, enable=False, catalog_name="cat-plugin")["ok"] is True
|
||||
gated["state"].update(pin=shas[1], requires=">=0.31")
|
||||
held = pc.dashboard_update_user_plugin("cat-plugin")
|
||||
assert held["ok"] is False and REFUSAL in held["error"]
|
||||
assert _head(gated["plugins_dir"] / "cat-plugin") == shas[0]
|
||||
@@ -85,10 +85,10 @@ class HostInstaller:
|
||||
|
||||
def refuse(self, entry: Any) -> None:
|
||||
"""Raise with the installer's own text when the catalog would refuse this entry here."""
|
||||
from hermes_cli.plugins_cmd_catalog import _refuse_unsupported_catalog_platform, raise_if_removed
|
||||
from hermes_cli.plugins_cmd_catalog import raise_if_removed, refuse_incompatible_catalog_entry
|
||||
|
||||
raise_if_removed(entry.name, entry.repo)
|
||||
_refuse_unsupported_catalog_platform(entry)
|
||||
refuse_incompatible_catalog_entry(entry)
|
||||
|
||||
def install_plugin(self, name: str, *, force: bool, enable: bool, ref: Optional[str],
|
||||
on_step: Callable[[InstallPhase], None]) -> dict[str, Any]:
|
||||
|
||||
@@ -172,9 +172,16 @@ the gallery. There is no separate listing to maintain.
|
||||
reporting are opt-in. Reviewers summarise these as disclosure lines on the
|
||||
entry PR; undisclosed behaviour found in review is a request for changes.
|
||||
14. **Compatibility metadata is truthful.** `requires_hermes` is a SemVer floor
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release
|
||||
(the loader skips the plugin otherwise). `version` matches the pinned code,
|
||||
and Python dependencies resolve under Hermes's core constraints
|
||||
(`">=0.21.5"`), never a CalVer date, and never newer than the current release.
|
||||
The entry's `requires_hermes` is enforced: an install by catalog name refuses
|
||||
on a Hermes it rejects, and `hermes plugins update` keeps an installed plugin
|
||||
at its current pin until Hermes is updated. Declare the same floor in
|
||||
`plugin.yaml` as well, because older Hermes versions enforce only that one
|
||||
(the loader skips the plugin when it does not match). A portable package
|
||||
(`plugin.json`) has no version field of its own, so the catalog entry's
|
||||
`requires_hermes` is its only floor: set one when the package needs a recent
|
||||
Hermes (a server declaration key an older release ignores). `version` matches the
|
||||
pinned code, and Python dependencies resolve under Hermes's core constraints
|
||||
(`hermes plugins validate --install-deps` is what CI runs).
|
||||
15. **No skins or forks of bundled plugins.** A change to a bundled plugin is a
|
||||
PR against `hermes-agent`, not a competing listing, and vendor-lookalike skins
|
||||
|
||||
@@ -210,7 +210,10 @@ A server entry key this Hermes does not know (one a newer release added) is
|
||||
ignored with a warning, which `hermes plugins validate` lists, and the package
|
||||
still installs. Releases from before this rule reject the package instead, and a
|
||||
release that ignores a key may behave less safely than intended, so declare a new
|
||||
key together with a catalog `requires_hermes` floor.
|
||||
key together with a catalog `requires_hermes` floor. `plugin.json` has no version
|
||||
field, so that catalog floor is a portable package's only one: an install by
|
||||
catalog name refuses on an older Hermes, and `hermes plugins update` keeps the
|
||||
installed pin until Hermes is updated.
|
||||
|
||||
An explicit per-server MCP call budget also reaches Hermes' outer tool executor. A call
|
||||
whose budget exceeds the shared concurrent-batch deadline becomes a sequential
|
||||
|
||||
@@ -53,7 +53,7 @@ directory of the hermes-agent repository, declaring:
|
||||
| `category` | Browse shelf: `desktop` (default), `memory`, `platform`, `web`, `tools`, `voice`, `automation`, `models` or `general` |
|
||||
| `maintainer` | Who owns the plugin |
|
||||
| `capabilities` | Declared tools, hooks, middleware, and required env vars |
|
||||
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) |
|
||||
| `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional). Enforced: installing on an older Hermes is refused with the version it needs, and `hermes plugins update` keeps the installed version until you update Hermes. `search` and `info` still list the entry, marked as needing a newer Hermes |
|
||||
| `platforms` | OS restrictions, empty = all (optional) |
|
||||
| `title` | Human name shown on cards, e.g. `NVIDIA App` (optional; defaults to `name`) |
|
||||
| `onboarding` | `true` offers the plugin on the desktop onboarding card, beside the hosted connectors, on the platforms it lists. Curated: official or verified entries only (optional, default `false`) |
|
||||
|
||||
Reference in new issue
Block a user