Files
hermes-agent/scripts/build/inputs.py
ethernet 1bf588234c refactor(build): share product recipes across distributions
Build TUI, web, desktop UI and runnable agent products from explicit
prepared inputs. Keep dependency preparation separate from distribution
packaging, with PM and native builds sharing uv environment construction.

Docker copies compiled frontend products instead of build dependencies.
Nix retains uv2nix environments and consumes shared assembly through store
references. Native desktop and Termux use the same launcher and frontend
contracts. Preserve the independent PM runtime and source imports from
arbitrary working directories.

Keep failed frontend builds from replacing the previous product, reject
source/output overlap, and bound dependency-process output draining.
Include hermes_wisdom in the Nix wheel: real CLI smoke tests exposed its
missing package declaration on the base revision too.

Verified focused Python and JavaScript suites, Docker build/runtime checks,
Nix desktop and CLI/ACP checks, standalone TUI and packaged Electron PTY,
and real full-Chromium interaction. Native signed installers, Android device
installation and the full repository suite remain CI verification.
2026-09-11 13:16:55 -04:00

123 lines
5.7 KiB
Python

"""Build-time paths, not a second runtime configuration or dependency resolver."""
from __future__ import annotations
from dataclasses import dataclass, field, fields
from pathlib import Path
import json
import re
import tomllib
RESOURCE_ENV = {
"skills": "HERMES_BUNDLED_SKILLS",
"optional-skills": "HERMES_OPTIONAL_SKILLS",
"plugins": "HERMES_BUNDLED_PLUGINS",
"locales": "HERMES_BUNDLED_LOCALES",
"optional-mcps": "HERMES_OPTIONAL_MCPS",
}
def dependency_site(environment: Path, python_version: str, target: str) -> Path:
"""Providers know the target pin; the builder's interpreter is irrelevant."""
minor = python_version.split("+")[0].rsplit(".", 1)[0]
return environment / ("Lib/site-packages" if target.startswith("win32") else
f"lib/python{minor}/site-packages")
def project_entries(project: Path) -> dict[str, str]:
entries = tomllib.loads(project.read_text(encoding="utf-8-sig"))["project"]["scripts"]
for name, entry in entries.items():
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name):
raise ValueError(f"invalid command name: {name}")
if not re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*", entry):
raise ValueError(f"invalid script entry: {entry}")
return entries
@dataclass(frozen=True)
class AgentInputs:
project: Path
code: Path
repo: str
placement: str
target: str
python: Path
site_packages: Path
environment: Path
pm_runtime: Path
bin_dir: str = "bin"
tools: Path | None = None
command_dir: Path | None = None
resources: dict[str, Path] = field(default_factory=dict)
frontends: dict[str, Path] = field(default_factory=dict)
ref: str | None = None
stamp: Path | None = None
features: Path | None = None
env: dict[str, str] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: dict) -> AgentInputs:
unknown = data.keys() - {f.name for f in fields(cls)}
if unknown:
raise ValueError(f"unknown agent inputs: {', '.join(sorted(unknown))}")
values = dict(data)
for key in ("project", "code", "python", "site_packages", "environment", "tools",
"pm_runtime", "command_dir", "stamp", "features"):
if values.get(key) is not None:
values[key] = Path(values[key])
for key in ("resources", "frontends"):
values[key] = {name: Path(path) for name, path in values.get(key, {}).items()}
return cls(**values)
def validate(self, out: Path) -> None:
if not re.fullmatch(r"(?:(?:linux|darwin|win32)-(?:x64|arm64)|linux-arm64-bionic)", self.target):
raise ValueError(f"unsupported target: {self.target}")
if self.placement not in ("contained", "fixed", "references"):
raise ValueError(f"unknown placement: {self.placement}")
if Path(self.repo).is_absolute() or ".." in Path(self.repo).parts:
raise ValueError("repo must be an output-relative path")
if not re.fullmatch(r"[A-Za-z0-9_-]+", self.bin_dir):
raise ValueError("bin_dir must be one output-relative directory")
if self.resources.keys() - RESOURCE_ENV.keys():
raise ValueError("unknown resource names")
if self.frontends.keys() - {"tui", "web"}:
raise ValueError("unknown frontend names")
paths = [self.project, self.code, self.python, self.site_packages, self.environment,
self.pm_runtime, *self.resources.values(), *self.frontends.values()]
paths += [p for p in (self.tools, self.command_dir, self.stamp, self.features) if p is not None]
for path in paths:
if not path.is_absolute():
raise ValueError(f"input path must be absolute: {path}")
if not path.exists():
raise FileNotFoundError(f"required agent input missing: {path}")
for path in (self.project, self.python):
if not path.is_file():
raise ValueError(f"expected a file: {path}")
for path in (self.code, self.site_packages, self.environment, self.pm_runtime):
if not path.is_dir():
raise ValueError(f"expected a directory: {path}")
marker = json.loads((self.pm_runtime / "pm-runtime.json").read_text(encoding="utf-8-sig"))
pm_python = (self.pm_runtime / marker["python"]).resolve()
pm_site = (self.pm_runtime / marker["sitePackages"]).resolve()
if not pm_python.is_file() or not pm_site.is_dir():
raise ValueError("prepared PM runtime is incomplete")
if self.stamp is not None:
stamp = json.loads(self.stamp.read_text(encoding="utf-8-sig"))
if stamp.get("distribution") in ("nix", "docker") and stamp.get("pmRuntime") != str(self.pm_runtime):
raise ValueError("install stamp must reference the supplied independent PM runtime")
if self.placement == "contained":
for path in (self.python, self.site_packages, self.environment, self.pm_runtime, self.tools):
if path is not None and not path.resolve().is_relative_to(out):
raise ValueError(f"contained input must be prepared inside output: {path}")
if self.placement == "references" and self.command_dir is None:
raise ValueError("referenced placement requires command_dir")
validate_frontends(self.frontends)
def validate_frontends(frontends: dict[str, Path]) -> None:
for name, path in frontends.items():
required = ("dist/entry.js", "package.json") if name == "tui" else ("index.html",)
for entry in required:
if not (path / entry).is_file():
raise FileNotFoundError(path / entry)