#!/usr/bin/env sh # hermes/install.sh — install Hermes Agent by Nous Research, and point it at # the covert-gpt LiteLLM gateway. # # WHY: Hermes requires Python 3.11+ and creates a self-contained venv at # ~/.hermes. The official installer handles the complexity of managing its # own uv-based Python environment. This module simply runs the official # installer with --skip-setup so it completes in non-interactive mode. # Everything under $HOME, survives Kasm redeploys. # # It then merges hermes/gateway.yaml into ~/.hermes/config.yaml, so a fresh box # gets a Hermes that can actually talk to the gateway. Without that step Hermes # installs and then points at nothing — the asymmetry mcp-config/ does not have, # since opencode rebuilds its config from Gitea on every shell via sync.sh. # # No secrets are handled here: gateway.yaml references the key by NAME # (key_env: LITELLM_KEY) and mcp-config/sync.sh exports it. Run # mcp-config/install.sh first. set -eu SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" REPO_ROOT="$(dirname -- "$SCRIPT_DIR")" # shellcheck source=../lib/fetch.sh . "$REPO_ROOT/lib/fetch.sh" say() { printf '%s\n' "$*" >&2; } # Hermes Agent version (synced with PyPI/npm). HERMES_VERSION="v0.18.2" # --- 1. the binary -------------------------------------------------------- # Check if hermes is already installed at the correct version using the # .versions stamp pattern from bin/install.sh. NOTE: this only skips the # DOWNLOAD — the config merge below still runs, so a config fix ships to an # already-installed box on the next bootstrap. if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ]; then say "hermes-agent $HERMES_VERSION already installed." else say "Installing hermes-agent via official installer..." # Run the official installer in non-interactive mode, skipping the setup # wizard. The installer creates ~/.hermes/venvs/hermes with its own # uv-managed Python. curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive --skip-setup # Verify installation by checking the binary exists AND produces expected output. if command -v hermes >/dev/null 2>&1; then mkdir -p "$HOME/.local/bin/.versions" printf '%s\n' "$HERMES_VERSION" >"$HOME/.local/bin/.versions/hermes" say "Installed hermes-agent $HERMES_VERSION successfully." else say "ERROR: hermes CLI not found after install" exit 1 fi fi # --- 2. point it at the gateway ------------------------------------------- # Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and # deliberately NON-DESTRUCTIVE: config.yaml is a large personal file # (personalities, display prefs, toolsets) that Hermes also rewrites itself, so # this replaces ONLY the covert-gpt custom_providers entry and never rewrites # the rest. model.default / model.provider are seeded only when unset, so a # deliberate model choice is preserved across bootstraps. # # PyYAML is needed to merge. Prefer the system python3; fall back to Hermes' own # venv, which always has it. If neither works the config step is skipped with a # warning rather than failing bootstrap — the binary is still installed, and a # missing provider is a far smaller problem than a broken box. GATEWAY_YAML="$SCRIPT_DIR/gateway.yaml" HERMES_CONFIG="$HOME/.hermes/config.yaml" PY="" for _c in python3 "$HOME/.hermes/venvs/hermes/bin/python" "$HOME/.hermes/hermes-agent/venv/bin/python"; do if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'import yaml' >/dev/null 2>&1; then PY="$_c"; break; fi done if [ -z "$PY" ]; then say "WARNING: no python3 with PyYAML found — skipping Hermes gateway config." say " Install PyYAML and re-run $0 to finish wiring the gateway." elif [ ! -f "$GATEWAY_YAML" ]; then say "WARNING: $GATEWAY_YAML missing — skipping Hermes gateway config." else HERMES_GATEWAY_YAML="$GATEWAY_YAML" HERMES_CONFIG_PATH="$HERMES_CONFIG" "$PY" - <<'PYEOF' import os, sys, tempfile import yaml gw_path = os.environ["HERMES_GATEWAY_YAML"] cfg_path = os.environ["HERMES_CONFIG_PATH"] with open(gw_path) as f: gw = yaml.safe_load(f) or {} entries = gw.get("custom_providers") or [] if not entries: print("hermes: gateway.yaml has no custom_providers — nothing to do", file=sys.stderr) raise SystemExit(0) cfg = {} if os.path.exists(cfg_path): try: with open(cfg_path) as f: cfg = yaml.safe_load(f) or {} except Exception as e: # A corrupt config is the user's to fix; never overwrite it blind. print(f"hermes: {cfg_path} is not valid YAML ({e}) — skipping", file=sys.stderr) raise SystemExit(0) if not isinstance(cfg, dict): print(f"hermes: {cfg_path} is not a mapping — skipping", file=sys.stderr) raise SystemExit(0) existing = cfg.get("custom_providers") if not isinstance(existing, list): existing = [] changed = [] for entry in entries: name = entry.get("name") if not name: continue for i, cur in enumerate(existing): if isinstance(cur, dict) and cur.get("name") == name: if cur != entry: existing[i] = entry changed.append(f"updated custom_providers[{name}]") break else: existing.append(entry) changed.append(f"added custom_providers[{name}]") cfg["custom_providers"] = existing # `default` is a BARE model id and `provider` is the custom: form; both # are the user's to change, so they are seeded on a fresh box and otherwise left # alone — EXCEPT to migrate the old masquerade, below. primary = entries[0]["name"] model = cfg.get("model") if not isinstance(model, dict): model = {} gw_url = (entries[0].get("base_url") or "").rstrip("/") cur_provider = (model.get("provider") or "").strip() cur_base = (model.get("base_url") or "").rstrip("/") # MIGRATE the old masquerade: `provider: openrouter` + `base_url: `. That shape routed requests here but left the picker listing # openrouter.ai's catalog, so leaving it in place would silently preserve the # exact bug this module exists to fix. `base_url` pointing at OUR gateway is the # fingerprint — a genuine OpenRouter user never has that. Any OTHER provider is # a deliberate choice and is never touched. if gw_url and cur_base == gw_url and cur_provider in ("", "openrouter", "custom"): if cur_provider != f"custom:{primary}": model["provider"] = f"custom:{primary}" changed.append( f"migrated model.provider {cur_provider or ''} -> custom:{primary}" ) elif not cur_provider: model["provider"] = f"custom:{primary}" changed.append(f"set model.provider=custom:{primary}") if not model.get("default"): first_model = next(iter((entries[0].get("models") or {})), None) if first_model: model["default"] = first_model changed.append(f"set model.default={first_model}") # model.base_url is ignored once a named custom provider supplies it, and a # stale value here is an active footgun — drop it if it points at this gateway. if gw_url and cur_base == gw_url: model.pop("base_url", None) changed.append("removed dead model.base_url") cfg["model"] = model if not changed: print("hermes: gateway config already current", file=sys.stderr) raise SystemExit(0) d = os.path.dirname(cfg_path) or "." os.makedirs(d, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=d) with os.fdopen(fd, "w") as f: yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False, allow_unicode=True) os.chmod(tmp, 0o600) os.replace(tmp, cfg_path) for c in changed: print(f"hermes: {c}", file=sys.stderr) PYEOF # The provider is useless without the key. mcp-config/sync.sh exports it; warn # rather than fail, since bootstrap ordering may put mcp-config after this. if [ -z "${LITELLM_KEY:-}" ] && [ ! -f "$HOME/.config/mcp-sync/key" ]; then say "NOTE: LITELLM_KEY is unset and ~/.config/mcp-sync/key is missing." say " Run mcp-config/install.sh, or Hermes will 401 against the gateway." fi # The old OpenRouter masquerade actively breaks the model picker (it lists # openrouter.ai's catalog, not the gateway's), so flag it if it lingers. if [ -n "${OPENROUTER_API_KEY:-}" ] && [ "${OPENROUTER_API_KEY:-}" = "${LITELLM_KEY:-x}" ]; then say "NOTE: OPENROUTER_API_KEY is set to the LiteLLM key in your environment." say " That is the old masquerade; unset it in ~/.hermes/.env so the" say " model picker shows the gateway's models instead of openrouter.ai's." fi fi