#!/usr/bin/env bash # ============================================================================ # LevelUp AI Employee — macOS / Linux Installer # ============================================================================ # Usage: # curl -fsSL https://install.lvluplocal.com/setup.sh | bash # curl -fsSL https://install.lvluplocal.com/setup.sh | bash -s -- --mode adopt # curl -fsSL https://install.lvluplocal.com/setup.sh | bash -s -- --telegram-bot-token 123:abc # # Or with optional environment variables: # curl -fsSL https://install.lvluplocal.com/setup.sh | \ # ANTHROPIC_API_KEY="sk-ant-..." OPENAI_API_KEY="sk-..." bash # # Install modes: # fresh Expect a clean machine. Abort if an existing OpenClaw setup is detected. # adopt Safely adopt an existing install. Reuse existing config/service where possible. # force-manage Reapply LevelUp-managed settings more aggressively (with backups first). # # Copyright (c) 2026 LevelUp Local. All rights reserved. Proprietary. # ============================================================================ set -Eeuo pipefail # --------------------------------------------------------------------------- # Colors & helpers # --------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # No Color info() { echo -e "${BLUE}ℹ${NC} $*"; } success() { echo -e "${GREEN}✅${NC} $*"; } warn() { echo -e "${YELLOW}⚠️${NC} $*"; } error() { echo -e "${RED}❌${NC} $*" >&2; } step() { echo -e "\n${BOLD}${CYAN}▸ $*${NC}"; } divider() { echo -e "${CYAN}────────────────────────────────────────────────${NC}"; } SCRIPT_VERSION="1.2.0" NODE_MIN_MAJOR=22 LEVELUP_MODEL="anthropic/claude-sonnet-4-20250514" LEVELUP_SOURCE_LINE='[ -f "$HOME/.levelup/env" ] && source "$HOME/.levelup/env"' INSTALL_MODE="${LEVELUP_INSTALL_MODE:-}" MODE_SOURCE="explicit" BACKUP_DIR="" BACKUP_CREATED=false OS="unknown" ARCH="$(uname -m)" LEVELUP_DIR="$HOME/.levelup" LEVELUP_BACKUPS_DIR="$LEVELUP_DIR/backups" LEVELUP_ENV_FILE="$LEVELUP_DIR/env" LEVELUP_INSTALL_INFO_FILE="$LEVELUP_DIR/install-info.json" OPENCLAW_DIR="$HOME/.openclaw" OPENCLAW_ENV_FILE="$OPENCLAW_DIR/.env" OPENCLAW_CONFIG_FILE="$OPENCLAW_DIR/openclaw.json" HAS_OPENCLAW_BIN=false HAS_OPENCLAW_CONFIG=false HAS_OPENCLAW_ENV=false HAS_LEVELUP_ENV=false HAS_LEVELUP_INSTALL_INFO=false SERVICE_PRESENT=false SERVICE_RUNNING=false SERVICE_STATUS_KNOWN=false OPENCLAW_CONFIG_VALID="unknown" OPENCLAW_CONFIG_EXISTS_REPORTED=false CURRENT_OC_VERSION="" EXISTING_INSTALL=false GATEWAY_STATUS_OUTPUT="" GATEWAY_STATUS_ERROR="" EXISTING_ANTHROPIC_API_KEY="" EXISTING_OPENAI_API_KEY="" EXISTING_GOOGLE_API_KEY="" EXISTING_TELEGRAM_BOT_TOKEN="" ANTHROPIC_API_KEY_EXPLICIT=false OPENAI_API_KEY_EXPLICIT=false GOOGLE_API_KEY_EXPLICIT=false TELEGRAM_BOT_TOKEN_EXPLICIT=false on_error() { local exit_code=$? local line_no="${1:-unknown}" error "Setup failed near line ${line_no}." if [ -n "$BACKUP_DIR" ]; then warn "A backup was created at: $BACKUP_DIR" fi exit "$exit_code" } trap 'on_error $LINENO' ERR usage() { cat <] Modes: fresh Clean install only. Refuses to run if OpenClaw state already exists. adopt Safely adopt an existing install. This becomes the default when existing OpenClaw state is detected. force-manage Reapply LevelUp-managed settings aggressively while still taking backups. Optional inputs: --telegram-bot-token Configure Telegram during install. ANTHROPIC_API_KEY Save an Anthropic key if provided. OPENAI_API_KEY Save an OpenAI key if provided. GOOGLE_API_KEY Save a Google AI key if provided. You can also preset the mode with: LEVELUP_INSTALL_MODE=adopt EOF } capture_explicit_inputs() { if [ -n "${ANTHROPIC_API_KEY:-}" ]; then ANTHROPIC_API_KEY_EXPLICIT=true fi if [ -n "${OPENAI_API_KEY:-}" ]; then OPENAI_API_KEY_EXPLICIT=true fi if [ -n "${GOOGLE_API_KEY:-}" ]; then GOOGLE_API_KEY_EXPLICIT=true fi if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then TELEGRAM_BOT_TOKEN_EXPLICIT=true fi } validate_install_mode() { local mode="$1" if [ -z "$mode" ]; then return 0 fi case "$mode" in fresh|adopt|force-manage) return 0 ;; *) error "Invalid install mode: $mode" usage exit 1 ;; esac } parse_args() { while [ $# -gt 0 ]; do case "$1" in --mode) if [ $# -lt 2 ]; then error "--mode requires a value" usage exit 1 fi INSTALL_MODE="$2" MODE_SOURCE="explicit" shift 2 ;; --telegram-bot-token) if [ $# -lt 2 ]; then error "--telegram-bot-token requires a value" usage exit 1 fi TELEGRAM_BOT_TOKEN="$2" TELEGRAM_BOT_TOKEN_EXPLICIT=true shift 2 ;; -h|--help) usage exit 0 ;; *) error "Unknown argument: $1" usage exit 1 ;; esac done validate_install_mode "$INSTALL_MODE" } command_exists() { command -v "$1" >/dev/null 2>&1 } timestamp_utc() { date -u +"%Y-%m-%dT%H:%M:%SZ" } timestamp_fs() { date +"%Y%m%d-%H%M%S" } read_env_like_value() { local key="$1" shift local file line value for file in "$@"; do [ -f "$file" ] || continue line="$(grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "$file" 2>/dev/null | tail -1 || true)" [ -n "$line" ] || continue value="${line#*=}" value="$(printf '%s' "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" value="$(printf '%s' "$value" | sed -e "s/^'//" -e "s/'$//" -e 's/^"//' -e 's/"$//')" if [ -n "$value" ]; then printf '%s' "$value" return 0 fi done return 1 } read_existing_telegram_bot_token() { [ -f "$OPENCLAW_CONFIG_FILE" ] || return 1 node - "$OPENCLAW_CONFIG_FILE" <<'NODE' const fs = require('fs'); const file = process.argv[2]; try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); const token = data && data.channels && data.channels.telegram && data.channels.telegram.botToken; if (typeof token === 'string' && token) { process.stdout.write(token); } } catch (_) { process.exit(0); } NODE } detect_gateway_service_with_system_tools() { SERVICE_PRESENT=false SERVICE_RUNNING=false case "$OS" in macos) if command_exists launchctl; then local launchd_line launchd_line="$(launchctl list 2>/dev/null | grep 'ai\.openclaw\.gateway' || true)" if [ -n "$launchd_line" ]; then SERVICE_PRESENT=true if printf '%s\n' "$launchd_line" | awk '{print $1}' | grep -Eq '^[0-9]+$'; then SERVICE_RUNNING=true fi fi fi ;; linux) if command_exists systemctl; then if systemctl --user status openclaw-gateway.service >/dev/null 2>&1; then SERVICE_PRESENT=true SERVICE_RUNNING=true elif systemctl --user cat openclaw-gateway.service >/dev/null 2>&1; then SERVICE_PRESENT=true elif systemctl --user list-unit-files openclaw-gateway.service 2>/dev/null | grep -Fq 'openclaw-gateway.service'; then SERVICE_PRESENT=true fi fi ;; esac } inspect_gateway_status_json() { local status_json="$1" eval "$(STATUS_JSON="$status_json" node -e ' const data = JSON.parse(process.env.STATUS_JSON || "{}"); const cliConfig = data.config && data.config.cli ? data.config.cli : null; const configValid = cliConfig && typeof cliConfig.valid === "boolean" ? String(cliConfig.valid) : "unknown"; console.log("SERVICE_PRESENT=" + (data.service && data.service.loaded ? "true" : "false")); console.log("SERVICE_RUNNING=" + (data.service && data.service.runtime && data.service.runtime.status === "running" ? "true" : "false")); console.log("OPENCLAW_CONFIG_VALID=" + configValid); console.log("OPENCLAW_CONFIG_EXISTS_REPORTED=" + (cliConfig && cliConfig.exists ? "true" : "false")); ')" } refresh_gateway_status_from_openclaw() { if ! command_exists openclaw; then return 0 fi local status_output="" local status_rc=0 if status_output="$(openclaw gateway status --json --deep --no-probe 2>&1)"; then status_rc=0 else status_rc=$? fi if [ "$status_rc" -eq 0 ]; then GATEWAY_STATUS_OUTPUT="$status_output" GATEWAY_STATUS_ERROR="" SERVICE_STATUS_KNOWN=true inspect_gateway_status_json "$status_output" else GATEWAY_STATUS_ERROR="$status_output" fi } resolve_install_mode() { if [ -n "$INSTALL_MODE" ]; then MODE_SOURCE="explicit" else MODE_SOURCE="auto" if [ "$EXISTING_INSTALL" = true ]; then INSTALL_MODE="adopt" else INSTALL_MODE="fresh" fi fi validate_install_mode "$INSTALL_MODE" if [ "$INSTALL_MODE" = "fresh" ] && [ "$EXISTING_INSTALL" = true ]; then error "Existing OpenClaw state detected. Refusing to run in fresh mode." info "Use --mode adopt (recommended) or --mode force-manage instead." exit 1 fi } detect_existing_state() { HAS_OPENCLAW_CONFIG=false HAS_OPENCLAW_ENV=false HAS_LEVELUP_ENV=false HAS_LEVELUP_INSTALL_INFO=false HAS_OPENCLAW_BIN=false [ -f "$OPENCLAW_CONFIG_FILE" ] && HAS_OPENCLAW_CONFIG=true [ -f "$OPENCLAW_ENV_FILE" ] && HAS_OPENCLAW_ENV=true [ -f "$LEVELUP_ENV_FILE" ] && HAS_LEVELUP_ENV=true [ -f "$LEVELUP_INSTALL_INFO_FILE" ] && HAS_LEVELUP_INSTALL_INFO=true if command_exists openclaw; then HAS_OPENCLAW_BIN=true CURRENT_OC_VERSION="$(openclaw --version 2>/dev/null || echo 'unknown')" fi detect_gateway_service_with_system_tools EXISTING_INSTALL=false if [ "$HAS_OPENCLAW_BIN" = true ] || [ "$HAS_OPENCLAW_CONFIG" = true ] || [ "$HAS_OPENCLAW_ENV" = true ] || [ "$HAS_LEVELUP_ENV" = true ] || [ "$HAS_LEVELUP_INSTALL_INFO" = true ] || [ "$SERVICE_PRESENT" = true ]; then EXISTING_INSTALL=true fi resolve_install_mode } print_preflight_summary() { step "Preflight check" info "Install mode: ${INSTALL_MODE} (${MODE_SOURCE})" info "OpenClaw binary present: ${HAS_OPENCLAW_BIN}" info "OpenClaw config present: ${HAS_OPENCLAW_CONFIG} (${OPENCLAW_CONFIG_FILE})" info "OpenClaw env present: ${HAS_OPENCLAW_ENV} (${OPENCLAW_ENV_FILE})" info "LevelUp env present: ${HAS_LEVELUP_ENV} (${LEVELUP_ENV_FILE})" info "LevelUp install info present: ${HAS_LEVELUP_INSTALL_INFO} (${LEVELUP_INSTALL_INFO_FILE})" info "Gateway service present: ${SERVICE_PRESENT}" info "Gateway service running: ${SERVICE_RUNNING}" if [ -n "$CURRENT_OC_VERSION" ]; then info "Detected OpenClaw version: ${CURRENT_OC_VERSION}" fi if [ "$INSTALL_MODE" = "adopt" ] && [ "$MODE_SOURCE" = "auto" ]; then warn "Existing OpenClaw state detected. Defaulting to adopt mode for safety." fi } ensure_backup_dir() { if [ "$BACKUP_CREATED" = true ]; then return 0 fi BACKUP_DIR="$LEVELUP_BACKUPS_DIR/$(timestamp_fs)" mkdir -p "$BACKUP_DIR" cat > "$BACKUP_DIR/RESTORE.txt" < "$BACKUP_DIR/preflight-gateway-status.json" fi if [ -n "$GATEWAY_STATUS_ERROR" ]; then ensure_backup_dir printf '%s\n' "$GATEWAY_STATUS_ERROR" > "$BACKUP_DIR/preflight-gateway-status.txt" fi if [ "$BACKUP_CREATED" = true ]; then success "Backup created at ${BACKUP_DIR}" fi } ensure_node() { step "Checking Node.js..." local need_node=false local node_version="" local node_major="0" if command_exists node; then node_version="$(node --version 2>/dev/null | sed 's/^v//')" node_major="$(printf '%s' "$node_version" | cut -d. -f1)" if [ "$node_major" -ge "$NODE_MIN_MAJOR" ]; then success "Node.js v${node_version} found (meets minimum v${NODE_MIN_MAJOR}+)" return 0 fi warn "Node.js v${node_version} found but v${NODE_MIN_MAJOR}+ is required" need_node=true else warn "Node.js not found" need_node=true fi if [ "$need_node" = true ]; then if [ "$OS" = "macos" ]; then if command_exists brew; then info "Installing Node.js 24 via Homebrew..." if ! brew install node@24; then brew install node fi else info "Installing Homebrew first..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" if ! brew install node@24; then brew install node fi fi else info "Installing Node.js 24 via NodeSource..." if command_exists apt-get; then curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - sudo apt-get install -y nodejs elif command_exists dnf; then curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - sudo dnf install -y nodejs elif command_exists yum; then curl -fsSL https://rpm.nodesource.com/setup_24.x | sudo bash - sudo yum install -y nodejs else error "Could not detect package manager. Please install Node.js ${NODE_MIN_MAJOR}+ manually:" error " https://nodejs.org/en/download/" exit 1 fi fi fi if ! command_exists node; then error "Node.js installation failed. Please install manually: https://nodejs.org" exit 1 fi if ! command_exists npm; then error "npm not found. This usually comes with Node.js." error "Please reinstall Node.js: https://nodejs.org" exit 1 fi success "Node.js $(node --version) ready" success "npm v$(npm --version 2>/dev/null) available" } install_or_reuse_openclaw() { step "Installing OpenClaw..." if command_exists openclaw; then CURRENT_OC_VERSION="$(openclaw --version 2>/dev/null || echo 'unknown')" if [ "$INSTALL_MODE" = "adopt" ]; then success "OpenClaw ${CURRENT_OC_VERSION} already installed. Reusing existing install in adopt mode." return 0 fi info "OpenClaw already installed (${CURRENT_OC_VERSION}). Updating..." npm install -g openclaw@latest else info "Installing OpenClaw globally via npm..." npm install -g openclaw@latest fi if ! command_exists openclaw; then error "OpenClaw installation failed." error "Try manually: npm install -g openclaw@latest" exit 1 fi CURRENT_OC_VERSION="$(openclaw --version 2>/dev/null || echo 'unknown')" success "OpenClaw ${CURRENT_OC_VERSION} ready" } resolve_existing_values() { EXISTING_ANTHROPIC_API_KEY="$(read_env_like_value ANTHROPIC_API_KEY "$OPENCLAW_ENV_FILE" "$LEVELUP_ENV_FILE" || true)" EXISTING_OPENAI_API_KEY="$(read_env_like_value OPENAI_API_KEY "$OPENCLAW_ENV_FILE" "$LEVELUP_ENV_FILE" || true)" EXISTING_GOOGLE_API_KEY="$(read_env_like_value GOOGLE_API_KEY "$OPENCLAW_ENV_FILE" "$LEVELUP_ENV_FILE" || true)" EXISTING_TELEGRAM_BOT_TOKEN="$(read_existing_telegram_bot_token || true)" } collect_configuration() { step "Configuring your AI employee..." echo "" resolve_existing_values if [ "$ANTHROPIC_API_KEY_EXPLICIT" = true ]; then info "Anthropic API key provided. It will be saved to the managed env files." elif [ -n "$EXISTING_ANTHROPIC_API_KEY" ]; then info "Existing Anthropic API key detected. Leaving it untouched." fi if [ "$OPENAI_API_KEY_EXPLICIT" = true ]; then info "OpenAI API key provided. It will be saved to the managed env files." elif [ -n "$EXISTING_OPENAI_API_KEY" ]; then info "Existing OpenAI API key detected. Leaving it untouched." fi if [ "$GOOGLE_API_KEY_EXPLICIT" = true ]; then info "Google AI key provided. It will be saved to the managed env files." elif [ -n "$EXISTING_GOOGLE_API_KEY" ]; then info "Existing Google AI key detected. Leaving it untouched." fi if [ "$TELEGRAM_BOT_TOKEN_EXPLICIT" = true ]; then if [ -n "$EXISTING_TELEGRAM_BOT_TOKEN" ]; then info "Telegram token provided. Existing Telegram config will be updated." else info "Telegram token provided. Telegram will be configured during install." fi elif [ -n "$EXISTING_TELEGRAM_BOT_TOKEN" ]; then info "Existing Telegram config detected. Leaving it untouched." else info "Telegram not requested. You can add it later with --telegram-bot-token." fi if [ "$ANTHROPIC_API_KEY_EXPLICIT" != true ] && [ "$OPENAI_API_KEY_EXPLICIT" != true ] && [ "$GOOGLE_API_KEY_EXPLICIT" != true ] \ && [ -z "$EXISTING_ANTHROPIC_API_KEY" ] && [ -z "$EXISTING_OPENAI_API_KEY" ] && [ -z "$EXISTING_GOOGLE_API_KEY" ]; then warn "No model provider key was supplied. Base install will finish, but the AI will not answer until you add one later." fi } get_explicit_managed_keys_csv() { local keys=() [ "$ANTHROPIC_API_KEY_EXPLICIT" = true ] && keys+=("ANTHROPIC_API_KEY") [ "$OPENAI_API_KEY_EXPLICIT" = true ] && keys+=("OPENAI_API_KEY") [ "$GOOGLE_API_KEY_EXPLICIT" = true ] && keys+=("GOOGLE_API_KEY") if [ ${#keys[@]} -eq 0 ]; then return 0 fi local IFS=, printf '%s' "${keys[*]}" } write_managed_env_file() { local file="$1" local format="$2" local tmp_file tmp_file="$(mktemp)" EXISTING_FILE="$file" \ OUTPUT_FILE="$tmp_file" \ FORMAT="$format" \ UPDATED_AT="$(timestamp_utc)" \ EXPLICIT_KEYS="$(get_explicit_managed_keys_csv)" \ ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ OPENAI_API_KEY="${OPENAI_API_KEY:-}" \ GOOGLE_API_KEY="${GOOGLE_API_KEY:-}" \ node <<'NODE' const fs = require('fs'); const file = process.env.EXISTING_FILE; const outputFile = process.env.OUTPUT_FILE; const format = process.env.FORMAT; const managedKeys = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; const explicitKeys = (process.env.EXPLICIT_KEYS || '').split(',').filter(Boolean); const explicitSet = new Set(explicitKeys); const values = Object.fromEntries(managedKeys.map((key) => [key, process.env[key] || ''])); let lines; if (fs.existsSync(file)) { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } else { lines = format === 'shell' ? [ '# LevelUp AI Employee — API Keys', '# Managed keys are only updated when explicitly provided. Unrelated lines are preserved.', `# Last updated: ${process.env.UPDATED_AT}`, '' ] : [ '# OpenClaw daemon environment', '# LevelUp only updates the keys below when explicitly provided. Unrelated lines are preserved.', `# Last updated: ${process.env.UPDATED_AT}`, '' ]; } const seen = new Set(); const output = []; function render(key, value) { const escaped = String(value).replace(/'/g, `'\\''`); return format === 'shell' ? `export ${key}='${escaped}'` : `${key}='${escaped}'`; } for (const line of lines) { const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/); if (match && managedKeys.includes(match[1])) { const key = match[1]; if (!explicitSet.has(key)) { output.push(line); continue; } if (seen.has(key)) { continue; } const value = values[key]; if (value) { output.push(render(key, value)); } else { output.push(line); } seen.add(key); continue; } output.push(line); } for (const key of explicitKeys) { if (seen.has(key)) { continue; } const value = values[key]; if (value) { output.push(render(key, value)); } } let result = output.join('\n'); if (!result.endsWith('\n')) { result += '\n'; } fs.writeFileSync(outputFile, result, { mode: 0o600 }); NODE mkdir -p "$(dirname "$file")" mv "$tmp_file" "$file" chmod 600 "$file" } ensure_shell_profiles_source_levelup_env() { local profile for profile in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do if [ -f "$profile" ]; then if ! grep -Fqx "$LEVELUP_SOURCE_LINE" "$profile" 2>/dev/null; then printf '\n# LevelUp AI Employee\n%s\n' "$LEVELUP_SOURCE_LINE" >> "$profile" fi fi done } apply_configuration() { step "Applying LevelUp configuration..." mkdir -p "$LEVELUP_DIR" "$OPENCLAW_DIR" local batch_file batch_file="$(mktemp)" cat > "$batch_file" < "$file" <