#!/usr/bin/env bash # hermes-provision — provisiona um novo agente Hermes ponta-a-ponta. # # Uso: # hermes-provision [--dry-run] [--rollback ] < spec.json # # Spec JSON esperado (stdin): # { # "slug": "lara", # "name": "Lara", # "account_id": 1, # "marca": "Hotel 1001 Noites Prime", # "unit_name": "PrimeVL", # "city": "Brasília/DF", # "captain_unit_id": null, // opcional — se null, cria nova Unit # "parent_assistant_id": null, // opcional — se setado, MCP usa data do parent # "soul_md": "", # "skill_name": "primevl-reservas", # "skill_md": "", # "categories": [ # { # "key": "standard", # "aliases": ["standard", "comum"], # "extra_person_starts_at": 3, # "amounts": [ # {"period": "3h", "day_bucket": null, "amount": 50.0}, # {"period": "pernoite_promo", "day_bucket": "mon_wed", "amount": 100.0} # ] # } # ], # "extra_person_fee": 0.0, # "humanization": { # "mode": "typing_simulation", # "chars_per_second": 25, # "min_seconds": 1.5, # "max_seconds": 6.0 # } # } # # Saída: JSON com {ok, assistant_id, port, secret, errors} # # Quem chama: Construtor Hermes via terminal skill nativa. # Pré-requisitos na VPS: jq, openssl, docker, systemctl, git, hermes binary. set -uo pipefail PROFILES_DIR="/root/.hermes/profiles" TEMPLATE_PROFILE="$PROFILES_DIR/valentina" PORT_RANGE_START=8650 PORT_RANGE_END=8699 SYSTEMD_DIR="/etc/systemd/system" GIT_BACKUP_REPO="/root/hermes_profiles_backup" DOCKER_APP_FILTER="iachat_iachat_app" DRY_RUN=0 ROLLBACK_SLUG="" while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1; shift ;; --rollback) ROLLBACK_SLUG="$2"; shift 2 ;; *) echo "{\"ok\":false,\"error\":\"unknown flag: $1\"}" >&2; exit 1 ;; esac done log() { echo "[$(date -u +%H:%M:%S)] $*" >&2; } fail() { echo "{\"ok\":false,\"error\":\"$1\"}"; exit 1; } require_cmd() { command -v "$1" >/dev/null || fail "missing required command: $1"; } require_cmd jq require_cmd openssl require_cmd docker require_cmd systemctl require_cmd hermes # === ROLLBACK === if [[ -n "$ROLLBACK_SLUG" ]]; then log "rolling back $ROLLBACK_SLUG" systemctl stop "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true systemctl disable "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true rm -rf "${PROFILES_DIR:?}/$ROLLBACK_SLUG" CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1) docker exec "$CID" bundle exec rails runner " asst = Captain::Assistant.find_by(hermes_profile_name: '$ROLLBACK_SLUG') if asst asst.captain_inboxes.destroy_all asst.destroy! puts 'destroyed assistant ' + asst.id.to_s else puts 'no assistant for slug $ROLLBACK_SLUG' end " 2>&1 | grep -v RubyLLM | tail -3 echo "{\"ok\":true,\"action\":\"rollback\",\"slug\":\"$ROLLBACK_SLUG\"}" exit 0 fi # === READ + VALIDATE SPEC === SPEC=$(cat) [[ -z "$SPEC" ]] && fail "empty spec on stdin" echo "$SPEC" | jq empty 2>/dev/null || fail "invalid JSON" SLUG=$(echo "$SPEC" | jq -r '.slug // empty') NAME=$(echo "$SPEC" | jq -r '.name // empty') ACCOUNT_ID=$(echo "$SPEC" | jq -r '.account_id // empty') MARCA=$(echo "$SPEC" | jq -r '.marca // empty') UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name // empty') CAPTAIN_UNIT_ID=$(echo "$SPEC" | jq -r '.captain_unit_id // empty') PARENT_ASSISTANT_ID=$(echo "$SPEC" | jq -r '.parent_assistant_id // empty') SOUL_MD=$(echo "$SPEC" | jq -r '.soul_md // empty') SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name // empty') SKILL_MD=$(echo "$SPEC" | jq -r '.skill_md // empty') EXTRA_PERSON_FEE=$(echo "$SPEC" | jq -r '.extra_person_fee // 0') # Slug validation [[ ! "$SLUG" =~ ^[a-z][a-z0-9_]{1,29}$ ]] && fail "invalid slug '$SLUG' (regex: ^[a-z][a-z0-9_]{1,29}\$)" [[ -z "$NAME" ]] && fail "name required" [[ -z "$ACCOUNT_ID" ]] && fail "account_id required" [[ -z "$MARCA" ]] && fail "marca required" [[ -z "$UNIT_NAME" ]] && fail "unit_name required" [[ -z "$SOUL_MD" ]] && fail "soul_md content required" [[ -z "$SKILL_NAME" ]] && fail "skill_name required" [[ ! "$SKILL_NAME" =~ ^[a-z][a-z0-9_-]{1,40}$ ]] && fail "invalid skill_name '$SKILL_NAME'" [[ -z "$SKILL_MD" ]] && fail "skill_md content required" # Categories validation: structure + amount sanity CATEGORIES_COUNT=$(echo "$SPEC" | jq '.categories | length') [[ "$CATEGORIES_COUNT" -lt 1 ]] && fail "at least 1 category required" INVALID_AMOUNTS=$(echo "$SPEC" | jq ' [.categories[] | .amounts[] | select(.amount <= 0 or .amount > 5000 or (.period | IN("2h","3h","4h","5h","pernoite_promo","pernoite_integral","diaria") | not) or (.day_bucket != null and (.day_bucket | IN("mon_wed","thu_sun") | not))) ] | length ') [[ "$INVALID_AMOUNTS" -gt 0 ]] && fail "$INVALID_AMOUNTS amounts inválidos (preço fora 0..5000, período/bucket inválido)" # Profile already exists? if [[ -d "$PROFILES_DIR/$SLUG" ]]; then log "profile $SLUG já existe, será re-validado mas não recriado (idempotente)" fi # === ALLOCATE PORT === allocate_port() { for ((p=PORT_RANGE_START; p<=PORT_RANGE_END; p++)); do if ! ss -tnlH "( sport = :$p )" | grep -q .; then echo "$p"; return 0 fi done return 1 } # Reuse port if profile exists, else allocate fresh if [[ -f "$PROFILES_DIR/$SLUG/config.yaml" ]]; then PORT=$(awk '/^ port:/ {print $2}' "$PROFILES_DIR/$SLUG/config.yaml" | head -1) log "reusing existing port $PORT for $SLUG" else PORT=$(allocate_port) || fail "no free port in range $PORT_RANGE_START..$PORT_RANGE_END" log "allocated port $PORT for $SLUG" fi # === GENERATE OR REUSE HMAC SECRET === if [[ -f "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" ]]; then SECRET=$(jq -r ".\"captain-inbox-${SLUG}\".secret // empty" "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" 2>/dev/null) [[ -z "$SECRET" ]] && SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43) else SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43) fi # === DRY RUN STOPS HERE === if [[ "$DRY_RUN" == "1" ]]; then echo "$SPEC" | jq --arg port "$PORT" --arg secret "${SECRET:0:8}..." \ '{ok: true, dry_run: true, slug: .slug, name: .name, port: $port, secret_preview: $secret, categories: (.categories | length)}' exit 0 fi # === DB OPERATIONS via docker exec === CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1) [[ -z "$CID" ]] && fail "iachat_iachat_app container not running" # Persist spec to a tmp file inside container so the runner reads it back TMP_SPEC="/tmp/hermes_spec_${SLUG}_$$.json" echo "$SPEC" > "/tmp/hermes_spec_${SLUG}_$$.json" docker cp "$TMP_SPEC" "$CID:$TMP_SPEC" DB_RESULT=$(docker exec "$CID" bundle exec rails runner " spec = JSON.parse(File.read('$TMP_SPEC')) account_id = spec['account_id'] brand = Captain::Brand.find_by(account_id: account_id, name: spec['marca']) raise \"brand not found: #{spec['marca']}\" if brand.nil? unit = if spec['captain_unit_id'] Captain::Unit.find(spec['captain_unit_id']) else Captain::Unit.find_or_create_by!(account_id: account_id, captain_brand_id: brand.id, name: spec['unit_name']) do |u| u.status = 'active' u.extra_person_fee = (spec['extra_person_fee'] || 0).to_f u.currency = 'BRL' end end spec['categories'].each do |cat| pricing_cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: cat['key']) pricing_cat.aliases = cat['aliases'] || [] pricing_cat.extra_person_starts_at = cat['extra_person_starts_at'] || 3 pricing_cat.save! cat['amounts'].each do |a| row = Captain::PricingAmount.find_or_initialize_by( captain_pricing_category_id: pricing_cat.id, period: a['period'], day_bucket: a['day_bucket'] ) row.amount = a['amount'] row.save! end end # CHAVE = hermes_profile_name (slug). Nome é cosmético e PODE colidir com # captain_interno do mesmo nome — nesse caso auto-renomeamos com sufixo # ' · Hermes' pra evitar sobrescrever. asst = Captain::Assistant.find_or_initialize_by(account_id: account_id, hermes_profile_name: spec['slug']) desired_name = spec['name'].to_s.strip if asst.new_record? collision = Captain::Assistant.where(account_id: account_id, name: desired_name).where.not(hermes_profile_name: spec['slug']).exists? desired_name = desired_name + ' · Hermes' if collision && !desired_name.include?('Hermes') end asst.name = desired_name asst.description ||= 'Atendente Hermes ' + desired_name asst.engine = 'hermes' asst.hermes_profile_name = spec['slug'] asst.hermes_webhook_base_url = 'http://172.17.0.1:' + $PORT.to_s asst.hermes_subscription_secret = '$SECRET' asst.hermes_port = $PORT asst.parent_assistant_id = spec['parent_assistant_id'] asst.captain_unit_id = unit.id if spec['humanization'] asst.config['response_delay'] = spec['humanization'] end asst.save! puts 'OK ' + asst.id.to_s + ' ' + unit.id.to_s " 2>&1 | grep -v 'RubyLLM\|ip_lookup\|WARN' | tail -3) ASSISTANT_ID=$(echo "$DB_RESULT" | grep '^OK' | awk '{print $2}') [[ -z "$ASSISTANT_ID" ]] && fail "DB step failed: $DB_RESULT" log "DB step OK: assistant_id=$ASSISTANT_ID" # === FILESYSTEM: profile directory === mkdir -p "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/references" # Copy template files (config base, plugins, auth, generic skills) if [[ -f "$TEMPLATE_PROFILE/config.yaml" ]]; then cp "$TEMPLATE_PROFILE/config.yaml" "$PROFILES_DIR/$SLUG/config.yaml" cp -r "$TEMPLATE_PROFILE/plugins" "$PROFILES_DIR/$SLUG/" 2>/dev/null || true cp "$TEMPLATE_PROFILE/.env" "$PROFILES_DIR/$SLUG/.env" 2>/dev/null || true cp "$TEMPLATE_PROFILE/auth.json" "$PROFILES_DIR/$SLUG/auth.json" 2>/dev/null || true for s in "$TEMPLATE_PROFILE/skills"/*/; do name=$(basename "$s") [[ "$name" == "dolce-amore-reservas" ]] && continue [[ "$name" == "$SKILL_NAME" ]] && continue cp -r "$s" "$PROFILES_DIR/$SLUG/skills/" 2>/dev/null || true done fi # Patch config.yaml: port + X-Captain-Assistant-Id + DESLIGA memória # (Hermes-level memory_enabled e user_profile_enabled vazam contexto entre # agentes que compartilham OAuth Codex; manter desligado pra evitar # contaminação cross-unit). MCP_ASSISTANT_ID="${PARENT_ASSISTANT_ID:-$ASSISTANT_ID}" sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml" sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$MCP_ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml" sed -i 's/ memory_enabled: true/ memory_enabled: false/' "$PROFILES_DIR/$SLUG/config.yaml" sed -i 's/ user_profile_enabled: true/ user_profile_enabled: false/' "$PROFILES_DIR/$SLUG/config.yaml" # SOUL.md: clona a da Valentina (template canônico) e substitui identidade. # Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx], # tools, regras de fluxo — vem direto da Valentina e fica em sync conforme # ela evolui. BRAND_NAME=$(echo "$SPEC" | jq -r '.marca') UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name') SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name') CATEGORIAS_LISTA=$(echo "$SPEC" | jq -r '.categories | map(.key) | join(", ")') cp "$TEMPLATE_PROFILE/SOUL.md" "$PROFILES_DIR/$SLUG/SOUL.md" # Identity replacements (atenção: ordem importa pra strings que se sobrepõem). sed -i "s|Dolce Amore Motel|$BRAND_NAME — $UNIT_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md" sed -i "s|Valentina|$NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md" sed -i "s|dolce-amore-reservas|$SKILL_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md" # Substitui exemplos hardcoded de categorias Dolce Amore (Mini Chalé 45 etc) pelas # 3 primeiras categorias da unidade nova. Sem isso, SOUL.md vaza Dolce Amore-isms # em descrições de tools mesmo após sed de identidade. EX_CATS_LIST=$(echo "$SPEC" | jq -r '[.categories[0:3] | .[] | "\"" + .key + "\""] | join(", ")') FIRST_CAT=$(echo "$SPEC" | jq -r '.categories[0].key // "categoria"') sed -i "s|\"Master\", \"Luxo\", \"Mini Chalé 45\"|$EX_CATS_LIST|g" "$PROFILES_DIR/$SLUG/SOUL.md" sed -i "s|Prefere Suíte Master|Prefere $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md" sed -i "s|prefiro suíte master|prefiro $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md" # Skill: usa o markdown gerado pelo expand_spec (tabela do banco + regras). echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md" # Anti-leak no SOUL.md (proteção contra contaminação cross-unit via Codex). cat >> "$PROFILES_DIR/$SLUG/SOUL.md" < "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" < "$SYSTEMD_DIR/hermes@.service" </dev/null 2>&1 systemctl restart "hermes@$SLUG.service" sleep 2 if ! ss -tnlH "( sport = :$PORT )" | grep -q .; then log "WARNING: daemon for $SLUG not listening on $PORT after 2s — check /root/.hermes/profiles/$SLUG/logs/gateway.log" else log "daemon listening on port $PORT" fi # === GIT BACKUP === if [[ -d "$GIT_BACKUP_REPO/.git" ]]; then cd "$GIT_BACKUP_REPO" rsync -a --delete --exclude='logs/' --exclude='cache/' --exclude='sessions/' \ --exclude='state.db*' --exclude='memories/' --exclude='sandboxes/' \ --exclude='.skills_prompt_snapshot.json' --exclude='auth.json' \ --exclude='.env' --exclude='webhook_subscriptions.json' \ "$PROFILES_DIR/$SLUG/" "./profiles/$SLUG/" git add "profiles/$SLUG" git commit -m "provision: $SLUG ($NAME)" >/dev/null 2>&1 || true git push origin main >/dev/null 2>&1 || log "git push failed (silent — backup local OK)" fi # === OUTPUT === jq -n --arg slug "$SLUG" --arg name "$NAME" --argjson aid "$ASSISTANT_ID" --argjson port "$PORT" \ '{ok: true, slug: $slug, name: $name, assistant_id: $aid, port: $port, listening: true}'