Files
hermes-agent/pm/plugins_state.py
ethernet bbec973514 refactor(pm): pm owns the dependency-environment layout and interpreter paths
hermes_cli.runtime_paths (venv generations, selection, activation) moves to
pm.environments, and gains venv_bin_dir / venv_python / project_python. Every
in-tree caller asks pm for an interpreter now; pm no longer reaches back into
hermes_cli for its own environment layout (pm.packages, pm.extras, pm.ensure,
pm.paths imported hermes_cli.runtime_paths). The three open-coded
"Scripts/python.exe or bin/python" ladders in pm collapse onto venv_python.

hermes_constants.venv_python_path / venv_bin_dir and hermes_cli.runtime_paths
stay as frozen-updater-surface shims only (tests/compat/old_updater_surface.json).

To keep the boot path light, pm/__init__ resolves its facade lazily (PEP 562)
and pm.registry loads the built-in package definitions on first read instead of
at import: `import hermes_bootstrap` now loads pm + pm.environments only (25ms,
was 37ms with the eager facade dragging in the downloader). The stripped-payload
fixtures that ship only pre-import files keep working for the same reason.

Also restores two frozen-surface re-exports the F401 sweep dropped
(banner._github_compare_behind, cua_backend.resolve_cua_driver_cmd).
2026-09-18 20:02:36 -04:00

139 lines
5.6 KiB
Python

"""Read every profile's enabled plugins in config order for the shared union.
Plugin admission owns writes; discovery never edits a profile's selection.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
def _profiles_root() -> Path:
# Plugin discovery and dependency publication must use the same home root.
from pm.environments import dependency_home_root
return dependency_home_root() / "profiles"
def _read_home_config(home: Path) -> Optional[dict[str, Any]]:
"""Read a selection once; only an absent file means an unknown home.
An unreadable selection must not shrink the next dependency generation.
Empty YAML is an explicit empty configuration, as in the CLI loader.
"""
config_path = home / "config.yaml"
try:
text = config_path.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return None
except (OSError, UnicodeError) as exc:
raise ValueError(f"could not read plugin selection: {config_path}") from exc
# Missing YAML support is a broken runtime, not an empty plugin selection.
import utils
from ruamel.yaml.error import YAMLError
try:
config = utils.fast_safe_load(text)
except YAMLError as exc:
raise ValueError(f"could not parse plugin selection: {config_path}") from exc
if config is None:
return {}
if not isinstance(config, dict):
raise ValueError(f"configuration must be a mapping: {config_path}")
for section in ("plugins", "memory"):
if config.get(section) is not None and not isinstance(config[section], dict):
raise ValueError(f"{section} must be a mapping: {config_path}")
plugins = config.get("plugins") or {}
for key in ("enabled", "disabled"):
names = plugins.get(key)
if names is not None and (not isinstance(names, list)
or any(not isinstance(name, str) for name in names)):
raise ValueError(f"plugins.{key} must be a list of names: {config_path}")
provider = (config.get("memory") or {}).get("provider")
if provider is not None and not isinstance(provider, str):
raise ValueError(f"memory.provider must be a name: {config_path}")
return config
def _enabled_from_config(config: dict[str, Any]) -> list[str]:
"""plugins.enabled from an already-parsed config, ORDER-PRESERVING."""
plugins_cfg = config.get("plugins")
if not isinstance(plugins_cfg, dict):
return []
enabled = plugins_cfg.get("enabled")
if not isinstance(enabled, list):
return []
disabled = plugins_cfg.get("disabled", [])
disabled = set(disabled) if isinstance(disabled, list) else set()
out: list[str] = []
for name in enabled:
if (isinstance(name, str) and name and name not in out
and name not in disabled and name.rsplit("/", 1)[-1] not in disabled):
out.append(name)
return out
def _is_directory(path: Path) -> bool:
import stat
try:
return stat.S_ISDIR(path.stat().st_mode)
except FileNotFoundError:
return False
except OSError as exc:
raise ValueError(f"could not inspect plugin directory: {path}") from exc
def _all_homes() -> list[Path]:
"""Enumerate the complete union or refuse; a partial scan cannot remove members."""
from pm.environments import dependency_home_root
homes = [dependency_home_root()]
root = _profiles_root()
try:
profiles = sorted(root.iterdir(), key=str)
except FileNotFoundError:
return homes
except OSError as exc:
raise ValueError(f"could not enumerate profiles: {root}") from exc
homes.extend(profile for profile in profiles if _is_directory(profile))
return homes
def enabled_plugins_ordered(*, proposed_home=None, enabled=None, disabled=None, installing: Path | None = None) -> dict[Path, list[str]]:
"""plugins_dir → ordered enabled list, per home. Keyed by the
PLUGINS DIR (where the member dirs live), not the home itself.
The ACTIVE MEMORY PROVIDER joins its home's list: providers install
via ``memory.provider`` (mnemosyne's documented path), not via
plugins.enabled — without this, a provider's dep plugin never joins
the union. Admission refuses a conflicting candidate without changing
the active environment or disabling an existing provider."""
out: dict[Path, list[str]] = {}
for home in _all_homes():
# ONE parse per home feeds both queries (enabled + provider).
config = _read_home_config(home)
config = config or {}
if proposed_home is not None and home.resolve() == Path(proposed_home).resolve():
config = {**config, "plugins": {"enabled": list(enabled or ()), "disabled": list(disabled or ())}}
names = _enabled_from_config(config)
provider = _provider_from_config(home, config, installing=installing)
if provider and provider not in names:
names.append(provider)
if names:
out[home / "plugins"] = names
return out
def _provider_from_config(home: Path, config: dict[str, Any], *, installing: Path | None = None) -> Optional[str]:
"""The ``memory.provider`` key of an already-parsed config, when its
plugin dir exists (no dir = not a member)."""
provider = (config.get("memory") or {}).get("provider")
if not provider or not provider.strip():
return None
name = provider.strip()
return name if (_is_directory(home / "plugins" / name) or
(installing is not None and (home / "plugins" / name).resolve() == installing.resolve())) else None