Files
hermes-agent/pm/plugin_declarations.py
ethernet b4a294fff9 Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
2026-09-17 13:52:05 -04:00

139 lines
5.3 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 external(self) -> bool:
return str(self.manifest.get("python_runtime") or "").strip().lower() == "external"
@property
def install_requirements(self) -> tuple[str, ...]:
return () if self.external else applicable_requirements(self.requirements)
@property
def is_member(self) -> bool:
return not self.external and (self.pyproject is not None or bool(self.install_requirements))
def __post_init__(self) -> None:
if not self.external:
applicable_requirements(self.requirements)
def applicable_requirements(specs: tuple[str, ...] | list[str]) -> tuple[str, ...]:
"""Index requirements only; let uv evaluate markers for the target interpreter.
The application checkout supplies Hermes itself. Installing it from an index
would replace that checkout; direct URLs bypass the reviewed package source.
Markers must survive snapshots built for a different Python or platform.
"""
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
return tuple(spec for spec in specs
if not (req := Requirement(spec)).url and canonicalize_name(req.name) != "hermes-agent")
def unsupported_requirements(specs: tuple[str, ...] | list[str]) -> tuple[str, ...]:
from packaging.requirements import Requirement
return tuple(spec for spec in specs if Requirement(spec).url)
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)