172 lines
5.8 KiB
Python
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
|