#!/usr/bin/env bash
# Hermes Agent bootstrap: clone, acquire uv/Python, then hand the checkout to
# the same completion an update runs -- command publication, product builds and
# post-build maintenance -- so a fresh install and a finished update land in one
# state. Heavy dependencies (tool binaries, browsers, node) are pm's job:
# `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     build the desktop app too (products stage)
#   --verbose             stream every child command's output (the default
#                         off a terminal and in CI)
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=""
# The update channel to install: stable (the latest published release, the default for the
# official repository's main line) or main (every commit). An explicit choice is persisted.
CHANNEL="${HERMES_INSTALL_CHANNEL:-}"
INSTALL_DIR="${HERMES_INSTALL_DIR:-}"
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
STAGE=""
WANT_MANIFEST=false
JSON=false
NON_INTERACTIVE=false
INCLUDE_DESKTOP=false
VERBOSE=false
SKIP_BROWSER=false
SKIP_COMPUTER_USE=false
INSTALL_LADDER=false
INSTALL_STARTED=0
INSTALL_RUN_ID=""
FAILURE_CLASS=""
INSTALL_SIGNAL=""
INSTALL_IN_STAGE=false

while [ $# -gt 0 ]; do
    case "$1" in
        --branch|-Branch|--commit|-Commit|--channel|-Channel|--dir|--hermes-home|-HermesHome|--stage|-Stage)
            option="$1"
            if [ $# -lt 2 ] || [ -z "$2" ] || [[ "$2" == -* ]]; then
                printf '%s needs a value\n' "$option" >&2
                exit 2
            fi
            case "$option" in
                --branch|-Branch) BRANCH="$2" ;;
                --commit|-Commit) INSTALL_COMMIT="$2" ;;
                --channel|-Channel) CHANNEL="$2" ;;
                --dir) INSTALL_DIR="$2" ;;
                --hermes-home|-HermesHome) HERMES_HOME="$2" ;;
                --stage|-Stage) STAGE="$2" ;;
            esac
            shift 2 ;;
        --manifest|-Manifest) WANT_MANIFEST=true; shift ;;
        --json|-Json) JSON=true; shift ;;
        --non-interactive|-NonInteractive) NON_INTERACTIVE=true; shift ;;
        --skip-setup) NON_INTERACTIVE=true; shift ;;
        --skip-browser|--no-playwright|-SkipBrowser) SKIP_BROWSER=true; shift ;;
        --skip-computer-use|-SkipComputerUse) SKIP_COMPUTER_USE=true; shift ;;
        --include-desktop|-IncludeDesktop) INCLUDE_DESKTOP=true; shift ;;
        --verbose|-Verbose) VERBOSE=true; shift ;;
        -h|--help)
            echo "Usage: install.sh [--channel stable|main] [--branch NAME] [--commit SHA]"
            echo "                  [--dir PATH] [--hermes-home PATH]"
            echo "                  [--manifest] [--stage NAME] [--json]"
            echo "                  [--non-interactive] [--include-desktop] [--verbose]"
            echo "                  [--skip-browser] [--skip-computer-use]"
            echo
            echo "  --channel       stable (default): the latest published release, then release"
            echo "                  to release. main: every commit. Also HERMES_INSTALL_CHANNEL;"
            echo "                  an explicit choice is saved like 'hermes update --set-channel'."
            echo "  --skip-browser  Do not install the browser tools (agent-browser + Chromium)."
            echo "                  Alias: --no-playwright. Remembered by later"
            echo "                  installs and 'hermes update'; undo with 'hermes pm install agent-browser'."
            echo "  --skip-computer-use"
            echo "                  Do not install the computer-use driver (cua-driver). Remembered"
            echo "                  the same way; undo with 'hermes pm install cua-driver'."
            exit 0 ;;
        *) echo "unknown option: $1" >&2; exit 1 ;;
    esac
done

case "$CHANNEL" in
    ""|stable|main) ;;
    *) printf -- '--channel must be stable or main, not %s\n' "$CHANNEL" >&2; exit 2 ;;
esac

INSTALL_DIR="${INSTALL_DIR:-$HERMES_HOME/hermes-agent}"
export HERMES_HOME

INSTALL_LOG="$HERMES_HOME/logs/install.log"

# Same glyphs as the pre-pm installer. Colour only on a terminal, so CI
# transcripts and the Hermes-Setup driver read plain text.
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
    C_RED=$'\033[0;31m' C_GREEN=$'\033[0;32m' C_YELLOW=$'\033[0;33m'
    C_CYAN=$'\033[0;36m' C_MAGENTA=$'\033[0;35m' C_BOLD=$'\033[1m'
    C_DIM=$'\033[2m' C_NC=$'\033[0m'
else
    C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_MAGENTA="" C_BOLD="" C_DIM="" C_NC=""
fi

log() { printf '%s→%s %s\n' "$C_CYAN" "$C_NC" "$1"; }
log_success() { printf '%s✓%s %s\n' "$C_GREEN" "$C_NC" "$1"; }
log_warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_NC" "$1"; }
log_error() { printf '%s✗%s %s\n' "$C_RED" "$C_NC" "$1" >&2; }
# $2: closed failure class for the local install receipt (write_install_receipt).
fail() { STAGE_REASON="$1"; FAILURE_CLASS="${2:-other}"; log_error "$1"; exit 1; }
# The last failed run_logged command's own error line (git's "fatal: ..."), so a
# failure reason -- which Hermes-Setup shows as-is -- names the cause, not just the step.
RUN_LOGGED_ERROR=""
native_detail() { [ -z "$RUN_LOGGED_ERROR" ] || printf ': %s' "$RUN_LOGGED_ERROR"; }
# Pick the error line from a command's output on stdin: the first fatal:/error:
# line (git's cause; later ones are generic), else the last non-empty one.
error_line() {
    local line pick="" last=""
    while IFS= read -r line || [ -n "$line" ]; do
        line="${line%$'\r'}"; line="${line##*$'\r'}"
        [ -n "${line//[[:space:]]/}" ] || continue
        last="$line"
        [ -n "$pick" ] || case "$line" in fatal:*|error:*|*" fatal: "*|*" error: "*) pick="$line" ;; esac
    done
    pick="${pick:-$last}"
    printf '%s' "${pick:0:240}"
}

# One local receipt per full-ladder run, under the profile's shared-metrics dir.
# Only closed tokens and two timestamps (never STAGE_REASON, paths or URLs); the
# installer never sends anything. Hermes reports it as hermes.install.run on a
# later start only while shared metrics collection is on, and deletes it
# unreported when collection is off. Best effort: never fails the install.
write_install_receipt() {
    # $1 outcome, $2 failed stage, $3 failure class. One file per run: the first writer wins.
    # A failed write (full disk) removes its partial temp file: the reader never sees dotfiles.
    {
        local dir="$HERMES_HOME/telemetry/shared_metrics/pending_installs" id="$INSTALL_RUN_ID"
        [ -n "$id" ] && [ ! -e "$dir/$id.json" ] && mkdir -p "$dir" && {
            printf '{"id":"%s","installer":"install_sh","outcome":"%s","failed_stage":"%s","failure_class":"%s","started_at":%s,"finished_at":%s}\n' \
                "$id" "$1" "$2" "$3" "$INSTALL_STARTED" "$(date +%s)" > "$dir/.$id.tmp" \
                && mv -f "$dir/.$id.tmp" "$dir/$id.json" \
                || rm -f "$dir/.$id.tmp"
        }
    } >/dev/null 2>&1 || :
}

# Ladder signal handling. The traps only note the signal while a stage owns the
# terminal: bash runs a trap once the foreground stage returns, and that stage's
# result decides. A stage whose child caught Ctrl-C and succeeded (setup's
# provider picker: "Provider setup skipped.") lets the ladder carry on, as bash's
# own wait-and-cooperative-exit did before these traps existed. A stage that
# failed after the signal is recorded as `interrupted`, then the signal is
# re-raised so the exit status is the signal's. A TERM or HUP sent to the
# installer alone (not its process group) takes effect when the running stage
# returns, and the receipt names the stage the run stopped before. The stage
# stays in the foreground because a background stage's children ignore Ctrl-C
# under bash 3.2 (macOS /bin/bash), even after `trap -`.
install_signal() {
    # $1 signal name. Outside a stage (platform check, banner, between stages) stop now.
    INSTALL_SIGNAL="$1"
    [ "$INSTALL_IN_STAGE" = true ] || install_interrupted
}

install_interrupted() {
    # Record the stage the signal cut off, then die by that signal (exit code if it is blocked).
    local stage="${s:-prerequisites}" sig="${INSTALL_SIGNAL:-INT}" code=130
    case "$sig" in HUP) code=129 ;; TERM) code=143 ;; esac
    write_install_receipt failed "${stage//-/_}" interrupted
    trap - INT TERM HUP EXIT
    kill -s "$sig" "$$" 2>/dev/null
    exit "$code"
}

# Inside a ladder stage: a child the signal killed ends the stage like bash's own
# cooperative exit (its status is the signal's, 128+N); a child that caught the
# signal keeps its own result, and the stage's receipt reads `interrupted`.
stage_signal() {
    # $1 status of the command the signal arrived during, $2 this signal's 128+N.
    STAGE_INTERRUPTED=true
    [ "$1" -ne "$2" ] || exit "$2"
}

print_banner() {
    printf '\n%s%s' "$C_MAGENTA" "$C_BOLD"
    printf '%s\n' "┌─────────────────────────────────────────────────────────┐"
    printf '%s\n' "│             ☤ Hermes Agent Installer                    │"
    printf '%s\n' "├─────────────────────────────────────────────────────────┤"
    printf '%s\n' "│  An open source AI agent by Nous Research.              │"
    printf '%s\n' "└─────────────────────────────────────────────────────────┘"
    printf '%s\n' "$C_NC"
}

# Interactive runs collapse child-process output (git, uv, pm, the builds)
# into one status line. CI, --verbose and a non-terminal stdout -- the
# Hermes-Setup --json driver, E2E transcripts -- keep the full stream those
# readers parse.
quiet_output() {
    [ "$VERBOSE" = true ] && return 1
    if [ -n "${CI:-}" ] || [ -n "${GITHUB_ACTIONS:-}" ] || [ -n "${HERMES_INSTALL_VERBOSE:-}" ]; then
        return 1
    fi
    [ -t 1 ]
}

status_line() {
    local text="  $1" width=$(( $2 - 1 ))
    [ "${#text}" -le "$width" ] || text="${text:0:width}"
    printf '\r\033[K%s%s%s' "$C_DIM" "$text" "$C_NC"
}

# run_logged [--may-fail] LABEL CMD...: run CMD. Quiet mode shows LABEL with
# CMD's latest output line rewritten in place, appends everything to
# $INSTALL_LOG and, on failure, prints the tail and the log path; --may-fail
# is for probes whose failure the caller handles (no report). Otherwise LABEL
# is logged and the output streams untouched. Returns CMD's exit status.
run_logged() {
    local may_fail=false
    if [ "$1" = --may-fail ]; then may_fail=true; shift; fi
    local label="$1"; shift
    RUN_LOGGED_ERROR=""
    if ! quiet_output || ! { mkdir -p "${INSTALL_LOG%/*}" && : >> "$INSTALL_LOG"; } 2>/dev/null; then
        log "$label"
        local err rc
        err="$(mktemp 2>/dev/null)" || { "$@"; return; }
        # stderr still streams (Hermes-Setup logs it); a copy names the failure.
        { "$@" 2>&1 1>&3 | tee "$err" >&2; } 3>&1
        rc=${PIPESTATUS[0]}
        [ "$rc" -eq 0 ] || RUN_LOGGED_ERROR="$(error_line < "$err")"
        rm -f "$err"
        return "$rc"
    fi
    local start cols rc line shown
    start=$(( $(wc -l < "$INSTALL_LOG") + 1 ))
    cols="$(tput cols 2>/dev/null)" || cols=80
    [ "${cols:-0}" -gt 20 ] 2>/dev/null || cols=80
    printf '==> %s (%s)\n' "$label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$INSTALL_LOG"
    status_line "$label" "$cols"
    # stdin closed: under `curl | bash` it is the script itself, and nothing
    # run here may prompt behind a status line.
    "$@" </dev/null 2>&1 | {
        while IFS= read -r line || [ -n "$line" ]; do
            line="${line%$'\r'}"
            printf '%s\n' "$line" >&3
            # git and uv redraw progress with bare CRs; show the newest.
            shown="${line##*$'\r'}"
            [ -z "$shown" ] || status_line "$label: $shown" "$cols"
        done
    } 3>>"$INSTALL_LOG"
    rc=${PIPESTATUS[0]}
    printf '\r\033[K'
    [ "$rc" -eq 0 ] || RUN_LOGGED_ERROR="$(tail -n +"$(( start + 1 ))" "$INSTALL_LOG" | error_line)"
    if [ "$rc" -ne 0 ] && [ "$may_fail" = false ]; then
        log_error "$label failed (exit $rc). Last output:"
        tail -n +"$(( start + 1 ))" "$INSTALL_LOG" | tail -n 20 | sed 's/^/    /' >&2
        printf '    full log: %s\n' "$INSTALL_LOG" >&2
    fi
    return "$rc"
}

# --- 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.13.5"

# 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.13.5/uv-x86_64-unknown-linux-gnu.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f"
            UV_PIN_SHA256="f68d291c75783122dad58ab77def0e13f3233f358731db5d6d7e8715603b928f"
            ;;
        linux-arm64)
            UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-unknown-linux-gnu.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403"
            UV_PIN_SHA256="4bb69cf95ed13f23713bb7bc09f241896ea0c75b7c557e615a20604b82f9c403"
            ;;
        linux-x64-musl)
            UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-musl.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/57ef9d9ccb01fb1bd998df995b0dd221b52f7d7841fbea93f2d4463bf61fb7a9"
            UV_PIN_SHA256="57ef9d9ccb01fb1bd998df995b0dd221b52f7d7841fbea93f2d4463bf61fb7a9"
            ;;
        linux-arm64-musl)
            UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-unknown-linux-musl.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/7906c79adb21c81abd559841ba76bb1d9af9d791acac7a041b2972e03ae16538"
            UV_PIN_SHA256="7906c79adb21c81abd559841ba76bb1d9af9d791acac7a041b2972e03ae16538"
            ;;
        darwin-x64)
            UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.13.5/uv-x86_64-apple-darwin.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/b0f52b0e3945b4107d89024ea38e625683011fbfc05a0d98f9fa2d6dcee28c66"
            UV_PIN_SHA256="b0f52b0e3945b4107d89024ea38e625683011fbfc05a0d98f9fa2d6dcee28c66"
            ;;
        darwin-arm64)
            UV_PIN_URL="https://github.com/astral-sh/uv/releases/download/0.13.5/uv-aarch64-apple-darwin.tar.gz"
            UV_PIN_MIRROR="https://hermes-assets.nousresearch.com/upstream/sha256/743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73"
            UV_PIN_SHA256="743f85035a1996ecb350659e01cd9faea9709829be09b1b4b03c73eaf3c9cd73"
            ;;
        *)
            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)
            # Same precedence as pm/store.py::_is_musl_libc: the native
            # userland's ELF interpreter decides; ldd and a musl loader on
            # disk are fallbacks only (a glibc host may carry musl as a
            # secondary toolchain, and minimal musl roots may lack ldd).
            local _libc="" _probe _head
            for _probe in /bin/sh /bin/ls; do
                _head="$(head -c 8192 "$_probe" 2>/dev/null | LC_ALL=C tr -d '\000')" || continue
                [[ "$_head" == $'\x7f'ELF* ]] || continue
                case "$_head" in
                    *ld-musl-*) _libc="musl"; break ;;
                    *ld-linux*) _libc="glibc"; break ;;
                esac
            done
            if [[ -z "$_libc" ]]; then
                _libc="$(ldd --version 2>&1 || true)"
                _libc="${_libc,,}"
            fi
            if [[ "$_libc" == *musl* ]]; then
                echo "linux-$_arch-musl"
            elif [[ "$_libc" == *glibc* || "$_libc" == *"gnu libc"* || "$_libc" == *"gnu c library"* ]]; then
                echo "linux-$_arch"
            elif compgen -G '/lib/ld-musl-*.so.1' >/dev/null; then
                echo "linux-$_arch-musl"
            else
                echo "linux-$_arch"
            fi
            ;;
        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
# (<store>/uv-<version>-<target>/, the store pm's store_root() resolves),
# 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
    # Always the pinned artifact, never a uv already on PATH: Hermes runs only
    # its own packaged toolchain.
    local _target
    if ! _target="$(uv_bootstrap_target)"; then
        fail "no pinned uv build for this platform ($(uname -s) $(uname -m)); Hermes does not support this host" unsupported_platform
    fi
    if ! uv_bootstrap_pin "$_target"; then
        fail "no pinned uv artifact for $_target; Hermes does not support this host" unsupported_platform
    fi
    local _store="${HERMES_RUNTIME_DIR:-$HERMES_HOME/tools}"
    local _entry="$_store/uv-$UV_PIN_VERSION-$_target"
    UV_CMD="$_entry/uv"
    if [ ! -x "$UV_CMD" ]; then
        log "Downloading uv $UV_PIN_VERSION ($_target)"
        local _tmp
        # no-tmp: ok — last-resort fallback when mktemp itself is missing
        _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)" download_failed ;;
            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}" download_failed
            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)" download_digest_mismatch
        fi
        if ! tar -xzf "$_tmp/uv.tar.gz" -C "$_tmp"; then
            rm -rf "$_tmp"
            fail "failed to extract pinned uv archive" uv_unusable
        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" uv_unusable
        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" uv_unusable
    fi
    log_success "uv ready ($("$UV_CMD" --version 2>/dev/null))"
}

check_platform() {
    # Termux is Linux by uname, but this installer builds a glibc source
    # install the phone cannot run (no Android wheels in the lock). The
    # signed APT package is the only supported shape there.
    if [ -n "${TERMUX_VERSION:-}" ] || case "${PREFIX:-}" in *com.termux/files/usr*) true ;; *) false ;; esac; then
        fail "Termux is installed from its APT repository, not install.sh: pkg install hermes-agent (setup: https://hermes-agent.nousresearch.com/docs/getting-started/termux)" unsupported_platform
    fi
    case "$(uname -s 2>/dev/null)" in
        Linux*) : ;;
        Darwin*) : ;;
        *) fail "unsupported platform: $(uname -s). On Windows use install.ps1." unsupported_platform ;;
    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
    if [ "$ok" = false ] && [ "${INSTALL_LADDER:-false}" = true ]; then
        local class="${FAILURE_CLASS:-other}"
        # A user abort beats the stage's own class: a child that caught Ctrl-C and exited 1
        # (pm, source_completion, `hermes setup`) still reaches the stage's `|| fail ... <class>`.
        case "$code" in 129|130|143) class=interrupted ;; esac
        [ "${STAGE_INTERRUPTED:-false}" = false ] || class=interrupted
        write_install_receipt failed "${STAGE//-/_}" "$class"
    fi
}

# The single authoritative stage list: emit_manifest prints it AND the
# no-flag ladder runs it. `products` is the shared completion tail -- the same
# call `hermes update` makes -- so the manifest and the run cannot disagree.
# `desktop` stays directly dispatchable via --stage for external callers, but
# is never listed: --include-desktop selects the desktop product inside
# `products` instead of adding a second build stage.
stage_names() {
    printf '%s\n' prerequisites repository venv python-deps config products setup gateway complete
}

# "title|category|needs_user_input".
products_record() {
    if [ "$INCLUDE_DESKTOP" = true ]; then
        echo "Install command and app + desktop|runtime|false"
    else
        echo "Install command and app|runtime|false"
    fi
}

# "$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" ;;
        config)        echo "Prepare config and skills|configuration|false" ;;
        products)      products_record ;;
        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' ']}'
}

git_runs() { command -v git >/dev/null 2>&1 && git --version >/dev/null 2>&1; }

# macOS ships /usr/bin/git as a stub until Apple's Command Line Tools are
# installed: it is on PATH, but every call fails ("see man xcode-select").
# Prefer Homebrew (headless); else open Apple's install dialog and wait for the
# user to finish it, so the install continues in this run.
install_macos_git() {
    # Developer tools present with their git (e.g. an Xcode license never
    # accepted, or a broken ~/.gitconfig): installing another git won't help,
    # so let the caller report git's own error. A folder alone is not enough:
    # a macOS upgrade can leave it behind empty.
    local _dev
    _dev="$(xcode-select -p 2>/dev/null)" || _dev=""
    [ -n "$_dev" ] && [ -x "$_dev/usr/bin/git" ] && return 0
    if command -v brew >/dev/null 2>&1; then
        # Auto-update runs git (the broken stub here) and can take minutes.
        run_logged --may-fail "Installing git with Homebrew" env HOMEBREW_NO_AUTO_UPDATE=1 brew install git || true
        hash -r
        git_runs && return 0
    fi
    # Opens Apple's install dialog. With a leftover folder it may refuse
    # instead ("already installed"); no dialog opens, so don't wait for one.
    local _xs
    if ! _xs="$(xcode-select --install 2>&1)"; then
        if [ -n "$_dev" ] && [ -d "$_dev" ]; then
            fail "The developer tools at $_dev are incomplete and xcode-select --install declined to reinstall them. Reinstall the Command Line Tools (run: sudo rm -rf /Library/Developer/CommandLineTools, then xcode-select --install), then re-run this installer." git_missing
        fi
        # Say why (e.g. no GUI session over SSH) before any wait.
        log_warn "xcode-select --install: $(printf '%s\n' "$_xs" | error_line)"
    fi
    local _remedy="Install Apple's Command Line Tools (run: xcode-select --install), then re-run this installer."
    # Hermes-Setup drives stages with --json: no stage timeout, a Cancel
    # button, and these log lines show as progress, so it waits too.
    if [ "$JSON" != true ] && { [ "$NON_INTERACTIVE" = true ] || ! has_terminal; }; then
        fail "git needs Apple's Command Line Tools. $_remedy" git_missing
    fi
    log "git needs Apple's Command Line Tools: click Install in the macOS dialog and accept the license"
    local _waited=0
    while [ "$_waited" -lt 900 ]; do
        git_runs && return 0
        sleep 5
        _waited=$((_waited + 5))
        [ $((_waited % 60)) -ne 0 ] || log "Still waiting for the Command Line Tools install ($((_waited / 60))m)"
    done
    fail "Command Line Tools did not finish within 15 minutes. $_remedy" git_missing
}

stage_prerequisites() {
    # No git on PATH at all is not the stub: report "git is required" below.
    if [ "$(uname -s 2>/dev/null)" = Darwin ] && command -v git >/dev/null 2>&1 && ! git_runs; then
        install_macos_git
    fi
    command -v git >/dev/null 2>&1 || fail "git is required. Install it with your system package manager." git_missing
    local _git_err
    if ! _git_err="$(git --version 2>&1)"; then
        fail "git is installed but cannot run: $(printf '%s\n' "$_git_err" | error_line)" git_missing
    fi
    command -v curl >/dev/null 2>&1 || fail "curl is required. Install it with your system package manager." curl_missing
    # PM's Node on musl is the unofficial-builds musl archive, which links the
    # system libstdc++; without it every node/npm stage fails verification.
    local _target
    _target="$(uv_bootstrap_target 2>/dev/null)" || _target=""
    if [[ "$_target" == *-musl ]]; then
        local _libdir _stdcxx=""
        for _libdir in /lib /usr/lib /usr/local/lib; do
            compgen -G "$_libdir/libstdc++.so.6*" >/dev/null && { _stdcxx=yes; break; }
        done
        [ -n "$_stdcxx" ] || fail "musl host: the Node.js runtime needs the system libstdc++. Install it (Alpine: apk add libstdc++, Void: xbps-install libstdc++) and re-run." libstdcxx_missing
    fi
    # glibc Node links libatomic.so.1, absent on minimal Debian/RHEL hosts. PM
    # installs the distro package with `sudo -n` under its install lock, so
    # cache sudo credentials now, while the terminal can answer the prompt.
    if [ "$NON_INTERACTIVE" != true ] && [[ "$_target" == linux-* && "$_target" != *-musl ]] \
        && [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1 && has_terminal \
        && ! { ldconfig -p 2>/dev/null || /sbin/ldconfig -p 2>/dev/null; } | grep -q 'libatomic\.so\.1'; then
        log "Node.js needs libatomic.so.1; sudo may ask for your password to install it"
        sudo -v </dev/tty || log_warn "sudo failed; if Node.js fails, install libatomic (Debian/Ubuntu: libatomic1) and re-run"
    fi
    log_success "prerequisites ok (git, curl)"
}

# The update channel this install chose (config.yaml update.installs.<id>.channel,
# id = sha16 of the canonical install path, as hermes_cli.update_channel keys it).
# Pre-Python reader of the block YAML Hermes writes; anything it cannot read prints
# nothing, which keeps the plain branch behaviour.
chosen_channel() {
    local config="$HERMES_HOME/config.yaml" root id
    [ -f "$config" ] || return 0
    root="$(cd "$INSTALL_DIR" 2>/dev/null && pwd -P)" || return 0
    if command -v sha256sum >/dev/null 2>&1; then id="$(printf '%s' "$root" | sha256sum)"
    elif command -v shasum >/dev/null 2>&1; then id="$(printf '%s' "$root" | shasum -a 256)"
    else return 0; fi
    awk -v id="${id:0:16}" -v q="'" '
        function bare(s,  f) {
            sub(/^[ \t]+/, "", s); sub(/[ \t]+#.*$/, "", s); sub(/[ \t\r]+$/, "", s)
            f = substr(s, 1, 1)
            if (length(s) > 1 && (f == "\"" || f == q) && substr(s, length(s), 1) == f) s = substr(s, 2, length(s) - 2)
            return s
        }
        /^[ \t]*(#|\r?$)/ { next }
        {
            match($0, /^ */); indent = RLENGTH; line = substr($0, indent + 1)
            colon = index(line, ":"); if (!colon) next
            while (depth && at[depth] >= indent) depth--
            at[++depth] = indent; key[depth] = bare(substr(line, 1, colon - 1))
            if (depth == 4 && key[1] == "update" && key[2] == "installs" && key[3] == id \
                && key[4] == "channel") { print bare(substr(line, colon + 1)); exit }
        }' "$config" | grep -E '^[a-z0-9]+(-[a-z0-9]+)*$' || :
}

# Forks and mirrors publish no official releases; they keep following their branch, as
# hermes_cli.update_channel.default_channel decides.
is_official_repo() {
    printf '%s' "$1" | tr '[:upper:]' '[:lower:]' \
        | grep -Eq '^(https://github\.com/|git@github\.com:|ssh://git@github\.com/)nousresearch/hermes-agent(\.git)?/?$'
}

# The latest published stable release (vX.Y.Z), the release `hermes update` resolves for the
# stable channel. github.com's /releases/latest redirect answers when the API quota is spent, and
# origin's highest vX.Y.Z tag (final tags are pushed by the publication run) when github.com's web
# endpoints are unreachable but git is not. $1 is the origin URL. Prints nothing when none answers.
latest_stable_release() {
    local tag
    tag="$(curl -fsSL --connect-timeout 5 --max-time 20 -H 'Accept: application/vnd.github+json' \
        https://api.github.com/repos/NousResearch/hermes-agent/releases/latest 2>/dev/null \
        | sed -n 's/^[[:space:]]*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)"
    if ! valid_stable_tag "$tag"; then
        tag="$(curl -fsSI --connect-timeout 5 --max-time 20 \
            https://github.com/NousResearch/hermes-agent/releases/latest 2>/dev/null \
            | tr -d '\r' | sed -n 's#^[Ll]ocation:.*/releases/tag/\([^/]*\)$#\1#p' | tail -n 1)"
    fi
    if ! valid_stable_tag "$tag"; then
        tag="$(git ls-remote --tags --refs "$1" 'refs/tags/v*' 2>/dev/null | sed 's#.*refs/tags/##' \
            | grep -E '^v(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' | sort -V | tail -n 1)"
    fi
    if valid_stable_tag "$tag"; then printf '%s' "$tag"; fi
}
# Same shape as hermes_cli.update_channel.STABLE_TAG_RE.
valid_stable_tag() {
    printf '%s' "$1" | grep -Eq '^v(0|[1-9][0-9]{0,2})\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
}

# The stable release this run installs, nothing to follow the branch, or "-" when stable applies
# but no release could be looked up: an explicit --channel, else stable for the official
# repository's main line. $1 is the origin URL.
stable_target() {
    [ -z "$INSTALL_COMMIT" ] || return 0
    case "$CHANNEL" in
        main) return 0 ;;
        stable) ;;
        *) [ "$BRANCH" = main ] && is_official_repo "$1" || return 0 ;;
    esac
    local release
    release="$(latest_stable_release "$1")"
    printf '%s' "${release:--}"
}

# Fetch tag $release into checkout $1 (releases can be cut off main, on release/* branches).
fetch_release() {
    run_logged --may-fail "Fetching $release" git -C "$1" fetch origin "+refs/tags/$release:refs/tags/$release"
}

# Stable applies but cannot be reached. A fresh install stops (installing main instead would be
# a channel nobody chose); a rerun keeps its checkout for `hermes update`, unless stable was
# asked for by name. $1 says what failed.
stable_unreachable() {
    if [ -d "$INSTALL_DIR/.git" ] && [ "$CHANNEL" != stable ]; then
        log_warn "$1; keeping the checkout where it is"
        return 0
    fi
    fail "$1$(native_detail). Rerun the installer to retry, or pass --channel main to install main's tip" git_fetch_failed
}

stage_repository() {
    local pinned=false channel="" release="" origin
    # An interrupted clone from an older installer can leave a .git with no
    # initial commit, where stash/checkout abort ("You do not have the
    # initial commit yet", #40998); a killed update or a sync tool can leave
    # one git rejects outright (empty HEAD, a gitdir: file pointing nowhere),
    # where `hermes update` cannot fetch. Move it aside -- never delete it, it
    # may hold something the user wants -- and clone fresh below.
    if [ -e "$INSTALL_DIR/.git" ] && ! git -C "$INSTALL_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
        local broken
        broken="${INSTALL_DIR}.broken-$(date -u +%Y%m%d-%H%M%S)"
        log_warn "$INSTALL_DIR is not a usable git checkout (interrupted clone or damaged .git); moving it aside to $broken"
        mv "$INSTALL_DIR" "$broken" || fail "cannot move $INSTALL_DIR aside" filesystem_error
    fi
    if [ -d "$INSTALL_DIR/.git" ]; then
        # A chosen channel (stable, a preview) owns where this checkout sits: `hermes update`
        # moves it. Switching it to the installer's branch would leave it on main while its
        # channel still says otherwise, and the next check would offer the release as an update.
        # Only an explicit --commit pin outranks the choice.
        # An explicit --channel is a new choice and replaces the recorded one.
        [ -n "$INSTALL_COMMIT" ] || [ -n "$CHANNEL" ] || channel="$(chosen_channel)"
        # An explicit HERMES_REPO_URL names the source for reruns too, not
        # just the first clone.
        if [ -n "${HERMES_REPO_URL:-}" ]; then
            git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL" || fail "cannot point origin at $REPO_URL" git_fetch_failed
        fi
        # No recorded choice: an official checkout tracks stable, as `hermes update` does.
        if [ -z "$channel" ]; then
            # The configured URL, as hermes_cli.update_channel reads it (`remote get-url`
            # applies insteadOf rewrites).
            origin="$(git -C "$INSTALL_DIR" config --get remote.origin.url 2>/dev/null)" || origin=""
            release="$(stable_target "$origin")"
        fi
        [ "$channel" != main ] || channel=""
        if [ "$release" = - ]; then
            stable_unreachable "could not look up the latest stable release"
            channel=stable release=""
        elif [ -n "$channel" ]; then
            log "Repairing $INSTALL_DIR on the $channel update channel it chose"
        elif [ -n "$release" ]; then
            log "Updating $INSTALL_DIR to the latest stable release ($release)"
        else
            log "Updating $INSTALL_DIR ($BRANCH)"
        fi
        # Explicit refspec: a tag-pinned --single-branch checkout from an older
        # installer maps only the tag, so a by-name fetch writes FETCH_HEAD and
        # never the origin/$BRANCH everything below resolves (#125112).
        # git 2.53+ aborts fetches into a partial clone whose packs lack a .promisor
        # marker (#124272), and an install stuck there never fetches the updater that
        # heals it. Marking is idempotent and never rewrites objects.
        if [ "$(git -C "$INSTALL_DIR" config --bool --get remote.origin.promisor)" = true ]; then
            local pack
            for pack in "$INSTALL_DIR"/.git/objects/pack/pack-*.pack; do
                if [ -f "$pack" ] && [ ! -e "${pack%.pack}.promisor" ]; then
                    : > "${pack%.pack}.promisor" || log_warn "could not mark $pack as a partial-clone pack"
                fi
            done
            # Existing treeless checkout: same commit-graph lazy-fetch loop guard (#127711).
            git -C "$INSTALL_DIR" config maintenance.commit-graph.enabled false \
                || log_warn "could not disable maintenance.commit-graph.enabled in $INSTALL_DIR"
            git -C "$INSTALL_DIR" config gc.writeCommitGraph false \
                || log_warn "could not disable gc.writeCommitGraph in $INSTALL_DIR"
            git -C "$INSTALL_DIR" config fetch.writeCommitGraph false \
                || log_warn "could not disable fetch.writeCommitGraph in $INSTALL_DIR"
            # A treeless (tree:0) checkout from a late-September installer downloads whole
            # directory snapshots again on every history walk (#129514). Fetch its trees once; the
            # new filter is recorded only after that succeeds, so `hermes update` retries otherwise.
            if [ "$(git -C "$INSTALL_DIR" config --get remote.origin.partialclonefilter)" = tree:0 ]; then
                if run_logged --may-fail "Fetching directory history once (treeless checkout)" \
                    git -C "$INSTALL_DIR" -c gc.auto=0 -c maintenance.auto=false fetch --refetch \
                    --filter=blob:none origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH"; then
                    git -C "$INSTALL_DIR" config remote.origin.partialclonefilter blob:none \
                        || log_warn "could not record the blobless filter in $INSTALL_DIR"
                else
                    log_warn "could not fetch the directory history; the next hermes update retries it"
                fi
            fi
        fi
        if [ -n "$channel" ]; then
            log "Keeping its checkout: \`hermes update\` moves it (\`hermes update --set-channel main\` follows main)"
            return 0
        fi
        if [ -n "$release" ] && ! fetch_release "$INSTALL_DIR"; then
            stable_unreachable "could not fetch $release"
            return 0
        fi
        if [ -n "$release" ]; then
            # A rerun only moves a checkout forward onto the release. Any other move (a main
            # checkout diverged from a release cut on release/*, one already past it, a shallow
            # one that cannot tell) is `hermes update`'s: it refuses a release that predates the
            # session stores this checkout wrote (update_downgrade_guard).
            if git -C "$INSTALL_DIR" merge-base --is-ancestor "$release" HEAD 2>/dev/null; then
                log "Keeping its checkout: it already has $release (\`hermes update\` moves it to the next release)"
                return 0
            fi
            if ! git -C "$INSTALL_DIR" merge-base --is-ancestor HEAD "$release" 2>/dev/null; then
                log "Keeping its checkout: $release does not descend from it (\`hermes update\` decides that move)"
                return 0
            fi
        else
            run_logged "Fetching origin/$BRANCH" git -C "$INSTALL_DIR" fetch origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" \
                || fail "git fetch failed$(native_detail)" git_fetch_failed
        fi
        local stamp
        stamp="$(date -u +%Y%m%d-%H%M%S)"
        # Park local work BEFORE switching branches: checkout refuses a dirty
        # tree that conflicts, and the reset below would discard it. Work
        # that cannot be parked stops the install -- never overwrite it.
        if [ -n "$(git -C "$INSTALL_DIR" status --porcelain)" ]; then
            # An interrupted update can leave unmerged index entries, where
            # stash aborts ("could not write index"). Dropping only the
            # index-level conflict state keeps the working-tree changes for
            # the stash below (#4735).
            if [ -n "$(git -C "$INSTALL_DIR" ls-files --unmerged)" ]; then
                log_warn "clearing unmerged index entries from a previous conflict"
                git -C "$INSTALL_DIR" reset -q || fail "cannot clear the unmerged index in $INSTALL_DIR" local_changes_blocked
            fi
            run_logged "Stashing local changes" \
                git -C "$INSTALL_DIR" stash push --include-untracked -m "hermes-install-autostash-$stamp" \
                || fail "could not stash local changes in $INSTALL_DIR; commit or move them aside, then rerun" local_changes_blocked
            log_warn "local changes stashed as hermes-install-autostash-$stamp"
        fi
        if [ -n "$release" ]; then
            # Detached at the release commit, the way `hermes update` places a stable checkout;
            # branches (and any commits on them) stay where they are.
            run_logged "Checking out $release" git -C "$INSTALL_DIR" checkout --detach "$release" \
                || fail "git checkout failed$(native_detail)" git_checkout_failed
            return 0
        fi
        # checkout's branch guess only sees remote refs the refspec maps, so a
        # narrow checkout (detached at its tag, no local branch) gets the branch
        # created at the fetched tip.
        if git -C "$INSTALL_DIR" show-ref --verify --quiet "refs/heads/$BRANCH"; then
            run_logged "Checking out $BRANCH" git -C "$INSTALL_DIR" checkout "$BRANCH" || fail "git checkout failed$(native_detail)" git_checkout_failed
        else
            run_logged "Checking out $BRANCH" git -C "$INSTALL_DIR" checkout -b "$BRANCH" "origin/$BRANCH" \
                || fail "git checkout failed$(native_detail)" git_checkout_failed
        fi
        if ! run_logged --may-fail "Fast-forwarding to origin/$BRANCH" \
            git -C "$INSTALL_DIR" merge --ff-only "origin/$BRANCH"; then
            # A release cut off the main line, a force-pushed remote, or the
            # user's own commits cannot fast-forward. Every stage below reads
            # files only the new tree has (pm/), so an install left on the old
            # tree cannot finish -- match the remote the way `hermes update`
            # does, after parking the old tip.
            # Only commits absent from origin need a rescue ref. Keep the same
            # namespace as `hermes update` so its pruning and recovery work.
            local dropped rescue_kind rescue_ref prior
            dropped="$(git -C "$INSTALL_DIR" rev-list --count "origin/$BRANCH..HEAD")" \
                || fail "cannot count commits before reset" git_reset_failed
            if [ "$dropped" -gt 0 ]; then
                rescue_kind="diverged"
                git -C "$INSTALL_DIR" merge-base HEAD "origin/$BRANCH" >/dev/null 2>&1 \
                    || rescue_kind="orphan"
                prior="$(git -C "$INSTALL_DIR" rev-parse --short=12 HEAD)" \
                    || fail "cannot identify commits before reset" git_reset_failed
                rescue_ref="refs/hermes-update-backups/$rescue_kind-$BRANCH-$stamp-$prior"
                git -C "$INSTALL_DIR" update-ref "$rescue_ref" HEAD \
                    || fail "cannot back up $dropped local commit(s); refusing to reset" git_reset_failed
                log_warn "$dropped commit(s) not on origin/$BRANCH backed up to $rescue_ref"
                log "List them with: git -C \"$INSTALL_DIR\" log origin/$BRANCH..$rescue_ref"
            fi
            run_logged "Resetting to origin/$BRANCH" git -C "$INSTALL_DIR" reset --hard "origin/$BRANCH" \
                || fail "git reset failed$(native_detail)" git_reset_failed
            log_warn "not fast-forwardable; reset to origin/$BRANCH"
        fi
    else
        # `mv <clone> <existing dir>` nests the checkout INSIDE it as
        # <dir>/tree, so a pre-existing destination must be empty (we take
        # the empty dir over) or we refuse: whatever lives there is not ours.
        if [ -e "$INSTALL_DIR" ] || [ -L "$INSTALL_DIR" ]; then
            if [ -d "$INSTALL_DIR" ] && [ ! -L "$INSTALL_DIR" ] && [ -z "$(ls -A "$INSTALL_DIR")" ]; then
                rmdir "$INSTALL_DIR" || fail "cannot replace empty $INSTALL_DIR" filesystem_error
            else
                fail "$INSTALL_DIR exists and is not a Hermes git checkout. Move it aside, or install elsewhere with --dir <path>." dir_not_checkout
            fi
        fi
        mkdir -p "$(dirname "$INSTALL_DIR")"
        local staged attempt label cloned=false progress=() materialize=(reset --hard HEAD) no_checkout=""
        release="$(stable_target "$REPO_URL")"
        [ "$release" != - ] || stable_unreachable "could not look up the latest stable release"
        [ -z "$INSTALL_COMMIT$release" ] || no_checkout=--no-checkout
        [ -z "$release" ] || log "Installing the latest stable release ($release); --channel main follows every commit"
        # Phase lines ("Receiving objects: 42%") feed the status line; git
        # prints none to a pipe unless asked.
        if quiet_output; then progress=(--progress); fi
        staged="$(mktemp -d "$(dirname "$INSTALL_DIR")/.hermes-clone-XXXXXX")" || fail "cannot stage clone" filesystem_error
        for attempt in 1 2 3; do
            # Blobless: every commit, tree and release tag (runtime identity is
            # the nearest reachable release; --commit pins and branch switches
            # still resolve), file contents fetched on demand. Not treeless:
            # a long-lived treeless checkout re-downloads whole trees on every
            # checkout and history walk (#129712).
            label="Cloning $REPO_URL ($BRANCH) into $INSTALL_DIR"
            [ "$attempt" = 1 ] || label="$label (attempt $attempt of 3)"
            # A pinned install materializes only the pin (below): checking out the
            # branch tip first fetches and rewrites every file the two differ in.
            if run_logged "$label" git clone ${progress[@]+"${progress[@]}"} \
                --filter=blob:none ${no_checkout:+"$no_checkout"} --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
            # The checkout step is where throttled downloads die: clone the
            # graph alone, then retry materializing the tree separately.
            log_warn "direct clone failed; trying deferred checkout"
            if run_logged "Cloning history" git clone ${progress[@]+"${progress[@]}"} \
                --filter=blob:none --no-checkout --branch "$BRANCH" "$REPO_URL" "$staged/tree"; then
                cloned=deferred
            fi
        fi
        if [ "$cloned" = false ]; then
            rm -rf "$staged"
            fail "git clone failed; no checkout published$(native_detail)" git_clone_failed
        fi
        if [ -n "$INSTALL_COMMIT" ]; then
            # Pin before publishing: a pinned or deferred clone has no files yet,
            # and an unpublished tree never leaves an empty checkout behind.
            git -C "$staged/tree" merge-base --is-ancestor "$INSTALL_COMMIT" "origin/$BRANCH" 2>/dev/null \
                || { rm -rf "$staged"; fail "commit $INSTALL_COMMIT is not on branch $BRANCH" commit_not_on_branch; }
        fi
        if [ -n "$release" ]; then
            fetch_release "$staged/tree" || { rm -rf "$staged"; stable_unreachable "could not fetch $release"; }
            materialize=(checkout --force --detach "$release")
        fi
        if [ -n "$INSTALL_COMMIT$release" ] || [ "$cloned" = deferred ]; then
            # The checkout step is where throttled downloads die: retry it alone.
            # A pin detaches HEAD and leaves the branch at its tip, as the published pin did.
            [ -z "$INSTALL_COMMIT" ] || materialize=(checkout --force --detach "$INSTALL_COMMIT")
            cloned=false
            for attempt in 1 2; do
                if run_logged "Checking out ${INSTALL_COMMIT:-${release:-files}} (attempt $attempt of 2)" \
                    git -C "$staged/tree" "${materialize[@]}"; then
                    cloned=true
                    [ -z "$INSTALL_COMMIT" ] || pinned=true
                    break
                fi
                [ "$attempt" = 2 ] || sleep 5
            done
            if [ "$cloned" = false ]; then
                rm -rf "$staged"
                fail "could not check out ${INSTALL_COMMIT:-the cloned tree}; no checkout published$(native_detail)" git_checkout_failed
            fi
        fi
        if ! mv "$staged/tree" "$INSTALL_DIR"; then
            rm -rf "$staged"
            fail "cannot publish cloned checkout" filesystem_error
        fi
        rmdir "$staged"
        # A treeless checkout must never write a commit-graph: over a graph with
        # changed-path data that lazy-fetches the trees of every unseen commit, in a
        # loop (#127711). gc.auto stays on: `hermes update` folds lazy-fetch packs with
        # `gc --auto`.
        git -C "$INSTALL_DIR" config maintenance.commit-graph.enabled false \
            || log_warn "could not disable maintenance.commit-graph.enabled in $INSTALL_DIR"
        git -C "$INSTALL_DIR" config gc.writeCommitGraph false \
            || log_warn "could not disable gc.writeCommitGraph in $INSTALL_DIR"
        git -C "$INSTALL_DIR" config fetch.writeCommitGraph false \
            || log_warn "could not disable fetch.writeCommitGraph in $INSTALL_DIR"
        log_success "Hermes Agent cloned"
    fi
    if [ -n "$INSTALL_COMMIT" ] && [ "$pinned" = false ]; then
        # A rerun over an existing checkout pins here (a fresh clone pinned
        # before publishing). A pin must come from the branch being installed:
        # the complete marker records both, and a commit off that branch would
        # make the next plain rerun "update" onto a different line.
        git -C "$INSTALL_DIR" merge-base --is-ancestor "$INSTALL_COMMIT" "origin/$BRANCH" 2>/dev/null \
            || fail "commit $INSTALL_COMMIT is not on branch $BRANCH" commit_not_on_branch
        run_logged "Pinning $INSTALL_COMMIT" git -C "$INSTALL_DIR" checkout "$INSTALL_COMMIT" \
            || fail "could not pin commit $INSTALL_COMMIT$(native_detail)" git_checkout_failed
    fi
}

stage_venv() {
    # Keep the installer stage protocol; PM alone creates dependency environments.
    local boot_py
    bootstrap_python
    log_success "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"
    # Only base interpreters qualify: an activated app venv must not become
    # PM's bootstrap parent. Prefer the existing managed Python, then a host
    # Python of the same supported minor before attempting a download (#10778).
    # This interpreter only boots PM; PM still owns the exact runtime pin.
    if ! boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py" 2>/dev/null)" \
        && ! boot_py="$("$UV_CMD" python find --system --no-project "$_py" 2>/dev/null)"; then
        run_logged "Downloading Python $_py" "$UV_CMD" python install --no-bin --no-registry "$_py" \
            || fail "bootstrap Python installation failed" python_install_failed
        boot_py="$(UV_SYSTEM_PYTHON=1 UV_NO_PROJECT=1 "$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" python_install_failed
    fi
    boot_py="${boot_py%$'\r'}"
    [ -x "$boot_py" ] && "$boot_py" --version >/dev/null 2>&1 || fail "bootstrap Python is not executable: $boot_py" python_install_failed
}

# 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
    local pm_args=(install)
    # PM records the opt-outs, so later installs and `hermes update` keep the
    # tools off until `hermes pm install <name>` opts back in.
    [ "$SKIP_BROWSER" = true ] && pm_args+=(--without agent-browser)
    [ "$SKIP_COMPUTER_USE" = true ] && pm_args+=(--without cua-driver)
    bootstrap_python
    # Refresh the sudo ticket cached in prerequisites (never prompts): the
    # clone and downloads can outlast sudo's timestamp_timeout.
    if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then sudo -n -v 2>/dev/null || :; fi
    (cd "$INSTALL_DIR" && run_logged "Installing dependencies (hash-verified via uv.lock)" \
        "$boot_py" -m pm.cli "${pm_args[@]}") \
        || fail "pm install failed" deps_install_failed
    log_success "dependencies installed"
}

stage_python_deps() {
    bootstrap_pm
}

desktop_product_present() {
    # Does this checkout already carry a built desktop app? A plain repair or
    # upgrade rerun on a desktop install must REBUILD it rather than leave a
    # bundle built from the previous code: the app is part of that install, and
    # the artifacts live inside the tree (gitignored), so an update makes them
    # stale instead of removing them.
    # electron-builder suffixes the output dir with the arch on every non-x64
    # target (linux-arm64-unpacked, mac-arm64, win-arm64-unpacked), so the x64
    # names alone miss a desktop build on ARM64 Linux/Windows (#94703).
    local release="$INSTALL_DIR/apps/desktop/release" dir
    for dir in linux-unpacked linux-arm64-unpacked mac mac-arm64 \
               win-unpacked win-ia32-unpacked win-arm64-unpacked; do
        [ -d "$release/$dir" ] && return 0
    done
    return 1
}

# Guarded: a login shell whose ~/.bash_profile sources ~/.bashrc (Fedora, RHEL)
# reads both files, so an unconditional prepend would put ~/.local/bin on PATH twice.
SHELL_PATH_LINE='case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; esac'
SHELL_PATH_SETUP_RE='^[[:space:]]*([^#[:space:]].*)?PATH=.*\.local/bin'

append_shell_path() {
    local rc="$1" line="$2" pattern="$3"
    # Existing user PATH setup wins; never append another line on a repair run.
    if [ -f "$rc" ] && grep -E "$pattern" "$rc" >/dev/null 2>&1; then
        return 0
    fi
    mkdir -p "$(dirname "$rc")"
    printf '\n# Hermes Agent command\n%s\n' "$line" >> "$rc" || fail "cannot update PATH in $rc" filesystem_error
    log_success "added ~/.local/bin to PATH in $rc"
}

wire_shell_path() {
    # The launcher is published by source_completion; shell rc files belong to
    # the installer, not to PM (updates must not modify a user's shell setup).
    # Never use the installer's inherited PATH as a proxy for a *new* shell.
    local login_shell="${SHELL:-/bin/bash}"
    case "${login_shell##*/}" in
        zsh)
            append_shell_path "$HOME/.zshrc" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
            append_shell_path "$HOME/.zprofile" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
            ;;
        fish)
            append_shell_path "$HOME/.config/fish/config.fish" 'fish_add_path "$HOME/.local/bin"' '^[[:space:]]*fish_add_path.*\.local/bin'
            ;;
        *)
            append_shell_path "$HOME/.bashrc" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
            append_shell_path "$HOME/.profile" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
            # A login bash reads only the first of .bash_profile, .bash_login, .profile,
            # so an existing earlier file hides the .profile line above.
            local rc
            for rc in "$HOME/.bash_profile" "$HOME/.bash_login"; do
                if [ -f "$rc" ]; then
                    append_shell_path "$rc" "$SHELL_PATH_LINE" "$SHELL_PATH_SETUP_RE"
                fi
            done
            ;;
    esac
}

stage_products() {
    # The whole tail in one place, by calling the completion an update calls:
    # publish the commands, build the products (tui/web, plus the desktop app),
    # then run the post-build maintenance that syncs bundled skills and migrates
    # config. Node, browsers and the frontend build tools arrive through pm as
    # the build asks for them; the bootstrap interpreter itself only re-enters
    # the tree on PM's selected Python.
    local boot_py
    local args=(--source "$INSTALL_DIR")
    bootstrap_python
    if [ "$INCLUDE_DESKTOP" = true ] || desktop_product_present; then
        args+=(--desktop)
    fi
    (cd "$INSTALL_DIR" && run_logged "Building the hermes command and apps" \
        "$boot_py" -I -B -X utf8 hermes_cli/source_completion.py "${args[@]}") \
        || fail "app products or command publication failed" products_build_failed
    wire_shell_path
    log_success "app products and hermes command ready"
}

stage_desktop() {
    # External-caller contract: `--stage desktop` stays dispatchable on its own
    # (the manifest never lists it now -- --include-desktop selects the desktop
    # product inside `products`). Same completion call, desktop selected.
    INCLUDE_DESKTOP=true
    stage_products
}

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_success "config prepared in $HERMES_HOME"
}

# Interactive stages read the terminal, not stdin: under `curl | bash` stdin
# IS the script. Probe by opening /dev/tty -- a Docker build has the device
# node in its mount namespace but opening it fails (ENXIO).
has_terminal() { (: </dev/tty) 2>/dev/null; }

stage_setup() {
    if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
    if ! has_terminal; then
        log "setup skipped (no terminal); run 'hermes setup' after install"
        return 0
    fi
    "$INSTALL_DIR/.hermes/bin/hermes" setup </dev/tty || fail "setup failed" setup_failed
}

stage_gateway() {
    if [ "$NON_INTERACTIVE" = true ]; then return 0; fi
    if ! has_terminal; then
        log "gateway setup skipped (no terminal); run 'hermes gateway install' after install"
        return 0
    fi
    # Setup installs the service when it handles the gateway; ask only if it did not.
    "$INSTALL_DIR/.hermes/bin/hermes" gateway install --if-missing </dev/tty || fail "gateway installation failed" gateway_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
    if [ -n "$CHANNEL" ]; then
        "$INSTALL_DIR/.hermes/bin/hermes" update --set-channel "$CHANNEL" \
            || fail "could not save the $CHANNEL update channel" other
    fi
    log_success "Hermes Agent install complete. Run: hermes"
}

print_path_reload_hint() {
    # The rc files only reach shells started later, and this installer is
    # always a child (`curl | bash`, `bash install.sh`) that cannot change its
    # parent's PATH. The inherited PATH is the parent's, so it says whether
    # the user can run `hermes` right away.
    case ":$PATH:" in *":$HOME/.local/bin:"*|*":$HOME/.local/bin/:"*) return 0 ;; esac
    local rc
    local login_shell="${SHELL:-}"
    case "${login_shell##*/}" in
        zsh) rc="source ~/.zshrc" ;;
        fish) rc="source ~/.config/fish/config.fish" ;;
        bash|"") rc="source ~/.bashrc" ;;
        *) rc=". ~/.profile" ;;
    esac
    log "Reload your shell to use hermes: open a new terminal, or run: $rc"
}

run_stage() (
    # Keep failure handling out of conditional calls, which disable errexit.
    set -e
    STAGE="$1"
    STAGE_REASON=""
    FAILURE_CLASS=""
    STAGE_INTERRUPTED=false
    STAGE_SKIPPED=false
    trap 'stage_result "$?"' EXIT
    if [ "$INSTALL_LADDER" = true ]; then
        trap 'stage_signal "$?" 130' INT
        trap 'stage_signal "$?" 143' TERM
        trap 'stage_signal "$?" 129' HUP
    fi
    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 ;;
        config) stage_config ;;
        products) stage_products ;;
        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). Under `curl | bash` BASH_SOURCE is
# empty, and `set -u` would abort on the bare expansion.
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
    if [ "$WANT_MANIFEST" = true ]; then
        emit_manifest
        exit 0
    fi

    if [ -n "$STAGE" ] && [ "$JSON" = true ]; then
        trap 'stage_result "$?"' EXIT
    elif [ -z "$STAGE" ]; then
        INSTALL_LADDER=true
        INSTALL_STARTED="$(date +%s 2>/dev/null)"
        INSTALL_RUN_ID="$(od -An -N16 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')" || INSTALL_RUN_ID=""
        [ "${#INSTALL_RUN_ID}" -eq 32 ] || INSTALL_RUN_ID="$(printf '%08x%08x%08x%08x' "$(( RANDOM * 32768 + RANDOM ))" "$$" "${INSTALL_STARTED:-0}" "$RANDOM")"
        # No clock, no receipt: the reader drops a receipt without both timestamps anyway.
        case "$INSTALL_STARTED" in ''|*[!0-9]*) INSTALL_RUN_ID="" ;; esac
        trap '[ "$?" -eq 0 ] || write_install_receipt failed prerequisites "${FAILURE_CLASS:-other}"' EXIT
        # Ctrl-C, SIGTERM or a closed terminal (SIGHUP) anywhere in the run: one `interrupted` receipt.
        trap 'install_signal INT' INT
        trap 'install_signal TERM' TERM
        trap 'install_signal HUP' HUP
    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.
    print_banner
    for s in $(stage_names); do
        # A TERM/HUP noted while the previous stage ran stops the run before this one.
        [ -z "$INSTALL_SIGNAL" ] || install_interrupted
        INSTALL_IN_STAGE=true
        run_stage "$s"
        rc=$?
        INSTALL_IN_STAGE=false
        if [ "$rc" -eq 0 ]; then
            # The stage succeeded, so a Ctrl-C it saw was handled there: carry on.
            [ "$INSTALL_SIGNAL" != INT ] || INSTALL_SIGNAL=""
            continue
        fi
        case "$rc" in
            129) INSTALL_SIGNAL="${INSTALL_SIGNAL:-HUP}" ;;
            130) INSTALL_SIGNAL="${INSTALL_SIGNAL:-INT}" ;;
            143) INSTALL_SIGNAL="${INSTALL_SIGNAL:-TERM}" ;;
        esac
        [ -z "$INSTALL_SIGNAL" ] || install_interrupted
        # The stage normally wrote its receipt; one killed outright (SIGKILL, OOM) did not.
        write_install_receipt failed "${s//-/_}" other
        exit "$rc"
    done
    write_install_receipt success none none
    # Every stage finished: the receipt says success, and a pending TERM/HUP still ends the run.
    [ -z "$INSTALL_SIGNAL" ] || install_interrupted
    trap - INT TERM HUP
    print_path_reload_hint
fi
