hermes-agent-bootstrap / bootstrap.sh
merve's picture
merve HF Staff
Use ANSI-C quoting for color vars so heredocs render colors
96dae5d verified
#!/usr/bin/env bash
# ============================================================================
# Hermes Agent on a Bucket β€” one-shot bootstrap
# ============================================================================
# Creates your own HF bucket from the merve/hermes-agent template, mounts it,
# installs Hermes, sets up Telegram, and launches the agent.
#
# Layout:
# ~/hermes-bucket mount of YOUR bucket (config, SOUL, skills, memories, ...)
# ~/.hermes-home HERMES_HOME (LOCAL). Secrets live here, everything else
# is symlinked to ~/hermes-bucket so Hermes data syncs.
# ~/.hermes Hermes Python install (code + venv)
#
# Why the shadow dir: Hermes hardcodes its secrets file at $HERMES_HOME/.env
# and atomically rewrites it on `hermes setup` / sanitization. If HERMES_HOME
# were the mount, those writes would land in a public bucket. The shadow dir
# keeps .env local while every other Hermes write still passes through to the
# bucket via symlink.
#
# bash <(curl -fsSL https://huggingface.co/merve/hermes-agent-bootstrap/resolve/main/bootstrap.sh)
#
# Idempotent: re-runs are safe.
# ============================================================================
set -euo pipefail
TEMPLATE="merve/hermes-agent"
MOUNT="$HOME/hermes-bucket"
HOME_DIR="$HOME/.hermes-home"
LEGACY_SECRETS="$HOME/.hermes-secrets.env"
SECRETS="$HOME_DIR/.env"
G=$'\033[0;32m'; Y=$'\033[0;33m'; C=$'\033[0;36m'; R=$'\033[0;31m'; N=$'\033[0m'
say() { printf "${C}β†’${N} %s\n" "$*"; }
ok() { printf "${G}βœ“${N} %s\n" "$*"; }
warn() { printf "${Y}!${N} %s\n" "$*"; }
die() { printf "${R}βœ—${N} %s\n" "$*" >&2; exit 1; }
ask() { local __var=$1; shift; local __prompt="$*"; read -rp " $__prompt" "$__var" </dev/tty; }
export PATH="$HOME/.local/bin:$PATH"
printf "\n${C}βš• Hermes Agent β€” bucket bootstrap${N}\n\n"
# ----------------------------------------------------------------------------
# 1. hf CLI
# ----------------------------------------------------------------------------
if ! command -v hf >/dev/null 2>&1; then
say "Installing hf CLI..."
curl -LsSf https://hf.co/cli/install.sh | bash
export PATH="$HOME/.local/bin:$PATH"
fi
ok "hf CLI: $(command -v hf)"
# ----------------------------------------------------------------------------
# 2. Hugging Face login
# ----------------------------------------------------------------------------
if ! hf auth whoami >/dev/null 2>&1; then
warn "Not logged in to Hugging Face."
echo " Run this in another terminal, then re-run the bootstrap:"
echo " hf auth login"
exit 1
fi
HF_USER="$(hf auth whoami --format json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("user",""))')"
[ -n "$HF_USER" ] || die "Couldn't determine your HF username from 'hf auth whoami'"
HF_TOKEN_VAL="$(python3 -c 'from huggingface_hub import get_token; print(get_token() or "")' 2>/dev/null || true)"
[ -n "$HF_TOKEN_VAL" ] || die "Couldn't read HF token from local cache"
ok "Hugging Face: logged in as $HF_USER"
BUCKET="$HF_USER/hermes-agent"
# ----------------------------------------------------------------------------
# 3. Your bucket β€” create if missing, copy template if empty
# ----------------------------------------------------------------------------
if hf buckets info "$BUCKET" >/dev/null 2>&1; then
ok "Bucket $BUCKET already exists"
else
say "Creating $BUCKET (private)..."
hf buckets create "$BUCKET" --private --exist-ok >/dev/null
ok "Bucket created (private): https://huggingface.co/buckets/$BUCKET"
fi
FILE_COUNT="$(hf buckets list "$BUCKET" --recursive --quiet 2>/dev/null | wc -l | tr -d ' ')"
if [ "${FILE_COUNT:-0}" -lt 3 ]; then
say "Seeding $BUCKET from template $TEMPLATE (server-side copy, ~1s)..."
python3 - <<PY
from huggingface_hub import HfApi
HfApi().copy_files(
source="hf://buckets/$TEMPLATE/",
destination="hf://buckets/$BUCKET/",
)
PY
ok "Template copied into $BUCKET"
else
ok "$BUCKET already populated ($FILE_COUNT files) β€” skipping template copy"
fi
# ----------------------------------------------------------------------------
# 4. hf-mount
# ----------------------------------------------------------------------------
if ! command -v hf-mount >/dev/null 2>&1; then
say "Installing hf-mount..."
curl -fsSL https://raw.githubusercontent.com/huggingface/hf-mount/main/install.sh | sh
fi
ok "hf-mount: $(command -v hf-mount)"
# ----------------------------------------------------------------------------
# 5. Mount your bucket
# ----------------------------------------------------------------------------
mkdir -p "$MOUNT"
if hf-mount status 2>/dev/null | grep -q "$MOUNT"; then
ok "Bucket already mounted at $MOUNT"
else
say "Mounting $BUCKET at $MOUNT..."
hf-mount start --hf-token "$HF_TOKEN_VAL" bucket "$BUCKET" "$MOUNT" >/dev/null
ok "Mounted $BUCKET β†’ $MOUNT"
fi
# ----------------------------------------------------------------------------
# 6. Hermes Agent install
# ----------------------------------------------------------------------------
if ! command -v hermes >/dev/null 2>&1; then
say "Installing Hermes Agent (this takes a minute)..."
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup --skip-browser >/dev/null
export PATH="$HOME/.local/bin:$PATH"
fi
ok "Hermes: $(command -v hermes)"
# ----------------------------------------------------------------------------
# 7. Shadow HERMES_HOME β€” keeps .env local, symlinks everything else to bucket
# ----------------------------------------------------------------------------
say "Setting up shadow HERMES_HOME at $HOME_DIR..."
mkdir -p "$HOME_DIR" "$HOME_DIR/audio_cache" "$HOME_DIR/image_cache" "$HOME_DIR/logs"
# Make sure bucket subdirs that Hermes writes to exist (so symlinks resolve)
for d in memories cron hooks pairing sessions; do
mkdir -p "$MOUNT/$d"
done
# Files mirrored from the bucket (read mostly; if Hermes ever rewrites one
# atomically the symlink severs and the local copy takes over β€” safe but no
# longer syncing for that file)
for f in config.yaml SOUL.md README.md .env.example; do
ln -sfn "$MOUNT/$f" "$HOME_DIR/$f"
done
# Directory trees that Hermes appends to: writes inside flow through to bucket
for d in skills memories cron hooks pairing sessions; do
ln -sfn "$MOUNT/$d" "$HOME_DIR/$d"
done
ok "Shadow dir wired: $HOME_DIR (.env stays local, data symlinks β†’ $MOUNT)"
# Migrate legacy secrets file from a previous bootstrap, if present
if [ -f "$LEGACY_SECRETS" ] && [ ! -f "$SECRETS" ]; then
cp "$LEGACY_SECRETS" "$SECRETS"
chmod 600 "$SECRETS"
warn "Migrated legacy $LEGACY_SECRETS β†’ $SECRETS"
warn " You can delete $LEGACY_SECRETS once you confirm things work."
fi
# ----------------------------------------------------------------------------
# 8. Telegram (required step)
# ----------------------------------------------------------------------------
printf "\n${C}Telegram setup${N}\n"
EXISTING_TG=""
if [ -f "$SECRETS" ] && grep -q "^TELEGRAM_BOT_TOKEN=" "$SECRETS"; then
EXISTING_TG="$(grep "^TELEGRAM_BOT_TOKEN=" "$SECRETS" | head -1 | cut -d= -f2-)"
fi
if [ -n "$EXISTING_TG" ]; then
ok "Telegram bot token already on disk ($SECRETS)"
TG_TOKEN="$EXISTING_TG"
USER_ID="$(grep "^TELEGRAM_ALLOWED_USERS=" "$SECRETS" 2>/dev/null | head -1 | cut -d= -f2- || true)"
else
echo " 1. Open Telegram, message @BotFather, send /newbot, follow the prompts."
echo " 2. Paste the bot token below (looks like 123456:ABC-DEF...)"
echo ""
ask TG_TOKEN "Telegram bot token: "
[ -n "$TG_TOKEN" ] || die "Telegram bot token required"
echo ""
echo " 3. Now message @userinfobot on Telegram. It replies with your numeric"
echo " user id (looks like 123456789). Paste it below β€” this goes into"
echo " TELEGRAM_ALLOWED_USERS so only you can DM the bot."
echo ""
ask USER_ID "Your Telegram numeric user id: "
[ -n "$USER_ID" ] || die "Telegram user id required"
case "$USER_ID" in
*[!0-9]*) die "User id must be numeric (got: $USER_ID)" ;;
esac
ok "Telegram user id: $USER_ID"
fi
# ----------------------------------------------------------------------------
# 9. Write secrets file (LOCAL, never reaches the bucket)
# ----------------------------------------------------------------------------
umask 077
cat > "$SECRETS" <<EOF
# Hermes secrets β€” local-only ($HOME_DIR is HERMES_HOME, not a mount)
HF_TOKEN=$HF_TOKEN_VAL
TELEGRAM_BOT_TOKEN=$TG_TOKEN
TELEGRAM_ALLOWED_USERS=$USER_ID
TELEGRAM_HOME_CHANNEL=$USER_ID
EOF
chmod 600 "$SECRETS"
ok "Secrets saved to $SECRETS (mode 600)"
# ----------------------------------------------------------------------------
# 10. Patch the hermes entrypoint so HERMES_HOME defaults to the shadow dir
# ----------------------------------------------------------------------------
# Shell-rc exports require users to source/relaunch. Patching the launcher at
# ~/.local/bin/hermes makes plain `hermes` and `hermes gateway` Just Work in
# *any* shell, immediately, no sourcing. If a user explicitly sets HERMES_HOME
# (e.g. for a profile), that wins via :=.
HERMES_BIN="$HOME/.local/bin/hermes"
if [ -f "$HERMES_BIN" ] && ! grep -q "HERMES_HOME:=" "$HERMES_BIN"; then
python3 - "$HERMES_BIN" "$HOME_DIR" <<'PY'
import sys, pathlib
bin_path, home_dir = sys.argv[1], sys.argv[2]
p = pathlib.Path(bin_path)
lines = p.read_text().splitlines(keepends=True)
# Find the exec line and inject defaults right before it
out = []
injected = False
for line in lines:
if not injected and line.lstrip().startswith("exec "):
out.append(f': "${{HERMES_HOME:={home_dir}}}"\n')
out.append("export HERMES_HOME\n")
injected = True
out.append(line)
p.write_text("".join(out))
PY
ok "Patched $HERMES_BIN so 'hermes' defaults to HERMES_HOME=$HOME_DIR"
else
ok "$HERMES_BIN already patched (or not found)"
fi
# Clean up any prior shell-rc block from older bootstrap versions
SHELL_NAME="${SHELL##*/}"
case "$SHELL_NAME" in
zsh) SHELL_RC="$HOME/.zshrc" ;;
bash) SHELL_RC="$HOME/.bashrc" ;;
*) SHELL_RC="$HOME/.profile" ;;
esac
if [ -f "$SHELL_RC" ] && grep -q "# hermes-bucket" "$SHELL_RC"; then
python3 - "$SHELL_RC" <<'PY'
import sys, pathlib, re
p = pathlib.Path(sys.argv[1])
text = p.read_text()
new = re.sub(r"\n*# hermes-bucket[^\n]*\n(?:[^\n]*\n)*?(?=\n|\Z)", "\n", text)
p.write_text(new)
PY
ok "Removed legacy hermes-bucket block from $SHELL_RC"
fi
# ----------------------------------------------------------------------------
# 11. Summary + launch
# ----------------------------------------------------------------------------
cat <<EOF
${G}βœ“ Ready.${N}
Your bucket: https://huggingface.co/buckets/$BUCKET
Bucket mount: $MOUNT
HERMES_HOME: $HOME_DIR (secrets local; everything else symlinks to mount)
Model: Qwen/Qwen3.6-35B-A3B (HF Inference Providers β†’ deepinfra)
${C}hermes${N} β€” chat in your terminal
${C}hermes gateway run${N} β€” start Telegram bot in the foreground (Ctrl-C to stop)
${C}hermes gateway install${N} && ${C}hermes gateway start${N} β€” run gateway as a background service
These work in any shell β€” no sourcing, no aliases. The hermes launcher
itself now defaults HERMES_HOME to $HOME_DIR.
EOF
if [ -t 0 ] && [ -t 1 ]; then
say "Launching Hermes now..."
sleep 1
export HERMES_HOME="$HOME_DIR"
exec hermes
else
say "Run ${C}source $SHELL_RC${N} then ${C}hermes${N} to start chatting."
fi