lrmd/hermes
0
1#!/bin/bash2set -euo pipefail3 4umask 00775 6# ════════════════════════════════════════════════════════════════7# HuggingMes — Hermes Gateway for HF Spaces8# ════════════════════════════════════════════════════════════════9 10# ── Startup Banner ──11APP_DIR="${HUGGINGMES_APP_DIR:-/opt/huggingmes}"12HERMES_HOME="${HERMES_HOME:-/opt/data}"13PUBLIC_PORT="${PORT:-7861}"14GATEWAY_API_PORT="${API_SERVER_PORT:-8642}"15DASHBOARD_PORT="${DASHBOARD_PORT:-9119}"16TELEGRAM_WEBHOOK_PORT="${TELEGRAM_WEBHOOK_PORT:-8765}"17SYNC_INTERVAL="${SYNC_INTERVAL:-600}"18BACKUP_DATASET="${BACKUP_DATASET_NAME:-huggingmes-backup}"19CF_PROXY_ENV_FILE="/tmp/huggingmes-cloudflare-proxy.env"20STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"21 22export HERMES_HOME23export API_SERVER_ENABLED="${API_SERVER_ENABLED:-true}"24export API_SERVER_HOST="${API_SERVER_HOST:-127.0.0.1}"25export API_SERVER_PORT="$GATEWAY_API_PORT"26export GATEWAY_HEALTH_URL="${GATEWAY_HEALTH_URL:-http://127.0.0.1:${GATEWAY_API_PORT}}"27export TELEGRAM_WEBHOOK_PORT28 29echo ""30echo " ╔══════════════════════════════════════════╗"31echo " ║ 🪽 HuggingMes Hermes Gateway ║"32echo " ╚══════════════════════════════════════════╝"33echo ""34 35if [ -z "${API_SERVER_KEY:-}" ]; then36 if [ -n "${GATEWAY_TOKEN:-}" ]; then37 export API_SERVER_KEY="$GATEWAY_TOKEN"38 else39 API_SERVER_KEY="$(python3 - <<'PY'40import secrets41print(secrets.token_urlsafe(32))42PY43)"44 export API_SERVER_KEY45 echo "GATEWAY_TOKEN not set - generated an ephemeral API token for this boot."46 fi47fi48 49# ── Setup directories ──50mkdir -p "$HERMES_HOME"/{cron,sessions,logs,hooks,memories,skills,skins,plans,workspace,home,plugins}51 52# Expose hermes CLI in ~/.local/bin so login shells (terminal backend) find it.53# Base image PATH includes /opt/data/.local/bin but hermes lives in the venv.54mkdir -p "$HERMES_HOME/.local/bin"55ln -sfn /opt/hermes/.venv/bin/hermes "$HERMES_HOME/.local/bin/hermes"56 57# Redirect Hermes plugin dir into volume so plugins survive container restarts58if [ ! -L "${HOME}/.hermes/plugins" ]; then59 mkdir -p "${HOME}/.hermes"60 rm -rf "${HOME}/.hermes/plugins"61 ln -sfn "$HERMES_HOME/plugins" "${HOME}/.hermes/plugins"62fi63 64# ── Restore workspace/state from HF Dataset ──65if [ -n "${HF_TOKEN:-}" ]; then66 echo "Restoring Hermes state from HF Dataset..."67 python3 "$APP_DIR/hermes-sync.py" restore || true68else69 echo "HF_TOKEN not set - dataset persistence is disabled."70fi71 72CLOUDFLARE_WORKERS_TOKEN="${CLOUDFLARE_WORKERS_TOKEN:-${CLOUDFLARE_API_TOKEN:-}}"73export CLOUDFLARE_WORKERS_TOKEN74if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ] || [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then75 export CLOUDFLARE_PROXY_DEBUG="${CLOUDFLARE_PROXY_DEBUG:-false}"76 echo "Preparing Cloudflare Telegram proxy..."77 python3 "$APP_DIR/cloudflare-proxy-setup.py" || true78 if [ -f "$CF_PROXY_ENV_FILE" ]; then79 . "$CF_PROXY_ENV_FILE"80 fi81fi82 83if [ -n "${CLOUDFLARE_WORKERS_TOKEN:-}" ]; then84 echo "Preparing Cloudflare Keepalive worker..."85 python3 "$APP_DIR/cloudflare-keepalive-setup.py" || true86fi87 88if [ -n "${TELEGRAM_USER_IDS:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then89 export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_IDS"90elif [ -n "${TELEGRAM_USER_ID:-}" ] && [ -z "${TELEGRAM_ALLOWED_USERS:-}" ]; then91 export TELEGRAM_ALLOWED_USERS="$TELEGRAM_USER_ID"92fi93 94if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${SPACE_HOST:-}" ] && [ -z "${TELEGRAM_WEBHOOK_URL:-}" ]; then95 if [ "${TELEGRAM_MODE:-webhook}" != "polling" ]; then96 export TELEGRAM_WEBHOOK_URL="https://${SPACE_HOST}/telegram"97 fi98fi99 100if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ] && [ -z "${TELEGRAM_WEBHOOK_SECRET:-}" ]; then101 SECRET_FILE="$HERMES_HOME/.huggingmes-telegram-webhook-secret"102 if [ -f "$SECRET_FILE" ]; then103 export TELEGRAM_WEBHOOK_SECRET104 TELEGRAM_WEBHOOK_SECRET="$(cat "$SECRET_FILE")"105 else106 TELEGRAM_WEBHOOK_SECRET="$(python3 - <<'PY'107import secrets108print(secrets.token_hex(32))109PY110)"111 printf '%s' "$TELEGRAM_WEBHOOK_SECRET" > "$SECRET_FILE"112 chmod 600 "$SECRET_FILE"113 export TELEGRAM_WEBHOOK_SECRET114 fi115fi116 117MODEL_INPUT="${HERMES_MODEL:-${LLM_MODEL:-}}"118MODEL_FOR_CONFIG="$MODEL_INPUT"119PROVIDER_FOR_CONFIG="${HERMES_INFERENCE_PROVIDER:-auto}"120LLM_API_KEY="${LLM_API_KEY:-}"121 122if [ -n "$MODEL_INPUT" ]; then123 MODEL_PREFIX="${MODEL_INPUT%%/*}"124else125 MODEL_PREFIX=""126fi127 128case "$MODEL_PREFIX" in129 openrouter)130 [ -n "$LLM_API_KEY" ] && export OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-$LLM_API_KEY}"131 [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="openrouter"132 MODEL_FOR_CONFIG="${MODEL_INPUT#openrouter/}"133 ;;134 huggingface|hf)135 [ -n "$LLM_API_KEY" ] && export HF_TOKEN="${HF_TOKEN:-$LLM_API_KEY}"136 [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="huggingface"137 MODEL_FOR_CONFIG="${MODEL_INPUT#huggingface/}"138 ;;139 vercel-ai-gateway|ai-gateway)140 [ -n "$LLM_API_KEY" ] && export AI_GATEWAY_API_KEY="${AI_GATEWAY_API_KEY:-$LLM_API_KEY}"141 [ "$PROVIDER_FOR_CONFIG" = "auto" ] && PROVIDER_FOR_CONFIG="ai-gateway"142 MODEL_FOR_CONFIG="${MODEL_INPUT#*/}"143 ;;144 anthropic)145 [ -n "$LLM_API_KEY" ] && export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$LLM_API_KEY}"146 ;;147 openai|openai-codex)148 [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"149 ;;150 google|gemini)151 [ -n "$LLM_API_KEY" ] && export GOOGLE_API_KEY="${GOOGLE_API_KEY:-$LLM_API_KEY}" GEMINI_API_KEY="${GEMINI_API_KEY:-$LLM_API_KEY}"152 PROVIDER_FOR_CONFIG="gemini"153 MODEL_FOR_CONFIG="${MODEL_INPUT#*/}" # strip "google/" or "gemini/" prefix — Hermes gemini provider needs bare model name154 ;;155 deepseek)156 [ -n "$LLM_API_KEY" ] && export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-$LLM_API_KEY}"157 ;;158 kimi-coding|moonshot)159 [ -n "$LLM_API_KEY" ] && export KIMI_API_KEY="${KIMI_API_KEY:-$LLM_API_KEY}"160 ;;161 kimi-coding-cn|moonshot-cn|kimi-cn)162 [ -n "$LLM_API_KEY" ] && export KIMI_CN_API_KEY="${KIMI_CN_API_KEY:-$LLM_API_KEY}"163 ;;164 minimax)165 [ -n "$LLM_API_KEY" ] && export MINIMAX_API_KEY="${MINIMAX_API_KEY:-$LLM_API_KEY}"166 ;;167 minimax-cn)168 [ -n "$LLM_API_KEY" ] && export MINIMAX_CN_API_KEY="${MINIMAX_CN_API_KEY:-$LLM_API_KEY}"169 ;;170 xiaomi)171 [ -n "$LLM_API_KEY" ] && export XIAOMI_API_KEY="${XIAOMI_API_KEY:-$LLM_API_KEY}"172 ;;173 zai|z-ai|z.ai|glm)174 [ -n "$LLM_API_KEY" ] && export GLM_API_KEY="${GLM_API_KEY:-$LLM_API_KEY}"175 ;;176 arcee|arcee-ai|arceeai)177 [ -n "$LLM_API_KEY" ] && export ARCEEAI_API_KEY="${ARCEEAI_API_KEY:-$LLM_API_KEY}"178 ;;179 gmi|gmi-cloud|gmicloud)180 [ -n "$LLM_API_KEY" ] && export GMI_API_KEY="${GMI_API_KEY:-$LLM_API_KEY}"181 ;;182 alibaba)183 [ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"184 ;;185 alibaba-coding-plan|alibaba_coding)186 [ -n "$LLM_API_KEY" ] && export DASHSCOPE_API_KEY="${DASHSCOPE_API_KEY:-$LLM_API_KEY}"187 ;;188 tencent-tokenhub|tencent|tokenhub|tencentmaas)189 [ -n "$LLM_API_KEY" ] && export TOKENHUB_API_KEY="${TOKENHUB_API_KEY:-$LLM_API_KEY}"190 ;;191 nvidia)192 [ -n "$LLM_API_KEY" ] && export NVIDIA_API_KEY="${NVIDIA_API_KEY:-$LLM_API_KEY}"193 ;;194 xai|grok)195 [ -n "$LLM_API_KEY" ] && export XAI_API_KEY="${XAI_API_KEY:-$LLM_API_KEY}"196 ;;197 kilocode)198 [ -n "$LLM_API_KEY" ] && export KILOCODE_API_KEY="${KILOCODE_API_KEY:-$LLM_API_KEY}"199 ;;200 opencode-zen)201 [ -n "$LLM_API_KEY" ] && export OPENCODE_ZEN_API_KEY="${OPENCODE_ZEN_API_KEY:-$LLM_API_KEY}"202 ;;203 opencode-go)204 [ -n "$LLM_API_KEY" ] && export OPENCODE_GO_API_KEY="${OPENCODE_GO_API_KEY:-$LLM_API_KEY}"205 ;;206esac207 208if [ -n "${CUSTOM_BASE_URL:-}" ]; then209 PROVIDER_FOR_CONFIG="${CUSTOM_PROVIDER:-custom}"210 [ -n "$LLM_API_KEY" ] && export OPENAI_API_KEY="${OPENAI_API_KEY:-$LLM_API_KEY}"211fi212 213export MODEL_FOR_CONFIG PROVIDER_FOR_CONFIG214export CUSTOM_BASE_URL="${CUSTOM_BASE_URL:-}"215export CUSTOM_API_KEY="${CUSTOM_API_KEY:-${LLM_API_KEY:-}}"216export CUSTOM_MODEL_CONTEXT_LENGTH="${CUSTOM_MODEL_CONTEXT_LENGTH:-131072}"217export CUSTOM_MODEL_MAX_TOKENS="${CUSTOM_MODEL_MAX_TOKENS:-8192}"218export TELEGRAM_BASE_URL="${TELEGRAM_BASE_URL:-}"219export TELEGRAM_BASE_FILE_URL="${TELEGRAM_BASE_FILE_URL:-}"220 221if [ -n "${CLOUDFLARE_PROXY_URL:-}" ] && [ -z "$TELEGRAM_BASE_URL" ]; then222 CLOUDFLARE_PROXY_URL="${CLOUDFLARE_PROXY_URL%/}"223 export TELEGRAM_BASE_URL="${CLOUDFLARE_PROXY_URL}/bot"224 export TELEGRAM_BASE_FILE_URL="${CLOUDFLARE_PROXY_URL}/file/bot"225fi226 227# ── Pool key promotion ──228# Mirror first key from comma-separated pool vars into the singular env var.229# Hermes providers read singular vars; this lets users supply pool keys like230# ANTHROPIC_API_KEYS=key1,key2 and have them picked up automatically.231promote_first_pool_key() {232 local singular_var="$1"233 local pool_var="$2"234 local singular_val="${!singular_var:-}"235 local pool_val="${!pool_var:-}"236 [ -n "$singular_val" ] && return 0237 [ -n "$pool_val" ] || return 0238 local first239 first=$(printf '%s' "$pool_val" | tr ',' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF{print; exit}')240 [ -n "$first" ] || return 0241 export "${singular_var}=$first"242}243 244promote_first_pool_key "OPENROUTER_API_KEY" "OPENROUTER_API_KEYS"245promote_first_pool_key "ANTHROPIC_API_KEY" "ANTHROPIC_API_KEYS"246promote_first_pool_key "OPENAI_API_KEY" "OPENAI_API_KEYS"247promote_first_pool_key "GOOGLE_API_KEY" "GOOGLE_API_KEYS"248promote_first_pool_key "GEMINI_API_KEY" "GEMINI_API_KEYS"249promote_first_pool_key "DEEPSEEK_API_KEY" "DEEPSEEK_API_KEYS"250promote_first_pool_key "KIMI_API_KEY" "KIMI_API_KEYS"251promote_first_pool_key "MINIMAX_API_KEY" "MINIMAX_API_KEYS"252promote_first_pool_key "NVIDIA_API_KEY" "NVIDIA_API_KEYS"253promote_first_pool_key "XAI_API_KEY" "XAI_API_KEYS"254promote_first_pool_key "KILOCODE_API_KEY" "KILOCODE_API_KEYS"255promote_first_pool_key "GLM_API_KEY" "GLM_API_KEYS"256promote_first_pool_key "ARCEEAI_API_KEY" "ARCEEAI_API_KEYS"257promote_first_pool_key "DASHSCOPE_API_KEY" "DASHSCOPE_API_KEYS"258promote_first_pool_key "GMI_API_KEY" "GMI_API_KEYS"259promote_first_pool_key "TOKENHUB_API_KEY" "TOKENHUB_API_KEYS"260 261# ── Build config ──262python3 - <<'PY'263import os264from pathlib import Path265 266import yaml267 268home = Path(os.environ["HERMES_HOME"])269path = home / "config.yaml"270try:271 config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}272except FileNotFoundError:273 config = {}274 275model_name = os.environ.get("MODEL_FOR_CONFIG", "").strip()276provider_name = os.environ.get("PROVIDER_FOR_CONFIG", "").strip()277 278if model_name:279 model = config.setdefault("model", {})280 model["default"] = model_name # always from env — deploy-time setting281 if provider_name and provider_name != "auto":282 model["provider"] = provider_name # explicit provider (openrouter, huggingface, custom…)283 else:284 model.pop("provider", None) # let Hermes infer from model-name prefix285else:286 model = config.get("model", {})287 print("No LLM_MODEL/HERMES_MODEL set; leaving Hermes model config unchanged.")288 289custom_base = os.environ.get("CUSTOM_BASE_URL", "").strip()290if custom_base and model_name:291 model.setdefault("base_url", custom_base.rstrip("/"))292 if os.environ.get("CUSTOM_API_KEY"):293 model.setdefault("api_key", os.environ["CUSTOM_API_KEY"])294 try:295 model.setdefault("context_length", int(os.environ.get("CUSTOM_MODEL_CONTEXT_LENGTH", "131072")))296 model.setdefault("max_tokens", int(os.environ.get("CUSTOM_MODEL_MAX_TOKENS", "8192")))297 except ValueError:298 pass299 300config.setdefault("terminal", {}).setdefault("cwd", os.environ.get("MESSAGING_CWD", str(home / "workspace")))301config.setdefault("compression", {}).setdefault("enabled", True)302config.setdefault("display", {}).setdefault("background_process_notifications", os.environ.get("HERMES_BACKGROUND_NOTIFICATIONS", "result"))303config.setdefault("security", {}).setdefault("redact_secrets", True)304 305platforms = config.setdefault("platforms", {})306 307if os.environ.get("TELEGRAM_BOT_TOKEN"):308 telegram = platforms.setdefault("telegram", {})309 telegram.setdefault("enabled", True)310 extra = telegram.setdefault("extra", {})311 if os.environ.get("TELEGRAM_BASE_URL"):312 extra.setdefault("base_url", os.environ["TELEGRAM_BASE_URL"])313 extra.setdefault("base_file_url", os.environ.get("TELEGRAM_BASE_FILE_URL") or os.environ["TELEGRAM_BASE_URL"])314 if os.environ.get("TELEGRAM_ALLOWED_USERS"):315 config.setdefault("telegram", {}).setdefault("allow_from", [316 item.strip()317 for item in os.environ["TELEGRAM_ALLOWED_USERS"].split(",")318 if item.strip()319 ])320 321path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")322path.chmod(0o600)323PY324 325# ── Startup Summary ──326HERMES_RUNTIME_VERSION="$(/opt/hermes/.venv/bin/hermes --version 2>/dev/null | awk '{print $NF; exit}' || true)"327echo ""328if [ -n "${HERMES_RUNTIME_VERSION:-}" ]; then329 echo "Version : ${HERMES_RUNTIME_VERSION}"330fi331echo "Model : ${MODEL_FOR_CONFIG:-unset}"332echo "Provider : ${PROVIDER_FOR_CONFIG:-unset}"333if [ -n "${TELEGRAM_BOT_TOKEN:-}" ]; then334 if [ -n "${TELEGRAM_WEBHOOK_URL:-}" ]; then335 echo "Telegram : webhook"336 else337 echo "Telegram : polling"338 fi339else340 echo "Telegram : not configured"341fi342if [ -n "${HF_TOKEN:-}" ]; then343 echo "Backup : ${BACKUP_DATASET} (every ${SYNC_INTERVAL:-600}s)"344else345 echo "Backup : disabled"346fi347if [ -n "${CLOUDFLARE_PROXY_URL:-}" ]; then348 echo "Proxy : ${CLOUDFLARE_PROXY_URL}"349fi350echo "Routes : /app/ (Hermes UI), /terminal/ (JupyterLab)"351echo "Dashboard : http://127.0.0.1:${DASHBOARD_PORT}"352echo "Gateway : http://127.0.0.1:${GATEWAY_API_PORT}"353echo ""354 355# ── JupyterLab terminal (on by default when GATEWAY_TOKEN is set) ──356JUPYTER_PID=""357start_jupyter() {358 if [ "${DEV_MODE:-true}" = "false" ]; then359 echo "JupyterLab disabled (DEV_MODE=false)."360 return 0361 fi362 # Guard: skip if already running363 if [ -n "${JUPYTER_PID:-}" ] && kill -0 "$JUPYTER_PID" 2>/dev/null; then364 return 0365 fi366 local token="${JUPYTER_TOKEN:-${API_SERVER_KEY:-}}"367 if [ -z "$token" ]; then368 echo "WARNING: No GATEWAY_TOKEN or JUPYTER_TOKEN set — JupyterLab skipped (terminal would be unauthenticated)." >&2369 return 0370 fi371 export JUPYTER_TOKEN="$token"372 local VENV_PYTHON="/opt/hermes/.venv/bin/python"373 if ! "$VENV_PYTHON" -c "import jupyterlab" >/dev/null 2>&1; then374 echo "WARNING: jupyterlab not installed in venv; skipping terminal." >&2375 return 0376 fi377 local root_dir="${JUPYTER_ROOT_DIR:-$HERMES_HOME/workspace}"378 mkdir -p "$root_dir"379 ln -sfn "$HERMES_HOME" "$root_dir/HuggingMes" 2>/dev/null || true380 echo "Starting JupyterLab terminal on port 8888 (root: $root_dir)"381 "$VENV_PYTHON" -m jupyterlab \382 --ip 127.0.0.1 \383 --port 8888 \384 --no-browser \385 --IdentityProvider.token="$JUPYTER_TOKEN" \386 --ServerApp.base_url=/terminal/ \387 --ServerApp.terminals_enabled=True \388 --ServerApp.terminado_settings='{"shell_command":["/bin/bash","-i"]}' \389 --ServerApp.allow_origin='*' \390 --ServerApp.allow_remote_access=True \391 --ServerApp.trust_xheaders=True \392 --ServerApp.tornado_settings="{'headers': {'Content-Security-Policy': 'frame-ancestors *'}}" \393 --IdentityProvider.cookie_options="{'SameSite': 'None', 'Secure': True}" \394 --ServerApp.disable_check_xsrf=True \395 --LabApp.news_url=None \396 --LabApp.check_for_updates_class=jupyterlab.NeverCheckForUpdate \397 --ServerApp.log_level=WARN \398 --ServerApp.root_dir="$root_dir" \399 >> "$HERMES_HOME/logs/jupyter.log" 2>&1 &400 JUPYTER_PID=$!401 export JUPYTER_PID402 echo "JupyterLab started (PID: $JUPYTER_PID)"403}404 405# ── Trap SIGTERM for graceful shutdown ──406SYNC_LOOP_PID=""407DASHBOARD_PID=""408graceful_shutdown() {409 echo "Shutting down HuggingMes..."410 if [ -n "${HF_TOKEN:-}" ]; then411 python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: shutdown sync failed."412 fi413 kill $(jobs -p) 2>/dev/null || true414 exit 0415}416trap graceful_shutdown SIGTERM SIGINT417 418# ── Shell capture wrappers ──419# Written to ~/.bashrc so terminal installs are recorded in workspace/startup.sh420# and replayed on next boot — packages survive Space restarts.421if [ ! -f "$STARTUP_FILE" ]; then422 touch "$STARTUP_FILE"423 chmod +x "$STARTUP_FILE"424 echo "Created workspace/startup.sh"425fi426cat > "$HOME/.bashrc" << 'BASHRC'427export PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:$PATH"428export DEBIAN_FRONTEND="${DEBIAN_FRONTEND:-noninteractive}"429if [ -z "${PS1:-}" ] || [ "$PS1" = "$ " ]; then430 export PS1="\u@\h:\w\$ "431fi432 433HERMES_HOME="${HERMES_HOME:-/opt/data}"434STARTUP_FILE="$HERMES_HOME/workspace/startup.sh"435 436_hm_append() {437 [ "${HUGGINGMES_CAPTURE_DISABLE:-0}" = "1" ] && return 0438 local line="$*"439 mkdir -p "$(dirname "$STARTUP_FILE")"440 touch "$STARTUP_FILE"441 chmod +x "$STARTUP_FILE" 2>/dev/null || true442 grep -qxF "$line" "$STARTUP_FILE" 2>/dev/null || echo "$line" >> "$STARTUP_FILE"443}444_hm_quote_args() {445 local quoted=()446 local arg447 for arg in "$@"; do448 printf -v arg '%q' "$arg"449 quoted+=("$arg")450 done451 printf '%s' "${quoted[*]}"452}453_hm_append_cmd() {454 local cmd="$1"455 shift456 local args457 args=$(_hm_quote_args "$@")458 if [ -n "$args" ]; then459 _hm_append "$cmd $args"460 else461 _hm_append "$cmd"462 fi463}464_hm_args_without_flags() {465 local out=()466 for arg in "$@"; do467 case "$arg" in468 ''|-|--*|-*) ;;469 *) out+=("$arg") ;;470 esac471 done472 printf '%s\n' "${out[@]}"473}474_hm_has_install_targets() {475 local item476 while IFS= read -r item; do477 [ -n "$item" ] && return 0478 done <<EOF479$(_hm_args_without_flags "$@")480EOF481 return 1482}483_hm_has_arg() {484 local needle="$1"485 shift486 for arg in "$@"; do487 [ "$arg" = "$needle" ] && return 0488 done489 return 1490}491_hm_can_sudo_apt() {492 command -v sudo >/dev/null 2>&1 && sudo -n apt-get --version >/dev/null 2>&1493}494_hm_apt_install() {495 if [ "$(id -u)" -eq 0 ]; then496 command apt-get update && command apt-get install -y "$@"497 elif _hm_can_sudo_apt; then498 sudo apt-get update && sudo apt-get install -y "$@"499 else500 echo "Error: apt install needs root." >&2501 return 1502 fi503}504apt-get() {505 case "${1:-}" in506 install)507 shift508 _hm_apt_install "$@"509 local rc=$?510 if [ $rc -eq 0 ]; then511 _hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"512 fi513 return $rc514 ;;515 update)516 if [ "$(id -u)" -eq 0 ]; then command apt-get "$@"517 elif _hm_can_sudo_apt; then sudo apt-get "$@"518 else command apt-get "$@"; fi519 return $?520 ;;521 *) command apt-get "$@"; return $? ;;522 esac523}524apt() {525 case "${1:-}" in526 install)527 shift528 _hm_apt_install "$@"529 local rc=$?530 if [ $rc -eq 0 ]; then531 _hm_has_install_targets "$@" && _hm_append_cmd "sudo apt-get update && sudo apt-get install -y" "$@"532 fi533 return $rc534 ;;535 update)536 if [ "$(id -u)" -eq 0 ]; then command apt "$@"537 elif _hm_can_sudo_apt; then sudo apt "$@"538 else command apt "$@"; fi539 return $?540 ;;541 *) command apt "$@"; return $? ;;542 esac543}544pip() {545 command pip "$@"546 local rc=$?547 if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \548 && ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \549 && _hm_has_install_targets "${@:2}"; then550 _hm_append_cmd "pip install" "${@:2}"551 fi552 return $rc553}554pip3() {555 command pip3 "$@"556 local rc=$?557 if [ $rc -eq 0 ] && [ "${1:-}" = "install" ] \558 && ! _hm_has_arg -r "${@:2}" && ! _hm_has_arg --requirement "${@:2}" \559 && _hm_has_install_targets "${@:2}"; then560 _hm_append_cmd "pip install" "${@:2}"561 fi562 return $rc563}564uv() {565 command uv "$@"566 local rc=$?567 if [ $rc -eq 0 ] && [ "${1:-}" = "pip" ] && [ "${2:-}" = "install" ] \568 && ! _hm_has_arg -r "${@:3}" && ! _hm_has_arg --requirements "${@:3}" \569 && _hm_has_install_targets "${@:3}"; then570 _hm_append_cmd "uv pip install" "${@:3}"571 fi572 return $rc573}574npm() {575 command npm "$@"576 local rc=$?577 if [ $rc -eq 0 ] && { [ "${1:-}" = "install" ] || [ "${1:-}" = "i" ]; } && { [ "${2:-}" = "-g" ] || [ "${2:-}" = "--global" ]; } && _hm_has_install_targets "${@:3}"; then578 _hm_append_cmd "npm install -g" "${@:3}"579 fi580 return $rc581}582hermes() {583 command hermes "$@"584 local rc=$?585 if [ $rc -eq 0 ] && [ "${1:-}" = "plugins" ] && [ "${2:-}" = "install" ] && _hm_has_install_targets "${@:3}"; then586 _hm_append_cmd "hermes plugins install" "${@:3}"587 fi588 return $rc589}590BASHRC591cat > "$HOME/.profile" << 'PROFILE'592[ -n "${BASH_VERSION:-}" ] && [ -f ~/.bashrc ] && . ~/.bashrc593PROFILE594echo "Shell capture wrappers ready."595 596# ── Optional package installs from HF Variables/Secrets ──597HM_STARTUP_FAILURES=0598 599if [ -n "${HUGGINGMES_APT_PACKAGES:-}" ]; then600 echo "Installing apt packages from HUGGINGMES_APT_PACKAGES..."601 read -r -a HM_APT_PACKAGES <<< "$HUGGINGMES_APT_PACKAGES"602 if command -v sudo >/dev/null 2>&1; then603 if sudo apt-get update && sudo apt-get install -y "${HM_APT_PACKAGES[@]}"; then604 echo "HUGGINGMES_APT_PACKAGES install complete."605 else606 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))607 echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2608 fi609 elif [ "$(id -u)" -eq 0 ]; then610 if apt-get update && apt-get install -y "${HM_APT_PACKAGES[@]}"; then611 echo "HUGGINGMES_APT_PACKAGES install complete."612 else613 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))614 echo "ERROR: HUGGINGMES_APT_PACKAGES install failed: ${HUGGINGMES_APT_PACKAGES}" >&2615 fi616 else617 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))618 echo "ERROR: root/sudo unavailable; HUGGINGMES_APT_PACKAGES skipped" >&2619 fi620fi621 622if [ -n "${HUGGINGMES_PIP_PACKAGES:-}" ]; then623 echo "Installing Python packages from HUGGINGMES_PIP_PACKAGES..."624 read -r -a HM_PIP_PACKAGES <<< "$HUGGINGMES_PIP_PACKAGES"625 if /opt/hermes/.venv/bin/pip install "${HM_PIP_PACKAGES[@]}"; then626 echo "HUGGINGMES_PIP_PACKAGES install complete."627 else628 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))629 echo "ERROR: HUGGINGMES_PIP_PACKAGES install failed: ${HUGGINGMES_PIP_PACKAGES}" >&2630 fi631fi632 633if [ -n "${HUGGINGMES_NPM_PACKAGES:-}" ]; then634 echo "Installing npm packages from HUGGINGMES_NPM_PACKAGES..."635 read -r -a HM_NPM_PACKAGES <<< "$HUGGINGMES_NPM_PACKAGES"636 if npm install -g "${HM_NPM_PACKAGES[@]}"; then637 echo "HUGGINGMES_NPM_PACKAGES install complete."638 else639 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))640 echo "ERROR: HUGGINGMES_NPM_PACKAGES install failed: ${HUGGINGMES_NPM_PACKAGES}" >&2641 fi642fi643 644# ── Arbitrary startup script (HUGGINGMES_RUN) ──645# Supports plain bash or base64-encoded scripts (prefix with base64: or b64:).646# Example: HUGGINGMES_RUN="pip install pandas && npm install -g typescript"647# Example: HUGGINGMES_RUN="base64:$(base64 -w0 setup.sh)"648hm_run_startup_auto() {649 local payload="$1"650 [ -n "$payload" ] || return 0651 local script_file652 script_file=$(mktemp "/tmp/huggingmes-startup.XXXXXX.sh")653 {654 echo 'export HUGGINGMES_CAPTURE_DISABLE=1'655 echo '[ -f ~/.bashrc ] && . ~/.bashrc'656 if [[ "$payload" == base64:* ]] || [[ "$payload" == b64:* ]]; then657 printf '%s' "${payload#*:}" | base64 -d658 else659 printf '%s\n' "$payload"660 fi661 } > "$script_file"662 chmod 700 "$script_file"663 echo "[startup:HUGGINGMES_RUN] running script"664 set +e665 bash "$script_file"666 local rc=$?667 set -e668 rm -f "$script_file"669 if [ $rc -eq 0 ]; then670 echo "[startup:HUGGINGMES_RUN] ok"671 else672 HM_STARTUP_FAILURES=$((HM_STARTUP_FAILURES + 1))673 echo "ERROR: HUGGINGMES_RUN script failed (exit ${rc})" >&2674 fi675}676 677if [ -n "${HUGGINGMES_RUN:-}" ]; then678 hm_run_startup_auto "$HUGGINGMES_RUN"679fi680 681# ── Run workspace startup script ──682# Replays install commands recorded by the shell wrappers from previous sessions.683if [ -s "$STARTUP_FILE" ]; then684 echo "Running workspace/startup.sh..."685 set +e686 HUGGINGMES_CAPTURE_DISABLE=1 bash -l "$STARTUP_FILE"687 set -e688 echo "Workspace startup script complete."689fi690 691if [ "$HM_STARTUP_FAILURES" -gt 0 ]; then692 echo "Warning: ${HM_STARTUP_FAILURES} startup step(s) failed. Check logs above." >&2693fi694 695# ── Start background services ──696node "$APP_DIR/health-server.js" &697HEALTH_PID=$!698 699if [ -n "${WEBHOOK_URL:-}" ]; then700 python3 - <<'PY' >/dev/null 2>&1 &701import json, os, urllib.request702body = json.dumps({703 "event": "restart",704 "status": "success",705 "message": "HuggingMes Hermes gateway has started.",706 "model": os.environ.get("MODEL_FOR_CONFIG", ""),707}).encode()708req = urllib.request.Request(os.environ["WEBHOOK_URL"], data=body, method="POST", headers={"Content-Type": "application/json"})709urllib.request.urlopen(req, timeout=10).read()710PY711fi712 713# ── Launch dashboard once (restarts if it dies) ──714start_dashboard_once() {715 if [ -n "${DASHBOARD_PID:-}" ] && kill -0 "$DASHBOARD_PID" 2>/dev/null; then716 return 0717 fi718 echo "Launching Hermes dashboard on 127.0.0.1:${DASHBOARD_PORT}..."719 (hermes dashboard --host 127.0.0.1 --insecure 2>&1 | tee -a "$HERMES_HOME/logs/dashboard.log") &720 DASHBOARD_PID=$!721}722 723# ── Start sync loop once — survives gateway restarts ──724start_background_sync_once() {725 [ -n "${HF_TOKEN:-}" ] || return 0726 if [ -n "${SYNC_LOOP_PID:-}" ] && kill -0 "$SYNC_LOOP_PID" 2>/dev/null; then727 return 0728 fi729 python3 -u "$APP_DIR/hermes-sync.py" loop &730 SYNC_LOOP_PID=$!731}732 733start_dashboard_once734start_jupyter735 736# ── Gateway restart loop ──737GATEWAY_RESTART_DELAY="${GATEWAY_RESTART_DELAY:-5}"738GATEWAY_MAX_RESTARTS="${GATEWAY_MAX_RESTARTS:-0}"739GATEWAY_RESTART_COUNT=0740GATEWAY_READY_TIMEOUT="${GATEWAY_READY_TIMEOUT:-120}"741 742while true; do743 # Monitor health-server — restart if it died unexpectedly744 if [ -n "${HEALTH_PID:-}" ] && ! kill -0 "$HEALTH_PID" 2>/dev/null; then745 echo "Warning: health-server exited (PID $HEALTH_PID dead); restarting..."746 node "$APP_DIR/health-server.js" &747 HEALTH_PID=$!748 echo "Health server restarted (PID: $HEALTH_PID)"749 fi750 751 # Monitor Hermes dashboard — restart if it died unexpectedly752 if [ -n "${DASHBOARD_PID:-}" ] && ! kill -0 "$DASHBOARD_PID" 2>/dev/null; then753 echo "Warning: Hermes dashboard exited; restarting..."754 start_dashboard_once755 fi756 757 # Monitor JupyterLab — restart if it died unexpectedly758 if [ "${DEV_MODE:-true}" != "false" ] && [ -n "${JUPYTER_PID:-}" ] && ! kill -0 "$JUPYTER_PID" 2>/dev/null; then759 echo "Warning: JupyterLab exited (PID $JUPYTER_PID dead); restarting..."760 unset JUPYTER_PID761 start_jupyter762 fi763 764 echo "Launching Hermes gateway..."765 (hermes gateway run 2>&1 | tee -a "$HERMES_HOME/logs/gateway.log") &766 GATEWAY_PID=$!767 768 ready=false769 for ((i=0; i<GATEWAY_READY_TIMEOUT; i++)); do770 if (echo > "/dev/tcp/127.0.0.1/${GATEWAY_API_PORT}") 2>/dev/null; then771 ready=true772 break773 fi774 if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then775 break776 fi777 sleep 1778 done779 780 if [ "$ready" != "true" ]; then781 echo ""782 echo "Hermes gateway failed to expose the API health port. Last 40 log lines:"783 echo "----------------------------------------"784 tail -40 "$HERMES_HOME/logs/gateway.log" || true785 exit 1786 fi787 788 # Start sync loop (only once — shared across all gateway restarts)789 start_background_sync_once790 791 set +e792 wait "$GATEWAY_PID"793 GATEWAY_EXIT_CODE=$?794 set -e795 796 # Sync state before restart797 if [ -n "${HF_TOKEN:-}" ]; then798 echo "Gateway exited — syncing state before restart..."799 python3 "$APP_DIR/hermes-sync.py" sync-once || echo "Warning: sync failed."800 fi801 802 GATEWAY_RESTART_COUNT=$((GATEWAY_RESTART_COUNT + 1))803 if [ "$GATEWAY_MAX_RESTARTS" != "0" ] && [ "$GATEWAY_RESTART_COUNT" -ge "$GATEWAY_MAX_RESTARTS" ]; then804 echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restart limit (${GATEWAY_MAX_RESTARTS}) reached."805 exit "$GATEWAY_EXIT_CODE"806 fi807 808 echo "Gateway exited (code ${GATEWAY_EXIT_CODE}); restarting in ${GATEWAY_RESTART_DELAY}s..."809 sleep "$GATEWAY_RESTART_DELAY"810done811 