#!/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; } # The installer links the CLI into ~/.local/bin, which is only on PATH in # shells that sourced the managed ~/.zshrc. bootstrap run by a non-shell # parent (the Coder agent's dotfiles script) has no such PATH, so the # post-install `command -v hermes` failed on a successful install. PATH="$HOME/.local/bin:$PATH" # 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. # # BUT: if the LiteLLM key has changed, Hermes may have the old key cached in its # config or local state. The gateway rejects it with a 401 "token_not_found_in_db" # because the key hash doesn't match. Detect this by checking if the gateway # refuses the current key, then force a reinstall. CURRENT_KEY_HASH="" if [ -f "$HOME/.config/mcp-sync/key" ]; then CURRENT_KEY_HASH="$(sha256sum "$HOME/.config/mcp-sync/key" 2>/dev/null | cut -d' ' -f1)" fi # Check if we need to force reinstall due to key change (compare against last-seen hash) PREVIOUS_KEY_HASH="" if [ -f "$HOME/.local/bin/.versions/hermes-key-hash" ]; then PREVIOUS_KEY_HASH="$(cat "$HOME/.local/bin/.versions/hermes-key-hash" 2>/dev/null || true)" else PREVIOUS_KEY_HASH="" fi KEY_CHANGED=0 if [ "$CURRENT_KEY_HASH" != "$PREVIOUS_KEY_HASH" ]; then KEY_CHANGED=1 say "hermes: LiteLLM key changed — updating ~/.hermes/.env and restarting Hermes." fi # Update the .env file if the key changed (Hermes reads its keys from here) if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then # Read the current key from mcp-sync NEW_KEY="$(tr -d ' \t\r\n' < "$HOME/.config/mcp-sync/key")" # Update LITELLM_KEY in .env (or add it if missing) if [ -f "$HOME/.hermes/.env" ]; then # Use a temporary file to avoid in-place edit issues if command -v sed >/dev/null 2>&1; then sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$NEW_KEY|" "$HOME/.hermes/.env" else # Fallback: awk awk -v key="$NEW_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env" fi say "hermes: Updated LITELLM_KEY in ~/.hermes/.env" else # Create .env if it doesn't exist. On a fresh home this runs BEFORE the # installer below has created ~/.hermes, so make the directory first. mkdir -p "$HOME/.hermes" printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env" say "hermes: Created ~/.hermes/.env with LITELLM_KEY" fi chmod 0600 "$HOME/.hermes/.env" fi # Kill any running Hermes process so it restarts with the new key if [ "$KEY_CHANGED" -eq 1 ]; then if [ -f "$HOME/.hermes/.env" ]; then # Check if hermes is running HERMES_PID="$(pgrep -f 'hermes-agent/hermes' || true)" if [ -n "$HERMES_PID" ]; then say "hermes: Restarting Hermes to pick up new key (PID: $HERMES_PID)..." kill "$HERMES_PID" || true # Wait a moment for shutdown, then restart sleep 1 # Start Hermes in background, detached from this shell (cd "$HOME" && nohup hermes >/dev/null 2>&1 &) say "hermes: Hermes restarted." fi fi fi if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ] && [ "$KEY_CHANGED" -eq 0 ]; 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 # Record the current key hash for next time mkdir -p "$HOME/.local/bin/.versions" printf '%s\n' "$CURRENT_KEY_HASH" >"$HOME/.local/bin/.versions/hermes-key-hash" # --- 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