Files

994 lines
50 KiB
Bash

#!/usr/bin/env bash
# Hermes-Relay — canonical one-line installer (full relay path).
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
#
# Two ways to install:
# - FULL RELAY (this script): sets up the WSS relay server, systemd user
# unit, editable pip install, shell shims, skills dir, and the optional
# compatibility bootstrap — everything needed for terminal, bridge/phone
# control, relay voice, desktop tooling, and remote access.
# - TOOLS-ONLY (native): `hermes plugins install Codename-11/hermes-relay/plugin`
# uses hermes-agent's built-in plugin installer to discover + enable the
# android_*/desktop_* tools and prompt for optional voice-provider keys
# (declared in plugin/plugin.yaml `requires_env`). The `/plugin` subdir is
# required because the manifest lives there, not at the repo root. This path
# does NOT install the relay server, systemd unit, pip-editable, shims, or
# bootstrap — use it when you only want the agent-side tools.
#
# Installs:
# 1. The hermes-relay repo to ~/.hermes/hermes-relay (editable, git-backed)
# 2. The Python package (plugin + relay server + bootstrap injection) via
# `pip install -e` into the hermes-agent venv so `python -m plugin.pair`
# works from anywhere and the plugin-owned compatibility bootstrap is
# available. The bootstrap is wired up via a `.pth` file dropped directly
# into the venv's site-packages so Python's `site` module auto-loads it at
# every interpreter startup. The bootstrap monkey-patches
# `aiohttp.web.Application` so when the gateway builds its app,
# missing compatibility routes can be injected onto the same router the
# gateway is in the middle of populating. Current upstream already serves
# `/api/sessions/*` and read-only `/v1/skills` + `/v1/toolsets`; the
# bootstrap now mainly protects older core builds and remaining gaps such
# as `/api/memory`, legacy `/api/skills`, `/api/config`, and
# `/api/available-models`. Feature-detected by route path — if your
# hermes-agent build already has a route natively, the bootstrap leaves it
# alone.
# 3. A symlink at ~/.hermes/plugins/hermes-relay → the clone's plugin/ dir
# so Hermes's plugin loader discovers + enables the plugin
# 4. The skill(s) under skills/ into ~/.hermes/config.yaml as a scanned
# external_dirs entry — Hermes follows that path at runtime, so
# `git pull` on the clone auto-updates the SKILL.md files
# 5. Shell shims at:
# - ~/.local/bin/hermes-pair → `<venv>/python -m plugin.pair "$@"`
# - ~/.local/bin/hermes-status → `<venv>/python -m plugin.status "$@"`
# - ~/.local/bin/hermes-relay → `<venv>/python -m plugin.cli "$@"`
# - ~/.local/bin/hermes-relay-update → curl-pipe re-runs install.sh
# - ~/.local/bin/hermes-relay-tailscale → `<venv>/python -m plugin.relay.tailscale_cli "$@"`
# Current upstream exposes plugin CLI commands, so `hermes pair` should
# work when the plugin is enabled. The pair/status shims remain
# script-friendly older-build fallbacks. The update shim is just a
# discoverable name for "re-run the canonical curl-pipe installer" —
# convenience UX, not a separate code path.
# 6. A systemd user unit at ~/.config/systemd/user/hermes-relay.service
# (optional — only on hosts with a systemd user session; skipped on
# macOS, WSL-without-systemd, bare chroots, etc.). When installed,
# the relay auto-starts on login, restarts on failure, and picks up
# API keys from ~/.hermes/.env via the Python-side env bootstrap.
#
# Updates:
# cd ~/.hermes/hermes-relay && git pull
# (No reinstall needed — editable pip install + external_dirs scan mean
# changes go live on the next invocation. The bootstrap .pth hook is
# refreshed through `hermes relay compat install` on every install.sh re-run.)
#
# Uninstall:
# bash ~/.hermes/hermes-relay/uninstall.sh
# (Or, if you don't have the clone any more:
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash)
#
# The uninstaller reverses every step here in the opposite order. It is
# idempotent and never touches shared state (~/.hermes/.env, the gateway's
# state.db, the hermes-agent venv core). Use --keep-clone to leave the git
# tree in place, --remove-secret to also wipe the QR signing identity,
# --dry-run to preview without changing anything.
#
# Flags:
# --branch <name> Git branch to install (default: main). Same effect
# as HERMES_RELAY_BRANCH; flag wins over env var.
# Useful for testing feature branches before merging
# to main, e.g. `hermes-relay-update --branch
# feature/bridge-feature-expansion`.
# --dashboard-plugin=yes Enable (default) the hermes-agent dashboard plugin.
# --dashboard-plugin=no Disable it — useful on hosts that don't run the
# hermes-agent dashboard or want a minimal relay
# install. Toggle at any time by re-running with
# the opposite flag.
# --help Print this header and exit.
#
# Overrides:
# HERMES_RELAY_HOME Target directory (default: ~/.hermes/hermes-relay)
# HERMES_RELAY_BRANCH Git branch to install (default: main). Superseded
# by --branch when both are provided.
# HERMES_VENV_PY Path to hermes-agent venv python. When unset the
# installer auto-detects, in order:
# ~/.hermes/hermes-agent/venv/bin/python (classic)
# ~/.hermes/hermes-agent/.venv/bin/python (uv-managed)
# /opt/hermes/.venv/bin/python (official Docker image)
# The Docker layout is immutable — the installer
# refuses it and steers to the native plugin install.
# HERMES_HOME Hermes config home (default: ~/.hermes)
# HERMES_RELAY_NO_SYSTEMD Skip step [6/6] even if systemd is available
# (set to any non-empty value)
set -euo pipefail
# ── Argument parsing ──────────────────────────────────────────────────────
# Parse CLI flags BEFORE setting config defaults so flags can override env
# vars. Currently only --branch is supported; everything else still goes
# through env vars (HERMES_RELAY_HOME, HERMES_VENV_PY, etc). The flag is
# pass-through-friendly for `hermes-relay-update --branch <name>` since
# the shim forwards args via `bash -s --`.
_BRANCH_FLAG=""
_DASHBOARD_PLUGIN_FLAG="" # "", "yes", or "no"
while [ $# -gt 0 ]; do
case "$1" in
--branch)
if [ $# -lt 2 ]; then
echo "install.sh: --branch needs an argument (e.g. --branch feature/foo)" >&2
exit 2
fi
_BRANCH_FLAG="$2"
shift 2
;;
--branch=*)
_BRANCH_FLAG="${1#--branch=}"
shift
;;
--dashboard-plugin=*)
_val="${1#--dashboard-plugin=}"
case "$_val" in
yes|on|true|1) _DASHBOARD_PLUGIN_FLAG="yes" ;;
no|off|false|0) _DASHBOARD_PLUGIN_FLAG="no" ;;
*)
echo "install.sh: --dashboard-plugin expects yes|no (got $_val)" >&2
exit 2
;;
esac
shift
;;
--dashboard-plugin)
if [ $# -lt 2 ]; then
echo "install.sh: --dashboard-plugin needs yes|no" >&2
exit 2
fi
case "$2" in
yes|on|true|1) _DASHBOARD_PLUGIN_FLAG="yes" ;;
no|off|false|0) _DASHBOARD_PLUGIN_FLAG="no" ;;
*)
echo "install.sh: --dashboard-plugin expects yes|no (got $2)" >&2
exit 2
;;
esac
shift 2
;;
-h|--help)
sed -n '3,97p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*)
# Unknown args silently passed through to the rest of the
# script — currently nothing else consumes them, but keeping
# this lenient avoids breaking on future shim extensions.
shift
;;
esac
done
# ── Config ─────────────────────────────────────────────────────────────────
REPO_URL="https://github.com/Codename-11/hermes-relay.git"
# Branch precedence: --branch flag wins over HERMES_RELAY_BRANCH env var
# wins over the default ("main"). The env var is kept for backwards
# compat with anyone scripting against the pre-flag interface.
BRANCH="${_BRANCH_FLAG:-${HERMES_RELAY_BRANCH:-main}}"
# Dashboard plugin: flag > env var > default ("yes"). Passing "no" stashes
# the dashboard manifest to manifest.json.disabled so the hermes-agent
# dashboard loader ignores the plugin entirely. Re-running with the
# opposite flag flips it back without touching any other state.
DASHBOARD_PLUGIN="${_DASHBOARD_PLUGIN_FLAG:-${HERMES_RELAY_DASHBOARD_PLUGIN:-yes}}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
RELAY_HOME="${HERMES_RELAY_HOME:-$HERMES_HOME/hermes-relay}"
# Resolved below (after helpers) — empty means "auto-detect".
VENV_PY="${HERMES_VENV_PY:-}"
# The official Docker image (nousresearch/hermes-agent) ships hermes-agent
# pre-installed here. It is an immutable container layout: no git clone at
# $HERMES_HOME/hermes-agent, no user systemd, and site-packages that vanish
# on the next `docker pull`. This script's editable-install path cannot work
# there, so detection of this interpreter triggers a steer to the native
# `hermes plugins install` path instead of dying mid-run.
DOCKER_VENV_PY="/opt/hermes/.venv/bin/python"
PLUGIN_LINK="$HERMES_HOME/plugins/hermes-relay"
SKILLS_DIR_IN_REPO="$RELAY_HOME/skills"
HERMES_CONFIG="$HERMES_HOME/config.yaml"
SHIM_PATH="$HOME/.local/bin/hermes-pair"
STATUS_SHIM_PATH="$HOME/.local/bin/hermes-status"
RELAY_SHIM_PATH="$HOME/.local/bin/hermes-relay"
UPDATE_SHIM_PATH="$HOME/.local/bin/hermes-relay-update"
TS_SHIM_PATH="$HOME/.local/bin/hermes-relay-tailscale"
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
SERVICE_SRC="$RELAY_HOME/relay_server/hermes-relay.service"
SERVICE_DST="$SYSTEMD_USER_DIR/hermes-relay.service"
# ── TUI: colors + symbols (TTY-aware) ──────────────────────────────────────
# All ANSI escapes resolve to empty strings when stdout is not a TTY
# (curl | bash, CI logs, redirected output) so the script stays grep-friendly.
# Override with NO_COLOR=1 to disable even on a TTY.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && command -v tput >/dev/null 2>&1; then
C_RESET="$(tput sgr0)"
C_BOLD="$(tput bold)"
C_DIM="$(tput dim 2>/dev/null || printf '\033[2m')"
C_RED="$(tput setaf 1)"
C_GREEN="$(tput setaf 2)"
C_YELLOW="$(tput setaf 3)"
C_BLUE="$(tput setaf 4)"
C_MAGENTA="$(tput setaf 5)"
C_CYAN="$(tput setaf 6)"
SYM_OK="✓"
SYM_ERR="✗"
SYM_WARN="⚠"
SYM_INFO="→"
SYM_STEP="▶"
SYM_PEND="◯"
else
C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""
C_YELLOW=""; C_BLUE=""; C_MAGENTA=""; C_CYAN=""
SYM_OK="[ok]"; SYM_ERR="[x]"; SYM_WARN="[!]"
SYM_INFO="->"; SYM_STEP=">"; SYM_PEND="o"
fi
# ── Helpers ────────────────────────────────────────────────────────────────
die() { printf " ${C_RED}${C_BOLD}%s${C_RESET} %s\n" "$SYM_ERR" "$*" >&2; exit 1; }
info() { printf " ${C_DIM}%s${C_RESET} %s\n" "$SYM_INFO" "$*"; }
ok() { printf " ${C_GREEN}%s${C_RESET} %s\n" "$SYM_OK" "$*"; }
warn() { printf " ${C_YELLOW}%s${C_RESET} %s\n" "$SYM_WARN" "$*"; }
# Section header — used by [N/6] step lines.
step() {
local num="$1" total="$2" title="$3"
printf "\n ${C_BOLD}${C_CYAN}%s${C_RESET} ${C_BOLD}[%s/%s]${C_RESET} ${C_BOLD}%s${C_RESET}\n" \
"$SYM_STEP" "$num" "$total" "$title"
}
# Bash spinner — runs while a backgrounded PID is alive. No-op (waits silently)
# when stdout isn't a TTY so curl | bash logs don't fill with spinner cruft.
# Usage:
# long_command &
# spin $! "Doing the thing"
spin() {
local pid=$1 msg=$2
if [ ! -t 1 ]; then
wait "$pid"
return $?
fi
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0
# Hide cursor for the duration of the spin.
printf '\033[?25l'
while kill -0 "$pid" 2>/dev/null; do
printf "\r ${C_CYAN}%s${C_RESET} %s " "${frames:$((i % ${#frames})):1}" "$msg"
i=$((i+1))
sleep 0.08
done
# Reap and capture exit code.
wait "$pid"
local rc=$?
# Show cursor + clear the spinner line.
printf '\r\033[K\033[?25h'
return $rc
}
require() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"
}
# ── Venv autodetection ─────────────────────────────────────────────────────
# hermes-agent installs land in several layouts. HERMES_VENV_PY always wins
# when set; otherwise probe, in order: classic pip/git venv, uv-managed
# .venv, official Docker image.
_VENV_AUTODETECTED=""
if [ -z "$VENV_PY" ]; then
for _cand in \
"$HERMES_HOME/hermes-agent/venv/bin/python" \
"$HERMES_HOME/hermes-agent/.venv/bin/python" \
"$DOCKER_VENV_PY"; do
if [ -x "$_cand" ]; then
VENV_PY="$_cand"
_VENV_AUTODETECTED=1
break
fi
done
fi
# ── Banner ─────────────────────────────────────────────────────────────────
banner() {
printf "\n"
printf " ${C_BOLD}${C_CYAN}╭─────────────────────────────────────────╮${C_RESET}\n"
printf " ${C_BOLD}${C_CYAN}│${C_RESET} ${C_BOLD}Hermes-Relay Installer${C_RESET} ${C_BOLD}${C_CYAN}│${C_RESET}\n"
printf " ${C_BOLD}${C_CYAN}│${C_RESET} ${C_DIM}Plugin + relay + bootstrap + skill${C_RESET} ${C_BOLD}${C_CYAN}│${C_RESET}\n"
printf " ${C_BOLD}${C_CYAN}╰─────────────────────────────────────────╯${C_RESET}\n"
printf "\n"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Repo:" "$REPO_URL ${C_DIM}($BRANCH)${C_RESET}"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Target:" "$RELAY_HOME"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Venv:" "${VENV_PY:-(not found — see below)}"
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Hermes:" "$HERMES_CONFIG"
}
banner
require git
require python3
# Immutable/container layout — refuse early with a usable next step instead
# of failing partway through the editable install / systemd setup.
if [ -n "$_VENV_AUTODETECTED" ] && [ "$VENV_PY" = "$DOCKER_VENV_PY" ]; then
printf "\n"
warn "Official Hermes Docker image detected ($DOCKER_VENV_PY)."
info "This installer's editable install + systemd + shell shims do not"
info "apply inside the immutable container image."
info "Install the plugin the native way instead:"
printf "\n ${C_BOLD}hermes plugins install Codename-11/hermes-relay/plugin${C_RESET}\n\n"
info "then enable it when prompted and restart the container."
info "(To force this script anyway, set HERMES_VENV_PY explicitly.)"
exit 1
fi
if [ ! -d "$HERMES_HOME/hermes-agent" ]; then
die "hermes-agent not found at $HERMES_HOME/hermes-agent — install Hermes first"
fi
if [ -z "$VENV_PY" ] || [ ! -x "$VENV_PY" ]; then
die "hermes-agent venv Python not found (tried $HERMES_HOME/hermes-agent/venv, $HERMES_HOME/hermes-agent/.venv, and /opt/hermes/.venv) — reinstall hermes-agent or set HERMES_VENV_PY"
fi
# ── 1/6 Clone or update the repo ──────────────────────────────────────────
step 1 6 "Syncing repo"
if [ -d "$RELAY_HOME/.git" ]; then
# Widen the remote refspec to the standard "all branches" form BEFORE
# fetching. Pre-v0.4 installs used `git clone --single-branch` which
# pinned the refspec to `+refs/heads/main:refs/remotes/origin/main` —
# that prevents `git checkout feature/...` from working even after an
# explicit `git fetch origin feature/...`, because no remote-tracking
# ref gets created. Widening the refspec is idempotent: sets it to
# the standard form if narrow, no-op if already wide. This lets
# `--branch <name>` and `HERMES_RELAY_BRANCH=<name> hermes-relay-update`
# work cleanly on clones that pre-date v0.4.
(cd "$RELAY_HOME" \
&& git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" \
&& git fetch --quiet origin "$BRANCH" \
&& git checkout --quiet "$BRANCH" \
&& git pull --ff-only --quiet) \
|| die "Failed to update existing clone at $RELAY_HOME"
ok "Updated existing clone at $RELAY_HOME"
elif [ -e "$RELAY_HOME" ]; then
die "$RELAY_HOME exists but is not a git clone — remove it or set HERMES_RELAY_HOME to a different path"
else
mkdir -p "$(dirname "$RELAY_HOME")"
# Deliberately NOT passing --single-branch here: we want future branch
# switches (`--branch feature/foo`) to work without a retroactive
# refspec fix. The clone is a few KB larger but the ergonomic win is
# much bigger than the disk cost.
git clone --quiet --branch "$BRANCH" "$REPO_URL" "$RELAY_HOME" \
|| die "Failed to clone $REPO_URL into $RELAY_HOME"
ok "Cloned $REPO_URL to $RELAY_HOME"
fi
# ── 2/6 Install Python package editable into the hermes venv ──────────────
step 2 6 "Installing plugin into hermes venv (editable)"
# Pick an installer. hermes-agent venvs created by `uv venv` (the upstream
# default) ship NO pip module — `python -m pip` fails with "No module named
# pip". Detect that and bootstrap pip via ensurepip, or fall back to `uv pip`,
# so the editable install works on uv-managed cores instead of dying here.
_relay_pip_ready=""
if "$VENV_PY" -m pip --version >/dev/null 2>&1; then
_relay_pip_ready="1"
elif "$VENV_PY" -m ensurepip --upgrade >/dev/null 2>&1 \
&& "$VENV_PY" -m pip --version >/dev/null 2>&1; then
_relay_pip_ready="1"
info "venv had no pip — bootstrapped it with ensurepip"
fi
if [ -n "$_relay_pip_ready" ]; then
"$VENV_PY" -m pip install --quiet --upgrade pip >/dev/null 2>&1 || true
# Run the long install in the background and spin while we wait — keeps the
# user oriented during the 5-30s install. spin() falls back to a silent
# wait when stdout isn't a TTY (curl | bash) so log capture stays clean.
( "$VENV_PY" -m pip install --quiet -e "$RELAY_HOME" >/dev/null 2>&1 ) &
spin $! "pip install -e $(basename "$RELAY_HOME")" \
|| die "pip install -e $RELAY_HOME failed"
elif command -v uv >/dev/null 2>&1; then
# Pip-less venv and ensurepip unavailable — use uv directly against the
# venv interpreter (uv is what created these venvs in the first place).
( uv pip install --quiet --python "$VENV_PY" -e "$RELAY_HOME" >/dev/null 2>&1 ) &
spin $! "uv pip install -e $(basename "$RELAY_HOME")" \
|| die "uv pip install -e $RELAY_HOME failed"
else
die "venv at $VENV_PY has no pip (uv-managed?) and 'uv' is not on PATH — install uv or add pip to the venv, then re-run"
fi
# Version readback, tolerant of the uv-only path (no pip module to query).
_relay_ver="$("$VENV_PY" -m pip show hermes-relay 2>/dev/null | awk '/^Name:/{n=$2}/^Version:/{print n" "$2}')"
if [ -z "$_relay_ver" ] && command -v uv >/dev/null 2>&1; then
_relay_ver="$(uv pip show --python "$VENV_PY" hermes-relay 2>/dev/null | awk '/^Name:/{n=$2}/^Version:/{print n" "$2}')"
fi
ok "Installed ${_relay_ver:-hermes-relay}"
# Drop the legacy bootstrap .pth into the venv's site-packages so Python loads
# the plugin-owned compatibility bootstrap at interpreter startup. This is what
# allows the plugin to inject missing compatibility routes onto the gateway's
# aiohttp app at startup. The .pth has to live directly in site-packages —
# setuptools' editable install does NOT ship data-files there, so we drop it
# manually here. Idempotent: a second run overwrites the same file.
#
# Removal: the bootstrap package and its .pth come out together via
# `bash uninstall.sh`. The bootstrap also feature-detects on route paths,
# so it cleanly no-ops on hermes-agent builds that already serve the same
# routes natively — leaving it installed is safe across all versions.
SITE_PKGS="$("$VENV_PY" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
if [ -n "$SITE_PKGS" ] && [ -d "$SITE_PKGS" ]; then
if "$VENV_PY" -m plugin.cli relay compat install --site-packages "$SITE_PKGS" >/dev/null 2>&1; then
ok "Installed compat hook via hermes relay compat → $SITE_PKGS/hermes_relay_bootstrap.pth"
else
warn "Could not install compat hook through plugin CLI"
info " Modern standard chat, Manage, and dashboard voice do not require it."
info " For older Hermes compatibility routes, retry: hermes relay compat install"
fi
else
info " Could not determine venv site-packages — bootstrap .pth NOT installed"
info " This means older hermes-agent builds won't get relay compatibility"
info " routes. Retry later with: hermes relay compat install"
fi
# ── 3/6 Symlink plugin into Hermes plugin dir ─────────────────────────────
step 3 6 "Registering plugin with Hermes"
mkdir -p "$(dirname "$PLUGIN_LINK")"
# Remove an old install (dir, symlink, or mismatched target)
if [ -L "$PLUGIN_LINK" ] || [ -e "$PLUGIN_LINK" ]; then
rm -rf "$PLUGIN_LINK"
fi
ln -s "$RELAY_HOME/plugin" "$PLUGIN_LINK"
ok "Symlinked $PLUGIN_LINK → $RELAY_HOME/plugin"
# Also remove any deprecated hand-installs
for stale in "$HERMES_HOME/plugins/hermes-android" "$HERMES_HOME/hermes-agent/plugins/hermes-android" "$HERMES_HOME/hermes-agent/plugins/hermes-relay"; do
if [ -L "$stale" ] || [ -e "$stale" ]; then
rm -rf "$stale"
ok "Removed stale $stale"
fi
done
# Remove any OTHER plugin dir that also declares `name: hermes-relay`. The
# gateway loader dedups discovered plugins by manifest name, so a stale
# duplicate (a backup copy from an older installer, or a leftover native
# install) can win the dedup and make the gateway load stale code — silently
# ignoring every later deploy. Keep only the canonical symlink created above.
plugins_dir="$(dirname "$PLUGIN_LINK")"
canonical_name="$(basename "$PLUGIN_LINK")"
if [ -d "$plugins_dir" ]; then
for entry in "$plugins_dir"/*; do
[ -e "$entry" ] || continue
[ "$(basename "$entry")" = "$canonical_name" ] && continue
if [ -f "$entry/plugin.yaml" ] \
&& grep -Eq '^[[:space:]]*name:[[:space:]]*["'\'']?hermes-relay["'\'']?[[:space:]]*$' "$entry/plugin.yaml"; then
rm -rf "$entry"
ok "Removed duplicate hermes-relay plugin dir: $entry"
fi
done
fi
# Dashboard plugin toggle. The hermes-agent dashboard auto-discovers plugins
# via `dashboard/manifest.json`. We flip visibility by renaming the manifest
# file — no separate config lives anywhere else, and the same state is
# observable by `ls plugin/dashboard/` so uninstall reasoning is trivial.
DASHBOARD_MANIFEST_ACTIVE="$RELAY_HOME/plugin/dashboard/manifest.json"
DASHBOARD_MANIFEST_DISABLED="$RELAY_HOME/plugin/dashboard/manifest.json.disabled"
if [ -d "$RELAY_HOME/plugin/dashboard" ]; then
case "$DASHBOARD_PLUGIN" in
yes)
if [ ! -f "$DASHBOARD_MANIFEST_ACTIVE" ] && [ -f "$DASHBOARD_MANIFEST_DISABLED" ]; then
mv "$DASHBOARD_MANIFEST_DISABLED" "$DASHBOARD_MANIFEST_ACTIVE"
fi
if [ -f "$DASHBOARD_MANIFEST_ACTIVE" ]; then
ok "Dashboard plugin enabled — relay tab will appear in the hermes-agent dashboard"
else
info " Dashboard plugin manifest missing — skipped (expected when installing an older branch)"
fi
;;
no)
if [ -f "$DASHBOARD_MANIFEST_ACTIVE" ]; then
mv "$DASHBOARD_MANIFEST_ACTIVE" "$DASHBOARD_MANIFEST_DISABLED"
fi
ok "Dashboard plugin disabled — hermes-agent dashboard will not load the relay tab"
;;
esac
# Best-effort rescan so the toggle takes effect without a dashboard
# restart. Silent if the dashboard isn't running, or binds somewhere
# we can't see from here.
#
# The dashboard may bind to 127.0.0.1, localhost, 0.0.0.0, or a
# specific LAN IP. On hosts with a systemd user unit we extract the
# actual --host / --port from the ExecStart line so we hit the right
# endpoint instead of guessing. Falls back to a small list of common
# bind addresses for installs that don't use systemd.
if command -v curl >/dev/null 2>&1; then
_dash_hosts=""
_dash_port=""
if command -v systemctl >/dev/null 2>&1; then
_exec_line="$(systemctl --user cat hermes-dashboard 2>/dev/null | grep -m1 '^ExecStart=' || true)"
if [ -n "$_exec_line" ]; then
_dash_hosts="$(echo "$_exec_line" | sed -nE 's/.*--host[[:space:]=]+([^[:space:]]+).*/\1/p')"
_dash_port="$(echo "$_exec_line" | sed -nE 's/.*--port[[:space:]=]+([0-9]+).*/\1/p')"
fi
fi
[ -z "$_dash_port" ] && _dash_port="9119"
# Always try loopback first; if the systemd unit advertises a
# specific bind host, try that second. Keep a couple of common
# fallbacks for non-systemd hosts.
_rescan_hit=""
for host in 127.0.0.1 localhost ${_dash_hosts:-} 0.0.0.0; do
[ -z "$host" ] && continue
for port in "$_dash_port" 9119 9100 9000; do
url="http://${host}:${port}/api/dashboard/plugins/rescan"
if curl -sf -m 2 -X GET "$url" >/dev/null 2>&1; then
info " Triggered dashboard rescan at ${host}:${port}"
_rescan_hit=1
break 2
fi
done
done
[ -z "$_rescan_hit" ] && info " (dashboard not reachable — restart it if the toggle doesn't take effect)"
fi
fi
# ── 4/6 Register skills dir in Hermes config (external_dirs) ──────────────
step 4 6 "Registering skills directory"
"$VENV_PY" - <<PY
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print(" [x] pyyaml not installed in hermes venv — this should have come with the plugin")
sys.exit(1)
cfg_path = Path("$HERMES_CONFIG")
target = "$SKILLS_DIR_IN_REPO"
data: dict = {}
if cfg_path.is_file():
try:
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
except Exception as exc:
print(f" [x] Failed to parse {cfg_path}: {exc}")
sys.exit(1)
if not isinstance(data, dict):
data = {}
skills_section = data.get("skills")
if not isinstance(skills_section, dict):
skills_section = {}
data["skills"] = skills_section
external_dirs = skills_section.get("external_dirs")
if not isinstance(external_dirs, list):
external_dirs = []
skills_section["external_dirs"] = external_dirs
# Idempotent — skip if already present (match by expanded path)
expanded_targets = {str(Path(p).expanduser().resolve()) for p in external_dirs if isinstance(p, str)}
if str(Path(target).expanduser().resolve()) in expanded_targets:
print(f" [ok] external_dirs already contains {target}")
else:
external_dirs.append(target)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
# Save a .bak next to the original — yaml.safe_dump round-trips lose
# inline comments and key ordering, so users with hand-edited configs
# can restore from the backup if the new layout isn't what they want.
if cfg_path.is_file():
backup = cfg_path.with_suffix(cfg_path.suffix + ".bak")
backup.write_text(cfg_path.read_text(encoding="utf-8"), encoding="utf-8")
print(f" [ok] Backed up existing config to {backup}")
cfg_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
print(f" [ok] Added {target} to skills.external_dirs in {cfg_path}")
PY
# ── 5/7 Install hermes-pair + hermes-status + hermes-relay shims ──
step 5 7 "Installing hermes-pair + hermes-status + hermes-relay shims"
mkdir -p "$(dirname "$SHIM_PATH")"
cat > "$SHIM_PATH" <<SHIM
#!/usr/bin/env bash
# Hermes-Relay pairing shim — routes to \`python -m plugin.pair\` inside the
# hermes-agent venv where the hermes-relay plugin is installed.
#
# Also available: /hermes-relay-pair slash command in any Hermes chat session.
#
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-pair: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-pair: set HERMES_VENV_PY or reinstall hermes-agent" >&2
exit 1
fi
exec "\$HERMES_VENV_PY" -m plugin.pair "\$@"
SHIM
chmod +x "$SHIM_PATH"
ok "Installed $SHIM_PATH"
cat > "$STATUS_SHIM_PATH" <<SHIM
#!/usr/bin/env bash
# Hermes-Relay phone-status shim — routes to \`python -m plugin.status\`
# inside the hermes-agent venv where the hermes-relay plugin is installed.
#
# Also available: /hermes-relay-status slash command in any Hermes chat session.
#
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-status: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-status: set HERMES_VENV_PY or reinstall hermes-agent" >&2
exit 1
fi
exec "\$HERMES_VENV_PY" -m plugin.status "\$@"
SHIM
chmod +x "$STATUS_SHIM_PATH"
ok "Installed $STATUS_SHIM_PATH"
cat > "$RELAY_SHIM_PATH" <<SHIM
#!/usr/bin/env bash
# Hermes-Relay management shim — routes to \`python -m plugin.cli\`
# inside the hermes-agent venv where the hermes-relay plugin is installed.
#
# Usage:
# hermes-relay insecure-api-key status
# hermes-relay insecure-api-key on
# hermes-relay insecure-api-key off
#
# Override paths with \$HERMES_VENV_PY / \$HERMES_RELAY_HOME if needed.
set -eu
HERMES_RELAY_HOME="\${HERMES_RELAY_HOME:-\$HOME/.hermes/hermes-relay}"
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-relay: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-relay: set HERMES_VENV_PY or reinstall hermes-agent" >&2
exit 1
fi
if [ ! -d "\$HERMES_RELAY_HOME/plugin" ]; then
echo "hermes-relay: cannot find relay repo at \$HERMES_RELAY_HOME" >&2
echo "hermes-relay: set HERMES_RELAY_HOME or reinstall hermes-relay" >&2
exit 1
fi
cd "\$HERMES_RELAY_HOME"
export PYTHONPATH="\$HERMES_RELAY_HOME\${PYTHONPATH:+:\$PYTHONPATH}"
exec "\$HERMES_VENV_PY" -m plugin.cli "\$@"
SHIM
chmod +x "$RELAY_SHIM_PATH"
ok "Installed $RELAY_SHIM_PATH"
# hermes-relay-update — discoverable name for "update Hermes-Relay". Just
# re-runs the canonical curl-pipe installer (which is idempotent and does
# all the work). Forwards any args to install.sh via `bash -s --` so e.g.
# \`hermes-relay-update --restart-gateway\` works once we ever add that flag.
# Honors the existing HERMES_RELAY_RESTART_GATEWAY / HERMES_RELAY_NO_RESTART_GATEWAY
# env vars without any wrapping logic — they pass through naturally.
cat > "$UPDATE_SHIM_PATH" <<'SHIM'
#!/usr/bin/env bash
# Hermes-Relay updater shim — re-runs the canonical one-line installer.
#
# Common usage:
# hermes-relay-update # update to latest main
# hermes-relay-update --branch feature/bridge-feature-expansion # switch to a branch
# hermes-relay-update --branch main # return to main
# HERMES_RELAY_RESTART_GATEWAY=1 hermes-relay-update # also restart gateway
#
# Bootstrap caveat (only relevant before --branch lands on main):
# The shim normally fetches install.sh from main, so --branch only works
# if main's install.sh already understands the flag (it does as of v0.4.0).
# To install a feature branch BEFORE it has been merged to main, override
# the install.sh URL with HERMES_RELAY_INSTALL_URL:
#
# HERMES_RELAY_INSTALL_URL=https://raw.githubusercontent.com/Codename-11/hermes-relay/feature/foo/install.sh \
# hermes-relay-update --branch feature/foo
#
# This is a one-shot escape hatch — after the install lands the host has
# the new install.sh on disk + the regular shim works for all subsequent
# updates, including switching back to main.
#
# install.sh is fully idempotent — it pulls the requested branch (default
# main), refreshes the editable pip install, recreates both shims, restarts
# hermes-relay, and prompts before restarting hermes-gateway. Set
# HERMES_RELAY_RESTART_GATEWAY=1 to opt into the gateway restart non-
# interactively.
INSTALL_URL="${HERMES_RELAY_INSTALL_URL:-https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh}"
exec curl -fsSL "$INSTALL_URL" | bash -s -- "$@"
SHIM
chmod +x "$UPDATE_SHIM_PATH"
ok "Installed $UPDATE_SHIM_PATH"
# hermes-relay-tailscale — thin wrapper around `python -m
# plugin.relay.tailscale_cli`. Installed unconditionally (the shim
# itself is cheap); the ONE-TIME enablement prompt happens below in
# step 7/7 and only if the tailscale binary is actually present.
cat > "$TS_SHIM_PATH" <<SHIM
#!/usr/bin/env bash
# Hermes-Relay Tailscale helper — routes to \`python -m
# plugin.relay.tailscale_cli\` inside the hermes-agent venv.
#
# Usage:
# hermes-relay-tailscale status
# hermes-relay-tailscale enable [--dashboard-listener-port 10443] [--dashboard-target-port 9119] [--api-port 8642] [--no-api]
# hermes-relay-tailscale disable [--dashboard-listener-port 10443] [--dashboard-target-port 9119] [--api-port 8642] [--no-api]
# hermes-relay-tailscale enable --dashboard-listener-port 443 # advanced override; only when :443 is free
# hermes-relay-tailscale enable --port 8767 # explicit legacy/direct Relay
# hermes-relay-tailscale disable --port 8767 # explicit legacy/direct Relay
#
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
# the install-time detected interpreter is tried first, then classic + uv layouts.
set -eu
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
if [ -z "\$HERMES_VENV_PY" ]; then
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
if [ -x "\$candidate" ]; then
HERMES_VENV_PY="\$candidate"
break
fi
done
fi
if [ ! -x "\$HERMES_VENV_PY" ]; then
echo "hermes-relay-tailscale: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
echo "hermes-relay-tailscale: set HERMES_VENV_PY or reinstall hermes-agent" >&2
exit 1
fi
exec "\$HERMES_VENV_PY" -m plugin.relay.tailscale_cli "\$@"
SHIM
chmod +x "$TS_SHIM_PATH"
ok "Installed $TS_SHIM_PATH"
# ── 6/7 Install systemd user service (optional) ───────────────────────────
# Idempotent: safe to re-run. Skipped gracefully on hosts without a systemd
# user session (macOS, bare chroots, WSL without systemd, containers, etc).
# The relay still runs fine on those — users just start it manually or via
# their preferred process supervisor.
#
# The unit has NO EnvironmentFile= — plugin/relay/_env_bootstrap.py loads
# ~/.hermes/.env into os.environ on startup, mirroring how the gateway
# handles API keys. Any future `systemctl --user restart hermes-relay`
# picks up fresh values from .env automatically.
step 6 7 "Installing systemd user service"
if [ -n "${HERMES_RELAY_NO_SYSTEMD:-}" ]; then
info " HERMES_RELAY_NO_SYSTEMD set — skipping"
elif ! command -v systemctl >/dev/null 2>&1; then
info " systemctl not found — skipping (run manually: $VENV_PY -m plugin.relay --no-ssl)"
elif ! systemctl --user show-environment >/dev/null 2>&1; then
info " systemd user session not available — skipping"
info " Run manually: $VENV_PY -m plugin.relay --no-ssl"
elif [ ! -f "$SERVICE_SRC" ]; then
info " Service template not found at $SERVICE_SRC — skipping"
else
mkdir -p "$SYSTEMD_USER_DIR"
# The committed template hardcodes the classic %h/.hermes/hermes-agent/venv
# layout. Rewrite that prefix to the venv we actually detected (uv-managed
# .venv, a HERMES_VENV_PY override, etc.) so ExecStart / PATH / VIRTUAL_ENV
# point at a real interpreter instead of dying with 203/EXEC on non-classic
# hosts. $VENV_PY is ".../<venv>/bin/python"; strip "/bin/python" for the dir.
# A `|` delimiter avoids clashing with the `/` path separators. We assume the
# venv path has no sed metacharacters (& | \) — true for the standard
# ~/.hermes/hermes-agent/{venv,.venv} layouts and any sane HERMES_VENV_PY.
_venv_bin_dir="${VENV_PY%/*}" # .../<venv>/bin
_venv_root_dir="${_venv_bin_dir%/*}" # .../<venv>
sed "s|%h/.hermes/hermes-agent/venv|$_venv_root_dir|g" "$SERVICE_SRC" > "$SERVICE_DST"
ok "Wrote $SERVICE_DST (venv → $_venv_root_dir)"
systemctl --user daemon-reload >/dev/null 2>&1 || true
# If a nohup-launched relay is holding :8767, stop+disable will fail
# to bind on start. Warn the user to kill it first rather than racing
# the installer against their manual process.
if pgrep -f "python -m plugin.relay" >/dev/null 2>&1 && \
! systemctl --user is-active hermes-relay.service >/dev/null 2>&1; then
info " A manual 'python -m plugin.relay' is already running."
info " Stop it first: pkill -f 'python -m plugin.relay'"
info " Then: systemctl --user enable --now hermes-relay.service"
else
# Was the service already running? If yes we MUST `restart` it
# explicitly — `enable --now` is a no-op on already-active services
# and the editable-install code refresh would never reach the live
# process. (Spent way too long debugging this on hermes-host
# 2026-04-12 — every install.sh run looked successful but the live
# relay kept serving stale code from before the last git pull.)
if systemctl --user is-active hermes-relay.service >/dev/null 2>&1; then
( systemctl --user restart hermes-relay.service >/dev/null 2>&1 ) &
if spin $! "Restarting hermes-relay (already active — picking up new code)"; then
ok "hermes-relay restarted — new code is live"
else
warn "Could not restart hermes-relay automatically"
info "Check: journalctl --user -u hermes-relay -n 30 --no-pager"
fi
elif systemctl --user enable --now hermes-relay.service >/dev/null 2>&1; then
ok "Enabled + started hermes-relay.service"
# Give aiohttp a second to bind before checking state.
sleep 1
if systemctl --user is-active hermes-relay.service >/dev/null 2>&1; then
ok "hermes-relay is running"
else
warn "hermes-relay failed to start"
info "Check: journalctl --user -u hermes-relay -n 30 --no-pager"
fi
else
warn "Could not enable hermes-relay.service"
info "Check: systemctl --user status hermes-relay.service"
fi
fi
info " Linger tip: 'loginctl enable-linger $USER' keeps it running after logout"
fi
# ── 7/7 Offer (don't force) Tailscale serve enablement ───────────────────
# ADR 25 — first-class Tailscale helper. The relay stays loopback-bound on
# :8767 while the recommended Tailscale ingress listens on HTTPS :10443 and
# proxies local Dashboard :9119; Relay rides Dashboard's same-origin plugin
# transport. The optional Hermes API server may also be published on :8642
# for headless compatibility.
# `tailscale serve` terminates TLS + identity for the tailnet. This step is
# fully optional:
#
# - Binary absent? → skip silently (no Tailscale = no offer)
# - TS_DECLINE=1 in env? → skip silently (scripted opt-out)
# - Non-interactive shell w/o TS_AUTO → skip silently with a one-line hint
# - Non-interactive shell + TS_AUTO=1 → enable non-interactively
# - Interactive shell → prompt with default "no"
#
# Wrapped in a protective `if` — nothing here can break the installer.
#
# TODO(upstream-merge #9295): remove this block once the canonical
# `hermes gateway run --tailscale` flag lands on hermes-agent main. The
# capability probe lives in plugin/relay/tailscale.py::canonical_upstream_present.
if command -v tailscale >/dev/null 2>&1; then
step 7 7 "Optional — publish Dashboard over Tailscale"
if [ -n "${TS_DECLINE:-}" ]; then
info " TS_DECLINE=1 set — skipping Tailscale serve offer"
else
ts_do=""
if [ ! -t 0 ] || [ "${CI:-}" = "true" ]; then
# Non-interactive — only proceed when explicitly opted in.
if [ "${TS_AUTO:-}" = "1" ]; then
ts_do="yes"
else
info " tailscale detected; skipping in non-interactive mode"
info " To enable later: ${C_BOLD}hermes-relay-tailscale enable${C_RESET}"
info " Or re-run install.sh with TS_AUTO=1 to auto-enable."
fi
else
printf "\n"
printf " ${C_DIM}%s${C_RESET} %s\n" "$SYM_INFO" "Tailscale is installed. Publish Dashboard + optional API over your tailnet?"
printf " ${C_DIM}%s${C_RESET} %s\n" "$SYM_INFO" "Runs dedicated HTTPS ${C_BOLD}:10443${C_RESET} → local Dashboard ${C_BOLD}:9119${C_RESET} (including Relay path), plus ${C_BOLD}:8642${C_RESET} for optional API fallback."
printf " ${C_DIM}%s${C_RESET} %s\n" "$SYM_INFO" "Loopback services stay private; Tailscale handles TLS + identity."
printf "\n ${C_BOLD}Enable now?${C_RESET} ${C_DIM}[y/N]${C_RESET} "
read -r ts_reply </dev/tty || ts_reply=""
case "$ts_reply" in
[Yy]*) ts_do="yes" ;;
*) ts_do="" ;;
esac
fi
if [ -n "$ts_do" ]; then
if "$VENV_PY" -m plugin.relay.tailscale_cli enable >/dev/null 2>&1; then
ok "tailscale serve enabled on HTTPS :10443 for local Dashboard :9119, plus optional API :8642"
info " Check: ${C_BOLD}hermes-relay-tailscale status${C_RESET}"
info " Revoke: ${C_BOLD}hermes-relay-tailscale disable${C_RESET}"
else
warn "tailscale serve command failed — relay still reachable on loopback"
info " Retry manually: ${C_BOLD}hermes-relay-tailscale enable${C_RESET}"
fi
elif [ -z "${TS_AUTO:-}" ] && [ -t 0 ] && [ "${CI:-}" != "true" ]; then
info " Declined — relay stays loopback-only."
info " Enable later with: ${C_BOLD}hermes-relay-tailscale enable${C_RESET}"
fi
fi
fi
# ── 7b/7 Offer (don't force) hermes-gateway restart ──────────────────────
# The hermes-agent gateway caches plugin tools (android_*, etc.) and skills
# at import time, so a fresh `git pull` of the plugin does NOT take effect
# until the gateway re-imports. We could auto-restart it, but that interrupts
# any active chat sessions and feels presumptuous since we don't own the
# gateway process. Instead:
#
# - If --restart-gateway was passed, restart unconditionally (scripted use)
# - If --no-restart-gateway was passed, skip silently
# - If stdin is a TTY (interactive), prompt with default "no"
# - If stdin is NOT a TTY (curl | bash), print a clear hint and skip
#
# Either way, the user is in control. No surprise restarts.
if [ -z "${HERMES_RELAY_NO_SYSTEMD:-}" ] \
&& [ -z "${HERMES_RELAY_NO_RESTART_GATEWAY:-}" ] \
&& command -v systemctl >/dev/null 2>&1 \
&& systemctl --user is-active hermes-gateway.service >/dev/null 2>&1; then
do_restart=""
if [ -n "${HERMES_RELAY_RESTART_GATEWAY:-}" ]; then
do_restart="yes"
elif [ -t 0 ]; then
printf "\n"
printf " ${C_YELLOW}%s${C_RESET} %s\n" "$SYM_WARN" "${C_BOLD}hermes-gateway is running${C_RESET}"
printf " ${C_DIM}%s${C_RESET}\n" "Restarting it lets the gateway re-import the updated plugin code"
printf " ${C_DIM}%s${C_RESET}\n" "(new tools, skills, etc). Active chat sessions are interrupted for ~2s."
printf "\n ${C_BOLD}Restart hermes-gateway now?${C_RESET} ${C_DIM}[y/N]${C_RESET} "
read -r reply </dev/tty || reply=""
case "$reply" in
[Yy]*) do_restart="yes" ;;
*) do_restart="" ;;
esac
else
warn "hermes-gateway is running with stale plugin imports"
info "To re-import: ${C_BOLD}systemctl --user restart hermes-gateway${C_RESET}"
info "Or re-run with HERMES_RELAY_RESTART_GATEWAY=1 to do it automatically"
fi
if [ -n "$do_restart" ]; then
( systemctl --user restart hermes-gateway.service >/dev/null 2>&1 ) &
if spin $! "Restarting hermes-gateway"; then
ok "hermes-gateway restarted — new plugin tools are live"
else
warn "Could not restart hermes-gateway automatically"
info "Run manually: ${C_BOLD}systemctl --user restart hermes-gateway${C_RESET}"
fi
fi
fi
# ── Done ───────────────────────────────────────────────────────────────────
printf "\n"
printf " ${C_BOLD}${C_GREEN}╭─────────────────────────────────────────╮${C_RESET}\n"
printf " ${C_BOLD}${C_GREEN}│${C_RESET} ${C_BOLD}${SYM_OK} Hermes-Relay installed${C_RESET} ${C_BOLD}${C_GREEN}│${C_RESET}\n"
printf " ${C_BOLD}${C_GREEN}╰─────────────────────────────────────────╯${C_RESET}\n"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Pair your phone${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}/hermes-relay-pair${C_RESET} ${C_DIM}# from any Hermes chat${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-pair${C_RESET} ${C_DIM}# from any shell${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-pair --mode auto --prefer tailscale${C_RESET} ${C_DIM}# one QR for LAN + Tailscale${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-status${C_RESET} ${C_DIM}# show live phone state${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Remote access${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-relay-tailscale enable${C_RESET} ${C_DIM}# HTTPS :10443 → Dashboard :9119 + optional API :8642${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-relay-tailscale enable --dashboard-listener-port 443${C_RESET} ${C_DIM}# advanced override; only if :443 is free${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-relay-tailscale enable --port 8767${C_RESET} ${C_DIM}# legacy/direct Relay only${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-relay-tailscale status${C_RESET} ${C_DIM}# show served Tailscale ports${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Self-setup / troubleshoot${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}/hermes-relay-self-setup${C_RESET} ${C_DIM}# agent walks you through verify, re-pair, fix${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Update later${C_RESET} ${C_DIM}(idempotent — pick whichever you remember)${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}hermes-relay-update${C_RESET} ${C_DIM}# shortest path${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Manage the relay service${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}systemctl --user status hermes-relay${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}systemctl --user restart hermes-relay${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}journalctl --user -u hermes-relay -f${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_BOLD}${C_CYAN}Uninstall${C_RESET}\n"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}bash $RELAY_HOME/uninstall.sh${C_RESET}\n" "$SYM_INFO"
printf " ${C_DIM}%s${C_RESET} ${C_BOLD}bash $RELAY_HOME/uninstall.sh --dry-run${C_RESET} ${C_DIM}# preview only${C_RESET}\n" "$SYM_INFO"
printf "\n"
printf " ${C_DIM}Scan the QR from the Hermes-Relay app: Settings → Connection → Scan Pairing QR${C_RESET}\n"
printf "\n"