#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# LocalTaskClaw — Install Wizard
# Usage: curl -fsSL https://raw.githubusercontent.com/vakovalskii/LocalTaskClaw/main/install.sh | bash
# =============================================================================
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
REPO_URL="https://github.com/vakovalskii/LocalTaskClaw"
SPINNER_PID=""
# --- Helpers -----------------------------------------------------------------
print_header() {
echo ""
echo -e "${BOLD}${CYAN}╔═══════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ 🤖 LocalTaskClaw Installer ║${NC}"
echo -e "${BOLD}${CYAN}║ Персональный ИИ-агент за 5 минут ║${NC}"
echo -e "${BOLD}${CYAN}╚═══════════════════════════════════════════════╝${NC}"
echo ""
}
info() { echo -e "${BLUE}[→]${NC} $*"; }
success() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*"; }
step() { echo -e "\n${BOLD}${CYAN}── $* ${NC}"; }
dim() { echo -e "${DIM}$*${NC}"; }
sanitize() {
# Strip control characters (CR, LF, tab, and other non-printable chars) that
# can sneak in from copy-paste and break YAML/env files
local val="$1"
val="${val//[$'\r\n\t']}" # remove CR, LF, tab
val="${val//[[:cntrl:]]}" # remove any remaining control chars
printf '%s' "$val"
}
prompt() {
local var_name="$1" message="$2" default="${3:-}" input
if [[ -n "$default" ]]; then
echo -ne "${BOLD}$message${NC} ${YELLOW}[${default}]${NC}: " >/dev/tty
else
echo -ne "${BOLD}$message${NC}: " >/dev/tty
fi
read -r input /dev/tty
read -rs input /dev/tty
input="$(sanitize "$input")"
# Show masked preview so user knows something was entered
local len=${#input}
if [[ $len -gt 8 ]]; then
local show="${input:0:4}$(printf '%*s' $((len - 8)) '' | tr ' ' '*')${input: -4}"
echo -e " ${YELLOW}→ Получено: ${show}${NC}" >/dev/tty
elif [[ $len -gt 0 ]]; then
echo -e " ${YELLOW}→ Получено: $(printf '%*s' "$len" '' | tr ' ' '*')${NC}" >/dev/tty
else
echo -e " ${RED}→ Пусто! Ничего не введено${NC}" >/dev/tty
fi
printf -v "$var_name" '%s' "$input"
}
spinner_start() {
local msg="$1"
echo -ne "${BLUE}[…]${NC} $msg "
(while true; do
for c in '⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏'; do
echo -ne "\r${BLUE}[$c]${NC} $msg "
sleep 0.1
done
done) &
SPINNER_PID=$!
disown "$SPINNER_PID" 2>/dev/null || true
}
spinner_stop() {
if [[ -n "$SPINNER_PID" ]]; then
kill "$SPINNER_PID" 2>/dev/null || true
wait "$SPINNER_PID" 2>/dev/null || true
SPINNER_PID=""
echo -ne "\r\033[K"
fi
}
write_secret() {
printf '%s' "$2" > "$1"
chmod 600 "$1"
}
generate_secret() {
if command -v openssl &>/dev/null; then
openssl rand -hex 24
else
python3 -c "import secrets; print(secrets.token_hex(24))"
fi
}
cleanup() {
spinner_stop; echo ""
warn "Установка прервана."; exit 130
}
trap cleanup INT TERM
# =============================================================================
# HEADER
# =============================================================================
print_header
# =============================================================================
# ШАГ 1 — ВЫБОР РЕЖИМА ИЗОЛЯЦИИ
# =============================================================================
step "Шаг 1 — Как установить агента?"
echo ""
echo -e " ${BOLD}1)${NC} 🐳 ${BOLD}Docker${NC} — с изоляцией ${CYAN}(рекомендуем для сервера)${NC}"
dim " Агент запускается в контейнерах. Доступ только к выделенному тому."
echo ""
echo -e " ${BOLD}2)${NC} ⚡ ${BOLD}Процессы${NC} — без изоляции, напрямую"
dim " Агент работает как Python-процесс. Видит всю файловую систему."
echo ""
echo -e " ${BOLD}3)${NC} 📁 ${BOLD}Ограничить папкой агента${NC} — процессы + sandbox"
dim " Как вариант 2, но агент заперт в ~/.localtaskclaw/workspace."
echo ""
prompt INSTALL_MODE "Выбор" "1"
case "$INSTALL_MODE" in
2) MODE_NAME="native" ;;
3) MODE_NAME="restricted" ;;
*) MODE_NAME="docker" ;;
esac
success "Режим: ${BOLD}${MODE_NAME}${NC}"
# =============================================================================
# ШАГ 2 — ПРОВЕРКА ЗАВИСИМОСТЕЙ
# =============================================================================
step "Шаг 2 — Проверка зависимостей"
# python3 нужен всегда (для поллинга Telegram)
if ! command -v python3 &>/dev/null; then
error "python3 не найден. Установи: brew install python3 / apt install python3"
exit 1
fi
success "Python $(python3 --version | awk '{print $2}')"
if ! command -v curl &>/dev/null; then
error "curl не найден"
exit 1
fi
success "curl найден"
if [[ "$MODE_NAME" == "docker" ]]; then
if ! command -v docker &>/dev/null; then
error "Docker не найден → https://docs.docker.com/engine/install/"
exit 1
fi
if ! docker info &>/dev/null; then
error "Docker daemon не запущен. Запусти: sudo systemctl start docker"
exit 1
fi
success "Docker $(docker --version | awk '{print $3}' | tr -d ',')"
if docker compose version &>/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose &>/dev/null; then
COMPOSE_CMD="docker-compose"
else
error "Docker Compose не найден → https://docs.docker.com/compose/install/"
exit 1
fi
success "Compose: $COMPOSE_CMD"
else
# Native / restricted — нужен git и pip
if ! command -v git &>/dev/null; then
error "git не найден. Установи: brew install git / apt install git"
exit 1
fi
success "git $(git --version | awk '{print $3}')"
if ! python3 -m pip --version &>/dev/null 2>&1; then
error "pip не найден. Запусти: python3 -m ensurepip --upgrade"
exit 1
fi
success "pip найден"
fi
# =============================================================================
# ШАГ 3 — TELEGRAM БОТ
# =============================================================================
step "Шаг 3 — Telegram бот"
TG_TOKEN="" BOT_USERNAME="" OWNER_ID="" OWNER_NAME=""
while true; do
prompt_secret TG_TOKEN "Токен бота (от @BotFather)"
# trim whitespace/CR that can sneak in from copy-paste
TG_TOKEN="${TG_TOKEN//[$'\r\n\t ']}"
if ! echo "$TG_TOKEN" | grep -qE '^[0-9]+:[A-Za-z0-9_-]{30,}$'; then
error "Неверный формат. Должно быть: 1234567890:ABCdef..."
info "Совет: скопируй токен из BotFather без лишних пробелов"
continue
fi
spinner_start "Проверяю токен..."
BOT_INFO=$(curl -s --max-time 10 "https://api.telegram.org/bot${TG_TOKEN}/getMe" 2>/dev/null || echo '{"ok":false}')
spinner_stop
BOT_OK=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print('yes' if d.get('ok') else 'no')" <<< "$BOT_INFO")
if [[ "$BOT_OK" == "yes" ]]; then
BOT_USERNAME=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d['result']['username'])" <<< "$BOT_INFO")
success "Бот: @${BOLD}${BOT_USERNAME}${NC}"
break
else
BOT_ERR=$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(d.get('description','unknown'))" <<< "$BOT_INFO")
error "Ошибка: $BOT_ERR"
fi
done
# Сбросить webhook если установлен — иначе getUpdates не работает
spinner_start "Сбрасываю webhook (если есть)..."
curl -s --max-time 5 "https://api.telegram.org/bot${TG_TOKEN}/deleteWebhook?drop_pending_updates=false" &>/dev/null || true
spinner_stop
echo ""
info "Открой бота ${BOLD}@${BOT_USERNAME}${NC} в Telegram и нажми ${BOLD}/start${NC}"
info "Жду 120 сек... Или введи Telegram ID вручную прямо сейчас."
echo ""
RESULT=$(python3 - "$TG_TOKEN" <<'PYEOF'
import sys, json, urllib.request, time
token = sys.argv[1]
offset = 0
# Skip already-seen updates
try:
url = f"https://api.telegram.org/bot{token}/getUpdates?limit=1&offset=-1&timeout=0"
r = urllib.request.urlopen(url, timeout=8)
data = json.loads(r.read())
if not data.get("ok"):
# getUpdates forbidden — webhook active? Try anyway
pass
updates = data.get("result", [])
if updates:
offset = updates[-1]["update_id"] + 1
except Exception as e:
sys.stderr.write(f"offset init error: {e}\n")
deadline = time.time() + 120
dots = 0
while time.time() < deadline:
try:
# long-poll 5s, socket timeout 10s
url = (f"https://api.telegram.org/bot{token}"
f"/getUpdates?timeout=5&offset={offset}"
f"&allowed_updates=%5B%22message%22%5D")
r = urllib.request.urlopen(url, timeout=10)
data = json.loads(r.read())
for upd in data.get("result", []):
offset = upd["update_id"] + 1
msg = upd.get("message", {})
text = msg.get("text", "")
if text.startswith("/start") or text:
uid = msg.get("from", {}).get("id", "")
name = msg.get("from", {}).get("first_name", "User")
if uid:
print(f"FOUND:{uid}:{name}")
sys.exit(0)
except Exception as e:
sys.stderr.write(f"poll error: {e}\n")
time.sleep(2)
continue
dots = (dots + 1) % 4
print(f"\r Ожидание /start{'.' * dots} ", end="", flush=True)
print("\rTIMEOUT" + " " * 40)
sys.exit(1)
PYEOF
) || true
if echo "$RESULT" | grep -q "^FOUND:"; then
OWNER_ID=$(echo "$RESULT" | grep "^FOUND:" | head -1 | cut -d: -f2)
OWNER_NAME=$(echo "$RESULT" | grep "^FOUND:" | head -1 | cut -d: -f3-)
success "Владелец: ${BOLD}${OWNER_NAME}${NC} (ID: ${BOLD}${OWNER_ID}${NC})"
else
echo ""
warn "Не поймал /start автоматически."
echo ""
echo -e " Найди свой Telegram ID на: ${CYAN}https://t.me/userinfobot${NC}"
echo -e " Или напиши ${BOLD}@userinfobot${NC} в Telegram — он ответит твоим ID."
echo ""
prompt OWNER_ID "Telegram ID владельца" ""
OWNER_NAME="Owner"
fi
# =============================================================================
# ШАГ 4 — LLM МОДЕЛЬ
# =============================================================================
step "Шаг 4 — Языковая модель"
echo -e " ${BOLD}1)${NC} Ollama — локальная модель на этом сервере ${CYAN}(рекомендуем)${NC}"
echo -e " ${BOLD}2)${NC} Внешний API — OpenAI / свой сервер / облако"
echo ""
prompt MODEL_SOURCE "Выбор" "1"
LLM_BASE_URL="" LLM_API_KEY="" MODEL_NAME=""
USE_OLLAMA=""
if [[ "$MODEL_SOURCE" == "1" ]]; then
USE_OLLAMA="yes"
# Hardware detection
TOTAL_RAM_GB=0 GPU_VRAM_GB=0
TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}' || echo "0")
TOTAL_RAM_GB=$(( TOTAL_RAM_KB / 1024 / 1024 ))
if command -v nvidia-smi &>/dev/null; then
GPU_VRAM_MB=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 | tr -d ' ' || echo "0")
GPU_VRAM_GB=$(( GPU_VRAM_MB / 1024 ))
fi
AVAILABLE_GB=$TOTAL_RAM_GB
[[ "$GPU_VRAM_GB" -gt 4 ]] && AVAILABLE_GB=$GPU_VRAM_GB
info "RAM: ${BOLD}${TOTAL_RAM_GB}GB${NC} GPU VRAM: ${BOLD}${GPU_VRAM_GB}GB${NC}"
echo ""
declare -A MODEL_OPTIONS
OPT_NUM=1
add_model_option() {
local min_gb="$1" name="$2" tag="$3" size="$4" note="$5"
if [[ "$AVAILABLE_GB" -ge "$min_gb" ]]; then
echo -e " ${BOLD}${OPT_NUM})${NC} ${name}:${tag} ${YELLOW}(${size})${NC} — ${note}"
MODEL_OPTIONS["$OPT_NUM"]="${name}:${tag}"
OPT_NUM=$((OPT_NUM + 1))
fi
}
add_model_option 3 "qwen2.5" "3b" "2.0GB" "минимум RAM"
add_model_option 6 "qwen2.5" "7b" "4.7GB" "★ отличный tool use"
add_model_option 10 "llama3.1" "8b" "4.9GB" "Meta, хорош для кода"
add_model_option 12 "qwen2.5" "14b" "9.0GB" "★★ сильный reasoning"
add_model_option 22 "qwen2.5" "32b" "19GB" "★★★ лучший до 32GB"
add_model_option 22 "deepseek-r1" "14b" "9.0GB" "reasoning + цепочка мыслей"
add_model_option 50 "qwen2.5" "72b" "47GB" "топ open-source"
CUSTOM_OPT=$OPT_NUM
echo -e " ${BOLD}${OPT_NUM})${NC} Ввести своё название"
echo ""
prompt OLLAMA_CHOICE "Выбор" "1"
if [[ "$OLLAMA_CHOICE" == "$CUSTOM_OPT" ]]; then
prompt MODEL_NAME "Название модели (например: mistral:7b)" ""
elif [[ -n "${MODEL_OPTIONS[$OLLAMA_CHOICE]:-}" ]]; then
MODEL_NAME="${MODEL_OPTIONS[$OLLAMA_CHOICE]}"
else
MODEL_NAME="${MODEL_OPTIONS[1]:-qwen2.5:7b}"
fi
success "Выбрана модель: ${BOLD}${MODEL_NAME}${NC}"
# Install Ollama
if command -v ollama &>/dev/null; then
success "Ollama уже установлен"
else
info "Устанавливаю Ollama..."
spinner_start "Установка Ollama..."
if curl -fsSL https://ollama.com/install.sh | sh > /tmp/ollama_install.log 2>&1; then
spinner_stop; success "Ollama установлен"
else
spinner_stop; error "Ошибка установки Ollama:"; tail -10 /tmp/ollama_install.log; exit 1
fi
fi
# Start Ollama
if ! curl -s --max-time 2 http://localhost:11434/ &>/dev/null; then
info "Запускаю Ollama..."
if command -v systemctl &>/dev/null && systemctl is-enabled ollama &>/dev/null 2>&1; then
systemctl start ollama
else
nohup ollama serve > /tmp/ollama.log 2>&1 &
sleep 3
fi
fi
# Pull model
echo ""
info "Скачиваю ${BOLD}${MODEL_NAME}${NC}..."
if ! ollama pull "$MODEL_NAME"; then
error "Ошибка скачивания. Попробуй вручную: ollama pull ${MODEL_NAME}"
exit 1
fi
success "Модель скачана"
if [[ "$MODE_NAME" == "docker" ]]; then
LLM_BASE_URL="http://host.docker.internal:11434/v1"
else
LLM_BASE_URL="http://localhost:11434/v1"
fi
LLM_API_KEY="ollama"
else
# External API
info "Примеры URL: https://api.openai.com/v1 / http://your-server:44334/v1"
echo ""
prompt LLM_BASE_URL "Base URL" ""
prompt_secret LLM_API_KEY "API ключ"
prompt MODEL_NAME "Название модели" "gpt-4o"
success "Модель: ${BOLD}${MODEL_NAME}${NC}"
fi
# =============================================================================
# ШАГ 5 — ВЕБ-ПОИСК
# =============================================================================
step "Шаг 5 — Веб-поиск"
BRAVE_KEY=""
success "Веб-поиск через DuckDuckGo включён по умолчанию (Brave API опционально — настройки)"
# =============================================================================
# ШАГ 6 — ПОДТВЕРЖДЕНИЕ
# =============================================================================
step "Шаг 6 — Проверка"
echo ""
# Helper: pad a string to N visible chars (accounts for multibyte UTF-8)
_pad() {
local str="$1" width="$2"
local visible_len=$(echo -n "$str" | LC_ALL=en_US.UTF-8 wc -m | tr -d ' ')
local pad=$((width - visible_len))
[[ $pad -lt 0 ]] && pad=0
printf '%s%*s' "$str" "$pad" ""
}
SHORT_URL="$LLM_BASE_URL"
local_search="DuckDuckGo (+ Brave)"
echo -e "${BOLD}┌──────────────────────────────────────────────────────────┐${NC}"
echo -e "${BOLD}│${NC} $(_pad "Режим" 16) $(_pad "$MODE_NAME" 38) ${BOLD}│${NC}"
echo -e "${BOLD}│${NC} $(_pad "Telegram бот" 16) $(_pad "@$BOT_USERNAME" 38) ${BOLD}│${NC}"
echo -e "${BOLD}│${NC} $(_pad "Владелец" 16) $(_pad "$OWNER_NAME ($OWNER_ID)" 38) ${BOLD}│${NC}"
echo -e "${BOLD}│${NC} $(_pad "Модель" 16) $(_pad "$MODEL_NAME" 38) ${BOLD}│${NC}"
echo -e "${BOLD}│${NC} $(_pad "LLM URL" 16) $(_pad "${SHORT_URL:0:38}" 38) ${BOLD}│${NC}"
echo -e "${BOLD}│${NC} $(_pad "Веб-поиск" 16) $(_pad "$local_search" 38) ${BOLD}│${NC}"
echo -e "${BOLD}└──────────────────────────────────────────────────────────┘${NC}"
echo ""
echo -ne "${BOLD}Запустить установку? [Y/n]${NC}: " >/dev/tty
read -r CONFIRM /tmp/localtaskclaw_clone.log 2>&1; then
spinner_stop; error "Ошибка клонирования:"; tail -5 /tmp/localtaskclaw_clone.log; exit 1
fi
spinner_stop; success "Код скачан"
fi
# Write secrets as env files
cat > "$INSTALL_DIR/secrets/core.env" << ENV
MODEL=${MODEL_NAME}
LLM_BASE_URL=${LLM_BASE_URL}
LLM_API_KEY=${LLM_API_KEY}
BOT_TOKEN=${TG_TOKEN}
OWNER_ID=${OWNER_ID}
API_SECRET=${API_SECRET}
WORKSPACE=/data/workspace
DB_PATH=/data/localtaskclaw.db
BRAVE_API_KEY=${BRAVE_KEY:-}
MAX_ITERATIONS=20
COMMAND_TIMEOUT=60
MAX_TOKENS=4096
API_PORT=11387
ENV
chmod 600 "$INSTALL_DIR/secrets/core.env"
cat > "$INSTALL_DIR/secrets/bot.env" << ENV
CORE_URL=http://core:11387
BOT_TOKEN=${TG_TOKEN}
API_SECRET=${API_SECRET}
OWNER_ID=${OWNER_ID}
ENV
chmod 600 "$INSTALL_DIR/secrets/bot.env"
# Also save api_secret standalone for ltc CLI
write_secret "$INSTALL_DIR/secrets/api_secret.txt" "$API_SECRET"
cat > "$INSTALL_DIR/docker-compose.yml" << COMPOSE
services:
core:
build:
context: ./app
dockerfile: Dockerfile
container_name: localtaskclaw-core
restart: unless-stopped
env_file: ./secrets/core.env
environment:
WORKSPACE: /data/workspace
DB_PATH: /data/localtaskclaw.db
API_PORT: "11387"
volumes:
- ./data:/data
ports:
- "11387:11387"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:11387/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
bot:
build:
context: ./app/bot
dockerfile: Dockerfile
container_name: localtaskclaw-bot
restart: unless-stopped
env_file: ./secrets/bot.env
environment:
CORE_URL: http://core:11387
depends_on:
core:
condition: service_healthy
COMPOSE
success "Конфиг: ${BOLD}$INSTALL_DIR${NC}"
info "Собираю и запускаю контейнеры..."
cd "$INSTALL_DIR"
spinner_start "docker compose build + up..."
if ! $COMPOSE_CMD up -d --build > /tmp/localtaskclaw_up.log 2>&1; then
spinner_stop
error "Ошибка запуска:"; tail -20 /tmp/localtaskclaw_up.log; exit 1
fi
spinner_stop; success "Контейнеры запущены"
# Health check
spinner_start "Жду готовности..."
HEALTHY=false
for i in $(seq 1 30); do
if curl -s --max-time 2 http://localhost:11387/health &>/dev/null; then
HEALTHY=true; break
fi
sleep 2
done
spinner_stop
[[ "$HEALTHY" == "true" ]] && success "Сервис готов!" || warn "Сервис ещё загружается..."
ADMIN_URL="http://localhost:11387/admin"
MANAGE_INFO="Логи: ${BOLD}ltc logs${NC}
Стоп: ${BOLD}ltc stop${NC}
Обновить: ${BOLD}ltc update${NC}"
fi
# =============================================================================
# УСТАНОВКА — NATIVE / RESTRICTED
# =============================================================================
if [[ "$MODE_NAME" == "native" || "$MODE_NAME" == "restricted" ]]; then
INSTALL_DIR="$HOME/.localtaskclaw"
WORKSPACE_DIR="$INSTALL_DIR/workspace"
DB_PATH="$HOME/.localtaskclaw/localtaskclaw.db"
VENV_DIR="$INSTALL_DIR/venv"
CODE_DIR="$INSTALL_DIR/app"
mkdir -p "$INSTALL_DIR" "$WORKSPACE_DIR" "$HOME/.localtaskclaw"
# Clone repo
if [[ -d "$CODE_DIR/.git" ]]; then
info "Обновляю код..."
git -C "$CODE_DIR" pull --rebase --quiet
else
info "Клонирую репозиторий..."
spinner_start "git clone..."
if ! git clone --quiet "$REPO_URL" "$CODE_DIR" > /tmp/localtaskclaw_clone.log 2>&1; then
spinner_stop; error "Ошибка клонирования:"; tail -5 /tmp/localtaskclaw_clone.log; exit 1
fi
spinner_stop; success "Код скачан"
fi
# Virtual environment
if [[ ! -d "$VENV_DIR" ]]; then
info "Создаю виртуальное окружение..."
python3 -m venv "$VENV_DIR"
fi
info "Устанавливаю зависимости..."
spinner_start "pip install..."
if ! "$VENV_DIR/bin/pip" install -q -r "$CODE_DIR/core/requirements.txt" > /tmp/localtaskclaw_pip.log 2>&1; then
spinner_stop; error "Ошибка pip:"; tail -10 /tmp/localtaskclaw_pip.log; exit 1
fi
if [[ -f "$CODE_DIR/bot/requirements.txt" ]]; then
"$VENV_DIR/bin/pip" install -q -r "$CODE_DIR/bot/requirements.txt" >> /tmp/localtaskclaw_pip.log 2>&1 || true
fi
spinner_stop; success "Зависимости установлены"
# Generate API secret
API_SECRET=$(generate_secret)
# Write secrets/core.env
SECRETS_DIR="$CODE_DIR/secrets"
mkdir -p "$SECRETS_DIR"
chmod 700 "$SECRETS_DIR"
cat > "$SECRETS_DIR/core.env" << ENV
# LocalTaskClaw Core — generated by installer
# DO NOT COMMIT
MODEL=${MODEL_NAME}
LLM_BASE_URL=${LLM_BASE_URL}
LLM_API_KEY=${LLM_API_KEY}
BOT_TOKEN=${TG_TOKEN}
OWNER_ID=${OWNER_ID}
API_SECRET=${API_SECRET}
WORKSPACE=${WORKSPACE_DIR}
DB_PATH=${DB_PATH}
BRAVE_API_KEY=${BRAVE_KEY:-}
MAX_ITERATIONS=20
COMMAND_TIMEOUT=60
MAX_TOKENS=4096
API_PORT=11387
ENV
chmod 600 "$SECRETS_DIR/core.env"
success "Конфиг: ${BOLD}$SECRETS_DIR/core.env${NC}"
# Write bot config
cat > "$SECRETS_DIR/bot.env" << ENV
CORE_URL=http://localhost:11387
BOT_TOKEN=${TG_TOKEN}
API_SECRET=${API_SECRET}
OWNER_ID=${OWNER_ID}
ENV
chmod 600 "$SECRETS_DIR/bot.env"
# --- macOS: LaunchAgent ---
if [[ "$(uname -s)" == "Darwin" ]]; then
LAUNCH_DIR="$HOME/Library/LaunchAgents"
mkdir -p "$LAUNCH_DIR"
cat > "$LAUNCH_DIR/io.localtaskclaw.core.plist" << PLIST
Label io.localtaskclaw.core
ProgramArguments
${VENV_DIR}/bin/python
-muvicorn
api:app
--host0.0.0.0
--port11387
WorkingDirectory ${CODE_DIR}/core
EnvironmentVariables
ENV_FILE ${SECRETS_DIR}/core.env
StandardOutPath /tmp/localtaskclaw-core.log
StandardErrorPath /tmp/localtaskclaw-core.log
RunAtLoad
KeepAlive
PLIST
cat > "$LAUNCH_DIR/io.localtaskclaw.bot.plist" << PLIST
Label io.localtaskclaw.bot
ProgramArguments
${VENV_DIR}/bin/python
${CODE_DIR}/bot/main.py
WorkingDirectory ${CODE_DIR}/bot
EnvironmentVariables
ENV_FILE ${SECRETS_DIR}/bot.env
StandardOutPath /tmp/localtaskclaw-bot.log
StandardErrorPath /tmp/localtaskclaw-bot.log
RunAtLoad
KeepAlive
PLIST
# Load services
launchctl unload "$LAUNCH_DIR/io.localtaskclaw.core.plist" 2>/dev/null || true
launchctl unload "$LAUNCH_DIR/io.localtaskclaw.bot.plist" 2>/dev/null || true
launchctl load "$LAUNCH_DIR/io.localtaskclaw.core.plist"
launchctl load "$LAUNCH_DIR/io.localtaskclaw.bot.plist"
success "LaunchAgents зарегистрированы (автозапуск при логине)"
# --- Linux: systemd user units ---
elif command -v systemctl &>/dev/null; then
SYSTEMD_DIR="$HOME/.config/systemd/user"
mkdir -p "$SYSTEMD_DIR"
cat > "$SYSTEMD_DIR/localtaskclaw-core.service" << SVC
[Unit]
Description=LocalTaskClaw Core
After=network.target
[Service]
Type=simple
WorkingDirectory=${CODE_DIR}/core
ExecStart=${VENV_DIR}/bin/python -m uvicorn api:app --host 0.0.0.0 --port 11387
Environment=ENV_FILE=${SECRETS_DIR}/core.env
Restart=always
RestartSec=5
StandardOutput=append:/tmp/localtaskclaw-core.log
StandardError=append:/tmp/localtaskclaw-core.log
[Install]
WantedBy=default.target
SVC
cat > "$SYSTEMD_DIR/localtaskclaw-bot.service" << SVC
[Unit]
Description=LocalTaskClaw Bot
After=localtaskclaw-core.service
Requires=localtaskclaw-core.service
[Service]
Type=simple
WorkingDirectory=${CODE_DIR}/bot
ExecStart=${VENV_DIR}/bin/python main.py
Environment=ENV_FILE=${SECRETS_DIR}/bot.env
Restart=always
RestartSec=5
StandardOutput=append:/tmp/localtaskclaw-bot.log
StandardError=append:/tmp/localtaskclaw-bot.log
[Install]
WantedBy=default.target
SVC
systemctl --user daemon-reload
systemctl --user enable --now localtaskclaw-core.service
systemctl --user enable --now localtaskclaw-bot.service
success "systemd user units запущены"
else
# Fallback: bare nohup
warn "systemd/launchd не найдены — запускаю через nohup"
pkill -f "uvicorn api:app" 2>/dev/null || true
pkill -f "localtaskclaw-bot" 2>/dev/null || true
sleep 1
cd "$CODE_DIR/core"
ENV_FILE="$SECRETS_DIR/core.env" \
nohup "$VENV_DIR/bin/python" -m uvicorn api:app --host 0.0.0.0 --port 11387 \
> /tmp/localtaskclaw-core.log 2>&1 &
cd "$CODE_DIR/bot"
ENV_FILE="$SECRETS_DIR/bot.env" \
nohup "$VENV_DIR/bin/python" main.py \
> /tmp/localtaskclaw-bot.log 2>&1 &
success "Процессы запущены (nohup)"
fi
# Health check
spinner_start "Жду готовности сервиса..."
HEALTHY=false
for i in $(seq 1 20); do
if curl -s --max-time 2 http://localhost:11387/health &>/dev/null; then
HEALTHY=true; break
fi
sleep 2
done
spinner_stop
[[ "$HEALTHY" == "true" ]] && success "Core готов!" || warn "Core ещё загружается (см. /tmp/localtaskclaw-core.log)"
ADMIN_URL="http://localhost:11387/admin"
MANAGE_INFO="Логи: ${BOLD}ltc logs${NC}
Стоп: ${BOLD}ltc stop${NC}
Обновить: ${BOLD}ltc update${NC}"
fi
# =============================================================================
# ФИНАЛ
# =============================================================================
echo ""
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ Установка завершена! ║${NC}"
echo -e "${BOLD}${GREEN}╠══════════════════════════════════════════════════════════╣${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "Режим:" 16) ${BOLD}$(_pad "$MODE_NAME" 36)${NC}${BOLD}${GREEN}║${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "Admin UI:" 16) ${BOLD}$(_pad "$ADMIN_URL" 36)${NC}${BOLD}${GREEN}║${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "API Secret:" 16) ${BOLD}$(_pad "$API_SECRET" 36)${NC}${BOLD}${GREEN}║${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "Telegram бот:" 16) ${BOLD}$(_pad "@$BOT_USERNAME" 36)${NC}${BOLD}${GREEN}║${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "Модель:" 16) ${BOLD}$(_pad "$MODEL_NAME" 36)${NC}${BOLD}${GREEN}║${NC}"
echo -e "${BOLD}${GREEN}║${NC} $(_pad "LLM URL:" 16) ${BOLD}$(_pad "${LLM_BASE_URL:0:36}" 36)${NC}${BOLD}${GREEN}║${NC}"
if [[ "$MODE_NAME" == "restricted" ]]; then
echo -e "${BOLD}${GREEN}║${NC} $(_pad "Workspace:" 16) ${BOLD}$(_pad "~/.localtaskclaw/workspace" 36)${NC}${BOLD}${GREEN}║${NC}"
fi
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "$MANAGE_INFO"
echo ""
info "Открой в браузере: ${BOLD}${ADMIN_URL}${NC}"
info "Используй API Secret как пароль для входа в UI"
echo ""
echo -e "${BOLD}${CYAN} Полезные команды:${NC}"
# Install ltc CLI
chmod +x "$CODE_DIR/ltc"
mkdir -p "$HOME/bin"
ln -sf "$CODE_DIR/ltc" "$HOME/bin/ltc"
# Add ~/bin to PATH if not already there
if ! echo "$PATH" | grep -q "$HOME/bin"; then
for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do
if [[ -f "$rc" ]] && ! grep -q 'HOME/bin' "$rc"; then
echo 'export PATH="$HOME/bin:$PATH"' >> "$rc"
fi
done
export PATH="$HOME/bin:$PATH"
fi
echo -e " ${GREEN}ltc${NC} CLI installed. Commands:"
echo ""
echo -e " ${BOLD}ltc status${NC} Check if services are running"
echo -e " ${BOLD}ltc stop${NC} Stop services"
echo -e " ${BOLD}ltc start${NC} Start services"
echo -e " ${BOLD}ltc restart${NC} Restart services"
echo -e " ${BOLD}ltc logs${NC} Tail core logs"
echo -e " ${BOLD}ltc test${NC} Run tests"
echo -e " ${BOLD}ltc seed${NC} Load demo kanban board"
echo -e " ${BOLD}ltc update${NC} Update to latest version"
echo -e " ${BOLD}ltc open${NC} Open admin UI in browser"
echo -e " ${BOLD}ltc uninstall${NC} Remove everything"
echo ""
echo -e " Admin UI: ${BOLD}${ADMIN_URL}${NC} (password: API Secret above)"
echo ""
# ── Предложение запустить демо канбан ──
echo ""
echo -e "${BOLD}${CYAN}── Тестовый запуск ──${NC}"
echo ""
echo -e " Хочешь загрузить демо-доску с AI-агентами?"
echo -e " Будут созданы: 4 воркера + 1 оркестратор + 5 задач."
echo -e " Оркестратор автоматически запустит всех агентов."
echo ""
echo -ne "${BOLD} Запустить демо? [y/N]${NC}: " >/dev/tty
read -r RUN_DEMO /dev/tty
read -r RUN_ORC /dev/null \
| python3 -c "import sys,json; tasks=json.load(sys.stdin).get('tasks',[]); orc=[t for t in tasks if t.get('agent_role')=='orchestrator']; print(orc[0]['id'] if orc else '')" 2>/dev/null)
if [[ -n "$ORC_TASK_ID" ]]; then
curl -s -X POST -H "X-Api-Key: ${API_SECRET}" "http://localhost:11387/kanban/tasks/${ORC_TASK_ID}/run" >/dev/null 2>&1
success "Оркестратор запущен (задача #${ORC_TASK_ID})! Следи в UI: ${BOLD}${ADMIN_URL}#kanban${NC}"
else
warn "Не удалось найти задачу оркестратора. Запусти вручную в UI."
fi
fi
else
warn "Ошибка загрузки демо. Можно запустить позже: ${BOLD}python ~/.localtaskclaw/app/scripts/seed_kanban.py${NC}"
fi
fi
echo ""