Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
byjaps
# PR #116425 fix(cli) plugin toolsets must not be flagged as unknown at startup
11 changes: 10 additions & 1 deletion hermes_cli/cli_init_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,16 @@ def _init_toolsets(self, toolsets):
if toolsets and "all" not in toolsets and "*" not in toolsets:
# MCP server names only resolve after discover_mcp_tools runs; skip them here.
mcp_names = set((CLI_CONFIG.get("mcp_servers") or {}).keys())
invalid = [t for t in toolsets if not validate_toolset(t) and t not in mcp_names]
# Plugin toolsets register during plugin discovery, which startup runs on a background thread
# that has not necessarily landed yet; names it declared (or the previous launch persisted, which
# get_plugin_toolset_keys_nowait serves) are not typos (#71650).
try:
from hermes_cli.plugins import get_plugin_toolset_keys_nowait
plugin_ts_names = get_plugin_toolset_keys_nowait()
except Exception:
plugin_ts_names = set()
invalid = [t for t in toolsets
if not validate_toolset(t) and t not in mcp_names and t not in plugin_ts_names]
if invalid:
self._console_print(f"[bold red]Warning: Unknown toolsets: {', '.join(invalid)}[/]")

Expand Down
11 changes: 8 additions & 3 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
)
from hermes_cli.plugins_discovery import ( # noqa: F401 — re-exported
ENTRY_POINTS_GROUP, _get_disabled_plugins, _get_enabled_plugins, collect_directory_manifests,
discover_entrypoint_manifests, gate_manifest, scan_directory,
discover_entrypoint_manifests, gate_manifest, resolve_manifest_winners, scan_directory,
)
from hermes_cli.plugins_loader import (
PluginLoaderMixin, _BARE_MODULE_SCOPE, _MODULE_NAMESPACE_LOCK, _NS_PARENT, _evict_modules,
Expand Down Expand Up @@ -1007,6 +1007,10 @@ def register_skill(
f"plugin name '{self.manifest.name}' automatically).")
if not name or not _NAMESPACE_RE.match(name):
raise ValueError(f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+.")
# Plugin register() helpers commonly pass the SKILL.md location as str
# (PluginManifest.path is stored as str); the registry and find_plugin_skill()
# promise a Path downstream.
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"SKILL.md not found at {path}")
namespace = self.manifest.skill_namespace or self.manifest.name
Expand Down Expand Up @@ -1332,9 +1336,10 @@ def _discover_and_load_inner(self) -> None:
logger.warning("Removed Hermes plugin %s is still listed in plugins.enabled; "
"remove it and configure native Relay plugins with %s",
", ".join(stale_relay_keys), RELAY_PLUGINS_CONFIG_ENV)
# Later sources win on key collision (project > user > bundled); gate the winners, then
# Later sources win on key collision (project > user > bundled) except a flat impostor claiming a
# bundled key from another directory (resolve_manifest_winners); gate the winners, then
# load survivors in requires_plugins order (see resolve_plugin_load_order).
winners = {manifest_key(m): m for m in manifests}
winners = resolve_manifest_winners(manifests)
to_load = {k: m for k, m in winners.items() if self._gate_manifest(m, disabled, enabled)}
for lookup_key in resolve_plugin_load_order(to_load):
manifest = to_load[lookup_key]
Expand Down
53 changes: 51 additions & 2 deletions hermes_cli/plugins_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, List, Optional, Set
from typing import Any, Dict, List, Optional, Set

from hermes_constants import get_hermes_home
from hermes_cli.config import cfg_get
Expand All @@ -27,6 +27,13 @@
ENTRY_POINTS_GROUP = "hermes_agent.plugins"
ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities"

# Per-harness manifest directories plugin repos ship for OTHER agent harnesses (e.g. obra/superpowers keeps one
# plugin.json per harness). Their plugin.json is not an Agent Plugins v1 manifest and can never validate, so
# parsing it on every discovery pass only spams warnings (#101962).
_FOREIGN_HARNESS_MANIFEST_DIRS = frozenset({
".claude-plugin", ".codex-plugin", ".cursor-plugin", ".devin-plugin", ".kimi-plugin",
})


def _select_entry_point_group(entry_points: Any, group: str) -> list:
"""Return one metadata entry-point group across supported Python APIs."""
Expand Down Expand Up @@ -109,7 +116,23 @@ def scan_directory(
manifests: List[PluginManifest] = []
if not path.is_dir():
return manifests
for child in sorted(path.iterdir()):
try:
children = sorted(path.iterdir())
except OSError as exc:
logger.warning("Failed to scan plugin directory %s: %s", path, exc)
return manifests
for child in children:
# Cache/dunder dirs (__pycache__, __MACOSX__, …) are never
# plugins. Walking them can raise PermissionError and take
# down every subsequent tool call (#86996).
if child.name.startswith("__") and child.name.endswith("__"):
logger.debug("Skipping dunder plugin path %s", child)
continue
if child.name in _FOREIGN_HARNESS_MANIFEST_DIRS:
logger.debug("Skipping %s (foreign-harness manifest convention)", child)
continue
# pathlib.Path.is_dir() swallows OSError, but injected Path-likes
# and test doubles can still raise. Fail closed per child.
try:
if not child.is_dir() or (depth == 0 and skip_names and child.name in skip_names):
continue
Expand Down Expand Up @@ -167,6 +190,32 @@ def _scan(label: str, directory: Path, source: str, skip_names: Optional[Set[str
return manifests


def resolve_manifest_winners(manifests: List[PluginManifest]) -> Dict[str, PluginManifest]:
"""Later sources win on key collision (project > user > bundled): a same-named copy under
``~/.hermes/plugins/<name>`` is the documented way to override a bundled plugin, and is logged. A flat
user/project manifest that claims a bundled key from a *differently named* directory is an impostor, not
an override (``impostor_dir/plugin.yaml`` with ``name: kanban``): it is skipped with a warning so
``hermes plugins enable kanban`` never activates unrelated code under the bundled name."""
winners: Dict[str, PluginManifest] = {}
for manifest in manifests:
key = manifest_key(manifest)
shadowed = winners.get(key)
if shadowed is not None and shadowed.source == "bundled" and manifest.source in {"user", "project"}:
own_dir = Path(manifest.path).name if manifest.path else ""
bundled_dir = Path(shadowed.path).name if shadowed.path else ""
if own_dir and bundled_dir and own_dir != bundled_dir:
logger.warning(
"Ignoring %s plugin at %s: its manifest name '%s' is a bundled plugin's key but the "
"directory is named '%s'; rename the directory to '%s' to override the bundled plugin",
manifest.source, manifest.path, key, own_dir, bundled_dir,
)
continue
logger.info("Plugin '%s' at %s (%s) shadows the bundled copy at %s", key, manifest.path,
manifest.source, shadowed.path)
winners[key] = manifest
return winners


@dataclass(frozen=True)
class ManifestGate:
"""Routing verdict for one winning manifest (see :func:`gate_manifest`)."""
Expand Down
39 changes: 28 additions & 11 deletions hermes_cli/plugins_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Mapping, Optional, Union

from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
from registration_lifecycle import replacement_coordinator
Expand Down Expand Up @@ -64,6 +64,13 @@ def _plugin_home_scope(home: Path):
reset_hermes_home_override(token)


def _load_error_text(exc: BaseException) -> str:
"""Human-readable load failure; ``sys.exit(0)`` has an empty ``str()`` so name the class and code."""
if isinstance(exc, SystemExit):
return f"SystemExit({exc.code!r}) raised during import/register()"
return str(exc)


def _dist_installed(req: str) -> Optional[bool]:
"""Best-effort presence probe on a requirement's distribution name; ``None`` when unprobeable."""
dist = re.split(r"[<>=!~\[;\s]", req, maxsplit=1)[0].strip()
Expand Down Expand Up @@ -199,7 +206,7 @@ def _credit() -> List[str]:
"Deferred platform '%s': pre-registered %d client tool(s) %s", lookup_key, len(registered),
registered,
)
except Exception as exc:
except (Exception, SystemExit) as exc:
# Tools registered before the raise are live: credit them or `hermes plugins list` under-reports
# (and _load_plugin's later diff would miss them too). Never break discovery (the platform stays
# deferred), but a broken tools.py IS the symptom, so warn — and say where it failed first.
Expand Down Expand Up @@ -302,8 +309,15 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
module = self._load_directory_module(manifest, module_name=module_name)
elif module is None:
module = self._load_entrypoint_module(manifest)
register_fn = None
if module is not None and not isinstance(module, types.ModuleType) and callable(module):
# An entry point declared as ``module:function`` resolves to the function object itself via
# ``ep.load()``, not its module (#72052).
register_fn = module
module = sys.modules.get(getattr(register_fn, "__module__", ""))
loaded.module = module
register_fn = getattr(module, "register", None)
if register_fn is None:
register_fn = getattr(module, "register", None)
if register_fn is None:
loaded.error = "no register() function"
logger.warning("Plugin '%s' has no register() function", manifest.name)
Expand All @@ -314,15 +328,17 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
from hermes_cli.plugins_ledger import _hook_source_of

self._drop_fallback_hooks(_hook_source_of(manifest.name, module))
except Exception as exc:
except (Exception, SystemExit) as exc:
# SystemExit too: a plugin module with an unguarded ``main()``/``sys.exit()`` must not take the
# whole process (and every other plugin's registry) down with it; KeyboardInterrupt still propagates.
owned = [r for r in self._registration_order if r.plugin_key == plugin_key]
self._dispose_registrations(owned)
self._forget_registrations(owned)
loaded.error = str(exc)
loaded.error = _load_error_text(exc)
# register() may have subscribed before raising; a failed plugin must leave no callable reachable
# from later event dispatch.
self._remove_plugin_subscriptions(plugin_key)
logger.warning("Failed to load plugin '%s': %s", manifest.name, exc, exc_info=_PLUGINS_DEBUG)
logger.warning("Failed to load plugin '%s': %s", manifest.name, _load_error_text(exc), exc_info=_PLUGINS_DEBUG)
# The failure path swept this plugin's whole ledger (not just the registration_start slice), so
# discovery-time pre-registrations are gone too.
# There is no live tool left to credit — attribution and the registry agree at zero. Only the
Expand Down Expand Up @@ -400,9 +416,9 @@ def _load_portable_plugin(self, manifest: PluginManifest, loaded: LoadedPlugin)
continue
self._portable_mcp_servers[internal_name] = dict(config)
loaded.enabled = True
except Exception as exc:
loaded.error = str(exc)
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc)
except (Exception, SystemExit) as exc:
loaded.error = _load_error_text(exc)
logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, loaded.error)
self._plugins[lookup_key] = loaded

def _directory_module_name(self, manifest: PluginManifest) -> str:
Expand Down Expand Up @@ -461,8 +477,9 @@ def _load_directory_module(
raise
return module

def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
"""Load a pip-installed plugin via its entry-point reference."""
def _load_entrypoint_module(self, manifest: PluginManifest) -> Union[types.ModuleType, Callable[..., Any]]:
"""Load a pip-installed plugin via its entry-point reference: the module for a bare ``module`` target,
the referenced attribute (normally ``register``) for the ``module:function`` form."""
for ep in _select_entry_point_group(importlib.metadata.entry_points(), ENTRY_POINTS_GROUP):
if ep.name == manifest.name:
return ep.load()
Expand Down
39 changes: 29 additions & 10 deletions hermes_cli/plugins_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,22 +375,35 @@ class PluginManifest:


def running_hermes_version() -> str:
"""Installed ``hermes-agent`` distribution version, else ``hermes_cli.__version__`` (source checkout)."""
"""Version of the Hermes code that is running: ``hermes_cli.__version__``. Distribution metadata is only
a fallback — on an editable/source install it is frozen at ``pip install -e`` time and drifts from the
checkout after every ``git pull`` (dist said 0.21.0 while the code was 0.21.4), so gating on it skipped
plugins that required exactly the release the user was running."""
try:
return importlib.metadata.version("hermes-agent")
except Exception:
from hermes_cli import __version__
return __version__
if __version__:
return str(__version__)
except Exception:
pass
return importlib.metadata.version("hermes-agent")


_VERSION_SEGMENT_RE = re.compile(r"^\d+")


def _version_tuple(v: str) -> Optional[tuple]:
"""``v1.2.3-rc1`` → ``(1, 2, 3)``; ``None`` when a segment is non-numeric."""
"""``v1.2.3-rc1`` / ``1.2.3rc1`` / ``1.2.3.post1`` → ``(1, 2, 3)``; ``None`` when a segment has no
leading digits. PEP 440 pre/post/dev suffixes glued to a segment (``0rc1``) are dropped so an rc
*target* still gates and an rc *running* version does not disable every gate."""
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
out = []
for x in parts[:3]:
m = _VERSION_SEGMENT_RE.match(x.strip())
if m is None:
return None
out.append(int(m.group(0)))
return tuple(out)


def version_satisfies(spec: str, current: str) -> bool:
Expand Down Expand Up @@ -464,6 +477,10 @@ def parse_manifest_file(
logger.warning("PyYAML not installed – cannot load %s", manifest_file)
return None
data = fast_safe_load(manifest_file.read_text(encoding="utf-8")) or {}
if not isinstance(data, Mapping):
logger.warning("Failed to parse %s: top level must be a mapping, got %s (#14066)",
manifest_file, type(data).__name__)
return None
name = data.get("name", plugin_dir.name)
key = f"{prefix}/{plugin_dir.name}" if prefix else name
kind = _manifest_kind(data, key, plugin_dir)
Expand All @@ -474,7 +491,9 @@ def parse_manifest_file(
description=data.get("description", ""), author=_display_author(data.get("author", "")),
requires_env=data.get("requires_env", []),
provides_tools=data.get("provides_tools", []),
provides_hooks=data.get("provides_hooks", []), source=source, path=str(plugin_dir),
# ``hooks:`` is the spelling the bundled manifests carried for months; external copies of it
# must keep declaring the same thing (#108371).
provides_hooks=data.get("provides_hooks", data.get("hooks", [])), source=source, path=str(plugin_dir),
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 [],
Expand Down
2 changes: 1 addition & 1 deletion plugins/disk-cleanup/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ name: disk-cleanup
version: 2.0.0
description: "Auto-track and clean up ephemeral files (test scripts, temp outputs, cron logs) created during Hermes sessions. Runs via plugin hooks — no agent action required."
author: "@LVT382009 (original), NousResearch (plugin port)"
hooks:
provides_hooks:
- post_tool_call
- on_session_end
2 changes: 1 addition & 1 deletion plugins/google_meet/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ provides_tools:
- meet_status
- meet_transcript
- meet_say
hooks:
provides_hooks:
- on_session_end
2 changes: 0 additions & 2 deletions plugins/memory/byterover/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,3 @@ external_dependencies:
- name: brv
install: "curl -fsSL https://byterover.dev/install.sh | sh"
check: "brv --version"
hooks:
- on_pre_compress
2 changes: 0 additions & 2 deletions plugins/memory/hindsight/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,3 @@ description: "Hindsight — long-term memory with knowledge graph, entity resolu
pip_dependencies:
- "hindsight-client>=0.6.1"
requires_env: []
hooks:
- on_session_end
2 changes: 0 additions & 2 deletions plugins/memory/holographic/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
name: holographic
version: 0.1.0
description: "Holographic memory — local SQLite fact store with FTS5 search, trust scoring, and HRR-based compositional retrieval."
hooks:
- on_session_end
2 changes: 0 additions & 2 deletions plugins/memory/honcho/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,3 @@ version: 1.0.0
description: "Honcho AI-native memory — cross-session user modeling with dialectic Q&A, semantic search, and persistent conclusions."
pip_dependencies:
- honcho-ai
hooks:
- on_session_end
2 changes: 0 additions & 2 deletions plugins/memory/openviking/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,3 @@ description: "OpenViking context database — session-managed memory with automa
pip_dependencies:
- httpx
requires_env: []
hooks:
- on_session_end
2 changes: 1 addition & 1 deletion plugins/observability/langfuse/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ author: NousResearch
requires_env:
- HERMES_LANGFUSE_PUBLIC_KEY
- HERMES_LANGFUSE_SECRET_KEY
hooks:
provides_hooks:
- pre_api_request
- post_api_request
- api_request_error
Expand Down
2 changes: 2 additions & 0 deletions plugins/plugin_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ def load_plugin_module(module_name: str, plugin_dir: Path, *, parents: Tuple[str
sub_mod = _new_module(full_sub_name, sub_file)
if _exec(sub_mod, logger):
loaded_submodules.append((sub_file.stem, sub_mod))
else:
sys.modules.pop(full_sub_name, None)
if not _exec(mod, logger):
sys.modules.pop(module_name, None)
return None
Expand Down
2 changes: 1 addition & 1 deletion plugins/security-guidance/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ name: security-guidance
version: "0.1.0"
description: "Append security warnings to file-write tool results when the new content contains known-dangerous patterns (pickle.load, yaml.load, eval(, os.system, dangerouslySetInnerHTML, verify=False, ECB, XXE, GitHub Actions injection, ...). 25 regex/substring rules forked from Anthropic's claude-plugins-official under Apache-2.0. Non-blocking — the file is written and the warning rides back to the model in the next turn so it can self-correct."
author: "Anthropic (patterns, Apache-2.0) / NousResearch (Hermes plugin port)"
hooks:
provides_hooks:
- transform_tool_result
- pre_tool_call
Loading
Loading