"""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 requires_python: str | None = None @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 python_error(self, version: str) -> str | None: """Why this member cannot join a workspace built for *version*; uv intersects every member's requires-python, so one excluding member fails the whole lock.""" from packaging.specifiers import SpecifierSet if not self.is_member or not self.requires_python: return None if SpecifierSet(self.requires_python).contains(version, prereleases=True): return None return f"requires Python {self.requires_python}, but Hermes runs on Python {version}" 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}") requires_python = document.get("project", {}).get("requires-python") if requires_python is not None and not isinstance(requires_python, str): raise ValueError(f"invalid project.requires-python: {project}") return PythonDeclaration(tuple(files), project, tuple(specs), manifest, requires_python) 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)