/ install.sh
install.sh
   1  #!/usr/bin/env bash
   2  #
   3  # Ag3ntum One-Command Installer
   4  # https://github.com/extractumio/ag3ntum
   5  #
   6  # Usage:
   7  #   curl -fsSL https://raw.githubusercontent.com/extractumio/ag3ntum/release/install.sh | bash
   8  #
   9  # Or download and run:
  10  #   chmod +x install.sh && ./install.sh
  11  #
  12  # Options:
  13  #   --dev          Development mode (Vite dev server, clones 'main' branch)
  14  #   --branch B     Clone specific branch (default: 'release')
  15  #   --help         Show help
  16  #
  17  # This script:
  18  #   1. Checks prerequisites (Docker, Git)
  19  #   2. Clones the repository (if needed)
  20  #   3. Prompts for configuration (API key, admin credentials)
  21  #   4. Generates configuration files
  22  #   5. Builds and starts containers
  23  #   6. Creates admin user
  24  #
  25  
  26  set -euo pipefail
  27  
  28  # =============================================================================
  29  # CONSTANTS
  30  # =============================================================================
  31  
  32  REPO_URL="https://github.com/extractumio/ag3ntum.git"
  33  MIN_DOCKER_VERSION="20.10"
  34  DEFAULT_BRANCH="release"
  35  
  36  # Braille spinner frames (same as web terminal)
  37  SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
  38  
  39  # Colors (ANSI escape codes)
  40  # Use $'...' syntax so escape sequences are interpreted at assignment time.
  41  # Single quotes ('\033[...') store the literal backslash sequence, which
  42  # only works with echo -e or printf — plain echo leaves them unrendered.
  43  RED=$'\033[0;31m'
  44  GREEN=$'\033[0;32m'
  45  YELLOW=$'\033[0;33m'
  46  BLUE=$'\033[0;34m'
  47  CYAN=$'\033[0;36m'
  48  WHITE=$'\033[0;37m'
  49  BOLD=$'\033[1m'
  50  DIM=$'\033[2m'
  51  NC=$'\033[0m' # No Color
  52  
  53  # Configuration defaults
  54  DEFAULT_API_PORT="40080"
  55  DEFAULT_WEB_PORT="50080"
  56  DEFAULT_HOSTNAME="localhost"
  57  
  58  # Global state
  59  DETECTED_OS=""
  60  IN_REPO=0
  61  
  62  # =============================================================================
  63  # UTILITY FUNCTIONS
  64  # =============================================================================
  65  
  66  print_info() {
  67      printf "${CYAN}ℹ${NC} %s\n" "$1"
  68  }
  69  
  70  print_success() {
  71      printf "${GREEN}✓${NC} %s\n" "$1"
  72  }
  73  
  74  print_warning() {
  75      printf "${YELLOW}⚠${NC} %s\n" "$1"
  76  }
  77  
  78  print_error() {
  79      printf "${RED}✗${NC} %s\n" "$1" >&2
  80  }
  81  
  82  print_step() {
  83      printf "\n${BOLD}${BLUE}▶${NC} ${BOLD}%s${NC}\n" "$1"
  84  }
  85  
  86  print_dim() {
  87      printf "${DIM}%s${NC}\n" "$1"
  88  }
  89  
  90  # Animated spinner for long-running operations
  91  # Usage: long_command & spinner $! "Message"
  92  spinner() {
  93      local pid=$1
  94      local message=$2
  95      local i=0
  96      local frame_count=${#SPINNER_FRAMES[@]}
  97  
  98      # Hide cursor
  99      printf "\033[?25l"
 100  
 101      while kill -0 "$pid" 2>/dev/null; do
 102          printf "\r${CYAN}%s${NC} %s" "${SPINNER_FRAMES[$i]}" "$message"
 103          i=$(( (i + 1) % frame_count ))
 104          sleep 0.1
 105      done
 106  
 107      # Show cursor and clear line
 108      printf "\033[?25h"
 109      printf "\r\033[K"
 110  
 111      # Check exit status
 112      wait "$pid"
 113      return $?
 114  }
 115  
 116  # Prompt for text input with default value
 117  prompt_text() {
 118      local prompt=$1
 119      local default=$2
 120      local var_name=$3
 121      local result
 122  
 123      if [[ -n "$default" ]]; then
 124          printf "${CYAN}?${NC} %s [${WHITE}%s${NC}]: " "$prompt" "$default"
 125      else
 126          printf "${CYAN}?${NC} %s: " "$prompt"
 127      fi
 128  
 129      read -r result < /dev/tty
 130      result="${result:-$default}"
 131      eval "$var_name='$result'"
 132  }
 133  
 134  # Prompt for password (no echo, with confirmation)
 135  prompt_password() {
 136      local prompt=$1
 137      local var_name=$2
 138      local result
 139      local confirm
 140  
 141      while true; do
 142          printf "${CYAN}?${NC} %s: " "$prompt"
 143          read -rs result < /dev/tty
 144          printf "\n"
 145  
 146          if [[ ${#result} -lt 8 ]]; then
 147              print_warning "Password must be at least 8 characters"
 148              continue
 149          fi
 150  
 151          printf "${CYAN}?${NC} Confirm password: "
 152          read -rs confirm < /dev/tty
 153          printf "\n"
 154  
 155          if [[ "$result" != "$confirm" ]]; then
 156              print_warning "Passwords do not match"
 157              continue
 158          fi
 159  
 160          break
 161      done
 162  
 163      eval "$var_name='$result'"
 164  }
 165  
 166  # Prompt for yes/no answer
 167  prompt_yesno() {
 168      local prompt=$1
 169      local default=${2:-y}
 170      local result
 171  
 172      local hint="[Y/n]"
 173      [[ "$default" == "n" ]] && hint="[y/N]"
 174  
 175      printf "${CYAN}?${NC} %s %s: " "$prompt" "$hint"
 176      read -r result < /dev/tty
 177      result="${result:-$default}"
 178  
 179      [[ "$result" =~ ^[Yy] ]]
 180  }
 181  
 182  # Validate API key format
 183  validate_api_key() {
 184      local key=$1
 185      if [[ ! "$key" =~ ^sk-ant- ]]; then
 186          return 1
 187      fi
 188      if [[ ${#key} -lt 20 ]]; then
 189          return 1
 190      fi
 191      return 0
 192  }
 193  
 194  # Validate port number
 195  validate_port() {
 196      local port=$1
 197      if [[ ! "$port" =~ ^[0-9]+$ ]]; then
 198          return 1
 199      fi
 200      if [[ "$port" -lt 1024 ]] || [[ "$port" -gt 65535 ]]; then
 201          return 1
 202      fi
 203      return 0
 204  }
 205  
 206  # Check if a port is available
 207  check_port_available() {
 208      local port=$1
 209  
 210      if command -v lsof &>/dev/null; then
 211          ! lsof -i ":$port" &>/dev/null
 212      elif command -v ss &>/dev/null; then
 213          ! ss -tuln 2>/dev/null | grep -q ":$port "
 214      elif command -v netstat &>/dev/null; then
 215          ! netstat -tuln 2>/dev/null | grep -q ":$port "
 216      else
 217          # Cannot check, assume available
 218          return 0
 219      fi
 220  }
 221  
 222  # Reclaim ownership of files left by a previous build using Docker (no sudo needed).
 223  # Container entrypoint chowns data dirs and secrets to UID 45045. On Linux, re-running
 224  # the installer without this would fail on file writes. Uses Docker to fix ownership.
 225  # macOS/Windows use Docker Desktop which handles permissions transparently.
 226  reclaim_previous_build_ownership() {
 227      local os_type
 228      os_type="$(uname -s)"
 229  
 230      # Only needed on Linux, and only as a non-root user
 231      if [[ "${os_type}" != "Linux" ]] || [[ "$(id -u)" == "0" ]]; then
 232          return 0
 233      fi
 234  
 235      local needs_reclaim=0
 236  
 237      # Check config files (secrets.yaml gets chowned to 45045 by container entrypoint)
 238      for f in config/*; do
 239          if [[ -f "$f" ]] && [[ ! -w "$f" ]]; then
 240              needs_reclaim=1
 241              break
 242          fi
 243      done
 244  
 245      if [[ "$needs_reclaim" == "1" ]]; then
 246          print_info "Reclaiming config file ownership from previous build..."
 247          docker run --rm -v "$(pwd)/config:/config" alpine chown -R "$(id -u):$(id -g)" /config
 248          print_success "Ownership reclaimed"
 249      fi
 250  }
 251  
 252  # =============================================================================
 253  # PREREQUISITE CHECKS
 254  # =============================================================================
 255  
 256  detect_os() {
 257      local os=""
 258      local version=""
 259  
 260      case "$(uname -s)" in
 261          Darwin)
 262              os="macos"
 263              version=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
 264              ;;
 265          Linux)
 266              if [[ -f /etc/os-release ]]; then
 267                  # shellcheck source=/dev/null
 268                  . /etc/os-release
 269                  os="${ID:-linux}"
 270                  version="${VERSION_ID:-unknown}"
 271              else
 272                  os="linux"
 273                  version="unknown"
 274              fi
 275              ;;
 276          MINGW*|MSYS*|CYGWIN*)
 277              os="windows"
 278              version="git-bash"
 279              ;;
 280          *)
 281              os="unknown"
 282              version="unknown"
 283              ;;
 284      esac
 285  
 286      DETECTED_OS="$os:$version"
 287  }
 288  
 289  check_docker() {
 290      if ! command -v docker &>/dev/null; then
 291          print_error "Docker is not installed"
 292          echo ""
 293          case "$DETECTED_OS" in
 294              macos*)
 295                  echo "Install Docker Desktop for Mac:"
 296                  echo "  ${CYAN}https://docs.docker.com/desktop/install/mac-install/${NC}"
 297                  ;;
 298              ubuntu*|debian*)
 299                  echo "Install Docker on Ubuntu/Debian:"
 300                  echo "  ${CYAN}curl -fsSL https://get.docker.com | sh${NC}"
 301                  echo "  ${CYAN}sudo usermod -aG docker \$USER${NC}"
 302                  echo ""
 303                  echo "Then log out and back in (or reboot) for group changes to take effect."
 304                  ;;
 305              *)
 306                  echo "Install Docker: ${CYAN}https://docs.docker.com/get-docker/${NC}"
 307                  ;;
 308          esac
 309          return 1
 310      fi
 311  
 312      # Check Docker daemon is running
 313      if ! docker info &>/dev/null; then
 314          print_error "Docker daemon is not running"
 315          echo ""
 316          case "$DETECTED_OS" in
 317              macos*)
 318                  echo "Start Docker Desktop and try again."
 319                  ;;
 320              *)
 321                  echo "Start the Docker service:"
 322                  echo "  ${CYAN}sudo systemctl start docker${NC}"
 323                  ;;
 324          esac
 325          return 1
 326      fi
 327  
 328      # Get and check version
 329      local docker_version
 330      docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "0.0")
 331  
 332      # Version comparison
 333      local major minor
 334      IFS='.' read -r major minor _ <<< "$docker_version"
 335      local min_major min_minor
 336      IFS='.' read -r min_major min_minor <<< "$MIN_DOCKER_VERSION"
 337  
 338      if [[ "$major" -lt "$min_major" ]] || { [[ "$major" -eq "$min_major" ]] && [[ "$minor" -lt "$min_minor" ]]; }; then
 339          print_warning "Docker version $docker_version is older than recommended $MIN_DOCKER_VERSION"
 340      fi
 341  
 342      print_success "Docker $docker_version"
 343      return 0
 344  }
 345  
 346  check_docker_compose() {
 347      local compose_cmd=""
 348      local compose_version=""
 349  
 350      # Check for docker compose (v2) first
 351      if docker compose version &>/dev/null 2>&1; then
 352          compose_cmd="docker compose"
 353          compose_version=$(docker compose version --short 2>/dev/null || echo "unknown")
 354      elif command -v docker-compose &>/dev/null; then
 355          compose_cmd="docker-compose"
 356          compose_version=$(docker-compose version --short 2>/dev/null || echo "unknown")
 357      else
 358          print_error "Docker Compose is not installed"
 359          echo ""
 360          echo "Docker Compose v2 is included with Docker Desktop."
 361          echo "For standalone installation:"
 362          echo "  ${CYAN}https://docs.docker.com/compose/install/${NC}"
 363          return 1
 364      fi
 365  
 366      print_success "Docker Compose $compose_version ($compose_cmd)"
 367      return 0
 368  }
 369  
 370  check_git() {
 371      if ! command -v git &>/dev/null; then
 372          print_error "Git is not installed"
 373          echo ""
 374          case "$DETECTED_OS" in
 375              macos*)
 376                  echo "Install Git with Xcode Command Line Tools:"
 377                  echo "  ${CYAN}xcode-select --install${NC}"
 378                  ;;
 379              ubuntu*|debian*)
 380                  echo "Install Git:"
 381                  echo "  ${CYAN}sudo apt-get update && sudo apt-get install -y git${NC}"
 382                  ;;
 383              *)
 384                  echo "Install Git: ${CYAN}https://git-scm.com/downloads${NC}"
 385                  ;;
 386          esac
 387          return 1
 388      fi
 389  
 390      local git_version
 391      git_version=$(git --version | awk '{print $3}')
 392      print_success "Git $git_version"
 393      return 0
 394  }
 395  
 396  check_curl() {
 397      if ! command -v curl &>/dev/null; then
 398          print_error "curl is not installed"
 399          return 1
 400      fi
 401      print_success "curl available"
 402      return 0
 403  }
 404  
 405  run_prerequisite_checks() {
 406      print_step "Checking Prerequisites"
 407  
 408      local failed=0
 409  
 410      check_docker || failed=1
 411      check_docker_compose || failed=1
 412      check_git || failed=1
 413      check_curl || failed=1
 414  
 415      if [[ $failed -eq 1 ]]; then
 416          echo ""
 417          print_error "Please install missing prerequisites and run the installer again."
 418          exit 1
 419      fi
 420  
 421      print_success "All prerequisites satisfied"
 422  }
 423  
 424  # =============================================================================
 425  # REPOSITORY SETUP
 426  # =============================================================================
 427  
 428  setup_repository() {
 429      print_step "Setting Up Repository"
 430  
 431      # Check if we're already in the ag3ntum directory (run.sh exists)
 432      if [[ -f "run.sh" ]] && [[ -f "docker-compose.yml" ]] && [[ -d "config" ]]; then
 433          print_success "Already in Ag3ntum directory"
 434          IN_REPO=1
 435          return 0
 436      fi
 437  
 438      # Check if ag3ntum directory exists in current location
 439      if [[ -d "ag3ntum" ]]; then
 440          print_info "Found existing ag3ntum directory"
 441          cd ag3ntum || exit 1
 442  
 443          if [[ -f "run.sh" ]] && [[ -f "docker-compose.yml" ]]; then
 444              print_success "Using existing repository"
 445              IN_REPO=1
 446              return 0
 447          else
 448              # Directory exists but is incomplete - fail with clear message
 449              print_error "ag3ntum directory exists but is incomplete (missing run.sh or docker-compose.yml)"
 450              print_info "Please remove the directory and try again:"
 451              echo "  rm -rf ag3ntum"
 452              exit 1
 453          fi
 454      fi
 455  
 456      # Clone the repository
 457      print_info "Cloning Ag3ntum repository (branch: ${AG3NTUM_BRANCH})..."
 458  
 459      # Clone with progress indicator
 460      local clone_output
 461      clone_output=$(mktemp)
 462  
 463      (git clone --depth 1 --branch "$AG3NTUM_BRANCH" "$REPO_URL" ag3ntum > "$clone_output" 2>&1) &
 464      local clone_pid=$!
 465  
 466      if ! spinner $clone_pid "Cloning repository"; then
 467          print_error "Failed to clone repository"
 468          cat "$clone_output"
 469          rm -f "$clone_output"
 470          exit 1
 471      fi
 472      rm -f "$clone_output"
 473  
 474      cd ag3ntum || exit 1
 475      IN_REPO=1
 476      print_success "Repository cloned successfully"
 477  }
 478  
 479  # =============================================================================
 480  # CONFIGURATION GATHERING
 481  # =============================================================================
 482  
 483  gather_configuration() {
 484      print_step "Configuration"
 485  
 486      echo ""
 487      print_info "Please provide the following configuration values."
 488      print_info "Press Enter to accept defaults shown in [brackets]."
 489      echo ""
 490  
 491      # API Key (required, sensitive)
 492      while true; do
 493          printf "${CYAN}?${NC} Anthropic API Key: "
 494          read -rs ANTHROPIC_API_KEY < /dev/tty
 495          printf "\n"
 496  
 497          if [[ -z "$ANTHROPIC_API_KEY" ]]; then
 498              print_warning "API key is required"
 499              print_dim "  Get yours at: https://console.anthropic.com/settings/keys"
 500              continue
 501          fi
 502  
 503          if ! validate_api_key "$ANTHROPIC_API_KEY"; then
 504              print_warning "Invalid API key format (should start with 'sk-ant-')"
 505              continue
 506          fi
 507  
 508          break
 509      done
 510      print_success "API key configured"
 511  
 512      echo ""
 513  
 514      # Admin credentials
 515      print_info "Create admin account"
 516  
 517      prompt_text "Admin username" "admin" ADMIN_USERNAME
 518  
 519      # Validate username
 520      while [[ ! "$ADMIN_USERNAME" =~ ^[a-zA-Z0-9_]{3,32}$ ]]; do
 521          print_warning "Username must be 3-32 alphanumeric characters or underscore"
 522          prompt_text "Admin username" "admin" ADMIN_USERNAME
 523      done
 524  
 525      while true; do
 526          prompt_text "Admin email" "" ADMIN_EMAIL
 527          if [[ "$ADMIN_EMAIL" == *"@"*"."* ]]; then
 528              break
 529          fi
 530          print_warning "Please enter a valid email address"
 531      done
 532  
 533      prompt_password "Admin password" ADMIN_PASSWORD
 534  
 535      print_success "Admin credentials configured"
 536  
 537      echo ""
 538  
 539      # Server configuration
 540      print_info "Server configuration"
 541  
 542      prompt_text "Public hostname or IP" "$DEFAULT_HOSTNAME" SERVER_HOSTNAME
 543  
 544      # API port
 545      while true; do
 546          prompt_text "API port" "$DEFAULT_API_PORT" API_PORT
 547          if validate_port "$API_PORT"; then
 548              if ! check_port_available "$API_PORT"; then
 549                  print_warning "Port $API_PORT appears to be in use"
 550                  if ! prompt_yesno "Continue anyway?" "n"; then
 551                      continue
 552                  fi
 553              fi
 554              break
 555          fi
 556          print_warning "Invalid port number (must be 1024-65535)"
 557      done
 558  
 559      # Web UI port (used in both prod and dev modes)
 560      while true; do
 561          prompt_text "Web UI port" "$DEFAULT_WEB_PORT" WEB_PORT
 562          if validate_port "$WEB_PORT"; then
 563              if [[ "$WEB_PORT" == "$API_PORT" ]]; then
 564                  print_warning "Web port must be different from API port"
 565                  continue
 566              fi
 567              if ! check_port_available "$WEB_PORT"; then
 568                  print_warning "Port $WEB_PORT appears to be in use"
 569                  if ! prompt_yesno "Continue anyway?" "n"; then
 570                      continue
 571                  fi
 572              fi
 573              break
 574          fi
 575          print_warning "Invalid port number (must be 1024-65535)"
 576      done
 577  
 578      print_success "Server configuration complete"
 579  }
 580  
 581  # =============================================================================
 582  # CONFIGURATION FILE GENERATION
 583  # =============================================================================
 584  
 585  generate_fernet_key() {
 586      # Generate a Fernet-compatible key (32 bytes, base64-encoded)
 587      # Fernet requires exactly 32 bytes of URL-safe base64-encoded key
 588      if command -v python3 &> /dev/null; then
 589          python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null
 590      elif command -v openssl &> /dev/null; then
 591          # Generate 32 random bytes and encode as URL-safe base64
 592          openssl rand -base64 32 | tr '+/' '-_'
 593      else
 594          # Fallback using /dev/urandom
 595          head -c 32 /dev/urandom | base64 | tr '+/' '-_'
 596      fi
 597  }
 598  
 599  generate_secrets_yaml() {
 600      print_info "Generating secrets.yaml..."
 601  
 602      # Generate Fernet key for encryption
 603      local fernet_key
 604      fernet_key=$(generate_fernet_key)
 605  
 606      cat > config/secrets.yaml << EOF
 607  _version: "0.2.0"
 608  
 609  # Ag3ntum Secrets Configuration
 610  # Generated by install.sh on $(date -Iseconds 2>/dev/null || date)
 611  # IMPORTANT: Keep this file secure and never commit to version control
 612  
 613  # Anthropic API Key
 614  anthropic_api_key: ${ANTHROPIC_API_KEY}
 615  
 616  # Fernet encryption key for encrypting user API keys in the database
 617  # DO NOT change this after users have stored encrypted tokens
 618  fernet_key: ${fernet_key}
 619  EOF
 620  
 621      # Clear the variable from memory
 622      unset ANTHROPIC_API_KEY
 623  
 624      # Container entrypoint will chown 45045 + chmod 600 at startup
 625      print_success "secrets.yaml created (container entrypoint will secure permissions)"
 626  }
 627  
 628  generate_api_yaml() {
 629      print_info "Generating api.yaml..."
 630  
 631      # Copy from example if it exists
 632      if [[ -f "config/api.yaml.example" ]]; then
 633          cp config/api.yaml.example config/api.yaml
 634      else
 635          print_warning "api.yaml.example not found, creating minimal config"
 636          cat > config/api.yaml << EOF
 637  _version: "0.2.0"
 638  
 639  server:
 640    hostname: "${SERVER_HOSTNAME}"
 641    protocol: "http"
 642    trusted_proxies: []
 643  
 644  api:
 645    host: "0.0.0.0"
 646    port: 40080
 647    external_port: ${API_PORT}
 648    reload: false
 649  
 650  web:
 651    host: "0.0.0.0"
 652    port: 50080
 653    external_port: ${WEB_PORT}
 654  
 655  security:
 656    enable_security_headers: true
 657    validate_host_header: true
 658    content_security_policy: "strict"
 659    additional_allowed_hosts: []
 660  
 661  database:
 662    path: "./data/ag3ntum.db"
 663  
 664  jwt:
 665    algorithm: "HS256"
 666    expiry_hours: 168
 667  
 668  redis:
 669    url: "redis://redis:6379/0"
 670    max_connections: 50
 671    socket_timeout: 5.0
 672    socket_connect_timeout: 5.0
 673    decode_responses: false
 674  
 675  task_queue:
 676    auto_resume:
 677      enabled: true
 678      max_session_age_hours: 6
 679      max_resume_attempts: 3
 680      resume_delay_seconds: 5
 681    queue:
 682      enabled: true
 683      processing_interval_ms: 500
 684      max_queue_size: 1000
 685      task_timeout_minutes: 30
 686    quotas:
 687      global_max_concurrent: 4
 688      per_user_max_concurrent: 2
 689      per_user_daily_limit: 50
 690  EOF
 691          print_success "api.yaml created"
 692          return
 693      fi
 694  
 695      # Update values using sed
 696      # macOS and GNU sed have different -i syntax - use function to handle
 697      sed_inplace() {
 698          if [[ "$(uname)" == "Darwin" ]]; then
 699              sed -i '' "$@"
 700          else
 701              sed -i "$@"
 702          fi
 703      }
 704  
 705      # Update hostname
 706      sed_inplace "s|hostname: \"localhost\"|hostname: \"${SERVER_HOSTNAME}\"|" config/api.yaml
 707  
 708      # Add localhost to additional_allowed_hosts if using a different hostname
 709      if [[ "${SERVER_HOSTNAME}" != "localhost" ]]; then
 710          sed_inplace "s|additional_allowed_hosts: \[\]|additional_allowed_hosts: [\"localhost\", \"127.0.0.1\"]|" config/api.yaml
 711      fi
 712  
 713      # Update API external port (appears in api section)
 714      sed_inplace "s|external_port: 40080|external_port: ${API_PORT}|" config/api.yaml
 715  
 716      # Update Web external port (appears in web section) - need to be careful not to change api port
 717      # This is a bit tricky since both are "external_port" - we'll use a two-step approach
 718      if [[ "$WEB_PORT" != "$DEFAULT_WEB_PORT" ]]; then
 719          # Create temp file to track section
 720          awk -v api_port="$API_PORT" -v web_port="$WEB_PORT" '
 721          /^api:/ { in_api=1; in_web=0 }
 722          /^web:/ { in_api=0; in_web=1 }
 723          /^[a-z]/ && !/^api:/ && !/^web:/ { in_api=0; in_web=0 }
 724          /external_port:/ {
 725              if (in_web) {
 726                  sub(/external_port: [0-9]+/, "external_port: " web_port)
 727              }
 728          }
 729          { print }
 730          ' config/api.yaml > config/api.yaml.tmp && mv config/api.yaml.tmp config/api.yaml
 731      fi
 732  
 733      # Clean up any backup files created by sed (e.g., .bak files)
 734      rm -f config/api.yaml.bak 2>/dev/null || true
 735  
 736      print_success "api.yaml created"
 737  }
 738  
 739  generate_agent_yaml() {
 740      print_info "Generating agent.yaml..."
 741  
 742      if [[ -f "config/agent.yaml.example" ]]; then
 743          cp config/agent.yaml.example config/agent.yaml
 744          print_success "agent.yaml created (using defaults)"
 745      else
 746          print_warning "agent.yaml.example not found, creating minimal config"
 747          cat > config/agent.yaml << 'EOF'
 748  _version: "0.2.0"
 749  
 750  # Ag3ntum Agent Configuration
 751  agent:
 752    default_model: claude-haiku-4-5-20251001
 753    models_available:
 754      - claude-haiku-4-5-20251001
 755      - claude-sonnet-4-5-20250929
 756      - claude-opus-4-5-20251101
 757    max_turns: 100
 758    timeout_seconds: 1800
 759    role: default
 760  EOF
 761          print_success "agent.yaml created (minimal)"
 762      fi
 763  }
 764  
 765  generate_external_mounts_yaml() {
 766      print_info "Generating external-mounts.yaml..."
 767  
 768      if [[ -f "config/external-mounts.yaml.example" ]]; then
 769          # Create a minimal version (no external mounts by default)
 770          cat > config/external-mounts.yaml << 'EOF'
 771  _version: "0.2.0"
 772  
 773  # =============================================================================
 774  # External Mounts Configuration
 775  # =============================================================================
 776  # Configure external folder mounts for agent sessions.
 777  # See external-mounts.yaml.example for full documentation.
 778  #
 779  # Mount Types:
 780  #   - global: Available to ALL users
 781  #   - per_user: Available only to specified users
 782  #
 783  # Access Modes:
 784  #   - ro: Read-only (agent cannot write)
 785  #   - rw: Read-write (agent can modify)
 786  # =============================================================================
 787  
 788  # Global mounts - available to ALL users
 789  global:
 790    ro: []
 791    rw: []
 792  
 793  # Per-user mounts - available only to specified users
 794  per_user:
 795    ro: []
 796    rw: []
 797  EOF
 798          print_success "external-mounts.yaml created (empty - configure as needed)"
 799      else
 800          print_warning "external-mounts.yaml.example not found, creating minimal config"
 801          cat > config/external-mounts.yaml << 'EOF'
 802  _version: "0.2.0"
 803  
 804  global:
 805    ro: []
 806    rw: []
 807  per_user:
 808    ro: []
 809    rw: []
 810  EOF
 811          print_success "external-mounts.yaml created (minimal)"
 812      fi
 813  }
 814  
 815  generate_llm_api_proxy_yaml() {
 816      print_info "Generating llm-api-proxy.yaml..."
 817  
 818      if [[ -f "config/llm-api-proxy.yaml.example" ]]; then
 819          cp config/llm-api-proxy.yaml.example config/llm-api-proxy.yaml
 820          print_success "llm-api-proxy.yaml created (using defaults)"
 821      else
 822          print_warning "llm-api-proxy.yaml.example not found, creating minimal config"
 823          cat > config/llm-api-proxy.yaml << 'EOF'
 824  _version: "0.2.0"
 825  
 826  proxy:
 827    host: 0.0.0.0
 828    port: 8082
 829    log_level: INFO
 830    enable_streaming: true
 831  
 832  providers:
 833    anthropic:
 834      type: anthropic
 835      base_url: https://api.anthropic.com
 836      api_key_env: ANTHROPIC_API_KEY
 837  
 838  models: {}
 839  
 840  routing:
 841    default_provider: anthropic
 842    allow_unmapped_models: false
 843  EOF
 844          print_success "llm-api-proxy.yaml created (minimal)"
 845      fi
 846  }
 847  
 848  generate_configuration() {
 849      print_step "Generating Configuration Files"
 850  
 851      # Ensure config directory exists and is writable
 852      mkdir -p config
 853      if [[ ! -w "config" ]]; then
 854          print_error "Config directory is not writable"
 855          exit 1
 856      fi
 857  
 858      # Reclaim ownership of files left by a previous build.
 859      # run.sh build chowns logs/, data/, users/ to UID 45045. install.sh itself
 860      # chowns config/secrets.yaml to 45045. On re-install these files become
 861      # unwritable by the current user. Reclaim everything we need to touch.
 862      reclaim_previous_build_ownership
 863  
 864      # Generate all configuration files
 865      generate_secrets_yaml
 866      generate_api_yaml
 867      generate_agent_yaml
 868      generate_external_mounts_yaml
 869      generate_llm_api_proxy_yaml
 870  
 871      # Verify all required config files were created
 872      local missing_files=()
 873      [[ ! -f "config/secrets.yaml" ]] && missing_files+=("secrets.yaml")
 874      [[ ! -f "config/api.yaml" ]] && missing_files+=("api.yaml")
 875      [[ ! -f "config/agent.yaml" ]] && missing_files+=("agent.yaml")
 876      [[ ! -f "config/external-mounts.yaml" ]] && missing_files+=("external-mounts.yaml")
 877      [[ ! -f "config/llm-api-proxy.yaml" ]] && missing_files+=("llm-api-proxy.yaml")
 878  
 879      if [[ ${#missing_files[@]} -gt 0 ]]; then
 880          print_error "Failed to create config files: ${missing_files[*]}"
 881          exit 1
 882      fi
 883  
 884      print_success "All configuration files generated successfully"
 885  }
 886  
 887  # =============================================================================
 888  # BUILD AND DEPLOY
 889  # =============================================================================
 890  
 891  run_build() {
 892      print_step "Building AG3NTUM"
 893  
 894      # Ensure run.sh is executable
 895      if [[ ! -x "./run.sh" ]]; then
 896          chmod +x ./run.sh 2>/dev/null || {
 897              print_error "Cannot make run.sh executable"
 898              exit 1
 899          }
 900      fi
 901  
 902      print_info "This may take 5-10 minutes on first build..."
 903      echo ""
 904  
 905      # Export port variables and mode for run.sh
 906      export AG3NTUM_API_PORT="$API_PORT"
 907      export AG3NTUM_WEB_PORT="$WEB_PORT"
 908      export AG3NTUM_MODE="$AG3NTUM_MODE"
 909  
 910      # Build command: add --dev flag if in dev mode
 911      local build_flags="--no-cache"
 912      if [[ "$AG3NTUM_MODE" == "dev" ]]; then
 913          build_flags="--no-cache --dev"
 914      fi
 915  
 916      # Run the build with output (use 'build' not 'rebuild' to preserve generated config)
 917      if ! ./run.sh build ${build_flags}; then
 918          print_error "Build failed"
 919          echo ""
 920          echo "Check the output above for errors."
 921          echo "You can also check Docker logs:"
 922          echo "  ${CYAN}docker compose logs${NC}"
 923          exit 1
 924      fi
 925  
 926      print_success "Build completed successfully"
 927  
 928      # Track installed version
 929      if [[ -f "VERSION" ]]; then
 930          cp VERSION .ag3ntum-version
 931          print_success "Version file created (.ag3ntum-version)"
 932      fi
 933  }
 934  
 935  wait_for_api() {
 936      local max_wait=60
 937      local interval=2
 938      local elapsed=0
 939      local health_url="http://localhost:${API_PORT:-40080}/api/v1/health"
 940  
 941      print_info "Waiting for API to be ready..."
 942      while [ "$elapsed" -lt "$max_wait" ]; do
 943          if curl -sf "$health_url" >/dev/null 2>&1; then
 944              print_success "API is ready"
 945              return 0
 946          fi
 947          sleep "$interval"
 948          elapsed=$((elapsed + interval))
 949      done
 950  
 951      print_warning "API health check timed out after ${max_wait}s (continuing anyway)"
 952      return 0
 953  }
 954  
 955  create_admin_user() {
 956      print_step "Creating Admin User"
 957  
 958      # Wait for API to be fully initialized (DB migrations, user sync, etc.)
 959      # before attempting to create a user via docker exec
 960      wait_for_api
 961  
 962      print_info "Creating user: $ADMIN_USERNAME ($ADMIN_EMAIL)"
 963  
 964      # Attempt to create the user, capturing output to detect "already exists"
 965      local create_output
 966      create_output=$(mktemp)
 967  
 968      if ./run.sh create-user \
 969          --username="$ADMIN_USERNAME" \
 970          --email="$ADMIN_EMAIL" \
 971          --password="$ADMIN_PASSWORD" \
 972          --admin > "$create_output" 2>&1; then
 973          cat "$create_output"
 974          rm -f "$create_output"
 975          print_success "Admin user created"
 976      elif grep -qi "already exists" "$create_output"; then
 977          # User or Linux account already exists — ask whether to replace or keep
 978          rm -f "$create_output"
 979          echo ""
 980          print_warning "User '$ADMIN_USERNAME' already exists."
 981          echo ""
 982          echo "  ${BOLD}Replace${NC} — Delete and recreate with the new credentials you just entered."
 983          echo "  ${BOLD}Keep${NC}    — Keep the existing account (login with your previous password)."
 984          echo ""
 985  
 986          if prompt_yesno "Replace existing user with new credentials?" "n"; then
 987              print_info "Replacing user (delete + recreate in single transaction)..."
 988  
 989              # Use a single Python process to delete-if-exists then create.
 990              # This avoids the race condition / DB mismatch that occurs when
 991              # delete_user.py and create_user.py run as separate processes
 992              # while the API server also holds the database open.
 993              # Credentials are passed via env vars to avoid shell injection from
 994              # special characters in passwords.
 995              if ! docker compose exec -T -u root \
 996                  -e "_INSTALL_USERNAME=$ADMIN_USERNAME" \
 997                  -e "_INSTALL_EMAIL=$ADMIN_EMAIL" \
 998                  -e "_INSTALL_PASSWORD=$ADMIN_PASSWORD" \
 999                  ag3ntum-api \
1000                  python3 -c '
1001  import asyncio, sys, os
1002  sys.path.insert(0, "/")
1003  os.environ.setdefault("AG3NTUM_ROOT", "/")
1004  
1005  from src.db.database import AsyncSessionLocal, init_db, engine
1006  from src.db import models
1007  from src.services.user_service import user_service
1008  from sqlalchemy import select
1009  
1010  username = os.environ["_INSTALL_USERNAME"]
1011  email = os.environ["_INSTALL_EMAIL"]
1012  password = os.environ["_INSTALL_PASSWORD"]
1013  
1014  async def replace_user():
1015      await init_db()
1016      # Step 1: Delete any DB records that would conflict (by username OR email,
1017      # matching the same uniqueness check that create_user uses).
1018      async with AsyncSessionLocal() as db:
1019          result = await db.execute(
1020              select(models.User).where(
1021                  (models.User.username == username) | (models.User.email == email)
1022              )
1023          )
1024          conflicts = result.scalars().all()
1025          if conflicts:
1026              for row in conflicts:
1027                  print(f"Deleting conflicting user: {row.username} ({row.email})")
1028                  await user_service.delete_user(db=db, username=row.username, delete_linux_user=True)
1029          else:
1030              print("No conflicting DB records (Linux user may exist, proceeding).")
1031  
1032      # Step 2: Create fresh
1033      async with AsyncSessionLocal() as db:
1034          user = await user_service.create_user(
1035              db=db,
1036              username=username,
1037              email=email,
1038              password=password,
1039              role="admin",
1040          )
1041          print(f"User created: {user.username} (UID {user.linux_uid})")
1042  
1043      await engine.dispose()
1044  
1045  asyncio.run(replace_user())
1046  '; then
1047                  print_error "Failed to replace admin user"
1048                  echo ""
1049                  echo "Try manually:"
1050                  echo "  ${CYAN}./run.sh delete-user --username=$ADMIN_USERNAME --force${NC}"
1051                  echo "  ${CYAN}./run.sh create-user --username=$ADMIN_USERNAME --email=$ADMIN_EMAIL --password=YOUR_PASSWORD --admin${NC}"
1052                  exit 1
1053              fi
1054              print_success "Admin user replaced successfully"
1055          else
1056              print_success "Keeping existing admin user '$ADMIN_USERNAME'"
1057              print_info "Login with your previous credentials"
1058              ADMIN_EMAIL="(previous email)"
1059          fi
1060      else
1061          # Some other failure
1062          cat "$create_output"
1063          rm -f "$create_output"
1064          print_warning "Failed to create admin user automatically"
1065          echo ""
1066          echo "You can create it manually:"
1067          echo "  ${CYAN}./run.sh create-user --username=$ADMIN_USERNAME --email=$ADMIN_EMAIL --password=YOUR_PASSWORD --admin${NC}"
1068      fi
1069  
1070      # Restart API so the process inherits the new user's group (Gotcha #12).
1071      # Without this, session directory access fails with PermissionError.
1072      print_info "Restarting API to activate user access..."
1073      docker compose restart ag3ntum-api 2>/dev/null || true
1074      wait_for_api
1075  
1076      # Clear password from memory
1077      unset ADMIN_PASSWORD
1078  }
1079  
1080  # =============================================================================
1081  # COMPLETION
1082  # =============================================================================
1083  
1084  show_banner() {
1085      printf "${CYAN}"
1086      cat << 'EOF'
1087  
1088      _    ____ _____       _
1089     / \  / ___|___ / _ __ | |_ _   _ _ __ ___
1090    / _ \| |  _  |_ \| '_ \| __| | | | '_ ` _ \
1091   / ___ \ |_| |___) | | | | |_| |_| | | | | | |
1092  /_/   \_\____|____/|_| |_|\__|\__,_|_| |_| |_|
1093  
1094  EOF
1095      printf "${NC}"
1096      printf "${WHITE}Self-hosted Claude Code execution platform${NC}\n"
1097      echo ""
1098  }
1099  
1100  show_completion() {
1101      echo ""
1102      printf "${GREEN}"
1103      cat << 'EOF'
1104   ___           _        _ _       _   _                   ____                      _      _       _
1105  |_ _|_ __  ___| |_ __ _| | | __ _| |_(_) ___  _ __       / ___|___  _ __ ___  _ __ | | ___| |_ ___| |
1106   | || '_ \/ __| __/ _` | | |/ _` | __| |/ _ \| '_ \     | |   / _ \| '_ ` _ \| '_ \| |/ _ \ __/ _ \ |
1107   | || | | \__ \ || (_| | | | (_| | |_| | (_) | | | |    | |__| (_) | | | | | | |_) | |  __/ ||  __/_|
1108  |___|_| |_|___/\__\__,_|_|_|\__,_|\__|_|\___/|_| |_|     \____\___/|_| |_| |_| .__/|_|\___|\__\___(_)
1109                                                                              |_|
1110  EOF
1111      printf "${NC}"
1112      echo ""
1113  
1114      local protocol="http"
1115      local api_url="${protocol}://${SERVER_HOSTNAME}:${API_PORT}"
1116      local web_url="${protocol}://${SERVER_HOSTNAME}:${WEB_PORT}"
1117  
1118      printf "${BOLD}Web Interface:${NC}  ${CYAN}%s${NC}\n" "$web_url"
1119      printf "${BOLD}API Endpoint:${NC}   ${CYAN}%s/api/v1${NC}\n" "$api_url"
1120      if [[ "$AG3NTUM_MODE" == "dev" ]]; then
1121          print_dim "  (Dev mode: Vite dev server with HMR)"
1122      else
1123          print_dim "  (Prod mode: pre-built static bundle)"
1124      fi
1125      echo ""
1126      printf "${BOLD}Login with:${NC}\n"
1127      printf "  Email:    ${WHITE}%s${NC}\n" "$ADMIN_EMAIL"
1128      printf "  Password: ${WHITE}(the password you entered)${NC}\n"
1129      echo ""
1130      printf "${YELLOW}Useful commands:${NC}\n"
1131      echo "  ./run.sh restart       # Restart after code changes"
1132      echo "  ./run.sh cleanup       # Stop and remove containers"
1133      echo "  ./run.sh create-user   # Create additional users"
1134      echo "  ./run.sh shell         # Shell into container"
1135      echo "  docker compose logs -f # View logs"
1136      echo ""
1137  
1138      if [[ "$SERVER_HOSTNAME" != "localhost" ]] && [[ "$SERVER_HOSTNAME" != "127.0.0.1" ]]; then
1139          printf "${YELLOW}Firewall note:${NC} Ensure ports ${WEB_PORT} (Web) and ${API_PORT} (API) are open.\n"
1140          echo ""
1141      fi
1142  
1143      print_success "Ag3ntum is ready to use!"
1144  }
1145  
1146  # =============================================================================
1147  # CLEANUP HANDLER
1148  # =============================================================================
1149  
1150  cleanup_on_exit() {
1151      # Clear any sensitive variables that might still be set
1152      unset ANTHROPIC_API_KEY 2>/dev/null || true
1153      unset ADMIN_PASSWORD 2>/dev/null || true
1154  
1155      # Show cursor (in case hidden by spinner)
1156      printf "\033[?25h" 2>/dev/null || true
1157  }
1158  
1159  trap cleanup_on_exit EXIT INT TERM
1160  
1161  # =============================================================================
1162  # MAIN
1163  # =============================================================================
1164  
1165  show_install_usage() {
1166      echo "Ag3ntum Installer"
1167      echo ""
1168      echo "Usage: install.sh [OPTIONS]"
1169      echo ""
1170      echo "Options:"
1171      echo "  --dev          Development mode (Vite dev server, clones 'main' branch)"
1172      echo "  --branch B     Clone specific branch (default: '${DEFAULT_BRANCH}')"
1173      echo "  --help, -h     Show this help"
1174      echo ""
1175      echo "Default: production mode from '${DEFAULT_BRANCH}' branch"
1176      echo ""
1177      echo "Examples:"
1178      echo "  install.sh                  # Production from 'release' branch"
1179      echo "  install.sh --dev            # Development from 'main' branch"
1180      echo "  install.sh --branch main    # Production from 'main' branch"
1181  }
1182  
1183  main() {
1184      # Parse arguments
1185      AG3NTUM_BRANCH="${DEFAULT_BRANCH}"
1186      AG3NTUM_MODE="prod"
1187  
1188      while [[ $# -gt 0 ]]; do
1189          case "$1" in
1190              --dev)
1191                  AG3NTUM_MODE="dev"
1192                  AG3NTUM_BRANCH="main"
1193                  shift
1194                  ;;
1195              --branch)
1196                  AG3NTUM_BRANCH="${2:?'--branch requires a value'}"
1197                  shift 2
1198                  ;;
1199              --branch=*)
1200                  AG3NTUM_BRANCH="${1#*=}"
1201                  shift
1202                  ;;
1203              --help|-h)
1204                  show_install_usage
1205                  exit 0
1206                  ;;
1207              *)
1208                  print_error "Unknown argument: $1"
1209                  show_install_usage
1210                  exit 1
1211                  ;;
1212          esac
1213      done
1214  
1215      # Show welcome banner
1216      show_banner
1217  
1218      # Detect operating system
1219      detect_os
1220  
1221      print_dim "Detected OS: $DETECTED_OS"
1222      print_dim "Mode: ${AG3NTUM_MODE} | Branch: ${AG3NTUM_BRANCH}"
1223      echo ""
1224  
1225      # Check prerequisites
1226      run_prerequisite_checks
1227  
1228      # Setup repository (clone if needed)
1229      setup_repository
1230  
1231      # Gather configuration from user
1232      gather_configuration
1233  
1234      # Generate configuration files
1235      generate_configuration
1236  
1237      # Build and deploy
1238      run_build
1239  
1240      # Create admin user
1241      create_admin_user
1242  
1243      # Show completion message
1244      show_completion
1245  }
1246  
1247  # Run main function with all script arguments
1248  main "$@"