288 lines
10 KiB
Python
288 lines
10 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 (``install-stamp.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
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
BUILD_INFO_NAME = "install-stamp.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"
|
|
"Manage updates from within the desktop app."
|
|
),
|
|
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."
|
|
)
|
|
|
|
# What the uninstaller says when it refuses to remove code from a sealed
|
|
# tree. The steward put the code there; the steward removes it. The
|
|
# desktop-app message is per-OS because each OS owns app removal
|
|
# differently.
|
|
_STEWARD_DELETE_DATA_PREAMBLE = "To delete your Hermes data (chats, configuration, etc),\n"
|
|
_STEWARD_DELETE_DATA_CLI = "run:\n$ hermes uninstall --data\n"
|
|
_STEWARD_DELETE_DATA_DESKTOP = "Open Hermes Desktop, go to Settings -> About, and delete your data from there.\n"
|
|
|
|
_STEWARD_UNINSTALL_MESSAGES = {
|
|
STEWARD_DOCKER: (
|
|
"✗ This Hermes runs from a Docker image.\n"
|
|
"\n"
|
|
"There is no code to uninstall — remove the container and image:\n"
|
|
" docker rm <container> && docker rmi nousresearch/hermes-agent\n"
|
|
"\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_CLI
|
|
),
|
|
STEWARD_NIX: (
|
|
"✗ This Hermes was installed by Nix.\n"
|
|
"\n"
|
|
"The store path is immutable — uninstall it the same way you\n"
|
|
"installed it: remove hermes-agent from your flake / profile\n"
|
|
"(e.g. `nix profile remove`), then rebuild.\n"
|
|
"\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_CLI
|
|
),
|
|
}
|
|
|
|
_STEWARD_MANAGED_BY_DESKTOP = "✗ Hermes is managed by the desktop app.\n"
|
|
|
|
_STEWARD_DESKTOP_UNINSTALL_BY_PLATFORM = {
|
|
"win32": (
|
|
_STEWARD_MANAGED_BY_DESKTOP +
|
|
"\n"
|
|
"Remove the app from Windows Settings → Apps → Installed apps.\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_DESKTOP
|
|
),
|
|
"darwin": (
|
|
_STEWARD_MANAGED_BY_DESKTOP +
|
|
"\n"
|
|
"Quit the app and drag Hermes.app from Applications to the Trash.\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_DESKTOP
|
|
),
|
|
}
|
|
|
|
_STEWARD_DESKTOP_UNINSTALL_DEFAULT = (
|
|
_STEWARD_MANAGED_BY_DESKTOP +
|
|
"\n"
|
|
"Delete the Hermes AppImage (or app directory) from wherever you\n"
|
|
"saved it.\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_DESKTOP
|
|
)
|
|
|
|
_STEWARD_UNINSTALL_FALLBACK = (
|
|
"✗ Hermes is managed by {steward}.\n"
|
|
"\n"
|
|
"The tree has no git checkout, so the uninstaller will not remove it.\n"
|
|
"Remove it with the tool that installed it.\n"
|
|
"\n" +
|
|
_STEWARD_DELETE_DATA_PREAMBLE +
|
|
_STEWARD_DELETE_DATA_DESKTOP
|
|
)
|
|
|
|
|
|
def steward_uninstall_message(steward: str, platform: "str | None" = None) -> str:
|
|
"""The uninstall refusal text for a sealed tree."""
|
|
if steward == STEWARD_DESKTOP:
|
|
key = platform if platform is not None else sys.platform
|
|
return _STEWARD_DESKTOP_UNINSTALL_BY_PLATFORM.get(key, _STEWARD_DESKTOP_UNINSTALL_DEFAULT)
|
|
message = _STEWARD_UNINSTALL_MESSAGES.get(steward)
|
|
if message is not None:
|
|
return message
|
|
return _STEWARD_UNINSTALL_FALLBACK.format(steward=steward)
|
|
|
|
|
|
@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 ``{}``.
|
|
|
|
Raises ``RuntimeError`` on a ``payload: light`` stamp: a light artifact
|
|
ships no Python runtime, so a Python process reading its own stamp as
|
|
light means the artifact was mispackaged. Failing loudly here beats
|
|
every consumer misclassifying the tree.
|
|
"""
|
|
try:
|
|
data = json.loads((Path(project_root) / BUILD_INFO_NAME).read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
if not isinstance(data, dict):
|
|
return {}
|
|
if data.get("payload") == "light":
|
|
raise RuntimeError(
|
|
f"install-stamp.json at {project_root} marks this artifact as 'light' "
|
|
"(no agent runtime). No Python process can legitimately run from a "
|
|
"light artifact — this build is mispackaged."
|
|
)
|
|
return data
|
|
|
|
|
|
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
|
|
|
|
|
|
# Stewards install_method() reports as-is. An unknown steward value (a newer
|
|
# package manager read by older code) reports "unknown" so consumers do not
|
|
# branch on an enum member they never heard of.
|
|
_KNOWN_STEWARDS = frozenset({STEWARD_DESKTOP, STEWARD_DOCKER, STEWARD_NIX})
|
|
|
|
|
|
def install_method(project_root: Path) -> str:
|
|
"""Derive the install method of the tree at ``project_root``.
|
|
|
|
Everything comes from the tree itself — the stamp for sealed trees,
|
|
``.git`` plus location for checkouts. No stored method flags.
|
|
|
|
* sealed tree, stamp ``distribution`` known → that steward
|
|
(``docker``, ``nix``, ``desktop-app``)
|
|
* ``.git`` at a managed install root → ``git`` (`hermes update` owns it)
|
|
* ``.git`` anywhere else → ``source`` (somebody's working tree;
|
|
update refuses and points at ``git pull``)
|
|
* neither → ``unknown``
|
|
"""
|
|
tree = runtime_tree(project_root)
|
|
if isinstance(tree, Sealed):
|
|
return tree.steward if tree.steward in _KNOWN_STEWARDS else "unknown"
|
|
if is_managed_install_root(tree.root):
|
|
return "git"
|
|
return "source"
|
|
|
|
|
|
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
|