/ scripts / install.sh
install.sh
   1  #!/bin/bash
   2  # ============================================================================
   3  # Hermes Agent Installer
   4  # ============================================================================
   5  # Installation script for Linux, macOS, and Android/Termux.
   6  # Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux.
   7  #
   8  # Usage:
   9  #   curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
  10  #
  11  # Or with options:
  12  #   curl -fsSL ... | bash -s -- --no-venv --skip-setup
  13  #
  14  # ============================================================================
  15  
  16  set -e
  17  
  18  # Colors
  19  RED='\033[0;31m'
  20  GREEN='\033[0;32m'
  21  YELLOW='\033[0;33m'
  22  BLUE='\033[0;34m'
  23  MAGENTA='\033[0;35m'
  24  CYAN='\033[0;36m'
  25  NC='\033[0m' # No Color
  26  BOLD='\033[1m'
  27  
  28  # Configuration
  29  REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git"
  30  REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git"
  31  HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
  32  # INSTALL_DIR is resolved AFTER arg parsing and OS detection so we can pick an
  33  # FHS-style layout for root installs.  Track whether the user gave us an
  34  # explicit directory — if so we never override it.
  35  if [ -n "${HERMES_INSTALL_DIR:-}" ]; then
  36      INSTALL_DIR="$HERMES_INSTALL_DIR"
  37      INSTALL_DIR_EXPLICIT=true
  38  else
  39      INSTALL_DIR=""
  40      INSTALL_DIR_EXPLICIT=false
  41  fi
  42  PYTHON_VERSION="3.11"
  43  NODE_VERSION="22"
  44  
  45  # FHS-style root install layout (set by resolve_install_layout when applicable):
  46  #   code at /usr/local/lib/hermes-agent, command at /usr/local/bin/hermes,
  47  #   data still at /root/.hermes (HERMES_HOME).  Matches Claude Code / Codex CLI
  48  #   and keeps Docker bind-mounted /root/ volumes lean.
  49  ROOT_FHS_LAYOUT=false
  50  
  51  # Options
  52  USE_VENV=true
  53  RUN_SETUP=true
  54  BRANCH="main"
  55  
  56  # Detect non-interactive mode (e.g. curl | bash)
  57  # When stdin is not a terminal, read -p will fail with EOF,
  58  # causing set -e to silently abort the entire script.
  59  if [ -t 0 ]; then
  60      IS_INTERACTIVE=true
  61  else
  62      IS_INTERACTIVE=false
  63  fi
  64  
  65  # Parse arguments
  66  while [[ $# -gt 0 ]]; do
  67      case $1 in
  68          --no-venv)
  69              USE_VENV=false
  70              shift
  71              ;;
  72          --skip-setup)
  73              RUN_SETUP=false
  74              shift
  75              ;;
  76          --branch)
  77              BRANCH="$2"
  78              shift 2
  79              ;;
  80          --dir)
  81              INSTALL_DIR="$2"
  82              INSTALL_DIR_EXPLICIT=true
  83              shift 2
  84              ;;
  85          --hermes-home)
  86              HERMES_HOME="$2"
  87              shift 2
  88              ;;
  89          -h|--help)
  90              echo "Hermes Agent Installer"
  91              echo ""
  92              echo "Usage: install.sh [OPTIONS]"
  93              echo ""
  94              echo "Options:"
  95              echo "  --no-venv      Don't create virtual environment"
  96              echo "  --skip-setup   Skip interactive setup wizard"
  97              echo "  --branch NAME  Git branch to install (default: main)"
  98              echo "  --dir PATH     Installation directory"
  99              echo "                   default (non-root):  ~/.hermes/hermes-agent"
 100              echo "                   default (root, Linux): /usr/local/lib/hermes-agent"
 101              echo "  --hermes-home PATH  Data directory (default: ~/.hermes, or \$HERMES_HOME)"
 102              echo "  -h, --help     Show this help"
 103              echo ""
 104              echo "Notes:"
 105              echo "  When running as root on Linux, Hermes installs the code under"
 106              echo "  /usr/local/lib/hermes-agent and links the command into"
 107              echo "  /usr/local/bin/hermes (FHS layout — matches Claude Code / Codex CLI)."
 108              echo "  Data, config, sessions, and logs still live in \$HERMES_HOME"
 109              echo "  (default /root/.hermes).  This keeps Docker bind-mounted volumes"
 110              echo "  small and ensures the command is on PATH for all shells."
 111              echo "  Existing installs at \$HERMES_HOME/hermes-agent are preserved in-place."
 112              exit 0
 113              ;;
 114          *)
 115              echo "Unknown option: $1"
 116              exit 1
 117              ;;
 118      esac
 119  done
 120  
 121  # ============================================================================
 122  # Helper functions
 123  # ============================================================================
 124  
 125  print_banner() {
 126      echo ""
 127      echo -e "${MAGENTA}${BOLD}"
 128      echo "┌─────────────────────────────────────────────────────────┐"
 129      echo "│             ⚕ Hermes Agent Installer                    │"
 130      echo "├─────────────────────────────────────────────────────────┤"
 131      echo "│  An open source AI agent by Nous Research.              │"
 132      echo "└─────────────────────────────────────────────────────────┘"
 133      echo -e "${NC}"
 134  }
 135  
 136  log_info() {
 137      echo -e "${CYAN}→${NC} $1"
 138  }
 139  
 140  log_success() {
 141      echo -e "${GREEN}✓${NC} $1"
 142  }
 143  
 144  log_warn() {
 145      echo -e "${YELLOW}⚠${NC} $1"
 146  }
 147  
 148  log_error() {
 149      echo -e "${RED}✗${NC} $1"
 150  }
 151  
 152  prompt_yes_no() {
 153      local question="$1"
 154      local default="${2:-yes}"
 155      local prompt_suffix
 156      local answer=""
 157  
 158      # Use case patterns (not ${var,,}) so this works on bash 3.2 (macOS /bin/bash).
 159      case "$default" in
 160          [yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) prompt_suffix="[Y/n]" ;;
 161          *) prompt_suffix="[y/N]" ;;
 162      esac
 163  
 164      if [ "$IS_INTERACTIVE" = true ]; then
 165          read -r -p "$question $prompt_suffix " answer || answer=""
 166      elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
 167          printf "%s %s " "$question" "$prompt_suffix" > /dev/tty
 168          IFS= read -r answer < /dev/tty || answer=""
 169      else
 170          answer=""
 171      fi
 172  
 173      answer="${answer#"${answer%%[![:space:]]*}"}"
 174      answer="${answer%"${answer##*[![:space:]]}"}"
 175  
 176      if [ -z "$answer" ]; then
 177          case "$default" in
 178              [yY]|[yY][eE][sS]|[tT][rR][uU][eE]|1) return 0 ;;
 179              *) return 1 ;;
 180          esac
 181      fi
 182  
 183      case "$answer" in
 184          [yY]|[yY][eE][sS]) return 0 ;;
 185          *) return 1 ;;
 186      esac
 187  }
 188  
 189  is_termux() {
 190      [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
 191  }
 192  
 193  # Decide where the repo checkout + venv live, and where the `hermes` command
 194  # symlink goes.  Called after detect_os so $OS/$DISTRO are known.
 195  #
 196  # Defaults:
 197  #   - Non-root, any OS:       INSTALL_DIR = $HERMES_HOME/hermes-agent
 198  #                             command link in $HOME/.local/bin
 199  #   - Termux (any uid):       INSTALL_DIR = $HERMES_HOME/hermes-agent
 200  #                             command link in $PREFIX/bin (already on PATH)
 201  #   - Root on Linux (new):    INSTALL_DIR = /usr/local/lib/hermes-agent
 202  #                             command link in /usr/local/bin
 203  #                             (unless a legacy install already exists at
 204  #                              $HERMES_HOME/hermes-agent — then preserve it)
 205  #
 206  # Always no-op when the user set --dir or $HERMES_INSTALL_DIR.
 207  resolve_install_layout() {
 208      if [ "$INSTALL_DIR_EXPLICIT" = true ]; then
 209          log_info "Install directory: $INSTALL_DIR (explicit)"
 210          return 0
 211      fi
 212  
 213      # Termux: package manager manages /data/data/..., keep code in HERMES_HOME.
 214      if is_termux; then
 215          INSTALL_DIR="$HERMES_HOME/hermes-agent"
 216          return 0
 217      fi
 218  
 219      # Root on Linux: prefer FHS layout unless a legacy install already exists.
 220      # macOS root installs keep the legacy layout because /usr/local/ on macOS
 221      # is Homebrew territory and we don't want to fight that.
 222      if [ "$OS" = "linux" ] && [ "$(id -u)" -eq 0 ]; then
 223          if [ -d "$HERMES_HOME/hermes-agent/.git" ]; then
 224              INSTALL_DIR="$HERMES_HOME/hermes-agent"
 225              log_info "Existing install detected at $INSTALL_DIR — keeping legacy layout"
 226              log_info "  (new root installs use /usr/local/lib/hermes-agent)"
 227              return 0
 228          fi
 229          INSTALL_DIR="/usr/local/lib/hermes-agent"
 230          ROOT_FHS_LAYOUT=true
 231          log_info "Root install on Linux — using FHS layout"
 232          log_info "  Code:    $INSTALL_DIR"
 233          log_info "  Command: /usr/local/bin/hermes"
 234          log_info "  Data:    $HERMES_HOME (unchanged)"
 235          return 0
 236      fi
 237  
 238      # Default: non-root, non-Termux → legacy user-scoped layout.
 239      INSTALL_DIR="$HERMES_HOME/hermes-agent"
 240  }
 241  
 242  get_command_link_dir() {
 243      if is_termux && [ -n "${PREFIX:-}" ]; then
 244          echo "$PREFIX/bin"
 245      elif [ "$ROOT_FHS_LAYOUT" = true ]; then
 246          echo "/usr/local/bin"
 247      else
 248          echo "$HOME/.local/bin"
 249      fi
 250  }
 251  
 252  get_command_link_display_dir() {
 253      if is_termux && [ -n "${PREFIX:-}" ]; then
 254          echo '$PREFIX/bin'
 255      elif [ "$ROOT_FHS_LAYOUT" = true ]; then
 256          echo '/usr/local/bin'
 257      else
 258          echo '~/.local/bin'
 259      fi
 260  }
 261  
 262  get_hermes_command_path() {
 263      local link_dir
 264      link_dir="$(get_command_link_dir)"
 265      if [ -x "$link_dir/hermes" ]; then
 266          echo "$link_dir/hermes"
 267      else
 268          echo "hermes"
 269      fi
 270  }
 271  
 272  # ============================================================================
 273  # System detection
 274  # ============================================================================
 275  
 276  detect_os() {
 277      case "$(uname -s)" in
 278          Linux*)
 279              if is_termux; then
 280                  OS="android"
 281                  DISTRO="termux"
 282              else
 283                  OS="linux"
 284                  if [ -f /etc/os-release ]; then
 285                      . /etc/os-release
 286                      DISTRO="$ID"
 287                  else
 288                      DISTRO="unknown"
 289                  fi
 290              fi
 291              ;;
 292          Darwin*)
 293              OS="macos"
 294              DISTRO="macos"
 295              ;;
 296          CYGWIN*|MINGW*|MSYS*)
 297              OS="windows"
 298              DISTRO="windows"
 299              log_error "Windows detected. Please use the PowerShell installer:"
 300              log_info "  irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex"
 301              exit 1
 302              ;;
 303          *)
 304              OS="unknown"
 305              DISTRO="unknown"
 306              log_warn "Unknown operating system"
 307              ;;
 308      esac
 309  
 310      log_success "Detected: $OS ($DISTRO)"
 311  }
 312  
 313  # ============================================================================
 314  # Dependency checks
 315  # ============================================================================
 316  
 317  install_uv() {
 318      if [ "$DISTRO" = "termux" ]; then
 319          log_info "Termux detected — using Python's stdlib venv + pip instead of uv"
 320          UV_CMD=""
 321          return 0
 322      fi
 323  
 324      log_info "Checking for uv package manager..."
 325  
 326      # Check common locations for uv
 327      if command -v uv &> /dev/null; then
 328          UV_CMD="uv"
 329          UV_VERSION=$($UV_CMD --version 2>/dev/null)
 330          log_success "uv found ($UV_VERSION)"
 331          return 0
 332      fi
 333  
 334      # Check ~/.local/bin (default uv install location) even if not on PATH yet
 335      if [ -x "$HOME/.local/bin/uv" ]; then
 336          UV_CMD="$HOME/.local/bin/uv"
 337          UV_VERSION=$($UV_CMD --version 2>/dev/null)
 338          log_success "uv found at ~/.local/bin ($UV_VERSION)"
 339          return 0
 340      fi
 341  
 342      # Check ~/.cargo/bin (alternative uv install location)
 343      if [ -x "$HOME/.cargo/bin/uv" ]; then
 344          UV_CMD="$HOME/.cargo/bin/uv"
 345          UV_VERSION=$($UV_CMD --version 2>/dev/null)
 346          log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
 347          return 0
 348      fi
 349  
 350      # Install uv
 351      log_info "Installing uv (fast Python package manager)..."
 352      if curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null; then
 353          # uv installs to ~/.local/bin by default
 354          if [ -x "$HOME/.local/bin/uv" ]; then
 355              UV_CMD="$HOME/.local/bin/uv"
 356          elif [ -x "$HOME/.cargo/bin/uv" ]; then
 357              UV_CMD="$HOME/.cargo/bin/uv"
 358          elif command -v uv &> /dev/null; then
 359              UV_CMD="uv"
 360          else
 361              log_error "uv installed but not found on PATH"
 362              log_info "Try adding ~/.local/bin to your PATH and re-running"
 363              exit 1
 364          fi
 365          UV_VERSION=$($UV_CMD --version 2>/dev/null)
 366          log_success "uv installed ($UV_VERSION)"
 367      else
 368          log_error "Failed to install uv"
 369          log_info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
 370          exit 1
 371      fi
 372  }
 373  
 374  check_python() {
 375      if [ "$DISTRO" = "termux" ]; then
 376          log_info "Checking Termux Python..."
 377          if command -v python >/dev/null 2>&1; then
 378              PYTHON_PATH="$(command -v python)"
 379              if "$PYTHON_PATH" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' 2>/dev/null; then
 380                  PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
 381                  log_success "Python found: $PYTHON_FOUND_VERSION"
 382                  return 0
 383              fi
 384          fi
 385  
 386          log_info "Installing Python via pkg..."
 387          pkg install -y python >/dev/null
 388          PYTHON_PATH="$(command -v python)"
 389          PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
 390          log_success "Python installed: $PYTHON_FOUND_VERSION"
 391          return 0
 392      fi
 393  
 394      log_info "Checking Python $PYTHON_VERSION..."
 395  
 396      # Let uv handle Python — it can download and manage Python versions
 397      # First check if a suitable Python is already available
 398      if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
 399          PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
 400          log_success "Python found: $PYTHON_FOUND_VERSION"
 401          return 0
 402      fi
 403  
 404      # Python not found — use uv to install it (no sudo needed!)
 405      log_info "Python $PYTHON_VERSION not found, installing via uv..."
 406      if "$UV_CMD" python install "$PYTHON_VERSION"; then
 407          PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
 408          PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
 409          log_success "Python installed: $PYTHON_FOUND_VERSION"
 410      else
 411          log_error "Failed to install Python $PYTHON_VERSION"
 412          log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
 413          exit 1
 414      fi
 415  }
 416  
 417  check_git() {
 418      log_info "Checking Git..."
 419  
 420      if command -v git &> /dev/null; then
 421          GIT_VERSION=$(git --version | awk '{print $3}')
 422          log_success "Git $GIT_VERSION found"
 423          return 0
 424      fi
 425  
 426      log_error "Git not found"
 427  
 428      if [ "$DISTRO" = "termux" ]; then
 429          log_info "Installing Git via pkg..."
 430          pkg install -y git >/dev/null
 431          if command -v git >/dev/null 2>&1; then
 432              GIT_VERSION=$(git --version | awk '{print $3}')
 433              log_success "Git $GIT_VERSION installed"
 434              return 0
 435          fi
 436      fi
 437  
 438      log_info "Please install Git:"
 439  
 440      case "$OS" in
 441          linux)
 442              case "$DISTRO" in
 443                  ubuntu|debian)
 444                      log_info "  sudo apt update && sudo apt install git"
 445                      ;;
 446                  fedora)
 447                      log_info "  sudo dnf install git"
 448                      ;;
 449                  arch)
 450                      log_info "  sudo pacman -S git"
 451                      ;;
 452                  *)
 453                      log_info "  Use your package manager to install git"
 454                      ;;
 455              esac
 456              ;;
 457          android)
 458              log_info "  pkg install git"
 459              ;;
 460          macos)
 461              log_info "  xcode-select --install"
 462              log_info "  Or: brew install git"
 463              ;;
 464      esac
 465  
 466      exit 1
 467  }
 468  
 469  check_node() {
 470      log_info "Checking Node.js (for browser tools)..."
 471  
 472      if command -v node &> /dev/null; then
 473          local found_ver=$(node --version)
 474          log_success "Node.js $found_ver found"
 475          HAS_NODE=true
 476          return 0
 477      fi
 478  
 479      # Check our own managed install from a previous run
 480      if [ -x "$HERMES_HOME/node/bin/node" ]; then
 481          export PATH="$HERMES_HOME/node/bin:$PATH"
 482          local found_ver=$("$HERMES_HOME/node/bin/node" --version)
 483          log_success "Node.js $found_ver found (Hermes-managed)"
 484          HAS_NODE=true
 485          return 0
 486      fi
 487  
 488      if [ "$DISTRO" = "termux" ]; then
 489          log_info "Node.js not found — installing Node.js via pkg..."
 490      else
 491          log_info "Node.js not found — installing Node.js $NODE_VERSION LTS..."
 492      fi
 493      install_node
 494  }
 495  
 496  install_node() {
 497      if [ "$DISTRO" = "termux" ]; then
 498          log_info "Installing Node.js via pkg..."
 499          if pkg install -y nodejs >/dev/null; then
 500              local installed_ver
 501              installed_ver=$(node --version 2>/dev/null)
 502              log_success "Node.js $installed_ver installed via pkg"
 503              HAS_NODE=true
 504          else
 505              log_warn "Failed to install Node.js via pkg"
 506              HAS_NODE=false
 507          fi
 508          return 0
 509      fi
 510  
 511      local arch=$(uname -m)
 512      local node_arch
 513      case "$arch" in
 514          x86_64)        node_arch="x64"    ;;
 515          aarch64|arm64) node_arch="arm64"  ;;
 516          armv7l)        node_arch="armv7l" ;;
 517          *)
 518              log_warn "Unsupported architecture ($arch) for Node.js auto-install"
 519              log_info "Install manually: https://nodejs.org/en/download/"
 520              HAS_NODE=false
 521              return 0
 522              ;;
 523      esac
 524  
 525      local node_os
 526      case "$OS" in
 527          linux) node_os="linux"  ;;
 528          macos) node_os="darwin" ;;
 529          *)
 530              log_warn "Unsupported OS for Node.js auto-install"
 531              HAS_NODE=false
 532              return 0
 533              ;;
 534      esac
 535  
 536      # Resolve the latest v22.x.x tarball name from the index page
 537      local index_url="https://nodejs.org/dist/latest-v${NODE_VERSION}.x/"
 538      local tarball_name
 539      tarball_name=$(curl -fsSL "$index_url" \
 540          | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \
 541          | head -1)
 542  
 543      # Fallback to .tar.gz if .tar.xz not available
 544      if [ -z "$tarball_name" ]; then
 545          tarball_name=$(curl -fsSL "$index_url" \
 546              | grep -oE "node-v${NODE_VERSION}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \
 547              | head -1)
 548      fi
 549  
 550      if [ -z "$tarball_name" ]; then
 551          log_warn "Could not find Node.js $NODE_VERSION binary for $node_os-$node_arch"
 552          log_info "Install manually: https://nodejs.org/en/download/"
 553          HAS_NODE=false
 554          return 0
 555      fi
 556  
 557      local download_url="${index_url}${tarball_name}"
 558      local tmp_dir
 559      tmp_dir=$(mktemp -d)
 560  
 561      log_info "Downloading $tarball_name..."
 562      if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then
 563          log_warn "Download failed"
 564          rm -rf "$tmp_dir"
 565          HAS_NODE=false
 566          return 0
 567      fi
 568  
 569      log_info "Extracting to ~/.hermes/node/..."
 570      if [[ "$tarball_name" == *.tar.xz ]]; then
 571          tar xf "$tmp_dir/$tarball_name" -C "$tmp_dir"
 572      else
 573          tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir"
 574      fi
 575  
 576      local extracted_dir
 577      extracted_dir=$(ls -d "$tmp_dir"/node-v* 2>/dev/null | head -1)
 578  
 579      if [ ! -d "$extracted_dir" ]; then
 580          log_warn "Extraction failed"
 581          rm -rf "$tmp_dir"
 582          HAS_NODE=false
 583          return 0
 584      fi
 585  
 586      # Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
 587      rm -rf "$HERMES_HOME/node"
 588      mkdir -p "$HERMES_HOME"
 589      mv "$extracted_dir" "$HERMES_HOME/node"
 590      rm -rf "$tmp_dir"
 591  
 592      mkdir -p "$HOME/.local/bin"
 593      ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
 594      ln -sf "$HERMES_HOME/node/bin/npm"  "$HOME/.local/bin/npm"
 595      ln -sf "$HERMES_HOME/node/bin/npx"  "$HOME/.local/bin/npx"
 596  
 597      export PATH="$HERMES_HOME/node/bin:$PATH"
 598  
 599      local installed_ver
 600      installed_ver=$("$HERMES_HOME/node/bin/node" --version 2>/dev/null)
 601      log_success "Node.js $installed_ver installed to ~/.hermes/node/"
 602      HAS_NODE=true
 603  }
 604  
 605  install_system_packages() {
 606      # Detect what's missing
 607      HAS_RIPGREP=false
 608      HAS_FFMPEG=false
 609      local need_ripgrep=false
 610      local need_ffmpeg=false
 611  
 612      log_info "Checking ripgrep (fast file search)..."
 613      if command -v rg &> /dev/null; then
 614          log_success "$(rg --version | head -1) found"
 615          HAS_RIPGREP=true
 616      else
 617          need_ripgrep=true
 618      fi
 619  
 620      log_info "Checking ffmpeg (TTS voice messages)..."
 621      if command -v ffmpeg &> /dev/null; then
 622          local ffmpeg_ver=$(ffmpeg -version 2>/dev/null | head -1 | awk '{print $3}')
 623          log_success "ffmpeg $ffmpeg_ver found"
 624          HAS_FFMPEG=true
 625      else
 626          need_ffmpeg=true
 627      fi
 628  
 629      # Termux always needs the Android build toolchain for the tested pip path,
 630      # even when ripgrep/ffmpeg are already present.
 631      if [ "$DISTRO" = "termux" ]; then
 632          local termux_pkgs=(clang rust make pkg-config libffi openssl)
 633          if [ "$need_ripgrep" = true ]; then
 634              termux_pkgs+=("ripgrep")
 635          fi
 636          if [ "$need_ffmpeg" = true ]; then
 637              termux_pkgs+=("ffmpeg")
 638          fi
 639  
 640          log_info "Installing Termux packages: ${termux_pkgs[*]}"
 641          if pkg install -y "${termux_pkgs[@]}" >/dev/null; then
 642              [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 643              [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 644              log_success "Termux build dependencies installed"
 645              return 0
 646          fi
 647  
 648          log_warn "Could not auto-install all Termux packages"
 649          log_info "Install manually: pkg install ${termux_pkgs[*]}"
 650          return 0
 651      fi
 652  
 653      # Nothing to install — done
 654      if [ "$need_ripgrep" = false ] && [ "$need_ffmpeg" = false ]; then
 655          return 0
 656      fi
 657  
 658      # Build a human-readable description + package list
 659      local desc_parts=()
 660      local pkgs=()
 661      if [ "$need_ripgrep" = true ]; then
 662          desc_parts+=("ripgrep for faster file search")
 663          pkgs+=("ripgrep")
 664      fi
 665      if [ "$need_ffmpeg" = true ]; then
 666          desc_parts+=("ffmpeg for TTS voice messages")
 667          pkgs+=("ffmpeg")
 668      fi
 669      local description
 670      description=$(IFS=" and "; echo "${desc_parts[*]}")
 671  
 672      # ── macOS: brew ──
 673      if [ "$OS" = "macos" ]; then
 674          if command -v brew &> /dev/null; then
 675              log_info "Installing ${pkgs[*]} via Homebrew..."
 676              if brew install "${pkgs[@]}"; then
 677                  [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 678                  [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 679                  return 0
 680              fi
 681          fi
 682          log_warn "Could not auto-install (brew not found or install failed)"
 683          log_info "Install manually: brew install ${pkgs[*]}"
 684          return 0
 685      fi
 686  
 687      # ── Linux: resolve package manager command ──
 688      local pkg_install=""
 689      case "$DISTRO" in
 690          ubuntu|debian) pkg_install="apt install -y"   ;;
 691          fedora)        pkg_install="dnf install -y"   ;;
 692          arch)          pkg_install="pacman -S --noconfirm" ;;
 693      esac
 694  
 695      if [ -n "$pkg_install" ]; then
 696          local install_cmd="$pkg_install ${pkgs[*]}"
 697  
 698          # Prevent needrestart/whiptail dialogs from blocking non-interactive installs
 699          case "$DISTRO" in
 700              ubuntu|debian) export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a ;;
 701          esac
 702  
 703          # Already root — just install
 704          if [ "$(id -u)" -eq 0 ]; then
 705              log_info "Installing ${pkgs[*]}..."
 706              if $install_cmd; then
 707                  [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 708                  [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 709                  return 0
 710              fi
 711          # Passwordless sudo — just install
 712          elif command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
 713              log_info "Installing ${pkgs[*]}..."
 714              if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then
 715                  [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 716                  [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 717                  return 0
 718              fi
 719          # sudo needs password — ask once for everything
 720          elif command -v sudo &> /dev/null; then
 721              if [ "$IS_INTERACTIVE" = true ]; then
 722                  echo ""
 723                  log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager."
 724                  log_info "Hermes Agent itself does not require or retain root access."
 725                  if prompt_yes_no "Install ${description}? (requires sudo)" "no"; then
 726                      if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd; then
 727                          [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 728                          [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 729                          return 0
 730                      fi
 731                  fi
 732              elif (: </dev/tty) 2>/dev/null; then
 733                  # Non-interactive (e.g. curl | bash) but a terminal is available.
 734                  # Read the prompt from /dev/tty (same approach the setup wizard uses).
 735                  # Probe by actually opening /dev/tty: a bare existence test passes
 736                  # in Docker builds where the device node is in the mount namespace
 737                  # but opening fails with ENXIO. See #16746.
 738                  echo ""
 739                  log_info "sudo is needed ONLY to install optional system packages (${pkgs[*]}) via your package manager."
 740                  log_info "Hermes Agent itself does not require or retain root access."
 741                  if prompt_yes_no "Install ${description}?" "yes"; then
 742                      if sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a $install_cmd < /dev/tty; then
 743                          [ "$need_ripgrep" = true ] && HAS_RIPGREP=true && log_success "ripgrep installed"
 744                          [ "$need_ffmpeg" = true ]  && HAS_FFMPEG=true  && log_success "ffmpeg installed"
 745                          return 0
 746                      fi
 747                  fi
 748              else
 749                  log_warn "Non-interactive mode and no terminal available — cannot install system packages"
 750                  log_info "Install manually after setup completes: sudo $install_cmd"
 751              fi
 752          fi
 753      fi
 754  
 755      # ── Fallback for ripgrep: cargo ──
 756      if [ "$need_ripgrep" = true ] && [ "$HAS_RIPGREP" = false ]; then
 757          if command -v cargo &> /dev/null; then
 758              log_info "Trying cargo install ripgrep (no sudo needed)..."
 759              if cargo install ripgrep; then
 760                  log_success "ripgrep installed via cargo"
 761                  HAS_RIPGREP=true
 762              fi
 763          fi
 764      fi
 765  
 766      # ── Show manual instructions for anything still missing ──
 767      if [ "$HAS_RIPGREP" = false ] && [ "$need_ripgrep" = true ]; then
 768          log_warn "ripgrep not installed (file search will use grep fallback)"
 769          show_manual_install_hint "ripgrep"
 770      fi
 771      if [ "$HAS_FFMPEG" = false ] && [ "$need_ffmpeg" = true ]; then
 772          log_warn "ffmpeg not installed (TTS voice messages will be limited)"
 773          show_manual_install_hint "ffmpeg"
 774      fi
 775  }
 776  
 777  show_manual_install_hint() {
 778      local pkg="$1"
 779      log_info "To install $pkg manually:"
 780      case "$OS" in
 781          linux)
 782              case "$DISTRO" in
 783                  ubuntu|debian) log_info "  sudo apt install $pkg" ;;
 784                  fedora)        log_info "  sudo dnf install $pkg" ;;
 785                  arch)          log_info "  sudo pacman -S $pkg"   ;;
 786                  *)             log_info "  Use your package manager or visit the project homepage" ;;
 787              esac
 788              ;;
 789          android)
 790              log_info "  pkg install $pkg"
 791              ;;
 792          macos) log_info "  brew install $pkg" ;;
 793      esac
 794  }
 795  
 796  # ============================================================================
 797  # Installation
 798  # ============================================================================
 799  
 800  clone_repo() {
 801      log_info "Installing to $INSTALL_DIR..."
 802  
 803      if [ -d "$INSTALL_DIR" ]; then
 804          if [ -d "$INSTALL_DIR/.git" ]; then
 805              log_info "Existing installation found, updating..."
 806              cd "$INSTALL_DIR"
 807  
 808              local autostash_ref=""
 809              if [ -n "$(git status --porcelain)" ]; then
 810                  local stash_name
 811                  stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
 812                  log_info "Local changes detected, stashing before update..."
 813                  git stash push --include-untracked -m "$stash_name"
 814                  autostash_ref="$(git rev-parse --verify refs/stash)"
 815              fi
 816  
 817              git fetch origin
 818              git checkout "$BRANCH"
 819              git pull --ff-only origin "$BRANCH"
 820  
 821              if [ -n "$autostash_ref" ]; then
 822                  local restore_now="yes"
 823                  if [ -t 0 ] && [ -t 1 ]; then
 824                      echo
 825                      log_warn "Local changes were stashed before updating."
 826                      log_warn "Restoring them may reapply local customizations onto the updated codebase."
 827                      printf "Restore local changes now? [Y/n] "
 828                      read -r restore_answer
 829                      case "$restore_answer" in
 830                          ""|y|Y|yes|YES|Yes) restore_now="yes" ;;
 831                          *) restore_now="no" ;;
 832                      esac
 833                  fi
 834  
 835                  if [ "$restore_now" = "yes" ]; then
 836                      log_info "Restoring local changes..."
 837                      if git stash apply "$autostash_ref"; then
 838                          git stash drop "$autostash_ref" >/dev/null
 839                          log_warn "Local changes were restored on top of the updated codebase."
 840                          log_warn "Review git diff / git status if Hermes behaves unexpectedly."
 841                      else
 842                          log_error "Update succeeded, but restoring local changes failed. Your changes are still preserved in git stash."
 843                          log_info "Resolve manually with: git stash apply $autostash_ref"
 844                          exit 1
 845                      fi
 846                  else
 847                      log_info "Skipped restoring local changes."
 848                      log_info "Your changes are still preserved in git stash."
 849                      log_info "Restore manually with: git stash apply $autostash_ref"
 850                  fi
 851              fi
 852          else
 853              log_error "Directory exists but is not a git repository: $INSTALL_DIR"
 854              log_info "Remove it or choose a different directory with --dir"
 855              exit 1
 856          fi
 857      else
 858          # Try SSH first (for private repo access), fall back to HTTPS
 859          # GIT_SSH_COMMAND disables interactive prompts and sets a short timeout
 860          # so SSH fails fast instead of hanging when no key is configured.
 861          log_info "Trying SSH clone..."
 862          if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \
 863             git clone --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then
 864              log_success "Cloned via SSH"
 865          else
 866              rm -rf "$INSTALL_DIR" 2>/dev/null  # Clean up partial SSH clone
 867              log_info "SSH failed, trying HTTPS..."
 868              if git clone --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then
 869                  log_success "Cloned via HTTPS"
 870              else
 871                  log_error "Failed to clone repository"
 872                  exit 1
 873              fi
 874          fi
 875      fi
 876  
 877      cd "$INSTALL_DIR"
 878  
 879      log_success "Repository ready"
 880  }
 881  
 882  setup_venv() {
 883      if [ "$USE_VENV" = false ]; then
 884          log_info "Skipping virtual environment (--no-venv)"
 885          return 0
 886      fi
 887  
 888      if [ "$DISTRO" = "termux" ]; then
 889          log_info "Creating virtual environment with Termux Python..."
 890  
 891          if [ -d "venv" ]; then
 892              log_info "Virtual environment already exists, recreating..."
 893              rm -rf venv
 894          fi
 895  
 896          "$PYTHON_PATH" -m venv venv
 897          log_success "Virtual environment ready ($(./venv/bin/python --version 2>/dev/null))"
 898          return 0
 899      fi
 900  
 901      log_info "Creating virtual environment with Python $PYTHON_VERSION..."
 902  
 903      if [ -d "venv" ]; then
 904          log_info "Virtual environment already exists, recreating..."
 905          rm -rf venv
 906      fi
 907  
 908      # uv creates the venv and pins the Python version in one step
 909      $UV_CMD venv venv --python "$PYTHON_VERSION"
 910  
 911      log_success "Virtual environment ready (Python $PYTHON_VERSION)"
 912  }
 913  
 914  install_deps() {
 915      log_info "Installing dependencies..."
 916  
 917      if [ "$DISTRO" = "termux" ]; then
 918          if [ "$USE_VENV" = true ]; then
 919              export VIRTUAL_ENV="$INSTALL_DIR/venv"
 920              PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
 921          else
 922              PIP_PYTHON="$PYTHON_PATH"
 923          fi
 924  
 925          if [ -z "${ANDROID_API_LEVEL:-}" ]; then
 926              ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
 927              if [ -z "$ANDROID_API_LEVEL" ]; then
 928                  ANDROID_API_LEVEL=24
 929              fi
 930              export ANDROID_API_LEVEL
 931              log_info "Using ANDROID_API_LEVEL=$ANDROID_API_LEVEL for Android wheel builds"
 932          fi
 933  
 934          "$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
 935          if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
 936              log_warn "Termux feature install (.[termux]) failed, trying base install..."
 937              if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
 938                  log_error "Package installation failed on Termux."
 939                  log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
 940                  log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
 941                  exit 1
 942              fi
 943          fi
 944  
 945          log_success "Main package installed"
 946          log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
 947  
 948          if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
 949              log_info "tinker-atropos submodule found — skipping install (optional, for RL training)"
 950              log_info "  To install later: $PIP_PYTHON -m pip install -e \"./tinker-atropos\""
 951          fi
 952  
 953          log_success "All dependencies installed"
 954          return 0
 955      fi
 956  
 957      if [ "$USE_VENV" = true ]; then
 958          # Tell uv to install into our venv (no need to activate)
 959          export VIRTUAL_ENV="$INSTALL_DIR/venv"
 960      fi
 961  
 962      # On Debian/Ubuntu (including WSL), some Python packages need build tools.
 963      # Check and offer to install them if missing.
 964      if [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ]; then
 965          local need_build_tools=false
 966          for pkg in gcc python3-dev libffi-dev; do
 967              if ! dpkg -s "$pkg" &>/dev/null; then
 968                  need_build_tools=true
 969                  break
 970              fi
 971          done
 972          if [ "$need_build_tools" = true ]; then
 973              log_info "Some build tools may be needed for Python packages..."
 974              if command -v sudo &> /dev/null; then
 975                  if sudo -n true 2>/dev/null; then
 976                      sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true
 977                      log_success "Build tools installed"
 978                  else
 979                      log_info "sudo is needed ONLY to install build tools (build-essential, python3-dev, libffi-dev) via apt."
 980                      log_info "Hermes Agent itself does not require or retain root access."
 981                      if prompt_yes_no "Install build tools?" "yes"; then
 982                          sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq build-essential python3-dev libffi-dev >/dev/null 2>&1 || true
 983                          log_success "Build tools installed"
 984                      fi
 985                  fi
 986              fi
 987          fi
 988      fi
 989  
 990      # Install the main package in editable mode with all extras.
 991      # Try [all] first, fall back to base install if extras have issues.
 992      ALL_INSTALL_LOG=$(mktemp)
 993      if ! $UV_CMD pip install -e ".[all]" 2>"$ALL_INSTALL_LOG"; then
 994          log_warn "Full install (.[all]) failed, trying base install..."
 995          log_info "Reason: $(tail -5 "$ALL_INSTALL_LOG" | head -3)"
 996          rm -f "$ALL_INSTALL_LOG"
 997          if ! $UV_CMD pip install -e "."; then
 998              log_error "Package installation failed."
 999              log_info "Check that build tools are installed: sudo apt install build-essential python3-dev"
1000              log_info "Then re-run: cd $INSTALL_DIR && uv pip install -e '.[all]'"
1001              exit 1
1002          fi
1003      else
1004          rm -f "$ALL_INSTALL_LOG"
1005      fi
1006  
1007      log_success "Main package installed"
1008  
1009      # tinker-atropos (RL training) is optional — skip by default.
1010      # To enable RL tools: git submodule update --init tinker-atropos && uv pip install -e "./tinker-atropos"
1011      if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
1012          log_info "tinker-atropos submodule found — skipping install (optional, for RL training)"
1013          log_info "  To install: $UV_CMD pip install -e \"./tinker-atropos\""
1014      fi
1015  
1016      log_success "All dependencies installed"
1017  }
1018  
1019  setup_path() {
1020      log_info "Setting up hermes command..."
1021  
1022      if [ "$USE_VENV" = true ]; then
1023          HERMES_BIN="$INSTALL_DIR/venv/bin/hermes"
1024      else
1025          HERMES_BIN="$(which hermes 2>/dev/null || echo "")"
1026          if [ -z "$HERMES_BIN" ]; then
1027              log_warn "hermes not found on PATH after install"
1028              return 0
1029          fi
1030      fi
1031  
1032      # Verify the entry point script was actually generated
1033      if [ ! -x "$HERMES_BIN" ]; then
1034          log_warn "hermes entry point not found at $HERMES_BIN"
1035          log_info "This usually means the pip install didn't complete successfully."
1036          if [ "$DISTRO" = "termux" ]; then
1037              log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
1038          else
1039              log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
1040          fi
1041          return 0
1042      fi
1043  
1044      local command_link_dir
1045      local command_link_display_dir
1046      command_link_dir="$(get_command_link_dir)"
1047      command_link_display_dir="$(get_command_link_display_dir)"
1048  
1049      # Create a user-facing shim for the hermes command.
1050      mkdir -p "$command_link_dir"
1051      ln -sf "$HERMES_BIN" "$command_link_dir/hermes"
1052      log_success "Symlinked hermes → $command_link_display_dir/hermes"
1053  
1054      if [ "$DISTRO" = "termux" ]; then
1055          export PATH="$command_link_dir:$PATH"
1056          log_info "$command_link_display_dir is the native Termux command path"
1057          log_success "hermes command ready"
1058          return 0
1059      fi
1060  
1061      # FHS layout: /usr/local/bin is normally on PATH for login shells (via
1062      # /etc/profile pathmunge), but on RHEL/CentOS/Rocky/Alma 8+ non-login
1063      # interactive root shells (su, sudo -s, tmux panes, some web terminals)
1064      # only source /etc/bashrc, which does NOT add /usr/local/bin — and
1065      # /root/.bash_profile doesn't either.  So verify with `command -v` and
1066      # fall back to writing a PATH guard into /root/.bashrc when needed.
1067      if [ "$ROOT_FHS_LAYOUT" = true ]; then
1068          export PATH="$command_link_dir:$PATH"
1069          # Probe a fresh non-login interactive bash the way the user will use it.
1070          # `bash -i -c` sources ~/.bashrc but NOT ~/.bash_profile or /etc/profile,
1071          # which is the exact scenario where RHEL root loses /usr/local/bin.
1072          if env -i HOME="$HOME" TERM="${TERM:-dumb}" bash -i -c 'command -v hermes' \
1073                  >/dev/null 2>&1; then
1074              log_info "/usr/local/bin is already on PATH for all shells"
1075              log_success "hermes command ready"
1076              return 0
1077          fi
1078  
1079          log_info "hermes not on PATH in non-login shells (common on RHEL-family)"
1080          PATH_LINE='export PATH="/usr/local/bin:$PATH"'
1081          PATH_COMMENT='# Hermes Agent — ensure /usr/local/bin is on PATH (RHEL non-login shells)'
1082          for SHELL_CONFIG in "$HOME/.bashrc" "$HOME/.bash_profile"; do
1083              [ -f "$SHELL_CONFIG" ] || continue
1084              if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null \
1085                      | grep -qE 'PATH=.*(/usr/local/bin|\$command_link_dir)'; then
1086                  echo "" >> "$SHELL_CONFIG"
1087                  echo "$PATH_COMMENT" >> "$SHELL_CONFIG"
1088                  echo "$PATH_LINE" >> "$SHELL_CONFIG"
1089                  log_success "Added /usr/local/bin to PATH in $SHELL_CONFIG"
1090              fi
1091          done
1092          log_success "hermes command ready"
1093          return 0
1094      fi
1095  
1096      # Check if ~/.local/bin is on PATH; if not, add it to shell config.
1097      # Detect the user's actual login shell (not the shell running this script,
1098      # which is always bash when piped from curl).
1099      if ! echo "$PATH" | tr ':' '\n' | grep -q "^$command_link_dir$"; then
1100          SHELL_CONFIGS=()
1101          IS_FISH=false
1102          LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
1103          case "$LOGIN_SHELL" in
1104              zsh)
1105                  [ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
1106                  [ -f "$HOME/.zprofile" ] && SHELL_CONFIGS+=("$HOME/.zprofile")
1107                  # If neither exists, create ~/.zshrc (common on fresh macOS installs)
1108                  if [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
1109                      touch "$HOME/.zshrc"
1110                      SHELL_CONFIGS+=("$HOME/.zshrc")
1111                  fi
1112                  ;;
1113              bash)
1114                  [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
1115                  [ -f "$HOME/.bash_profile" ] && SHELL_CONFIGS+=("$HOME/.bash_profile")
1116                  ;;
1117              fish)
1118                  # fish uses ~/.config/fish/config.fish and fish_add_path — not export PATH=
1119                  IS_FISH=true
1120                  FISH_CONFIG="$HOME/.config/fish/config.fish"
1121                  mkdir -p "$(dirname "$FISH_CONFIG")"
1122                  touch "$FISH_CONFIG"
1123                  ;;
1124              *)
1125                  [ -f "$HOME/.bashrc" ] && SHELL_CONFIGS+=("$HOME/.bashrc")
1126                  [ -f "$HOME/.zshrc" ] && SHELL_CONFIGS+=("$HOME/.zshrc")
1127                  ;;
1128          esac
1129          # Also ensure ~/.profile has it (sourced by login shells on
1130          # Ubuntu/Debian/WSL even when ~/.bashrc is skipped)
1131          [ "$IS_FISH" = "false" ] && [ -f "$HOME/.profile" ] && SHELL_CONFIGS+=("$HOME/.profile")
1132  
1133          PATH_LINE='export PATH="$HOME/.local/bin:$PATH"'
1134  
1135          for SHELL_CONFIG in "${SHELL_CONFIGS[@]}"; do
1136              if ! grep -v '^[[:space:]]*#' "$SHELL_CONFIG" 2>/dev/null | grep -qE 'PATH=.*\.local/bin'; then
1137                  echo "" >> "$SHELL_CONFIG"
1138                  echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$SHELL_CONFIG"
1139                  echo "$PATH_LINE" >> "$SHELL_CONFIG"
1140                  log_success "Added ~/.local/bin to PATH in $SHELL_CONFIG"
1141              fi
1142          done
1143  
1144          # fish uses fish_add_path instead of export PATH=...
1145          if [ "$IS_FISH" = "true" ]; then
1146              if ! grep -q 'fish_add_path.*\.local/bin' "$FISH_CONFIG" 2>/dev/null; then
1147                  echo "" >> "$FISH_CONFIG"
1148                  echo "# Hermes Agent — ensure ~/.local/bin is on PATH" >> "$FISH_CONFIG"
1149                  echo 'fish_add_path "$HOME/.local/bin"' >> "$FISH_CONFIG"
1150                  log_success "Added ~/.local/bin to PATH in $FISH_CONFIG"
1151              fi
1152          fi
1153  
1154          if [ "$IS_FISH" = "false" ] && [ ${#SHELL_CONFIGS[@]} -eq 0 ]; then
1155              log_warn "Could not detect shell config file to add ~/.local/bin to PATH"
1156              log_info "Add manually: $PATH_LINE"
1157          fi
1158      else
1159          log_info "~/.local/bin already on PATH"
1160      fi
1161  
1162      # Export for current session so hermes works immediately
1163      export PATH="$command_link_dir:$PATH"
1164  
1165      log_success "hermes command ready"
1166  }
1167  
1168  copy_config_templates() {
1169      log_info "Setting up configuration files..."
1170  
1171      # Create ~/.hermes directory structure (config at top level, code in subdir)
1172      mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills}
1173  
1174      # Create .env at ~/.hermes/.env (top level, easy to find)
1175      if [ ! -f "$HERMES_HOME/.env" ]; then
1176          if [ -f "$INSTALL_DIR/.env.example" ]; then
1177              cp "$INSTALL_DIR/.env.example" "$HERMES_HOME/.env"
1178              log_success "Created ~/.hermes/.env from template"
1179          else
1180              touch "$HERMES_HOME/.env"
1181              log_success "Created ~/.hermes/.env"
1182          fi
1183      else
1184          log_info "~/.hermes/.env already exists, keeping it"
1185      fi
1186  
1187      # Create config.yaml at ~/.hermes/config.yaml (top level, easy to find)
1188      if [ ! -f "$HERMES_HOME/config.yaml" ]; then
1189          if [ -f "$INSTALL_DIR/cli-config.yaml.example" ]; then
1190              cp "$INSTALL_DIR/cli-config.yaml.example" "$HERMES_HOME/config.yaml"
1191              log_success "Created ~/.hermes/config.yaml from template"
1192          fi
1193      else
1194          log_info "~/.hermes/config.yaml already exists, keeping it"
1195      fi
1196  
1197      # Create SOUL.md if it doesn't exist (global persona file)
1198      if [ ! -f "$HERMES_HOME/SOUL.md" ]; then
1199          cat > "$HERMES_HOME/SOUL.md" << 'SOUL_EOF'
1200  # Hermes Agent Persona
1201  
1202  <!--
1203  This file defines the agent's personality and tone.
1204  The agent will embody whatever you write here.
1205  Edit this to customize how Hermes communicates with you.
1206  
1207  Examples:
1208    - "You are a warm, playful assistant who uses kaomoji occasionally."
1209    - "You are a concise technical expert. No fluff, just facts."
1210    - "You speak like a friendly coworker who happens to know everything."
1211  
1212  This file is loaded fresh each message -- no restart needed.
1213  Delete the contents (or this file) to use the default personality.
1214  -->
1215  SOUL_EOF
1216          log_success "Created ~/.hermes/SOUL.md (edit to customize personality)"
1217      fi
1218  
1219      log_success "Configuration directory ready: ~/.hermes/"
1220  
1221      # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
1222      log_info "Syncing bundled skills to ~/.hermes/skills/ ..."
1223      if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then
1224          log_success "Skills synced to ~/.hermes/skills/"
1225      else
1226          # Fallback: simple directory copy if Python sync fails
1227          if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then
1228              cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true
1229              log_success "Skills copied to ~/.hermes/skills/"
1230          fi
1231      fi
1232  }
1233  
1234  install_node_deps() {
1235      if [ "$HAS_NODE" = false ]; then
1236          log_info "Skipping Node.js dependencies (Node not installed)"
1237          return 0
1238      fi
1239  
1240      if [ "$DISTRO" = "termux" ]; then
1241          log_info "Skipping automatic Node/browser dependency setup on Termux"
1242          log_info "Browser automation is not part of the tested Termux install path yet."
1243          log_info "If you want to experiment manually later, run: cd $INSTALL_DIR && npm install"
1244          return 0
1245      fi
1246  
1247      if [ -f "$INSTALL_DIR/package.json" ]; then
1248          log_info "Installing Node.js dependencies (browser tools)..."
1249          cd "$INSTALL_DIR"
1250          npm install --silent 2>/dev/null || {
1251              log_warn "npm install failed (browser tools may not work)"
1252          }
1253          log_success "Node.js dependencies installed"
1254  
1255          # Install Playwright browser + system dependencies.
1256          # Playwright's --with-deps only supports apt-based systems natively.
1257          # For Arch/Manjaro we install the system libs via pacman first.
1258          # Other systems must install Chromium dependencies manually.
1259          log_info "Installing browser engine (Playwright Chromium)..."
1260          case "$DISTRO" in
1261              ubuntu|debian|raspbian|pop|linuxmint|elementary|zorin|kali|parrot)
1262                  log_info "Playwright may request sudo to install browser system dependencies (shared libraries)."
1263                  log_info "This is standard Playwright setup — Hermes itself does not require root access."
1264                  cd "$INSTALL_DIR" && npx playwright install --with-deps chromium 2>/dev/null || {
1265                      log_warn "Playwright browser installation failed — browser tools will not work."
1266                      log_warn "Try running manually: cd $INSTALL_DIR && npx playwright install --with-deps chromium"
1267                  }
1268                  ;;
1269              arch|manjaro)
1270                  if command -v pacman &> /dev/null; then
1271                      log_info "Arch/Manjaro detected — installing Chromium system dependencies via pacman..."
1272                      if command -v sudo &> /dev/null && sudo -n true 2>/dev/null; then
1273                          sudo NEEDRESTART_MODE=a pacman -S --noconfirm --needed \
1274                              nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true
1275                      elif [ "$(id -u)" -eq 0 ]; then
1276                          pacman -S --noconfirm --needed \
1277                              nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib >/dev/null 2>&1 || true
1278                      else
1279                          log_warn "Cannot install browser deps without sudo. Run manually:"
1280                          log_warn "  sudo pacman -S nss atk at-spi2-core cups libdrm libxkbcommon mesa pango cairo alsa-lib"
1281                      fi
1282                  fi
1283                  cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
1284                      log_warn "Playwright browser installation failed — browser tools will not work."
1285                  }
1286                  ;;
1287              fedora|rhel|centos|rocky|alma)
1288                  log_warn "Playwright does not support automatic dependency installation on RPM-based systems."
1289                  log_info "Install Chromium system dependencies manually before using browser tools:"
1290                  log_info "  sudo dnf install nss atk at-spi2-core cups-libs libdrm libxkbcommon mesa-libgbm pango cairo alsa-lib"
1291                  cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
1292                      log_warn "Playwright browser installation failed — install dependencies above and retry."
1293                  }
1294                  ;;
1295              opensuse*|sles)
1296                  log_warn "Playwright does not support automatic dependency installation on zypper-based systems."
1297                  log_info "Install Chromium system dependencies manually before using browser tools:"
1298                  log_info "  sudo zypper install mozilla-nss libatk-1_0-0 at-spi2-core cups-libs libdrm2 libxkbcommon0 Mesa-libgbm1 pango cairo libasound2"
1299                  cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || {
1300                      log_warn "Playwright browser installation failed — install dependencies above and retry."
1301                  }
1302                  ;;
1303              *)
1304                  log_warn "Playwright does not support automatic dependency installation on $DISTRO."
1305                  log_info "Install Chromium/browser system dependencies for your distribution, then run:"
1306                  log_info "  cd $INSTALL_DIR && npx playwright install chromium"
1307                  log_info "Browser tools will not work until dependencies are installed."
1308                  cd "$INSTALL_DIR" && npx playwright install chromium 2>/dev/null || true
1309                  ;;
1310          esac
1311          log_success "Browser engine setup complete"
1312      fi
1313  
1314      # Install TUI dependencies
1315      if [ -f "$INSTALL_DIR/ui-tui/package.json" ]; then
1316          log_info "Installing TUI dependencies..."
1317          cd "$INSTALL_DIR/ui-tui"
1318          npm install --silent 2>/dev/null || {
1319              log_warn "TUI npm install failed (hermes --tui may not work)"
1320          }
1321          log_success "TUI dependencies installed"
1322      fi
1323  
1324  
1325  }
1326  
1327  run_setup_wizard() {
1328      if [ "$RUN_SETUP" = false ]; then
1329          log_info "Skipping setup wizard (--skip-setup)"
1330          return 0
1331      fi
1332  
1333      # The setup wizard reads from /dev/tty, so it works even when the
1334      # install script itself is piped (curl | bash). Only skip if no
1335      # terminal is available at all (e.g. Docker build, CI).
1336      #
1337      # Probe by actually opening /dev/tty: a bare existence test passes
1338      # in Docker builds where the device node is in the mount namespace
1339      # but opening fails with ENXIO, so the wizard would proceed and
1340      # then crash on `< /dev/tty` below.
1341      if ! (: </dev/tty) 2>/dev/null; then
1342          log_info "Setup wizard skipped (no terminal available). Run 'hermes setup' after install."
1343          return 0
1344      fi
1345  
1346      echo ""
1347      log_info "Starting setup wizard..."
1348      echo ""
1349  
1350      cd "$INSTALL_DIR"
1351  
1352      # Run hermes setup using the venv Python directly (no activation needed).
1353      # Redirect stdin from /dev/tty so interactive prompts work when piped from curl.
1354      if [ "$USE_VENV" = true ]; then
1355          "$INSTALL_DIR/venv/bin/python" -m hermes_cli.main setup < /dev/tty
1356      else
1357          python -m hermes_cli.main setup < /dev/tty
1358      fi
1359  }
1360  
1361  maybe_start_gateway() {
1362      # Check if any messaging platform tokens were configured
1363      ENV_FILE="$HERMES_HOME/.env"
1364      if [ ! -f "$ENV_FILE" ]; then
1365          return 0
1366      fi
1367  
1368      HAS_MESSAGING=false
1369      for VAR in TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_ENABLED; do
1370          VAL=$(grep "^${VAR}=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
1371          if [ -n "$VAL" ] && [ "$VAL" != "your-token-here" ]; then
1372              HAS_MESSAGING=true
1373              break
1374          fi
1375      done
1376  
1377      if [ "$HAS_MESSAGING" = false ]; then
1378          return 0
1379      fi
1380  
1381      echo ""
1382      log_info "Messaging platform token detected!"
1383      log_info "The gateway needs to be running for Hermes to send/receive messages."
1384  
1385      # If WhatsApp is enabled and no session exists yet, run foreground first for QR scan
1386      WHATSAPP_VAL=$(grep "^WHATSAPP_ENABLED=" "$ENV_FILE" 2>/dev/null | cut -d'=' -f2-)
1387      WHATSAPP_SESSION="$HERMES_HOME/whatsapp/session/creds.json"
1388      if [ "$WHATSAPP_VAL" = "true" ] && [ ! -f "$WHATSAPP_SESSION" ]; then
1389          if [ "$IS_INTERACTIVE" = true ]; then
1390              echo ""
1391              log_info "WhatsApp is enabled but not yet paired."
1392              log_info "Running 'hermes whatsapp' to pair via QR code..."
1393              echo ""
1394              if prompt_yes_no "Pair WhatsApp now?" "yes"; then
1395                  HERMES_CMD="$(get_hermes_command_path)"
1396                  $HERMES_CMD whatsapp || true
1397              fi
1398          else
1399              log_info "WhatsApp pairing skipped (non-interactive). Run 'hermes whatsapp' to pair."
1400          fi
1401      fi
1402  
1403      # Probe by actually opening /dev/tty: a bare existence test passes
1404      # in Docker builds where the device node is in the mount namespace
1405      # but opening fails with ENXIO. See #16746.
1406      if ! (: </dev/tty) 2>/dev/null; then
1407          log_info "Gateway setup skipped (no terminal available). Run 'hermes gateway install' later."
1408          return 0
1409      fi
1410  
1411      echo ""
1412      local should_install_gateway=false
1413      if [ "$DISTRO" = "termux" ]; then
1414          if prompt_yes_no "Would you like to start the gateway in the background?" "yes"; then
1415              should_install_gateway=true
1416          fi
1417      else
1418          if prompt_yes_no "Would you like to install the gateway as a background service?" "yes"; then
1419              should_install_gateway=true
1420          fi
1421      fi
1422  
1423      if [ "$should_install_gateway" = true ]; then
1424          HERMES_CMD="$(get_hermes_command_path)"
1425  
1426          if [ "$DISTRO" != "termux" ] && command -v systemctl &> /dev/null; then
1427              log_info "Installing systemd service..."
1428              if $HERMES_CMD gateway install 2>/dev/null; then
1429                  log_success "Gateway service installed"
1430                  if $HERMES_CMD gateway start 2>/dev/null; then
1431                      log_success "Gateway started! Your bot is now online."
1432                  else
1433                      log_warn "Service installed but failed to start. Try: hermes gateway start"
1434                  fi
1435              else
1436                  log_warn "Systemd install failed. You can start manually: hermes gateway"
1437              fi
1438          else
1439              if [ "$DISTRO" = "termux" ]; then
1440                  log_info "Termux detected — starting gateway in best-effort background mode..."
1441              else
1442                  log_info "systemd not available — starting gateway in background..."
1443              fi
1444              nohup $HERMES_CMD gateway > "$HERMES_HOME/logs/gateway.log" 2>&1 &
1445              GATEWAY_PID=$!
1446              log_success "Gateway started (PID $GATEWAY_PID). Logs: ~/.hermes/logs/gateway.log"
1447              log_info "To stop: kill $GATEWAY_PID"
1448              log_info "To restart later: hermes gateway"
1449              if [ "$DISTRO" = "termux" ]; then
1450                  log_warn "Android may stop background processes when Termux is suspended or the system reclaims resources."
1451              fi
1452          fi
1453      else
1454          log_info "Skipped. Start the gateway later with: hermes gateway"
1455      fi
1456  }
1457  
1458  print_success() {
1459      echo ""
1460      echo -e "${GREEN}${BOLD}"
1461      echo "┌─────────────────────────────────────────────────────────┐"
1462      echo "│              ✓ Installation Complete!                   │"
1463      echo "└─────────────────────────────────────────────────────────┘"
1464      echo -e "${NC}"
1465      echo ""
1466  
1467      # Show file locations
1468      echo -e "${CYAN}${BOLD}📁 Your files:${NC}"
1469      echo ""
1470      echo -e "   ${YELLOW}Config:${NC}    $HERMES_HOME/config.yaml"
1471      echo -e "   ${YELLOW}API Keys:${NC}  $HERMES_HOME/.env"
1472      echo -e "   ${YELLOW}Data:${NC}      $HERMES_HOME/cron/, sessions/, logs/"
1473      echo -e "   ${YELLOW}Code:${NC}      $INSTALL_DIR"
1474      echo ""
1475  
1476      echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
1477      echo ""
1478      echo -e "${CYAN}${BOLD}🚀 Commands:${NC}"
1479      echo ""
1480      echo -e "   ${GREEN}hermes${NC}              Start chatting"
1481      echo -e "   ${GREEN}hermes setup${NC}        Configure API keys & settings"
1482      echo -e "   ${GREEN}hermes config${NC}       View/edit configuration"
1483      echo -e "   ${GREEN}hermes config edit${NC}  Open config in editor"
1484      echo -e "   ${GREEN}hermes gateway install${NC} Install gateway service (messaging + cron)"
1485      echo -e "   ${GREEN}hermes update${NC}       Update to latest version"
1486      echo ""
1487  
1488      echo -e "${CYAN}─────────────────────────────────────────────────────────${NC}"
1489      echo ""
1490      if [ "$DISTRO" = "termux" ]; then
1491          echo -e "${YELLOW}⚡ 'hermes' was linked into $(get_command_link_display_dir), which is already on PATH in Termux.${NC}"
1492          echo ""
1493      elif [ "$ROOT_FHS_LAYOUT" = true ]; then
1494          echo -e "${YELLOW}⚡ 'hermes' was linked into /usr/local/bin and is ready to use — no shell reload needed.${NC}"
1495          echo ""
1496      else
1497          echo -e "${YELLOW}⚡ Reload your shell to use 'hermes' command:${NC}"
1498          echo ""
1499          LOGIN_SHELL="$(basename "${SHELL:-/bin/bash}")"
1500          if [ "$LOGIN_SHELL" = "zsh" ]; then
1501              echo "   source ~/.zshrc"
1502          elif [ "$LOGIN_SHELL" = "bash" ]; then
1503              echo "   source ~/.bashrc"
1504          elif [ "$LOGIN_SHELL" = "fish" ]; then
1505              echo "   source ~/.config/fish/config.fish"
1506          else
1507              echo "   source ~/.bashrc   # or ~/.zshrc"
1508          fi
1509          echo ""
1510      fi
1511  
1512      # Show Node.js warning if auto-install failed
1513      if [ "$HAS_NODE" = false ]; then
1514          echo -e "${YELLOW}"
1515          echo "Note: Node.js could not be installed automatically."
1516          echo "Browser tools need Node.js. Install manually:"
1517          if [ "$DISTRO" = "termux" ]; then
1518              echo "  pkg install nodejs"
1519          else
1520              echo "  https://nodejs.org/en/download/"
1521          fi
1522          echo -e "${NC}"
1523      fi
1524  
1525      # Show ripgrep note if not installed
1526      if [ "$HAS_RIPGREP" = false ]; then
1527          echo -e "${YELLOW}"
1528          echo "Note: ripgrep (rg) was not found. File search will use"
1529          echo "grep as a fallback. For faster search in large codebases,"
1530          if [ "$DISTRO" = "termux" ]; then
1531              echo "install ripgrep: pkg install ripgrep"
1532          else
1533              echo "install ripgrep: sudo apt install ripgrep (or brew install ripgrep)"
1534          fi
1535          echo -e "${NC}"
1536      fi
1537  }
1538  
1539  # ============================================================================
1540  # Main
1541  # ============================================================================
1542  
1543  main() {
1544      print_banner
1545  
1546      detect_os
1547      resolve_install_layout
1548      install_uv
1549      check_python
1550      check_git
1551      check_node
1552      install_system_packages
1553  
1554      clone_repo
1555      setup_venv
1556      install_deps
1557      install_node_deps
1558      setup_path
1559      copy_config_templates
1560      run_setup_wizard
1561      maybe_start_gateway
1562  
1563      print_success
1564  }
1565  
1566  main