Files
hermes-agent/scripts/install.sh
ethernet 5e4a2a3d24 refactor(pm): remove legacy dependency and launch managers
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.

Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.

Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.

Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.

Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
2026-09-12 14:57:38 -04:00

474 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# Hermes Agent bootstrap: git checkout + venv + hermes command on PATH.
# Heavy dependencies (tool binaries, browsers, node) are pm's job after
# this: `hermes pm install`. Stage protocol kept for Hermes-Setup:
# --manifest print the stage list as JSON
# --stage NAME [--json] run one stage
# --non-interactive skip stages that need input
# --include-desktop add the desktop build stage
set -u
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
# wrong user's home directory when running under sudo -u <user>. See #21269.
# pm's own venv sync re-isolates (pm/environment.py), so this bootstrap
# hygiene can't break the locked sync the way it used to before pm owned it.
export UV_NO_CONFIG=1
REPO_URL="${HERMES_REPO_URL:-https://github.com/NousResearch/hermes-agent.git}"
BRANCH="main"
INSTALL_COMMIT=""
INSTALL_DIR="${HERMES_INSTALL_DIR:-}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
STAGE=""
WANT_MANIFEST=false
JSON=false
NON_INTERACTIVE=false
INCLUDE_DESKTOP=false
while [ $# -gt 0 ]; do
case "$1" in
--branch|-Branch) BRANCH="$2"; shift 2 ;;
--commit|-Commit) INSTALL_COMMIT="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--hermes-home|-HermesHome) HERMES_HOME="$2"; shift 2 ;;
--manifest|-Manifest) WANT_MANIFEST=true; shift ;;
--stage|-Stage) STAGE="$2"; shift 2 ;;
--json|-Json) JSON=true; shift ;;
--non-interactive|-NonInteractive) NON_INTERACTIVE=true; shift ;;
--skip-setup|--skip-browser) NON_INTERACTIVE=true; shift ;;
--include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;;
-h|--help)
echo "Usage: install.sh [--branch NAME] [--commit SHA] [--dir PATH]"
echo " [--hermes-home PATH]"
echo " [--manifest] [--stage NAME] [--json]"
echo " [--non-interactive] [--include-desktop]"
exit 0 ;;
*) echo "unknown option: $1" >&2; exit 1 ;;
esac
done
INSTALL_DIR="${INSTALL_DIR:-$HERMES_HOME/hermes-agent}"
export HERMES_HOME
log() { printf "\033[1;34m[hermes]\033[0m %s\n" "$1"; }
fail() { STAGE_REASON="$1"; printf "\033[1;31m[hermes]\033[0m %s\n" "$1" >&2; exit 1; }
# --- BEGIN GENERATED: bootstrap pins (scripts/gen-bootstrap-pins.py) ---
# Derived from pm/lock.json. DO NOT EDIT BY HAND:
# run scripts/gen-bootstrap-pins.py after a pin bump.
UV_PIN_VERSION="0.12.3"
# Sets UV_PIN_URL + UV_PIN_SHA256 for a <os>-<arch> target key.
uv_bootstrap_pin() {
case "$1" in
linux-x64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-unknown-linux-gnu.tar.gz"
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101"
UV_PIN_SHA256="600cf9a742aca00d292673b16b5acffaa7b8c269a364ad0c2e79498dcb1fe101"
;;
linux-arm64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-unknown-linux-gnu.tar.gz"
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2"
UV_PIN_SHA256="bb66cb52e7b1823aed1183630d8d8e5c958840d584a4c55ec10a4cfc168dcca2"
;;
darwin-x64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-x86_64-apple-darwin.tar.gz"
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b"
UV_PIN_SHA256="4c9f52262a14da336e4a42ed24992d12d0c956acde87619e4611d321dffa602b"
;;
darwin-arm64)
UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.12.3/uv-aarch64-apple-darwin.tar.gz"
UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843"
UV_PIN_SHA256="546f7f8a6c70ff13a3a9d2bc958db3427298cebf3e0cb756f9177133b7068843"
;;
*)
UV_PIN_URL=""
UV_PIN_SHA256=""
return 1
;;
esac
}
# --- END GENERATED: bootstrap pins ---
uv_bootstrap_target() {
# Map this host to a pm/lock.json target key (<os>-<arch>).
local _arch
case "$(uname -m)" in
arm64|aarch64) _arch="arm64" ;;
x86_64|amd64) _arch="x64" ;;
*) return 1 ;;
esac
case "$(uname -s)" in
Linux) echo "linux-$_arch" ;;
Darwin) echo "darwin-$_arch" ;;
*) return 1 ;;
esac
}
# Provision uv for this host from the pinned pm/lock.json artifact. Stages
# the EXACT artifact pm itself uses into the same store slot
# (~/.hermes/tools/uv-<version>-<target>/), sha256-verified, so the byte
# authority is pm/lock.json - no astral-latest, no curl|sh.
UV_CMD=""
ensure_uv() {
[ -n "$UV_CMD" ] && return 0
if command -v uv >/dev/null 2>&1; then
# Developer shortcut: an existing uv on PATH is fine to use; this
# branch fetches nothing.
UV_CMD="uv"
return 0
fi
local _target
if ! _target="$(uv_bootstrap_target)"; then
fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); install uv manually: https://docs.astral.sh/uv/"
fi
if ! uv_bootstrap_pin "$_target"; then
fail "no pinned uv artifact for $_target; install uv manually: https://docs.astral.sh/uv/"
fi
local _store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}"
local _entry="$_store/uv-$UV_PIN_VERSION-$_target"
UV_CMD="$_entry/uv"
if [ ! -x "$UV_CMD" ]; then
log "staging pinned uv $UV_PIN_VERSION ($_target) into the pm store"
local _tmp
_tmp="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-uv-bootstrap.$$")"
mkdir -p "$_tmp"
local _fetched_from="$UV_PIN_URL"
# Only network availability failures permit trying identical mirrored bytes.
if curl -LsSf "$UV_PIN_URL" -o "$_tmp/uv.tar.gz"; then
:
else
local _curl_status=$?
case "$_curl_status" in
5|6|7|18|22|28|52|55|56) ;;
*) rm -rf "$_tmp"; fail "failed to download pinned uv from $UV_PIN_URL (curl $_curl_status)" ;;
esac
if [ -n "${UV_PIN_MIRROR:-}" ] && curl -LsSf "$UV_PIN_MIRROR" -o "$_tmp/uv.tar.gz"; then
_fetched_from="$UV_PIN_MIRROR"
else
rm -rf "$_tmp"
fail "failed to download pinned uv from $UV_PIN_URL or ${UV_PIN_MIRROR:-no mirror}"
fi
fi
local _digest
if command -v sha256sum >/dev/null 2>&1; then
_digest="$(sha256sum "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
else
_digest="$(shasum -a 256 "$_tmp/uv.tar.gz" | cut -d' ' -f1)"
fi
if [ "$_digest" != "$UV_PIN_SHA256" ]; then
rm -rf "$_tmp"
fail "uv download digest mismatch from $_fetched_from (expected $UV_PIN_SHA256, got $_digest)"
fi
if ! tar -xzf "$_tmp/uv.tar.gz" -C "$_tmp"; then
rm -rf "$_tmp"
fail "failed to extract pinned uv archive"
fi
local _unpacked
_unpacked="$(find "$_tmp" -mindepth 1 -maxdepth 2 -name uv -type f | head -n1)"
if [ -z "$_unpacked" ]; then
rm -rf "$_tmp"
fail "uv binary not found in the downloaded archive"
fi
mkdir -p "$_entry"
mv "$_unpacked" "$UV_CMD"
[ -f "$(dirname "$_unpacked")/uvx" ] && mv "$(dirname "$_unpacked")/uvx" "$_entry/uvx"
chmod +x "$UV_CMD"
chmod +x "$_entry/uvx" 2>/dev/null || true
rm -rf "$_tmp"
fi
# Bootstrap keeps the installer private; only UV_CMD invokes it.
if ! "$UV_CMD" --version >/dev/null 2>&1; then
fail "pinned uv staged but does not run on this host"
fi
log "uv ready ($("$UV_CMD" --version 2>/dev/null))"
}
check_platform() {
case "$(uname -s 2>/dev/null)" in
Linux*) : ;;
Darwin*) : ;;
*) fail "unsupported platform: $(uname -s). On Windows use install.ps1." ;;
esac
}
json_string() {
local value="$1" code char escaped
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
for ((code = 1; code < 32; code++)); do
printf -v char '\\%03o' "$code"
printf -v char '%b' "$char"
printf -v escaped '\\u%04x' "$code"
value="${value//"$char"/$escaped}"
done
printf '"%s"' "$value"
}
json_frame() {
# $1 ok, $2 stage, $3 skipped, $4 reason
if [ -n "${4:-}" ]; then
printf '{"ok":%s,"stage":%s,"skipped":%s,"reason":%s}\n' "$1" "$(json_string "$2")" "$3" "$(json_string "$4")"
else
printf '{"ok":%s,"stage":%s,"skipped":%s}\n' "$1" "$(json_string "$2")" "$3"
fi
}
stage_result() {
local code="$1" ok=false reason="${STAGE_REASON:-}"
if [ "$code" -eq 0 ]; then
ok=true
else
reason="${reason:-stage failed (exit $code)}"
fi
if [ "$JSON" = true ]; then
json_frame "$ok" "$STAGE" "${STAGE_SKIPPED:-false}" "$reason"
fi
}
# The single authoritative stage list: emit_manifest prints it AND the
# no-flag ladder runs it, so --include-desktop affects the real run
# exactly as the manifest advertises.
stage_names() {
printf '%s\n' prerequisites repository venv python-deps node-deps path config setup gateway
[ "$INCLUDE_DESKTOP" = true ] && printf '%s\n' desktop
printf '%s\n' complete
}
# "$1" stage name -> its manifest record fields (title|category|needs_user_input).
stage_record() {
case "$1" in
prerequisites) echo "System prerequisites|runtime|false" ;;
repository) echo "Download Hermes Agent|runtime|false" ;;
venv) echo "Create Python environment|runtime|false" ;;
python-deps) echo "Install Python dependencies|runtime|false" ;;
node-deps) echo "Install tool dependencies|runtime|false" ;;
path) echo "Install hermes command|runtime|false" ;;
config) echo "Prepare config and skills|configuration|false" ;;
setup) echo "Configure API keys and settings|configuration|true" ;;
gateway) echo "Configure gateway service|configuration|true" ;;
desktop) echo "Build desktop app|runtime|false" ;;
complete) echo "Finish install|runtime|false" ;;
esac
}
emit_manifest() {
printf '%s' '{"protocol_version":1,"stages":['
_sep=""
for _s in $(stage_names); do
IFS='|' read -r _title _category _needs <<< "$(stage_record "$_s")"
printf '%s{"name":"%s","title":"%s","category":"%s","needs_user_input":%s}' \
"$_sep" "$_s" "$_title" "$_category" "$_needs"
_sep=","
done
printf '%s\n' ']}'
}
stage_prerequisites() {
command -v git >/dev/null 2>&1 || fail "git is required. Install it with your system package manager."
command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager."
log "prerequisites ok (git, curl)"
}
stage_repository() {
if [ -d "$INSTALL_DIR/.git" ]; then
log "updating $INSTALL_DIR"
git -C "$INSTALL_DIR" fetch origin "$BRANCH" || fail "git fetch failed"
git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed"
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH" || log "not fast-forwardable; keeping local state"
else
log "cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
local staged attempt cloned=false
staged="$(mktemp -d "$(dirname "$INSTALL_DIR")/.hermes-clone-XXXXXX")" || fail "cannot stage clone"
for attempt in 1 2 3; do
if git clone --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
cloned=true
break
fi
rm -rf "$staged/tree"
[ "$attempt" = 3 ] || sleep "$((attempt * 5))"
done
if [ "$cloned" = false ]; then
log "direct clone failed; trying deferred blob download"
if git clone --depth 1 --single-branch --filter=blob:none --no-checkout \
--branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
for attempt in 1 2; do
if git -C "$staged/tree" reset --hard HEAD; then
cloned=true
break
fi
[ "$attempt" = 2 ] || sleep 5
done
fi
fi
if [ "$cloned" = false ]; then
rm -rf "$staged"
fail "git clone failed; no checkout published"
fi
if ! mv "$staged/tree" "$INSTALL_DIR"; then
rm -rf "$staged"
fail "cannot publish cloned checkout"
fi
rmdir "$staged"
fi
if [ -n "$INSTALL_COMMIT" ]; then
git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" || fail "could not pin commit $INSTALL_COMMIT"
fi
}
stage_venv() {
# Keep the installer stage protocol; PM alone creates dependency environments.
local boot_py
bootstrap_python
log "bootstrap Python ready; PM prepares the dependency environment"
}
# Tool-only bootstrap: acquire uv and Python before PM's own dependencies exist.
# The application dependency graph is never installed in this interpreter.
bootstrap_python() {
ensure_uv
local _py
# Read packages.python.version by following object names and braces, not
# indentation — same pre-Python reader contract as setup-hermes.sh's pin().
_py="$(awk -F '"' '
/^[[:space:]]*("[^"]+"[[:space:]]*:[[:space:]]*)?\{/ { path[++depth] = $2; next }
/^[[:space:]]*\}[[:space:]]*,?[[:space:]]*$/ { delete path[depth--]; next }
path[2] == "packages" && path[3] == "python" && $2 == "version" && depth == 3 { print $4; exit }
' "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)"
[ -n "$_py" ] || _py="3.14"
"$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed"
boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed"
boot_py="${boot_py%$'\r'}"
}
# uv exits before PM can replace its tool entry. pm.cli then prepares and
# enters its independently locked runtime before mutating application deps.
bootstrap_pm() {
local boot_py
bootstrap_python
log "delegating python + venv + tools to pm (hash-verified via uv.lock)"
(cd "$INSTALL_DIR" && "$boot_py" -m pm.cli install) || fail "pm install failed"
}
stage_python_deps() {
bootstrap_pm
}
stage_node_deps() {
# Tool binaries, node, browsers: pm packages, installed on demand or
# via `hermes pm install`. Nothing to do at bootstrap time.
log "tool dependencies are managed by pm (hermes pm install)"
}
stage_path() {
local link_dir="$HOME/.local/bin"
local boot_py
bootstrap_python
(cd "$INSTALL_DIR" && "$boot_py" -I -X utf8 hermes_cli/_launchers.py "$link_dir") || fail "launcher publication failed"
case ":$PATH:" in
*":$link_dir:"*) : ;;
*) log "add $link_dir to your PATH to use the hermes command" ;;
esac
log "hermes command installed at $link_dir/hermes"
}
stage_config() {
mkdir -p "$HERMES_HOME"/cron "$HERMES_HOME"/sessions "$HERMES_HOME"/logs \
"$HERMES_HOME"/pairing "$HERMES_HOME"/hooks "$HERMES_HOME"/image_cache \
"$HERMES_HOME"/audio_cache "$HERMES_HOME"/memories "$HERMES_HOME"/skills
if [ ! -f "$HERMES_HOME/.env" ]; then
cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env" 2>/dev/null || touch "$HERMES_HOME/.env"
fi
chmod 600 "$HERMES_HOME/.env"
if [ ! -f "$HERMES_HOME/config.yaml" ] && [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
fi
log "config prepared in $HERMES_HOME"
}
stage_setup() {
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
"$INSTALL_DIR/.hermes/bin/hermes" setup || fail "setup failed"
}
stage_gateway() {
if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
"$INSTALL_DIR/.hermes/bin/hermes" gateway install || fail "gateway installation failed"
}
stage_desktop() {
# `hermes desktop --build-only` is the current authority (same path as
# `hermes gui` / the update flow); no installer-local node/electron code.
"$INSTALL_DIR/.hermes/bin/hermes" desktop --build-only || fail "desktop build failed"
}
stage_complete() {
local commit
commit="$INSTALL_COMMIT"
[ -n "$commit" ] || commit=$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null) || commit=""
if [ -n "$commit" ]; then
printf '{\n "schemaVersion": 1,\n "pinnedCommit": "%s",\n "pinnedBranch": "%s",\n "completedAt": "%s"\n}\n' \
"$commit" "$BRANCH" "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" > "$INSTALL_DIR/.hermes-bootstrap-complete.tmp"
mv -f "$INSTALL_DIR/.hermes-bootstrap-complete.tmp" "$INSTALL_DIR/.hermes-bootstrap-complete"
fi
log "install complete. Run: hermes"
}
run_stage() (
# Keep failure handling out of conditional calls, which disable errexit.
set -e
STAGE="$1"
STAGE_REASON=""
STAGE_SKIPPED=false
trap 'stage_result "$?"' EXIT
if [ "$NON_INTERACTIVE" = true ] && { [ "$STAGE" = setup ] || [ "$STAGE" = gateway ]; }; then
STAGE_SKIPPED=true
STAGE_REASON="needs user input"
exit 0
fi
case "$1" in
prerequisites) stage_prerequisites ;;
repository) stage_repository ;;
venv) stage_venv ;;
python-deps) stage_python_deps ;;
node-deps) stage_node_deps ;;
path) stage_path ;;
config) stage_config ;;
setup) stage_setup ;;
gateway) stage_gateway ;;
desktop) stage_desktop ;;
complete) stage_complete ;;
*) STAGE_REASON="unknown stage: $1"; printf '%s\n' "$STAGE_REASON" >&2; exit 2 ;;
esac
)
# Main. Guarded so the script can be SOURCED for its functions (the
# installer-test harness sources it with --manifest, which must define
# the functions and stop before main).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
if [ "$WANT_MANIFEST" = true ]; then
emit_manifest
exit 0
fi
if [ -n "$STAGE" ] && [ "$JSON" = true ]; then
trap 'stage_result "$?"' EXIT
fi
check_platform
trap - EXIT
if [ -n "$STAGE" ]; then
run_stage "$STAGE"
exit "$?"
fi
# No --stage: run the whole ladder — the same authoritative list the
# manifest prints, so --include-desktop inserts desktop here too.
for s in $(stage_names); do
run_stage "$s"
rc=$?
[ "$rc" -eq 0 ] || exit "$rc"
done
fi