hermes-agent/hermes_cli/runtime_tree.py

172 lines
5.8 KiB
Python

"""Derive who owns the running tree — no stored mode flags.
The install model has two axes (design:
.hermes/plans/2026-08-07_183000-two-axis-install-model.md):
* A tree with ``.git`` is a **git checkout**: ``hermes update`` owns it.
The checkout's existence IS the fact; no manifest records it.
* A tree without ``.git`` is **sealed**: something external replaces it
wholesale. The build stamp (``.hermes_build_info.json``) names that
steward in its ``distribution`` field: ``desktop-app`` (the embedded
desktop bundle), ``docker``, ``nix``, or a future package manager.
The update channel (``stable`` or ``main``) lives in config.yaml under
``update.channel``. It applies to git checkouts only — sealed trees
version-track through their stewards.
If a future feature writes to user checkouts (nothing does today), it
must add an explicit opt-out fact FIRST. The old ``manageStyle: ejected``
stickiness guarded against desktop-side adoption and rematerialization;
both are deleted, so the guard went with them.
This is a pure-stdlib leaf module. It does not import hermes_cli.config.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
BUILD_INFO_NAME = ".hermes_build_info.json"
STEWARD_DESKTOP = "desktop-app"
STEWARD_DOCKER = "docker"
STEWARD_NIX = "nix"
CHANNEL_MAIN = "main"
CHANNEL_STABLE = "stable"
_VALID_CHANNELS = (CHANNEL_MAIN, CHANNEL_STABLE)
# What `hermes update` says in a sealed tree, per steward. The fallback
# covers stewards this build does not know (a newer package-manager value
# read by older code).
STEWARD_UPDATE_MESSAGES = {
STEWARD_DESKTOP: (
"✗ This Hermes runs from inside the desktop app bundle.\n"
"\n"
"The app updates itself, and every app update carries the agent\n"
"with it. There is nothing for `hermes update` to do here.\n"
"\n"
"To manage the agent from the command line with git, run\n"
"`hermes update --eject`. It installs a source checkout and a\n"
"desktop app built from it."
),
STEWARD_DOCKER: (
"✗ This Hermes runs from a Docker image.\n"
"\n"
"The image is immutable. Pull the new image to update:\n"
" docker pull nousresearch/hermes-agent:latest"
),
STEWARD_NIX: (
"✗ This Hermes runs from the Nix store.\n"
"\n"
"The store path is immutable. Update through your flake:\n"
" nix flake update && rebuild your profile or system"
),
}
_STEWARD_FALLBACK_MESSAGE = (
"✗ This Hermes install is managed by {steward}.\n"
"\n"
"The tree has no git checkout, so `hermes update` cannot update it.\n"
"Update it with the tool that installed it."
)
@dataclass(frozen=True)
class GitCheckout:
"""A tree with .git — `hermes update` owns it."""
root: Path
@dataclass(frozen=True)
class Sealed:
"""A gitless tree — the steward replaces it wholesale."""
root: Path
steward: str
def read_build_info(project_root: Path) -> dict:
"""The baked build stamp of ``project_root``, or ``{}``."""
try:
data = json.loads((Path(project_root) / BUILD_INFO_NAME).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def runtime_tree(project_root: Path) -> GitCheckout | Sealed:
"""Classify the tree at ``project_root``.
``.git`` present (a directory, or a worktree/submodule gitfile) means a
git checkout. Everything else is sealed, with the steward read from the
build stamp; a missing or unknown stamp gives steward ``"unknown"``.
"""
root = Path(project_root)
if (root / ".git").exists():
return GitCheckout(root=root)
distribution = read_build_info(root).get("distribution")
steward = distribution if isinstance(distribution, str) and distribution else "unknown"
return Sealed(root=root, steward=steward)
def steward_update_message(steward: str) -> str:
"""The `hermes update` refusal text for a sealed tree."""
message = STEWARD_UPDATE_MESSAGES.get(steward)
if message is not None:
return message
return _STEWARD_FALLBACK_MESSAGE.format(steward=steward)
def managed_install_roots() -> tuple[Path, ...]:
"""The canonical roots where installers create the agent checkout.
* per-user: ``$HERMES_HOME/hermes-agent`` (usually ``~/.hermes``)
* FHS root installs (install.sh as root on Linux):
``/usr/local/lib/hermes-agent``
"""
from hermes_constants import get_hermes_home
return (get_hermes_home() / "hermes-agent", Path("/usr/local/lib/hermes-agent"))
def is_managed_install_root(path: Path) -> bool:
"""True when ``path`` is a canonical installer-created checkout root.
`hermes update` updates these without a question. A checkout anywhere
else is somebody's working tree, and update asks first.
"""
try:
resolved = Path(path).resolve()
except OSError:
return False
for root in managed_install_roots():
try:
if resolved == root.resolve():
return True
except OSError:
continue
return False
def resolve_update_channel(config: Optional[dict] = None) -> str:
"""The effective update channel for a git checkout.
``update.channel`` from config.yaml when it is ``stable`` or ``main``;
anything else (missing, ``auto``, unknown) means ``main``. Sealed trees
never ask: their stewards own versioning.
"""
configured = None
if isinstance(config, dict):
update_cfg = config.get("update")
if isinstance(update_cfg, dict):
configured = update_cfg.get("channel")
if isinstance(configured, str) and configured.strip().lower() in _VALID_CHANNELS:
return configured.strip().lower()
return CHANNEL_MAIN