"""Plugin discovery: directory scanning, entry-point manifests, and the enable/disable gate. Split out of :mod:`hermes_cli.plugins`. Names that tests patch on the origin (``get_bundled_plugins_dir``, ``_env_enabled``) are looked up lazily through it. """ from __future__ import annotations import importlib.metadata import logging from dataclasses import dataclass from pathlib import Path from typing import Any, List, Optional, Set from hermes_constants import get_hermes_home from hermes_cli.config import cfg_get from hermes_cli.plugin_capabilities import VALID_CAPABILITY_IDS from hermes_cli.plugin_capabilities import parse_declared_capabilities as _parse_declared_capabilities from hermes_cli.plugins_manifest import ( PluginManifest, _detect_kind_from_source, manifest_key, _resolve_module_source, parse_manifest_file, portable_plugin_manifest, ) from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV logger = logging.getLogger("hermes_cli.plugins") ENTRY_POINTS_GROUP = "hermes_agent.plugins" ENTRY_POINT_CAPABILITIES_GROUP = "hermes_agent.plugin_capabilities" def _select_entry_point_group(entry_points: Any, group: str) -> list: """Return one metadata entry-point group across supported Python APIs.""" if hasattr(entry_points, "select"): return list(entry_points.select(group=group)) if isinstance(entry_points, dict): return list(entry_points.get(group, [])) return [ep for ep in entry_points if ep.group == group] def discover_entrypoint_manifests() -> List["PluginManifest"]: """Return metadata-only manifests for installed entry-point plugins. Kind comes from an import-free source scan (memory/model providers route to their own discovery). Capabilities come from the companion ``hermes_agent.plugin_capabilities`` group (``.`` entries pointing at the same object), so consent works without importing plugin code. Failures are isolated per entry point. """ manifests: List[PluginManifest] = [] try: eps = importlib.metadata.entry_points() group_eps = _select_entry_point_group(eps, ENTRY_POINTS_GROUP) capability_eps = _select_entry_point_group(eps, ENTRY_POINT_CAPABILITIES_GROUP) except Exception as exc: logger.debug("Entry-point scan failed: %s", exc) return manifests for ep in group_eps: try: capabilities = [] for capability in VALID_CAPABILITY_IDS: declaration_name = f"{ep.name}.{capability}" if any( declaration.name == declaration_name and declaration.value == ep.value for declaration in capability_eps ): capabilities.append(capability) dist = getattr(ep, "dist", None) metadata = getattr(dist, "metadata", None) manifest = PluginManifest( name=ep.name, version=str(getattr(dist, "version", "") or ""), description=( str(metadata.get("Summary", "") or "") if metadata is not None else "" ), source="entrypoint", path=ep.value, key=ep.name, capabilities=_parse_declared_capabilities( capabilities, ep.name ), ) manifest.kind = _classify_entrypoint_value_kind(ep.value) manifests.append(manifest) except Exception as exc: logger.debug("Entry-point manifest for %r skipped: %s", getattr(ep, "name", "?"), exc) return manifests def _classify_entrypoint_value_kind(value: str) -> str: """Classify an entry-point target by import-free source scan (unresolvable -> standalone).""" try: module_name = str(value).split(":", 1)[0].strip() if not module_name: return "standalone" return _detect_kind_from_source(_resolve_module_source(module_name)) or "standalone" except Exception: return "standalone" def _get_disabled_plugins() -> set: """Read ``plugins.disabled`` — a deny-list that wins over ``plugins.enabled``.""" try: from hermes_cli.config import load_config config = load_config() disabled = cfg_get(config, "plugins", "disabled", default=[]) return set(disabled) if isinstance(disabled, list) else set() except Exception: return set() def _get_enabled_plugins() -> Optional[set]: """Read the ``plugins.enabled`` allow-list (plugins are opt-in). ``None`` = key missing/malformed ("nothing enabled yet"; the first ``migrate_config`` run grandfathers installed user plugins); ``set()`` = explicitly empty; else the allow-list. """ try: from hermes_cli.config import load_config plugins_cfg = load_config().get("plugins") enabled = plugins_cfg.get("enabled") if isinstance(plugins_cfg, dict) else None return set(enabled) if isinstance(enabled, list) else None except Exception: return None def scan_directory( path: Path, source: str, *, skip_names: Optional[Set[str]] = None, prefix: str = "", depth: int = 0, ) -> List[PluginManifest]: """Read manifests under *path*: flat ``//plugin.yaml`` (key ``name``) or category ``///plugin.yaml`` (key ``cat/name``; a manifest-less directory recurses one level, depth capped at two). *skip_names* ignores top-level names; portable ``plugin.json`` packages are accepted alongside YAML manifests.""" manifests: List[PluginManifest] = [] if not path.is_dir(): return manifests for child in sorted(path.iterdir()): if not child.is_dir(): continue if depth == 0 and skip_names and child.name in skip_names: continue manifest_file = child / "plugin.yaml" if not manifest_file.exists(): manifest_file = child / "plugin.yml" if manifest_file.exists(): manifest = parse_manifest_file(manifest_file, child, source, prefix) if manifest is not None: manifests.append(manifest) continue portable_file = child / "plugin.json" if portable_file.exists() or portable_file.is_symlink(): try: manifests.append(portable_plugin_manifest(child, source, prefix)) except Exception as exc: logger.warning("Failed to parse %s: %s", portable_file, exc) continue if depth >= 1: logger.debug("Skipping %s (no plugin.yaml, depth cap reached)", child) continue sub_prefix = f"{prefix}/{child.name}" if prefix else child.name manifests.extend(scan_directory(child, source, prefix=sub_prefix, depth=depth + 1)) return manifests def collect_directory_manifests() -> List[PluginManifest]: """Read directory manifests in full-discovery order (bundled top-level, bundled/platforms, user, opt-in project) without loading or mutating anything, so startup probes share the exact precedence/containment rules of the real discovery sweep.""" from hermes_cli import plugins as _origin # patched names resolve through the origin manifests: List[PluginManifest] = [] # Excluded bundled top-level categories have their own discovery; platforms scan separately. repo_plugins = _origin.get_bundled_plugins_dir() logger.debug("Scanning bundled plugins: %s", repo_plugins) bundled = scan_directory( repo_plugins, "bundled", skip_names={"memory", "context_engine", "platforms", "model-providers"}, ) logger.debug(" bundled (top-level): %d manifest(s)", len(bundled)) manifests.extend(bundled) bundled_platforms = scan_directory(repo_plugins / "platforms", "bundled") logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms)) manifests.extend(bundled_platforms) user_dir = get_hermes_home() / "plugins" logger.debug("Scanning user plugins: %s", user_dir) user_manifests = scan_directory(user_dir, "user") logger.debug(" user: %d manifest(s)", len(user_manifests)) manifests.extend(user_manifests) if _origin._env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): project_dir = Path.cwd() / ".hermes" / "plugins" logger.debug("Scanning project plugins: %s", project_dir) project_manifests = scan_directory(project_dir, "project") logger.debug(" project: %d manifest(s)", len(project_manifests)) manifests.extend(project_manifests) else: logger.debug("Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)") return manifests @dataclass(frozen=True) class ManifestGate: """Routing verdict for one winning manifest (see :func:`gate_manifest`).""" action: str # "load" (dependency-ordered pass) | "placeholder" | "load_now" | "defer" enabled: bool = False error: Optional[str] = None log: Optional[tuple] = None # (level, message, *args) def gate_manifest( manifest: PluginManifest, disabled: Set[str], enabled: Optional[Set[str]] ) -> ManifestGate: """Decide how one winning manifest is handled. Gate order matters: legacy relay refusal, explicit disable, category-owned kinds (exclusive / model-provider), bundled auto-loads (backend now, platform deferred), then ``plugins.enabled`` opt-in (path-derived key or legacy bare name).""" lookup_key = manifest_key(manifest) name = manifest.name # Relay lifecycle is core-owned; an old plugin copy would compete for its registries. if lookup_key in LEGACY_RELAY_PLUGIN_KEYS or name in LEGACY_RELAY_PLUGIN_KEYS: error = ( "removed — Relay lifecycle is owned by Hermes core; configure " f"{RELAY_PLUGINS_CONFIG_ENV} instead" ) return ManifestGate( "placeholder", error=error, log=(logging.WARNING, "Refusing to load removed Hermes Relay plugin '%s'; %s", lookup_key, error), ) if lookup_key in disabled or name in disabled: return ManifestGate( "placeholder", error="disabled via config", log=(logging.DEBUG, "Skipping disabled plugin '%s'", lookup_key), ) # Exclusive plugins (memory providers) have their own activation path; record only. if manifest.kind == "exclusive": return ManifestGate( "placeholder", error="exclusive plugin — activate via .provider config", log=(logging.DEBUG, "Skipping '%s' (exclusive, handled by category discovery)", lookup_key), ) # Model providers load via providers/__init__.py; a second import here would create two # ProviderProfile instances and break the bundled-vs-user "last writer wins" override. if manifest.kind == "model-provider": return ManifestGate( "placeholder", enabled=True, log=(logging.DEBUG, "Skipping '%s' (model-provider, handled by providers/ discovery)", lookup_key), ) # Bundled backends auto-load; selection among them is ``.provider`` config. if manifest.source == "bundled" and manifest.kind == "backend": return ManifestGate("load_now") # Bundled platforms register LAZILY: eagerly importing ~20 heavy SDKs added seconds to every # `hermes` invocation. A deferred loader keeps every platform available on first use. if manifest.source == "bundled" and manifest.kind == "platform": return ManifestGate("defer") if enabled is None or not (lookup_key in enabled or name in enabled): return ManifestGate( "placeholder", error=f"not enabled in config (run `hermes plugins enable {lookup_key}` to activate)", log=(logging.DEBUG, "Skipping '%s' (not in plugins.enabled)", lookup_key), ) return ManifestGate("load")