iachat/bin/hermes-provision
Rodribm10 280d250983 feat(captain/hermes): script hermes-provision pra Construtor autônomo
Bash script em bin/hermes-provision (instalado em /usr/local/bin/ na VPS)
que recebe spec JSON via stdin e provisiona um agente Hermes ponta-a-ponta:
- Valida spec (slug regex, preços 0-5000, períodos/buckets do catálogo)
- Aloca porta livre no range 8650-8699
- Gera HMAC secret via openssl rand
- Cria Captain::Unit (find_or_create), Captain::PricingCategory/Amount,
  Captain::Assistant (engine=hermes) via docker exec rails runner
- Copia template profile da Valentina, patcheia config.yaml com porta +
  X-Captain-Assistant-Id (parent_id se setado, senão self id)
- Escreve SOUL.md/SKILL.md do spec
- Gera webhook_subscriptions.json com secret
- Cria systemd unit hermes@<slug>.service e enable+start
- rsync profile pra repo de backup git local
- Idempotente: re-rodar com mesmo slug não duplica nada (find_or_create)
- --dry-run valida sem escrever
- --rollback <slug> destrói tudo (DB + systemd + filesystem)

Construtor (Hermes daemon) chama via terminal skill nativa:
  echo '<spec>' | /usr/local/bin/hermes-provision

Próximo passo: atualizar SOUL.md/SKILL.md do Construtor pra invocar
o script ao final do fluxo socrático.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:59:30 -03:00

334 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# hermes-provision — provisiona um novo agente Hermes ponta-a-ponta.
#
# Uso:
# hermes-provision [--dry-run] [--rollback <slug>] < 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": "<conteúdo SOUL.md inteiro>",
# "skill_name": "primevl-reservas",
# "skill_md": "<conteúdo 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
asst = Captain::Assistant.find_or_initialize_by(account_id: account_id, name: spec['name'])
asst.description ||= 'Atendente Hermes ' + spec['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']
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
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"
# Write SOUL.md and SKILL.md from spec
echo "$SPEC" | jq -r '.soul_md' > "$PROFILES_DIR/$SLUG/SOUL.md"
echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md"
# Write webhook_subscriptions.json
cat > "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" <<JSON
{
"captain-inbox-${SLUG}": {
"description": "Subscription Hermes — $NAME",
"events": [],
"secret": "$SECRET",
"prompt": "[ctx: cid={conversation_internal_id} aid={account_id} contact={contact_id} name=\"{contact_first_name}\" full_name=\"{contact_name}\" cpf_ok={contact_cpf_present} email_ok={contact_email_present} reservas={contact_total_reservas}]\nCliente acabou de dizer no WhatsApp: \"{message}\"\n\nResponda como $NAME, brevemente, em português brasileiro. Use a skill $SKILL_NAME pra preços, regras e fluxo. Nunca se apresente. Nunca diga que é IA.",
"skills": [],
"deliver": "http_callback",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deliver_extra": {
"chat_id": "https://iachat.hoteis1001noites.com.br/webhooks/captain/hermes_callback?slug=${SLUG}"
}
}
}
JSON
log "filesystem OK"
# === SYSTEMD ===
cat > "$SYSTEMD_DIR/hermes@.service" <<UNIT
[Unit]
Description=Hermes Agent gateway for profile %i
After=network.target docker.service
[Service]
Type=simple
Environment=HERMES_HOME=$PROFILES_DIR/%i
ExecStart=/usr/local/bin/hermes gateway run --replace
Restart=on-failure
RestartSec=5
StandardOutput=append:$PROFILES_DIR/%i/logs/gateway.log
StandardError=append:$PROFILES_DIR/%i/logs/gateway.log
[Install]
WantedBy=multi-user.target
UNIT
mkdir -p "$PROFILES_DIR/$SLUG/logs"
systemctl daemon-reload
systemctl enable "hermes@$SLUG.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}'