#!/usr/bin/env bash set -euo pipefail # EdgeLab AI Agent -- Quick Start Installer v2.2.4 # https://edgelab.su # Usage: curl -fsSL https://edgelab.su/install | sudo bash # Supports: Ubuntu 22.04 / 24.04 / 25.04, amd64 / arm64 # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- readonly EDGELAB_VERSION="2.2.4" readonly NODESOURCE_MAJOR=22 readonly PYTHON_MIN_MINOR=12 readonly GATEWAY_REPO="https://github.com/qwwiwi/jarvis-telegram-gateway.git" readonly GATEWAY_DIR_NAME="claude-gateway" # shellcheck disable=SC2034 # reserved for future skills integration readonly GROQ_API_URL="https://api.groq.com/openai/v1/audio/transcriptions" readonly OV_PYPI_PKG="openviking" readonly TOTAL_STEPS=16 readonly BOT_TOKEN_MIN_LEN=40 readonly BOT_TOKEN_MAX_LEN=50 # Template repo pinned at reviewed SHA (D3 per PLAN.md) readonly TEMPLATE_REPO="https://github.com/qwwiwi/public-architecture-claude-code.git" readonly TEMPLATE_SHA="93cc7ddf10c03472616a3a32ff7e6ac731ebe6f2" readonly SUPERPOWERS_REPO="https://github.com/pcvelz/superpowers.git" # F5: pin Superpowers to a reviewed SHA (supply-chain). Override for testing # via EDGELAB_SUPERPOWERS_SHA env var. readonly SUPERPOWERS_SHA="${EDGELAB_SUPERPOWERS_SHA:-04bad33282e792ecfd1007a138331f1e6b288eed}" # Skills pulled from the pinned template repo SKILLS_FROM_TEMPLATE=(groq-voice markdown-new perplexity-research datawrapper excalidraw youtube-transcript) # Skills bundled in this installer (in ./skills/ next to install.sh) SKILLS_FROM_INSTALLER=(onboarding self-compiler quick-reminders present) # URL to fetch installer-bundled skills if running via curl | bash readonly INSTALLER_REPO="https://github.com/qwwiwi/edgelab-install.git" readonly INSTALLER_REF="${EDGELAB_INSTALLER_REF:-main}" # Shared curl options: timeout + retry (prevents hanging forever on bad network) readonly CURL_OPTS=(-fsSL --max-time 60 --retry 2 --retry-delay 3) # --------------------------------------------------------------------------- # Terminal colors (tput-safe) # --------------------------------------------------------------------------- if [[ -t 1 ]] && command -v tput &>/dev/null; then COLOR_GREEN=$(tput setaf 2) COLOR_YELLOW=$(tput setaf 3) COLOR_RED=$(tput setaf 1) COLOR_CYAN=$(tput setaf 6) COLOR_BOLD=$(tput bold) COLOR_RESET=$(tput sgr0) else COLOR_GREEN="" COLOR_YELLOW="" COLOR_RED="" COLOR_CYAN="" COLOR_BOLD="" COLOR_RESET="" fi # --------------------------------------------------------------------------- # Logging helpers # --------------------------------------------------------------------------- info() { echo "${COLOR_GREEN}[INFO]${COLOR_RESET} $*"; } warn() { echo "${COLOR_YELLOW}[WARN]${COLOR_RESET} $*" >&2; } error() { echo "${COLOR_RED}[ERROR]${COLOR_RESET} $*" >&2; } step() { echo ""; echo "${COLOR_CYAN}${COLOR_BOLD}[$1/${TOTAL_STEPS}]${COLOR_RESET} $2"; } # F10: guard — any function that builds paths from AGENT_NAME must fail loudly # if the variable is empty. Empty AGENT_NAME used to produce "~/.claude-lab//.claude" # and "# EdgeLab memory rotation ()" cron markers that collide across agents. _require_agent_name() { if [[ -z "${AGENT_NAME:-}" ]]; then error "AGENT_NAME is empty -- state file corrupted or gather_inputs did not run." exit 1 fi } # --------------------------------------------------------------------------- # apt wrapper -- waits for dpkg lock # --------------------------------------------------------------------------- apt_get() { apt-get -o DPkg::Lock::Timeout=120 "$@" } # Shared curl wrapper: enforces --max-time + --retry curl_safe() { curl "${CURL_OPTS[@]}" "$@" } # --------------------------------------------------------------------------- # Cleanup trap # --------------------------------------------------------------------------- TMPFILES=() TMPDIRS=() cleanup() { local f d for f in "${TMPFILES[@]:-}"; do [[ -n "$f" && -f "$f" ]] && rm -f "$f" || true done for d in "${TMPDIRS[@]:-}"; do [[ -n "$d" && -d "$d" ]] && rm -rf "$d" || true done } trap cleanup EXIT # --------------------------------------------------------------------------- # State variables # --------------------------------------------------------------------------- AGENT_NAME="" AGENT_ROLE="" OPERATOR_NAME="" OPERATOR_TIMEZONE="" OPERATOR_LANGUAGE="" CONFIGURED_BOT_TOKEN="" CONFIGURED_BOT_USERNAME="" CONFIGURED_TG_ID="" CONFIGURED_GROQ="" CONFIGURED_OV="" TEMPLATE_CLONE_DIR="" INSTALLER_SKILLS_DIR="" INSTALLED_SKILLS=() # --------------------------------------------------------------------------- # prompt_or_env helper (T02) # --------------------------------------------------------------------------- # Usage: prompt_or_env VAR_NAME ENV_NAME "prompt text" [default] [--secret] prompt_or_env() { local var_name="$1" local env_name="$2" local prompt_text="$3" local default_val="${4:-}" local flag="${5:-}" local secret=0 if [[ "$flag" == "--secret" ]]; then secret=1 fi # 1: env override local env_val="${!env_name:-}" if [[ -n "$env_val" ]]; then printf -v "$var_name" '%s' "$env_val" return 0 fi # 2: non-interactive mode local noninteractive=0 if [[ "${NONINTERACTIVE:-}" == "1" ]]; then noninteractive=1 fi if [[ "${CI:-}" == "true" ]]; then noninteractive=1 fi if [[ ! -r /dev/tty ]]; then noninteractive=1 fi if [[ "$noninteractive" -eq 1 ]]; then if [[ -n "$default_val" ]]; then printf -v "$var_name" '%s' "$default_val" return 0 fi printf -v "$var_name" '%s' '' return 0 fi # 3: interactive prompt via /dev/tty local value="" if [[ -n "$default_val" ]]; then if [[ "$secret" -eq 1 ]]; then read -rsp "${prompt_text} [${default_val}]: " value < /dev/tty || true echo "" else read -rp "${prompt_text} [${default_val}]: " value < /dev/tty || true fi [[ -z "$value" ]] && value="$default_val" else if [[ "$secret" -eq 1 ]]; then read -rsp "${prompt_text}: " value < /dev/tty || true echo "" else read -rp "${prompt_text}: " value < /dev/tty || true fi fi printf -v "$var_name" '%s' "$value" return 0 } # --------------------------------------------------------------------------- # fill_template helper (T03) # --------------------------------------------------------------------------- # Usage: fill_template SRC DST KEY1 VALUE1 [KEY2 VALUE2 ...] fill_template() { local src="$1" local dst="$2" shift 2 if [[ ! -f "$src" ]]; then error "fill_template: source '${src}' not found." return 1 fi # F8: Python3-based templating. Safer than sed (no BSD/GNU escape # differences, handles multiline values, no regex metacharacter # escaping required). Python3 is installed by step 4; fill_template # first called in step 7. python3 - "$src" "$dst" "$@" <<"PY_FILL_TEMPLATE_EOF" import sys src, dst, *kv = sys.argv[1:] with open(src, "r", encoding="utf-8") as f: body = f.read() for k, v in zip(kv[0::2], kv[1::2]): body = body.replace("{{" + k + "}}", v) with open(dst, "w", encoding="utf-8") as f: f.write(body) PY_FILL_TEMPLATE_EOF } # --------------------------------------------------------------------------- # install_skill_bundle helper (T07) # --------------------------------------------------------------------------- # Usage: install_skill_bundle SRC_SKILL_DIR DST_PARENT_DIR SKILL_NAME install_skill_bundle() { local src="$1" local dst_parent="$2" local skill_name="$3" if [[ ! -d "$src" ]]; then error "install_skill_bundle: source '${src}' not found." return 1 fi local dst="${dst_parent}/${skill_name}" mkdir -p "$dst_parent" # F9: stage UNDER dst_parent so the final mv is a same-filesystem rename # (atomic). /tmp on tmpfs crossing ext4 used to copy+unlink, not atomic. local stage="${dst_parent}/.${skill_name}.staging.$$" rm -rf "$stage" 2>/dev/null || true mkdir -p "$stage" TMPDIRS+=("$stage") if ! rsync -a --delete "${src}/" "${stage}/${skill_name}/"; then rm -rf "$stage" error "install_skill_bundle: rsync failed for '${skill_name}'." return 1 fi if [[ -d "$dst" ]]; then rm -rf "${dst}.prev" 2>/dev/null || true mv "$dst" "${dst}.prev" fi # F9: on mv failure, restore .prev so destination does not end up empty. if ! mv "${stage}/${skill_name}" "$dst"; then error "install_skill_bundle: mv of staged '${skill_name}' failed." if [[ -d "${dst}.prev" ]]; then mv "${dst}.prev" "$dst" || true warn "Restored previous version of '${skill_name}'." fi rm -rf "$stage" return 1 fi rm -rf "${dst}.prev" 2>/dev/null || true rm -rf "$stage" 2>/dev/null || true INSTALLED_SKILLS+=("$skill_name") return 0 } # --------------------------------------------------------------------------- # State machine helpers (T06) -- resumable via JSON state file # --------------------------------------------------------------------------- COMPLETED_STEPS=() state_file() { echo "/root/.claude-lab/.install-state" } load_state() { local sf sf=$(state_file) mkdir -p "$(dirname "$sf")" if [[ ! -f "$sf" ]]; then return 0 fi local v v=$(jq -r '.version // ""' "$sf" 2>/dev/null || echo "") if [[ "$v" != "$EDGELAB_VERSION" ]]; then info "State file from version '${v:-unknown}' -- ignoring for v${EDGELAB_VERSION}." COMPLETED_STEPS=() return 0 fi mapfile -t COMPLETED_STEPS < <(jq -r '.completed_steps[]? // empty' "$sf" 2>/dev/null || true) # F4: drift detection on REAL_USER (persisted in state file). local saved_user saved_user=$(jq -r '.real_user // ""' "$sf" 2>/dev/null || echo "") if [[ -n "$saved_user" && -n "${REAL_USER:-}" && "$saved_user" != "$REAL_USER" ]]; then error "State file was created for user '${saved_user}', but current install runs as '${REAL_USER}'." error "Remove ${sf} and re-run the installer." exit 1 fi # F1: reload persisted gather_inputs answers (safe fields only, NO secrets). local val for field in agent_name agent_role operator_name operator_timezone operator_language; do val=$(jq -r ".inputs.${field} // \"\"" "$sf" 2>/dev/null || echo "") case "$field" in agent_name) [[ -n "$val" ]] && AGENT_NAME="$val" ;; agent_role) [[ -n "$val" ]] && AGENT_ROLE="$val" ;; operator_name) [[ -n "$val" ]] && OPERATOR_NAME="$val" ;; operator_timezone) [[ -n "$val" ]] && OPERATOR_TIMEZONE="$val" ;; operator_language) [[ -n "$val" ]] && OPERATOR_LANGUAGE="$val" ;; esac done # F1: re-derive secrets from disk on resume (never stored in state file). if [[ -n "${AGENT_NAME:-}" && -n "${REAL_HOME:-}" ]]; then local token_file="${REAL_HOME}/${GATEWAY_DIR_NAME}/secrets/bot-token" if [[ -s "$token_file" ]]; then CONFIGURED_BOT_TOKEN=$(cat "$token_file" 2>/dev/null || echo "") if [[ -n "$CONFIGURED_BOT_TOKEN" ]]; then local resp resp=$(_tg_getme "$CONFIGURED_BOT_TOKEN" 2>/dev/null || echo '{"ok":false}') local ok ok=$(echo "$resp" | jq -r '.ok // false' 2>/dev/null || echo "false") if [[ "$ok" == "true" ]]; then CONFIGURED_BOT_USERNAME=$(echo "$resp" | jq -r '.result.username // ""' 2>/dev/null || echo "") fi fi fi local cfg_file="${REAL_HOME}/${GATEWAY_DIR_NAME}/config.json" if [[ -f "$cfg_file" ]]; then local first_id first_id=$(jq -r '.allowlist_user_ids[0] // empty' "$cfg_file" 2>/dev/null || echo "") if [[ -n "$first_id" && "$first_id" =~ ^[0-9]+$ ]]; then CONFIGURED_TG_ID="$first_id" fi fi fi if [[ ${#COMPLETED_STEPS[@]} -gt 0 ]]; then info "Resuming install: ${#COMPLETED_STEPS[@]} step(s) already done." if [[ -n "${AGENT_NAME:-}" ]]; then info "Restored agent='${AGENT_NAME}' from state file." fi fi } is_step_done() { local name="$1" local s for s in "${COMPLETED_STEPS[@]:-}"; do [[ "$s" == "$name" ]] && return 0 done return 1 } record_step() { local name="$1" # Idempotent: do not add duplicate entries (F1 -- gather_inputs records # its own completion before run_step records it again). if ! is_step_done "$name"; then COMPLETED_STEPS+=("$name") fi local sf sf=$(state_file) mkdir -p "$(dirname "$sf")" local tmp # M3: keep tmpfile on the same filesystem as state file (atomic mv). tmp=$(mktemp -p "$(dirname "$sf")") TMPFILES+=("$tmp") local steps_json steps_json=$(printf '%s\n' "${COMPLETED_STEPS[@]}" | jq -R . | jq -s .) local started_at="" if [[ -f "$sf" ]]; then started_at=$(jq -r '.started_at // ""' "$sf" 2>/dev/null || echo "") fi if [[ -z "$started_at" || "$started_at" == "null" ]]; then started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) fi # F1: persist safe gather_inputs fields (NO secrets) + REAL_USER (F4 drift check). jq -n \ --arg version "$EDGELAB_VERSION" \ --arg started "$started_at" \ --arg updated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --arg real_user "${REAL_USER:-}" \ --arg agent_name "${AGENT_NAME:-}" \ --arg agent_role "${AGENT_ROLE:-}" \ --arg operator_name "${OPERATOR_NAME:-}" \ --arg operator_timezone "${OPERATOR_TIMEZONE:-}" \ --arg operator_language "${OPERATOR_LANGUAGE:-}" \ --argjson steps "$steps_json" \ '{ version: $version, started_at: $started, updated_at: $updated, real_user: $real_user, completed_steps: $steps, inputs: { agent_name: $agent_name, agent_role: $agent_role, operator_name: $operator_name, operator_timezone: $operator_timezone, operator_language: $operator_language } }' \ > "$tmp" mv "$tmp" "$sf" chmod 600 "$sf" } run_step() { local name="$1" local fn="$2" if is_step_done "$name"; then info "Skipping '${name}' -- already completed." return 0 fi # F3: only record success. If step returns non-zero, state is NOT updated # so the resume path retries the step. local rc=0 "$fn" || rc=$? if [[ $rc -eq 0 ]]; then record_step "$name" else warn "Step '${name}' failed (rc=${rc}). State NOT recorded; will retry on resume." return $rc fi } # --------------------------------------------------------------------------- # Template / skill sourcing # --------------------------------------------------------------------------- fetch_template() { if [[ -n "$TEMPLATE_CLONE_DIR" && -d "$TEMPLATE_CLONE_DIR" ]]; then echo "$TEMPLATE_CLONE_DIR" return 0 fi local dir dir=$(mktemp -d) TMPDIRS+=("$dir") info "Cloning pinned template @ ${TEMPLATE_SHA:0:8}..." >&2 if ! git clone --quiet "$TEMPLATE_REPO" "$dir" >&2; then error "Failed to clone template repo from ${TEMPLATE_REPO}" return 1 fi if ! git -C "$dir" checkout --quiet "$TEMPLATE_SHA" 2>/dev/null; then error "Failed to checkout SHA ${TEMPLATE_SHA}" return 1 fi TEMPLATE_CLONE_DIR="$dir" echo "$dir" } locate_installer_skills() { if [[ -n "$INSTALLER_SKILLS_DIR" && -d "$INSTALLER_SKILLS_DIR" ]]; then echo "$INSTALLER_SKILLS_DIR" return 0 fi local src="${BASH_SOURCE[0]:-}" if [[ -n "$src" && -f "$src" ]]; then local script_dir script_dir=$(cd "$(dirname "$src")" && pwd) if [[ -d "${script_dir}/skills" ]]; then INSTALLER_SKILLS_DIR="${script_dir}/skills" echo "$INSTALLER_SKILLS_DIR" return 0 fi fi local dir dir=$(mktemp -d) TMPDIRS+=("$dir") info "Cloning installer skills from ${INSTALLER_REPO} (ref=${INSTALLER_REF})..." >&2 if ! git clone --quiet --depth 1 --branch "$INSTALLER_REF" "$INSTALLER_REPO" "$dir" >&2; then # M7 (Phase 5): if the pinned ref does not exist (e.g. a feature branch # that has been merged and deleted), fall back to the default branch # so installations still succeed -- with a loud warning. warn "Installer repo clone with ref='${INSTALLER_REF}' failed. Retrying with default branch." rm -rf "$dir" dir=$(mktemp -d) TMPDIRS+=("$dir") if ! git clone --quiet --depth 1 "$INSTALLER_REPO" "$dir" >&2; then error "Failed to clone installer repo for bundled skills (also on default branch)." return 1 fi fi if [[ ! -d "${dir}/skills" ]]; then error "Installer repo has no skills/ subtree." return 1 fi INSTALLER_SKILLS_DIR="${dir}/skills" echo "$INSTALLER_SKILLS_DIR" } # --------------------------------------------------------------------------- # Pre-flight checks # --------------------------------------------------------------------------- preflight() { if [[ $EUID -ne 0 ]]; then error "This script must be run as root (or with sudo)." echo " Try: curl -fsSL https://edgelab.su/install | sudo bash" exit 1 fi if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 source /etc/os-release if [[ "${ID:-}" != "ubuntu" ]]; then warn "Detected OS: ${ID:-unknown}. This script is designed for Ubuntu." warn "Proceeding anyway -- some steps may fail." else case "${VERSION_ID:-}" in 22.04|24.04|25.04) info "Detected Ubuntu ${VERSION_ID}" ;; *) warn "Detected Ubuntu ${VERSION_ID:-unknown}." warn "Only 22.04, 24.04 and 25.04 are officially supported." ;; esac fi else warn "Cannot detect OS (missing /etc/os-release). Proceeding with caution." fi local arch arch=$(dpkg --print-architecture 2>/dev/null || uname -m) case "$arch" in amd64|x86_64) info "Architecture: amd64" ;; arm64|aarch64) info "Architecture: arm64" ;; *) warn "Architecture ${arch} is not officially supported." warn "Proceeding -- some packages may not be available." ;; esac info "EdgeLab installer v${EDGELAB_VERSION}" bootstrap_deps } # Bootstrap: install minimal prereqs that the state machine itself depends on # (jq + git + curl + ca-certificates). Called from preflight BEFORE any # run_step/record_step, because record_step uses jq to emit the state file. bootstrap_deps() { local missing=() local b for b in jq git curl; do command -v "$b" >/dev/null 2>&1 || missing+=("$b") done if [[ ${#missing[@]} -eq 0 ]]; then return 0 fi info "Installing bootstrap deps: ${missing[*]}" export DEBIAN_FRONTEND=noninteractive apt_get update -qq apt_get install -y -qq ca-certificates "${missing[@]}" } # --------------------------------------------------------------------------- # Detect real user # --------------------------------------------------------------------------- detect_real_user() { if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then REAL_USER="$SUDO_USER" else local svc_user="edgelab" if ! id "$svc_user" &>/dev/null; then info "Creating service user '${svc_user}'..." useradd -m -s /bin/bash "$svc_user" fi REAL_USER="$svc_user" fi REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) # NOTE: REAL_USER / REAL_HOME are intentionally NOT readonly (F4) -- # load_state() re-populates them from the state file and performs a # drift-detection check when resuming an install. # as_user() uses `env -C "$REAL_HOME"` -- precheck accessibility so we # fail loudly (not with cryptic env rc=125) if REAL_HOME is missing or # not traversable (unusual NFS/quota edge cases). if [[ -z "$REAL_HOME" || ! -d "$REAL_HOME" || ! -x "$REAL_HOME" ]]; then error "REAL_HOME='${REAL_HOME}' is not a traversable directory for user '${REAL_USER}'." exit 1 fi info "Installing for user: ${REAL_USER} (home: ${REAL_HOME})" } as_user() { if [[ "$(id -u)" -eq 0 && "$REAL_USER" != "root" ]]; then # sudo -H sets HOME to the target user's home; CWD is set to REAL_HOME # so project-level config lookups (e.g. Claude Code reading .mcp.json) # do not resolve against /root. sudo -u "$REAL_USER" -H -- env -C "$REAL_HOME" "$@" else "$@" fi } # write_as_user: copy SRC to DST with REAL_USER ownership and MODE (default 0644). # Works from root even when SRC is a root-owned 0600 mktemp file that REAL_USER # cannot read (the old `as_user cp` pattern fails in that case). write_as_user() { local src="$1" local dst="$2" local mode="${3:-0644}" local dst_dir dst_dir=$(dirname "$dst") if [[ ! -d "$dst_dir" ]]; then mkdir -p "$dst_dir" chown "${REAL_USER}:${REAL_USER}" "$dst_dir" 2>/dev/null || true fi install -o "${REAL_USER}" -g "${REAL_USER}" -m "${mode}" "$src" "$dst" } fix_owner() { local path="$1" [[ -e "$path" ]] || return 0 # M5: -h affects symlinks themselves, -P never traverses them. chown -RhP "${REAL_USER}:${REAL_USER}" "$path" } # --------------------------------------------------------------------------- # Step 1: gather_inputs (T04) # --------------------------------------------------------------------------- gather_inputs() { step 1 "Gathering configuration..." echo "" echo "${COLOR_BOLD}Tell me about your agent.${COLOR_RESET}" echo "You can press Enter to accept any default; values can be edited later." echo "" prompt_or_env AGENT_NAME EDGELAB_AGENT_NAME "Agent name (short, e.g. 'jarvis', 'friday')" "jarvis" prompt_or_env AGENT_ROLE EDGELAB_AGENT_ROLE "Agent role (one line description)" "personal AI assistant" prompt_or_env OPERATOR_NAME EDGELAB_USER_NAME "Your name (how the agent should address you)" "boss" prompt_or_env OPERATOR_TIMEZONE EDGELAB_TIMEZONE "Your timezone (IANA, e.g. 'Europe/Moscow')" "UTC" prompt_or_env OPERATOR_LANGUAGE EDGELAB_LANGUAGE "Preferred response language (e.g. 'en', 'ru')" "en" AGENT_NAME=$(echo "$AGENT_NAME" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]-_') if [[ -z "$AGENT_NAME" ]]; then AGENT_NAME="jarvis" warn "Agent name empty after sanitization -- using 'jarvis'." fi info "Agent: ${AGENT_NAME} (${AGENT_ROLE})" info "Operator: ${OPERATOR_NAME} / tz=${OPERATOR_TIMEZONE} / lang=${OPERATOR_LANGUAGE}" # Apply system timezone so `date`, cron and quick-reminders compute correct local times. if [[ -n "$OPERATOR_TIMEZONE" && "$OPERATOR_TIMEZONE" != "UTC" ]]; then if command -v timedatectl >/dev/null 2>&1; then if timedatectl set-timezone "$OPERATOR_TIMEZONE" 2>/dev/null; then info "System timezone set to ${OPERATOR_TIMEZONE}" else warn "Failed to set system timezone to ${OPERATOR_TIMEZONE} -- reminders may show wrong time. Fix manually: sudo timedatectl set-timezone ${OPERATOR_TIMEZONE}" fi else warn "timedatectl not available -- system timezone unchanged (stays UTC)." fi fi # F1: persist inputs to state file IMMEDIATELY (before heavier steps can fail). # This ensures SIGKILL+resume finds AGENT_NAME / OPERATOR_* on disk. record_step "gather_inputs" } # --------------------------------------------------------------------------- # Step 2: System packages # --------------------------------------------------------------------------- install_system_packages() { step 2 "Installing system packages..." export DEBIAN_FRONTEND=noninteractive if systemctl is-active --quiet unattended-upgrades 2>/dev/null; then systemctl stop unattended-upgrades 2>/dev/null || true info "Temporarily stopped unattended-upgrades (will restart after install)." fi apt_get update -qq local pkgs=( curl wget git jq htop tmux rsync build-essential ufw fail2ban software-properties-common apt-transport-https ca-certificates gnupg python3-pip ) apt_get install -y -qq "${pkgs[@]}" info "System packages installed." } # --------------------------------------------------------------------------- # Step 3: Node.js 22 # --------------------------------------------------------------------------- install_nodejs() { step 3 "Installing Node.js ${NODESOURCE_MAJOR}..." if command -v node &>/dev/null; then local current_major current_major=$(node -v | sed 's/v//' | cut -d. -f1) if [[ "$current_major" -ge "$NODESOURCE_MAJOR" ]]; then info "Node.js $(node -v) already installed -- skipping." return 0 fi fi local keyring="/usr/share/keyrings/nodesource.gpg" local tmp_key tmp_key=$(mktemp) TMPFILES+=("$tmp_key") curl_safe https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ | gpg --no-tty --batch --yes --dearmor -o "$tmp_key" install -m 644 "$tmp_key" "$keyring" local node_list="/etc/apt/sources.list.d/nodesource.list" echo "deb [signed-by=${keyring}] https://deb.nodesource.com/node_${NODESOURCE_MAJOR}.x nodistro main" \ > "$node_list" apt_get update -qq apt_get install -y -qq nodejs info "Node.js $(node -v) installed." } # --------------------------------------------------------------------------- # Step 4: Python 3.12 with 25.04 fallback # --------------------------------------------------------------------------- install_python() { step 4 "Checking Python 3.${PYTHON_MIN_MINOR}+..." if command -v python3 &>/dev/null; then local py_minor py_minor=$(python3 -c 'import sys; print(sys.version_info.minor)') if [[ "$py_minor" -ge "$PYTHON_MIN_MINOR" ]]; then apt_get install -y -qq python3-venv 2>/dev/null || true info "Python 3.${py_minor} found -- ensured venv support." return 0 fi fi local os_ver="" if [[ -f /etc/os-release ]]; then # shellcheck disable=SC1091 os_ver=$(. /etc/os-release; echo "${VERSION_ID:-}") fi if [[ "$os_ver" == "25.04" ]]; then info "Ubuntu 25.04 -- using system python3.13 (deadsnakes not yet available)." apt_get install -y -qq python3 python3-venv python3-dev 2>/dev/null || true info "Python $(python3 --version 2>&1) ready." return 0 fi info "Installing Python 3.${PYTHON_MIN_MINOR} via deadsnakes PPA..." add-apt-repository -y ppa:deadsnakes/ppa apt_get update -qq apt_get install -y -qq \ "python3.${PYTHON_MIN_MINOR}" \ "python3.${PYTHON_MIN_MINOR}-venv" \ "python3.${PYTHON_MIN_MINOR}-dev" apt_get install -y -qq "python3.${PYTHON_MIN_MINOR}-distutils" 2>/dev/null || true "python3.${PYTHON_MIN_MINOR}" -m ensurepip --upgrade 2>/dev/null || true info "Python 3.${PYTHON_MIN_MINOR} installed (system python3 unchanged)." } # --------------------------------------------------------------------------- # Step 5: Claude Code CLI # --------------------------------------------------------------------------- install_claude_code() { step 5 "Installing Claude Code CLI..." local claude_bin="${REAL_HOME}/.local/bin/claude" if [[ -x "$claude_bin" ]]; then info "Claude Code CLI already installed at ${claude_bin} -- updating." # M1: capture exit code instead of silent `|| true`; warn on failure. local update_log update_log=$(mktemp) TMPFILES+=("$update_log") if ! as_user "$claude_bin" update >"$update_log" 2>&1; then warn "claude update returned non-zero. Output:" sed 's/^/ /' "$update_log" >&2 || true warn "Continuing with the currently installed CLI version." fi return 0 fi info "Installing via official Anthropic installer..." local installer_tmp installer_tmp=$(mktemp) TMPFILES+=("$installer_tmp") curl_safe https://claude.ai/install.sh -o "$installer_tmp" \ || { error "Failed to download Claude Code installer."; exit 1; } chmod 644 "$installer_tmp" as_user bash "$installer_tmp" if [[ -x "${claude_bin}" ]]; then local ver ver=$(as_user "$claude_bin" --version 2>/dev/null || echo "unknown") info "Claude Code CLI v${ver} installed at ${claude_bin}" else error "Claude Code installation failed -- ${claude_bin} not found." error "Try installing manually as ${REAL_USER}:" error " curl -fsSL https://claude.ai/install.sh | bash" exit 1 fi ensure_path_export } # ensure_path_export: make sure `~/.local/bin` is on REAL_USER's PATH for # future shells. Anthropic's installer drops `claude` there but does not # touch shellrc, so fresh SSH sessions cannot find the binary. # # Placement matters: Ubuntu's stock `.bashrc` begins with # [ -z "$PS1" ] && return # which aborts the file for non-interactive shells (e.g. `ssh host 'cmd'`). # So in `.bashrc` we PREPEND the export (runs before the guard), and in # `.profile` we APPEND (login shells read .profile fully). # # Idempotency is gated by a unique marker line written alongside the export, # not by substring-matching the export itself (which could false-positive on # unrelated user content). ensure_path_export() { local marker='# Added by EdgeLab installer: expose ~/.local/bin (claude, python3.12, etc.)' local export_line='export PATH="$HOME/.local/bin:$PATH"' local rc_file placement for rc_file in "${REAL_HOME}/.bashrc:prepend" "${REAL_HOME}/.profile:append"; do local rc="${rc_file%:*}" placement="${rc_file##*:}" if [[ ! -f "$rc" ]]; then as_user touch "$rc" fi if grep -Fq "$marker" "$rc" 2>/dev/null; then continue fi local tmp tmp=$(mktemp) TMPFILES+=("$tmp") if [[ "$placement" == "prepend" ]]; then { echo "$marker"; echo "$export_line"; echo ''; cat "$rc"; } >"$tmp" else { cat "$rc"; echo ''; echo "$marker"; echo "$export_line"; } >"$tmp" fi install -o "${REAL_USER}" -g "${REAL_USER}" -m 0644 "$tmp" "$rc" info "Added ~/.local/bin to PATH in $(basename "$rc") (${placement})" done } # --------------------------------------------------------------------------- # Step 6: ~/.claude/ (Anthropic-owned: creds + plugins + mcp.json + stub) # --------------------------------------------------------------------------- setup_global_claude() { step 6 "Setting up ~/.claude/ (Anthropic CLI home)..." local claude_dir="${REAL_HOME}/.claude" if [[ ! -d "$claude_dir" ]]; then as_user mkdir -p "$claude_dir" fi local claude_md="${claude_dir}/CLAUDE.md" local existing_size=0 if [[ -f "$claude_md" ]]; then existing_size=$(stat -c%s "$claude_md" 2>/dev/null || stat -f%z "$claude_md" 2>/dev/null || echo 0) fi if [[ "$existing_size" -gt 500 ]]; then local backup backup="${claude_dir}/CLAUDE.md.v2_1_backup.$(date +%Y%m%d%H%M%S)" as_user cp "$claude_md" "$backup" info "Existing CLAUDE.md (${existing_size} bytes) backed up to ${backup##*/}" warn "This looks like a v2.1.0 workspace. The v2.2.0 agent workspace lives at:" warn " ~/.claude-lab/${AGENT_NAME}/.claude/" warn "Your previous CLAUDE.md is preserved; nothing was deleted." fi local stub_tmp stub_tmp=$(mktemp) TMPFILES+=("$stub_tmp") cat > "$stub_tmp" << STUB_EOF # Claude Code (global stub) My agent workspace lives at: ~/.claude-lab/${AGENT_NAME}/.claude/ For agent identity, rules and skills, see: ~/.claude-lab/${AGENT_NAME}/.claude/CLAUDE.md EdgeLab installer v${EDGELAB_VERSION} STUB_EOF write_as_user "$stub_tmp" "$claude_md" 0644 local settings_json="${claude_dir}/settings.json" if [[ ! -f "$settings_json" ]]; then local settings_tmp settings_tmp=$(mktemp) TMPFILES+=("$settings_tmp") cat > "$settings_tmp" << 'SJEOF' { "env": { "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "400000" }, "permissions": { "allow": [ "Bash(npm:*)", "Bash(node:*)", "Bash(git:*)", "Bash(python3:*)", "Bash(pip3:*)", "Bash(cat:*)", "Bash(ls:*)", "Bash(mkdir:*)", "Bash(chmod:*)", "Bash(echo:*)", "Read", "Write", "Edit" ] } } SJEOF write_as_user "$settings_tmp" "$settings_json" 0644 info "settings.json written (400K context window + permissions)." else info "settings.json already exists -- not overwriting." fi as_user mkdir -p "${claude_dir}/plugins" local mcp_json="${claude_dir}/mcp.json" if [[ ! -f "$mcp_json" ]]; then echo '{"mcpServers": {}}' | as_user tee "$mcp_json" > /dev/null fi fix_owner "$claude_dir" info "\$HOME/.claude/ set up (stub + settings + plugins/ + mcp.json)." } # --------------------------------------------------------------------------- # Step 7: Agent workspace at ~/.claude-lab/{AGENT_NAME}/.claude/ # --------------------------------------------------------------------------- setup_agent_workspace() { _require_agent_name step 7 "Setting up agent workspace for '${AGENT_NAME}'..." local lab_dir="${REAL_HOME}/.claude-lab" local agent_root="${lab_dir}/${AGENT_NAME}" local ws="${agent_root}/.claude" as_user mkdir -p \ "${ws}" \ "${ws}/core" \ "${ws}/core/hot" \ "${ws}/core/warm" \ "${ws}/skills" \ "${ws}/scripts" \ "${ws}/templates" \ "${ws}/logs" local ws_claude_md="${ws}/CLAUDE.md" local bundled_tpl="" local src="${BASH_SOURCE[0]:-}" if [[ -n "$src" && -f "$src" ]]; then local script_dir script_dir=$(cd "$(dirname "$src")" && pwd) if [[ -f "${script_dir}/templates/CLAUDE.md" ]]; then bundled_tpl="${script_dir}/templates/CLAUDE.md" fi fi if [[ -z "$bundled_tpl" ]]; then local inline_tpl inline_tpl=$(mktemp) TMPFILES+=("$inline_tpl") cat > "$inline_tpl" << 'INLINE_EOF' # My AI Agent ({{AGENT_NAME}}) ## Role You are {{AGENT_NAME}} -- {{AGENT_ROLE}}. ## Communication - Respond in {{LANGUAGE}} unless told otherwise - Address me as {{USER_NAME}} - My timezone is {{TIMEZONE}} - Be concise -- short answers unless I ask for detail - Code first, explanation after ## Rules - Do not delete files without confirmation - Do not run destructive commands (rm -rf, DROP TABLE) without asking - Always explain what you are about to do before doing it @core/USER.md @core/rules.md @core/warm/decisions.md @core/hot/handoff.md INLINE_EOF bundled_tpl="$inline_tpl" fi fill_template "$bundled_tpl" "$ws_claude_md" \ "AGENT_NAME" "$AGENT_NAME" \ "AGENT_ROLE" "$AGENT_ROLE" \ "USER_NAME" "$OPERATOR_NAME" \ "TIMEZONE" "$OPERATOR_TIMEZONE" \ "LANGUAGE" "$OPERATOR_LANGUAGE" cat > "${ws}/core/USER.md" << UEOF # USER.md -- Operator profile **Name:** ${OPERATOR_NAME} **Timezone:** ${OPERATOR_TIMEZONE} **Preferred language:** ${OPERATOR_LANGUAGE} ## Notes - Edit this file freely -- the agent reads it on every start. UEOF cat > "${ws}/core/rules.md" << 'REOF' # Rules - Ask before destructive operations (rm -rf, DROP TABLE, sudo on shared infra). - Never commit secrets. Never print tokens/keys in plain text. - On each correction: update LEARNINGS.md so the mistake does not repeat. - Prefer small, reversible changes. REOF cat > "${ws}/core/MEMORY.md" << 'MEOF' # MEMORY.md Long-term notes. Things worth remembering across sessions. MEOF cat > "${ws}/core/LEARNINGS.md" << 'LEOF' # LEARNINGS.md One line per correction. Format: `- [short title] (date) -- what went wrong -> rule`. LEOF cat > "${ws}/core/hot/recent.md" << 'RCEOF' # recent.md -- full journal (NOT in @include) RCEOF cat > "${ws}/core/hot/handoff.md" << 'HEOF' # handoff.md -- last 10 entries (@include) HEOF cat > "${ws}/core/warm/decisions.md" << 'DEOF' # decisions.md -- last 14 days of decisions (@include) DEOF fix_owner "$lab_dir" info "Agent workspace ready at ${ws}" } # --------------------------------------------------------------------------- # Step 8: Skill bundle (T08) # --------------------------------------------------------------------------- install_skills() { _require_agent_name step 8 "Installing ${#SKILLS_FROM_TEMPLATE[@]} template skills + ${#SKILLS_FROM_INSTALLER[@]} bundled skills..." local ws="${REAL_HOME}/.claude-lab/${AGENT_NAME}/.claude" local dst_parent="${ws}/skills" as_user mkdir -p "$dst_parent" local tpl_dir if ! tpl_dir=$(fetch_template); then warn "Template unavailable -- skipping template skills." else local tpl_skills_root="${tpl_dir}/skills" if [[ ! -d "$tpl_skills_root" ]]; then tpl_skills_root="$tpl_dir" fi local name for name in "${SKILLS_FROM_TEMPLATE[@]}"; do local src="${tpl_skills_root}/${name}" if [[ ! -d "$src" ]]; then warn "Template skill '${name}' not found at ${src} -- skipping." continue fi install_skill_bundle "$src" "$dst_parent" "$name" info " installed ${name} (from template)" done fi local skills_dir if ! skills_dir=$(locate_installer_skills); then warn "Installer skills bundle not found -- stubs skipped." else local name for name in "${SKILLS_FROM_INSTALLER[@]}"; do local src="${skills_dir}/${name}" if [[ ! -d "$src" ]]; then warn "Bundled skill '${name}' not found at ${src} -- skipping." continue fi install_skill_bundle "$src" "$dst_parent" "$name" info " installed ${name} (bundled)" done fi fix_owner "$dst_parent" info "Skills installed: ${INSTALLED_SKILLS[*]:-}" } # --------------------------------------------------------------------------- # Step 9: Superpowers (direct git clone, D7) # --------------------------------------------------------------------------- install_superpowers() { step 9 "Installing Superpowers plugin..." local plugins_dir="${REAL_HOME}/.claude/plugins" local sp_dir="${plugins_dir}/superpowers" local cfg="${plugins_dir}/config.json" as_user mkdir -p "$plugins_dir" # F5: clone depth=1 then fetch + checkout the pinned SHA (supply-chain). if [[ -d "$sp_dir" ]]; then info "Superpowers already present -- fetching pinned SHA ${SUPERPOWERS_SHA:0:8}." as_user git -C "$sp_dir" fetch --depth=1 origin "$SUPERPOWERS_SHA" 2>/dev/null \ || warn "Superpowers fetch failed -- keeping existing checkout." as_user git -C "$sp_dir" checkout --quiet "$SUPERPOWERS_SHA" 2>/dev/null \ || warn "Superpowers checkout of pinned SHA failed." else as_user git clone --quiet --depth 1 "$SUPERPOWERS_REPO" "$sp_dir" \ || { warn "Failed to clone Superpowers -- skipping."; return 0; } as_user git -C "$sp_dir" fetch --depth=1 origin "$SUPERPOWERS_SHA" 2>/dev/null \ || warn "Superpowers fetch --depth=1 of pinned SHA failed -- using HEAD." as_user git -C "$sp_dir" checkout --quiet "$SUPERPOWERS_SHA" 2>/dev/null \ || warn "Superpowers checkout of pinned SHA failed -- using HEAD." fi # F5/H2: defensive jq merge of plugins config. local tmp tmp=$(mktemp) TMPFILES+=("$tmp") local abs_path="${sp_dir}" if [[ -f "$cfg" ]]; then # Validate existing config is a JSON object before merging. if ! jq -e 'type=="object"' "$cfg" >/dev/null 2>&1; then local backup="${cfg}.bak.$(date +%s)" cp "$cfg" "$backup" 2>/dev/null || true warn "Existing ${cfg} is not a JSON object -- backed up to $(basename "$backup")." warn "Skipping Superpowers plugin registration; re-run after inspecting the backup." fix_owner "$plugins_dir" return 0 fi if ! jq --arg p "$abs_path" \ '.plugins = ((.plugins // {}) + {"superpowers": {"enabled": true, "path": $p}})' \ "$cfg" > "$tmp" 2>/dev/null; then warn "jq merge of plugins config failed -- leaving ${cfg} untouched." return 0 fi # Guard against empty-output (edge case: jq succeeded but wrote nothing). if [[ ! -s "$tmp" ]]; then warn "jq produced empty output -- leaving ${cfg} untouched." return 0 fi else if ! jq -n --arg p "$abs_path" \ '{plugins: {superpowers: {enabled: true, path: $p}}}' > "$tmp" 2>/dev/null; then warn "Failed to write initial plugins config -- skipping." return 0 fi fi write_as_user "$tmp" "$cfg" 0644 fix_owner "$plugins_dir" info "Superpowers installed at ${sp_dir} @ ${SUPERPOWERS_SHA:0:8}" } # --------------------------------------------------------------------------- # Step 10: Authorize Claude Code (interactive) # --------------------------------------------------------------------------- authorize_claude() { step 10 "Claude Code authorization (manual follow-up)..." local creds_dir="${REAL_HOME}/.claude" if [[ -f "${creds_dir}/.credentials.json" ]]; then info "Claude Code already authorized -- skipping." return 0 fi # v2.2.3: Anthropic CLI v2.1.114+ drops into an interactive chat after # successful OAuth and does not return to the parent shell. Running # `claude login` inline from `curl | sudo bash` therefore blocks the # installer indefinitely, and Ctrl+C kills the whole pipe chain. # We intentionally DO NOT call `claude login` here -- the final banner # prints the exact command the user must run manually after install. echo "" echo "${COLOR_YELLOW}Claude Code authorization is a separate, manual step.${COLOR_RESET}" echo "The final banner prints the exact command to run." echo "" return 0 } # --------------------------------------------------------------------------- # Step 11: Telegram Gateway # --------------------------------------------------------------------------- install_gateway() { step 11 "Setting up Telegram Gateway..." local gateway_dir="${REAL_HOME}/${GATEWAY_DIR_NAME}" if [[ -d "$gateway_dir" ]]; then info "Gateway directory exists -- pulling latest changes." as_user bash -c "cd '${gateway_dir}' && git pull --ff-only" || true else as_user git clone --depth 1 "$GATEWAY_REPO" "$gateway_dir" fi local secrets_dir="${gateway_dir}/secrets" # M6 (Phase 5): atomic directory creation with mode 700 and correct owner. # The old mkdir+chmod sequence briefly left the dir at 755, which is a # security window on shared hosts. install -d -m 700 -o "$REAL_USER" -g "$REAL_USER" "$secrets_dir" if [[ -f "${gateway_dir}/requirements.txt" ]]; then local venv_dir="${gateway_dir}/.venv" if [[ ! -d "$venv_dir" ]]; then local py_cmd="python3" if command -v "python3.${PYTHON_MIN_MINOR}" &>/dev/null; then py_cmd="python3.${PYTHON_MIN_MINOR}" fi as_user "$py_cmd" -m venv "$venv_dir" fi if ! as_user "${venv_dir}/bin/pip" install -r "${gateway_dir}/requirements.txt" --quiet; then error "Failed to install gateway Python deps. Re-run installer after fixing network/pip." return 1 fi fi # F6: fail-fast -- venv python must exist and have required packages importable. local venv_py="${gateway_dir}/.venv/bin/python" if [[ ! -x "$venv_py" ]]; then error "Gateway venv python not found at ${venv_py}." return 1 fi if ! as_user "$venv_py" -c "import requests" 2>/dev/null; then error "Gateway venv is missing required package (requests)." error "Check ${gateway_dir}/requirements.txt and re-run installer." return 1 fi info "Telegram Gateway ready at ${gateway_dir}" } # --------------------------------------------------------------------------- # Step 12: Bot token (interactive) # --------------------------------------------------------------------------- setup_bot_token() { step 12 "Configuring Telegram bot token..." local secrets_dir="${REAL_HOME}/${GATEWAY_DIR_NAME}/secrets" local token_file="${secrets_dir}/bot-token" if [[ -f "$token_file" ]] && [[ -s "$token_file" ]]; then info "Bot token already configured -- skipping." CONFIGURED_BOT_TOKEN=$(cat "$token_file") local resp resp=$(_tg_getme "$CONFIGURED_BOT_TOKEN") local bot_ok bot_ok=$(echo "$resp" | jq -r '.ok // false' 2>/dev/null || echo "false") if [[ "$bot_ok" == "true" ]]; then CONFIGURED_BOT_USERNAME=$(echo "$resp" | jq -r '.result.username // ""' 2>/dev/null || echo "") info "Bot: @${CONFIGURED_BOT_USERNAME}" fi return 0 fi echo "" echo "Your agent needs a Telegram bot to communicate with you." echo "" local _has_token="" prompt_or_env _has_token EDGELAB_HAS_BOT_TOKEN "Do you have a Telegram bot token? (y/n)" "y" if [[ "${_has_token,,}" != "y" && "${_has_token,,}" != "yes" ]]; then echo "" echo "${COLOR_BOLD}How to create a Telegram bot:${COLOR_RESET}" echo " 1. Open Telegram and search for @BotFather" echo " 2. Send /newbot" echo " 3. Choose a name (e.g., 'My AI Agent')" echo " 4. Choose a username (e.g., 'myai_agent_bot')" echo " 5. Copy the token BotFather gives you" echo "" fi local bot_token="" prompt_or_env bot_token EDGELAB_BOT_TOKEN "Bot token (press Enter to skip)" "" --secret if [[ -z "$bot_token" ]]; then warn "Bot token skipped -- gateway will not work without it." warn "Add the token later to: ${token_file}" return 0 fi local colon_count colon_count=$(echo "$bot_token" | tr -cd ':' | wc -c) local token_len=${#bot_token} if [[ "$colon_count" -ne 1 ]]; then warn "Token format looks wrong (expected exactly one ':')." warn "Saving anyway -- verify it works." elif [[ "$token_len" -lt "$BOT_TOKEN_MIN_LEN" \ || "$token_len" -gt "$BOT_TOKEN_MAX_LEN" ]]; then warn "Token length (${token_len}) is unusual (expected ${BOT_TOKEN_MIN_LEN}-${BOT_TOKEN_MAX_LEN} chars)." warn "Saving anyway -- verify it works." fi ( umask 077 echo "$bot_token" > "$token_file" ) fix_owner "$token_file" chmod 600 "$token_file" local resp resp=$(_tg_getme "$bot_token") local bot_ok bot_ok=$(echo "$resp" | jq -r '.ok // false' 2>/dev/null || echo "false") if [[ "$bot_ok" == "true" ]]; then CONFIGURED_BOT_TOKEN="$bot_token" CONFIGURED_BOT_USERNAME=$(echo "$resp" | jq -r '.result.username // ""' 2>/dev/null || echo "") info "Bot verified: @${CONFIGURED_BOT_USERNAME}" else warn "Telegram API did not confirm the token -- check it manually." warn "Token saved to ${token_file}" CONFIGURED_BOT_TOKEN="$bot_token" fi } # F2: build a curl config-file containing the URL (and optional data lines). # Token stays out of argv -- /proc//cmdline sees only `curl -K /tmp/xxx`. # Usage: _tg_curl_cfg [key=value ...] -> prints cfg path. _tg_curl_cfg() { local token="$1" local method="$2" shift 2 local cfg ( umask 077 cfg=$(mktemp) # Emit URL line first (curl config-file syntax: key = "value"). printf 'url = "https://api.telegram.org/bot%s/%s"\n' "$token" "$method" > "$cfg" # Emit each data pair as its own data line. local kv for kv in "$@"; do printf 'data = "%s"\n' "$kv" >> "$cfg" done printf '%s\n' "$cfg" ) } _tg_getme() { local token="$1" local cfg cfg=$(_tg_curl_cfg "$token" "getMe") TMPFILES+=("$cfg") local resp resp=$(curl_safe -K "$cfg" 2>/dev/null || echo '{"ok":false}') rm -f "$cfg" printf '%s' "$resp" } # --------------------------------------------------------------------------- # Step 13: Telegram config + systemd # --------------------------------------------------------------------------- setup_telegram_config() { _require_agent_name step 13 "Configuring Telegram gateway..." local gateway_dir="${REAL_HOME}/${GATEWAY_DIR_NAME}" local config_file="${gateway_dir}/config.json" local tg_id="" echo "" echo "Your Telegram user ID is needed so only you can talk to the bot." echo "Get your ID: open @userinfobot in Telegram, it shows your numeric ID." echo "" prompt_or_env tg_id EDGELAB_TG_ID "Your Telegram ID (press Enter to skip)" "" if [[ -n "$tg_id" ]] && ! [[ "$tg_id" =~ ^[0-9]+$ ]]; then warn "Invalid Telegram ID '${tg_id}' -- must be a number. Skipping." tg_id="" fi if [[ -n "$tg_id" ]]; then CONFIGURED_TG_ID="$tg_id" fi local allowlist="[]" if [[ -n "$tg_id" ]]; then allowlist="[${tg_id}]" fi local token_file_path="${REAL_HOME}/${GATEWAY_DIR_NAME}/secrets/bot-token" local groq_file_path="${REAL_HOME}/${GATEWAY_DIR_NAME}/secrets/groq-api-key" local workspace_path="${REAL_HOME}/.claude-lab/${AGENT_NAME}/.claude" # F7: if config already has the right agent + workspace, skip regeneration. # Protects user edits (system_reminder, timeout_sec, extra agents, etc.). local regen_config=1 if [[ -f "$config_file" ]]; then if jq -e --arg a "$AGENT_NAME" --arg w "$workspace_path" \ '.agents[$a].workspace == $w' \ "$config_file" >/dev/null 2>&1; then info "Gateway config already has agent='${AGENT_NAME}' at ${workspace_path} -- skipping regeneration." regen_config=0 else local backup="${config_file}.bak.$(date +%s)" cp "$config_file" "$backup" 2>/dev/null || true fix_owner "$backup" 2>/dev/null || true warn "Existing config.json differs -- backed up to $(basename "$backup") before overwrite." fi fi local tmp tmp=$(mktemp) TMPFILES+=("$tmp") if [[ $regen_config -eq 1 ]]; then jq -n \ --arg comment "EdgeLab AI Agent -- Telegram Gateway Config" \ --arg agent_name "$AGENT_NAME" \ --arg user_name "$OPERATOR_NAME" \ --arg token_file "$token_file_path" \ --arg groq_file "$groq_file_path" \ --arg workspace "$workspace_path" \ --argjson allowlist "${allowlist}" \ '{ _comment: $comment, poll_interval_sec: 2, allowlist_user_ids: $allowlist, agents: { ($agent_name): { enabled: true, user_name: $user_name, telegram_bot_token_file: $token_file, groq_api_key_file: $groq_file, workspace: $workspace, model: "opus", timeout_sec: 300, streaming_mode: "partial", system_reminder: "" } } }' > "$tmp" write_as_user "$tmp" "$config_file" 0600 fix_owner "$config_file" info "Gateway config written to ${config_file}" fi local service_file="/etc/systemd/system/claude-gateway.service" cat > "$service_file" << SVCEOF [Unit] Description=Claude AI Telegram Gateway After=network-online.target Wants=network-online.target [Service] Type=simple User=${REAL_USER} Group=${REAL_USER} WorkingDirectory=${REAL_HOME}/${GATEWAY_DIR_NAME} ExecStart=${REAL_HOME}/${GATEWAY_DIR_NAME}/.venv/bin/python ${REAL_HOME}/${GATEWAY_DIR_NAME}/gateway.py Restart=on-failure RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier=claude-gateway Environment=HOME=${REAL_HOME} Environment=PATH=${REAL_HOME}/.local/bin:/usr/local/bin:/usr/bin:/bin [Install] WantedBy=multi-user.target SVCEOF systemctl daemon-reload info "claude-gateway.service installed." if [[ -n "$CONFIGURED_BOT_TOKEN" ]]; then systemctl enable claude-gateway --quiet 2>/dev/null || true # F6: drop `|| true` -- we want to see start failures. if ! systemctl start claude-gateway; then error "systemctl start claude-gateway failed." journalctl -u claude-gateway -n 10 --no-pager 2>/dev/null || true return 1 fi # F6: poll is-active up to 10s (0.5s increments) before claiming success. local i=0 while [[ $i -lt 20 ]]; do if systemctl is-active --quiet claude-gateway; then break fi sleep 0.5 i=$((i + 1)) done if ! systemctl is-active --quiet claude-gateway; then error "claude-gateway did not become active within 10s. Last 10 log lines:" journalctl -u claude-gateway -n 10 --no-pager 2>/dev/null || true return 1 fi info "Gateway started! Write to your bot in Telegram." else info "Gateway not started -- configure bot token first." fi } # --------------------------------------------------------------------------- # Step 14: Test connection # --------------------------------------------------------------------------- test_connection() { step 14 "Testing Telegram connection..." if [[ -z "$CONFIGURED_BOT_TOKEN" ]]; then warn "No bot token configured -- skipping connection test." return 0 fi if [[ -z "$CONFIGURED_TG_ID" ]]; then warn "No Telegram ID configured -- skipping connection test." return 0 fi # F2: sensitive URL (contains bot token) must not appear in argv. # Build a curl config-file with url + data lines, then curl_safe -K cfg. local cfg cfg=$(_tg_curl_cfg "$CONFIGURED_BOT_TOKEN" "sendMessage" \ "chat_id=${CONFIGURED_TG_ID}" \ "text=Your AI agent (${AGENT_NAME}) is connected! Write me anything." \ "parse_mode=HTML") TMPFILES+=("$cfg") local resp resp=$(curl_safe -K "$cfg" -X POST 2>/dev/null || echo '{"ok":false}') rm -f "$cfg" local msg_ok msg_ok=$(echo "$resp" | jq -r '.ok // false' 2>/dev/null || echo "false") if [[ "$msg_ok" == "true" ]]; then info "Connection verified! Check your Telegram." else local err_desc err_desc=$(echo "$resp" | jq -r '.description // "unknown error"' 2>/dev/null || echo "unknown") warn "Test message failed: ${err_desc}" warn "You may need to message the bot first (/start) before it can message you." fi } # --------------------------------------------------------------------------- # Step 15: API keys + cron # --------------------------------------------------------------------------- setup_api_keys_and_cron() { step 15 "Setting up optional API keys and memory rotation cron..." echo "" echo "${COLOR_BOLD}Optional API key (press Enter to skip):${COLOR_RESET}" echo "" echo " Groq (free) -- voice message transcription" echo " Get key: https://console.groq.com/keys" echo "" echo " (OpenViking semantic memory -- moved to day-2 installer.)" echo "" _setup_groq_key _setup_memory_cron } _setup_groq_key() { local secrets_dir="${REAL_HOME}/${GATEWAY_DIR_NAME}/secrets" local groq_key_file="${secrets_dir}/groq-api-key" if [[ -f "$groq_key_file" ]] && [[ -s "$groq_key_file" ]]; then info "Groq API key already configured -- skipping." CONFIGURED_GROQ="yes" return 0 fi local groq_key="" prompt_or_env groq_key EDGELAB_GROQ_KEY "Groq API key (press Enter to skip)" "" --secret if [[ -z "$groq_key" ]]; then warn "Groq skipped -- voice messages will not be transcribed." warn "You can add the key later to: ${groq_key_file}" return 0 fi if [[ ! "$groq_key" =~ ^gsk_ ]]; then warn "Key does not start with 'gsk_' -- saving anyway." fi ( umask 077 echo "$groq_key" > "$groq_key_file" ) fix_owner "$groq_key_file" chmod 600 "$groq_key_file" local header_tmp header_tmp=$(mktemp) TMPFILES+=("$header_tmp") ( umask 077 echo "Authorization: Bearer ${groq_key}" > "$header_tmp" ) chmod 600 "$header_tmp" local http_code http_code=$(curl_safe -o /dev/null -w "%{http_code}" \ -H @"${header_tmp}" \ "https://api.groq.com/openai/v1/models" 2>/dev/null || echo "000") rm -f "$header_tmp" if [[ "$http_code" == "200" ]]; then info "Groq API key validated and saved." CONFIGURED_GROQ="yes" else # M8 (Phase 5): do not claim "configured" on 401/403/5xx. Mark unverified # so the final banner tells the truth. warn "Groq API returned HTTP ${http_code} -- key saved but unverified." CONFIGURED_GROQ="unverified" fi } _setup_openviking() { local venv_dir="${REAL_HOME}/${GATEWAY_DIR_NAME}/.venv" if [[ -d "$venv_dir" ]]; then as_user "${venv_dir}/bin/pip" install "$OV_PYPI_PKG" --upgrade --quiet \ || warn "Failed to install openviking -- install manually: pip install openviking" else warn "Gateway venv not found -- skipping OpenViking pip install." fi local ov_dir="${REAL_HOME}/.openviking" # M6 (Phase 5): atomic directory creation at mode 700 (contains ov.conf with API key). install -d -m 700 -o "$REAL_USER" -g "$REAL_USER" "$ov_dir" local ov_conf="${ov_dir}/ov.conf" local ov_key="" if [[ -f "$ov_conf" ]]; then local existing_key existing_key=$(jq -r '.server.root_api_key // "CHANGE_ME"' "$ov_conf" 2>/dev/null || echo "CHANGE_ME") if [[ "$existing_key" != "CHANGE_ME" && -n "$existing_key" ]]; then info "OpenViking already configured -- skipping." CONFIGURED_OV="yes" _install_ov_service "$venv_dir" "$ov_dir" return 0 fi fi prompt_or_env ov_key EDGELAB_OV_KEY "OpenViking API key (press Enter to skip)" "" --secret if [[ -z "$ov_key" ]]; then ov_key="CHANGE_ME" warn "OpenViking skipped -- memory will not be available." warn "Configure later in: ${ov_conf}" else CONFIGURED_OV="yes" fi local tmp tmp=$(mktemp) TMPFILES+=("$tmp") jq -n --arg key "$ov_key" '{ server: { host: "127.0.0.1", port: 1933, root_api_key: $key }, account: "default", user: "agent" }' > "$tmp" write_as_user "$tmp" "$ov_conf" 0600 fix_owner "$ov_conf" if [[ "$ov_key" != "CHANGE_ME" ]]; then info "OpenViking config written with API key." else info "OpenViking config template written to ${ov_conf}" fi _install_ov_service "$venv_dir" "$ov_dir" } _install_ov_service() { local venv_dir="$1" local ov_dir="$2" local ov_service="/etc/systemd/system/openviking.service" if [[ ! -f "$ov_service" ]] && [[ -x "${venv_dir}/bin/openviking" ]]; then cat > "$ov_service" << OVSEOF [Unit] Description=OpenViking Semantic Memory Server After=network-online.target Wants=network-online.target [Service] Type=simple User=${REAL_USER} Group=${REAL_USER} ExecStart=${venv_dir}/bin/openviking serve --config ${ov_dir}/ov.conf Restart=on-failure RestartSec=10 StandardOutput=journal StandardError=journal SyslogIdentifier=openviking Environment=HOME=${REAL_HOME} [Install] WantedBy=multi-user.target OVSEOF systemctl daemon-reload info "openviking.service installed." fi } _setup_memory_cron() { _require_agent_name local ws="${REAL_HOME}/.claude-lab/${AGENT_NAME}/.claude" local scripts_dir="${ws}/scripts" as_user mkdir -p "$scripts_dir" write_rotate_warm_script "$scripts_dir" write_trim_hot_script "$scripts_dir" write_compress_warm_script "$scripts_dir" chmod +x "${scripts_dir}/rotate-warm.sh" \ "${scripts_dir}/trim-hot.sh" \ "${scripts_dir}/compress-warm.sh" fix_owner "$scripts_dir" local cron_marker="# EdgeLab memory rotation (${AGENT_NAME})" local existing_cron existing_cron=$(crontab -u "$REAL_USER" -l 2>/dev/null || echo "") if echo "$existing_cron" | grep -qF "$cron_marker"; then info "Memory rotation cron already installed -- skipping." return 0 fi local cron_log="${ws}/logs/cron.log" as_user mkdir -p "$(dirname "$cron_log")" fix_owner "$(dirname "$cron_log")" local new_cron="${existing_cron} ${cron_marker} 30 4 * * * AGENT_WORKSPACE=${ws} ${scripts_dir}/rotate-warm.sh >> ${cron_log} 2>&1 0 5 * * * AGENT_WORKSPACE=${ws} ${scripts_dir}/trim-hot.sh >> ${cron_log} 2>&1 0 6 * * * AGENT_WORKSPACE=${ws} ${scripts_dir}/compress-warm.sh >> ${cron_log} 2>&1 " echo "$new_cron" | crontab -u "$REAL_USER" - info "3 memory rotation cron jobs installed for ${REAL_USER}." } # --------------------------------------------------------------------------- # Cron helper scripts # --------------------------------------------------------------------------- write_rotate_warm_script() { local dir="$1" cat > "${dir}/rotate-warm.sh" << 'RWEOF' #!/usr/bin/env bash set -euo pipefail # Rotate WARM memory: move decisions.md entries older than 14 days to MEMORY.md WS="${AGENT_WORKSPACE:-${HOME}/.claude}" DECISIONS="${WS}/core/warm/decisions.md" MEMORY="${WS}/core/MEMORY.md" if [[ ! -f "$DECISIONS" ]]; then exit 0; fi CUTOFF=$(date -d "-14 days" +%Y-%m-%d 2>/dev/null || date -v-14d +%Y-%m-%d 2>/dev/null || exit 0) tmp=$(mktemp) keep=$(mktemp) trap 'rm -f "$tmp" "$keep"' EXIT current_date="" current_section="" while IFS= read -r line; do if [[ "$line" =~ ^##[[:space:]]([0-9]{4}-[0-9]{2}-[0-9]{2}) ]]; then if [[ -n "$current_date" ]]; then if [[ "$current_date" < "$CUTOFF" ]]; then echo "$current_section" >> "$tmp" else echo "$current_section" >> "$keep" fi fi current_date="${BASH_REMATCH[1]}" current_section="$line" else current_section="${current_section} ${line}" fi done < "$DECISIONS" if [[ -n "$current_date" ]]; then if [[ "$current_date" < "$CUTOFF" ]]; then echo "$current_section" >> "$tmp" else echo "$current_section" >> "$keep" fi fi if [[ -s "$tmp" ]]; then echo "" >> "$MEMORY" echo "## Archived from decisions.md ($(date +%Y-%m-%d))" >> "$MEMORY" cat "$tmp" >> "$MEMORY" awk '/^## [0-9]{4}-[0-9]{2}-[0-9]{2}/{exit} {print}' "$DECISIONS" > "${DECISIONS}.new" cat "$keep" >> "${DECISIONS}.new" mv "${DECISIONS}.new" "$DECISIONS" echo "[rotate-warm] Archived $(grep -c '^##' "$tmp" || echo 0) sections" fi RWEOF } write_trim_hot_script() { local dir="$1" cat > "${dir}/trim-hot.sh" << 'THEOF' #!/usr/bin/env bash set -euo pipefail WS="${AGENT_WORKSPACE:-${HOME}/.claude}" HANDOFF="${WS}/core/hot/handoff.md" if [[ ! -f "$HANDOFF" ]]; then exit 0; fi entry_count=$(grep -c '^### ' "$HANDOFF" 2>/dev/null || echo 0) if [[ "$entry_count" -le 10 ]]; then exit 0 fi header=$(head -1 "$HANDOFF") tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT tac "$HANDOFF" | awk '/^### /{count++} count<=10{print}' | tac > "$tmp" echo "$header" > "$HANDOFF" echo "" >> "$HANDOFF" cat "$tmp" >> "$HANDOFF" echo "[trim-hot] Trimmed to last 10 entries (was ${entry_count})" THEOF } write_compress_warm_script() { local dir="$1" cat > "${dir}/compress-warm.sh" << 'CWEOF' #!/usr/bin/env bash set -euo pipefail WS="${AGENT_WORKSPACE:-${HOME}/.claude}" DECISIONS="${WS}/core/warm/decisions.md" if [[ ! -f "$DECISIONS" ]]; then exit 0; fi size=$(stat -c%s "$DECISIONS" 2>/dev/null || stat -f%z "$DECISIONS" 2>/dev/null || echo 0) if [[ "$size" -gt 10240 ]]; then echo "[compress-warm] decisions.md is ${size} bytes (>10KB) -- consider running rotate-warm.sh" fi CWEOF } # --------------------------------------------------------------------------- # Step 16: Final banner # --------------------------------------------------------------------------- print_banner() { _require_agent_name step 16 "Installation complete!" echo "" echo "${COLOR_BOLD}${COLOR_GREEN}" cat << 'BANNER' ============================================= EdgeLab AI Agent -- installation complete ============================================= BANNER echo "${COLOR_RESET}" if [[ -n "$CONFIGURED_BOT_USERNAME" ]]; then echo " Agent '${AGENT_NAME}' is live! Write to @${CONFIGURED_BOT_USERNAME} in Telegram." echo "" elif [[ -n "$CONFIGURED_BOT_TOKEN" ]]; then echo " Gateway is running. Write to your bot in Telegram." echo "" else echo " Bot token not configured. To set up:" echo " echo 'YOUR_TOKEN' > ~/${GATEWAY_DIR_NAME}/secrets/bot-token" echo " chmod 600 ~/${GATEWAY_DIR_NAME}/secrets/bot-token" echo " sudo systemctl restart claude-gateway" echo "" fi echo " Workspace: ~/.claude-lab/${AGENT_NAME}/.claude/" echo "" echo " Installed skills (${#INSTALLED_SKILLS[@]}):" local s for s in "${INSTALLED_SKILLS[@]:-}"; do echo " - ${s}" done [[ ${#INSTALLED_SKILLS[@]} -eq 0 ]] && echo " (none)" echo "" echo " API keys status:" # M8 (Phase 5): distinguish "verified" vs "saved but API check failed". case "${CONFIGURED_GROQ:-}" in yes) echo " Groq (voice): ${COLOR_GREEN}configured${COLOR_RESET}" ;; unverified) echo " Groq (voice): ${COLOR_YELLOW}saved, unverified (API check failed)${COLOR_RESET}" ;; *) echo " Groq (voice): ${COLOR_YELLOW}not configured${COLOR_RESET}" ;; esac echo " OpenViking: ${COLOR_YELLOW}day-2 installer${COLOR_RESET}" echo "" if [[ -z "$CONFIGURED_TG_ID" ]]; then echo " Telegram ID not set. Add it to config:" echo " nano ~/${GATEWAY_DIR_NAME}/config.json" echo "" fi cat << EOF ${COLOR_BOLD}${COLOR_YELLOW}NEXT STEP: authorize Claude Code (manual)${COLOR_RESET} sudo -u ${REAL_USER} bash -lc 'claude login' The \`claude login\` flow will: 1. Show a menu -- pick "Subscription (Claude Max, Pro)" 2. Print a URL. Open it in your browser, sign in with the Gmail account dedicated to this agent, then copy the code shown. 3. Paste the code back into the terminal when prompted. 4. After "Security notes..." press Enter to accept. 5. You land in a Claude chat. Type \`/exit\` (or press Ctrl+C) to return to the shell. Personalize your agent: nano ~/.claude-lab/${AGENT_NAME}/.claude/CLAUDE.md Run /onboarding (inside claude) to personalize your agent via chat. Installed: Claude Code, Telegram Gateway, Cron Cron jobs: rotate-warm (04:30), trim-hot (05:00), compress-warm (06:00) Documentation: https://guides.edgelab.su Community: https://edgelab.su EOF } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { echo "" echo "${COLOR_BOLD}EdgeLab AI Agent Installer v${EDGELAB_VERSION}${COLOR_RESET}" echo "" preflight detect_real_user load_state run_step "gather_inputs" gather_inputs run_step "system_packages" install_system_packages run_step "nodejs" install_nodejs run_step "python" install_python run_step "claude_code" install_claude_code run_step "global_claude" setup_global_claude run_step "agent_workspace" setup_agent_workspace run_step "skills" install_skills run_step "superpowers" install_superpowers run_step "authorize_claude" authorize_claude run_step "gateway" install_gateway run_step "bot_token" setup_bot_token run_step "telegram_config" setup_telegram_config run_step "test_connection" test_connection run_step "api_keys_cron" setup_api_keys_and_cron systemctl start unattended-upgrades 2>/dev/null || true print_banner } # Allow sourcing without executing main (for BATS tests) if [[ "${INSTALL_SH_SOURCED_FOR_TESTING:-}" != "1" ]]; then main "$@" fi