Files
hermes-agent/pm/plugin_declarations.py
ethernet 53e6f001c7 refactor(pm): consolidate runtime ownership and updater completion
Run historical updater completion in a fresh interpreter so cached imports
cannot revive retired dependency installers. Share Git and ZIP completion,
carry receipt and recovery state, and preserve child exit status.

Route plugin admission, binary acquisition, desktop launch and build paths
through PM. Replace redundant helpers and tests with real worker, package,
publication and launch checks. Keep the shipped compatibility surface fixed.

Targeted Python and desktop checks pass. Native update journeys and fresh
production image qualification remain pending. This is a checkpoint before
those acceptance runs.
2026-09-12 16:30:35 -04:00

107 lines
4.0 KiB
Python

"""Read plugin build declarations without importing CLI configuration or plugin code."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import tomllib
def native_manifest_file(plugin_dir: Path) -> Path | None:
for path in (plugin_dir / "plugin.yaml", plugin_dir / "plugin.yml"):
try:
path.stat()
except FileNotFoundError:
continue
except OSError as exc:
raise ValueError(f"Could not inspect plugin manifest {path}: {exc}") from exc
return path
return None
def read_native_manifest(path: Path) -> dict:
import hermes_yaml as yaml
try:
data = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise ValueError(f"Could not read plugin manifest {path}: {exc}") from exc
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError(f"Plugin manifest must be a mapping: {path}")
return data
def manifest_version_error(manifest: dict, name: str) -> str | None:
from hermes_cli.plugins_manifest import SUPPORTED_MANIFEST_VERSION, requires_hermes_error
reason = requires_hermes_error(manifest)
if reason:
return f"Plugin '{name}' {reason}"
version = manifest.get("manifest_version")
if version is None:
return None
try:
parsed = int(version)
except (TypeError, ValueError):
return f"Plugin '{name}' has invalid manifest_version '{version}' (expected an integer)."
if parsed > SUPPORTED_MANIFEST_VERSION:
return (f"Plugin '{name}' requires manifest_version {version}, "
f"but this installer only supports up to {SUPPORTED_MANIFEST_VERSION}.")
return None
@dataclass(frozen=True)
class PythonDeclaration:
files: tuple[Path, ...]
pyproject: Path | None
requirements: tuple[str, ...]
manifest: dict
@property
def is_member(self) -> bool:
return self.pyproject is not None or bool(self.requirements)
def read_python_declaration(plugin_dir: Path) -> PythonDeclaration:
"""One effective surface for consent, membership, builds and currency.
A real pyproject owns packaging. Legacy manifests bridge plugins without
one, including old PM-generated pyprojects. Both YAML suffixes and both
dependency aliases have identical semantics. Reading never initializes a
home, discovers code, or depends on the caller's CLI import state.
"""
native = native_manifest_file(plugin_dir)
files = [native] if native is not None else []
manifest = read_native_manifest(native) if native is not None else {}
if native is None:
portable = plugin_dir / "plugin.json"
try:
portable.lstat()
except FileNotFoundError:
pass
else:
from hermes_cli.agent_plugins import read_agent_plugin_manifest
manifest, _diagnostics = read_agent_plugin_manifest(plugin_dir)
files.append(portable)
project = plugin_dir / "pyproject.toml"
try:
text = project.read_text(encoding="utf-8-sig")
except FileNotFoundError:
text = None
if text is not None:
files.append(project)
if "GENERATED by pm" not in text:
document = tomllib.loads(text)
specs = document.get("project", {}).get("dependencies", [])
if not isinstance(specs, list) or any(not isinstance(v, str) for v in specs):
raise ValueError(f"invalid project.dependencies: {project}")
return PythonDeclaration(tuple(files), project, tuple(specs), manifest)
specs = []
for key in ("pip_dependencies", "python_dependencies"):
values = manifest.get(key, [])
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
raise ValueError(f"invalid {key}: {native}")
specs.extend(values)
return PythonDeclaration(tuple(files), None, tuple(dict.fromkeys(specs)), manifest)