Ctrl K
ComunidadGuías…

manage.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
set -Eeuo pipefail

ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$ROOT_DIR/.env"
HERMES_ENV="$ROOT_DIR/data/hermes/.env"
STACK_SECRETS_DIR="$ROOT_DIR/data/stack-secrets"
N8N_BOOTSTRAP_ENV="$STACK_SECRETS_DIR/n8n-bootstrap.env"
N8N_BOOTSTRAP_STATE="$STACK_SECRETS_DIR/n8n-bootstrap-state.json"
OMNIROUTE_N8N_KEY_ENV="$STACK_SECRETS_DIR/omniroute-n8n-router.env"
HERMES_DASHBOARD_ACCESS_FILE="$STACK_SECRETS_DIR/hermes-dashboard-access.env"
TEMP_SECRET_FILES=()

# The operator panel mounts the repository at the identical host path so the
# Docker CLI inside the container resolves compose bind mounts exactly like a
# host invocation, and it writes .env/config files as the invoking user.
export PANEL_STACK_PATH="${PANEL_STACK_PATH:-$ROOT_DIR}"
export PANEL_RUN_AS="${PANEL_RUN_AS:-$(id -u):$(id -g)}"
if [[ -z "${PANEL_DOCKER_GID:-}" && -S /var/run/docker.sock ]]; then
  PANEL_DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || printf '984')"
  export PANEL_DOCKER_GID
fi

cleanup_temp_secrets() {
  local file
  for file in "${TEMP_SECRET_FILES[@]}"; do
    rm -rf -- "$file"
  done
}
trap cleanup_temp_secrets EXIT

usage() {
  cat <<'EOF'
Hermes Linux Stack Manager v0.5.9

Usage:
  ./manage.sh                 Open the interactive manager
  ./manage.sh menu            Open the interactive manager
  ./manage.sh help            Show this grouped command reference

Interactive groups:
  services                    Containers, status, start/stop/restart, logs
  router                      Smart Router dashboard, routing, health, policy
  hermes                      Hermes Agent, Telegram, API and agent settings
  n8n                         n8n provisioning and MCP integration
  content                     Content Bot status, publishing docs and platform setup
  media                       Media Studio status, jobs, logs and reconfiguration
  execution                   Sandbox, Docker execution, SSH and approvals
  maintenance                 Update, backups, restore and rollback
  panel                       Operator console: status, config, logs, actions
  security                    Diagnostics, image integrity and access info

Common direct commands:
  status                      Show container status
  health [--json]             Show per-service health
  logs [SERVICE]              Follow logs (hermes/9router/omniroute/smart-router/webui/n8n/content/media/notebooklm/caddy/rustfs)
  doctor                      Run diagnostics and hardening checks
  migrate-hermes-permissions [--dry-run]
                              Repair Hermes log ownership/mode under data/hermes/logs
  dashboard-access [--show-password]
                              Hermes dashboard URL, username and optional password
  configure                   Re-run the interactive installer
  uninstall [--purge]         Remove containers; --purge also removes local runtime data

Smart Router automation:
  set-router-mode MODE        observe | route
  router-policy POLICY        heuristic | calibrated | learned
  router-status               Mode, policy, active features and URLs
  router-access [--show-secrets]
                              Dashboard/control URLs and local credentials
  router-summary [HOURS]      Authenticated telemetry summary
  router-routes               Route profiles
  router-provider-health      Provider/model health and circuit state
  router-system               Operations Center system/feature state
  router-info                 Runtime router information
  router-calibrate FILE       Build calibrated policy from labeled JSONL
  router-report FILE          Evaluate policy against labeled JSONL
  router-replay FILE [OUT]    Replay requests offline

n8n automation:
  n8n-status                  Provisioning/MCP status without secrets
  set-n8n-api-key             Store owner API key
  set-n8n-instance-mcp-token  Store/validate Instance MCP token
  set-n8n-mcp-mode MODE       instance | trigger | off
  bootstrap-n8n               Bootstrap/reconcile managed n8n objects
  reconcile-n8n               Reconcile managed n8n objects
  verify-n8n                  Verify hosted chat and MCP integration
  rotate-n8n-trigger-token    Rotate Trigger-mode bearer token

Content Bot automation:
  content-status              Content Bot configuration summary (no secrets)
  content-connect-instagram   Print the pending Instagram/Meta setup checklist
  content-connect-bale        Store and verify the Bale bot token and channel
  content-connect-eitaa       Store and verify the Eitaa bot token and channel
  content-connect-linkedin    Store and verify the LinkedIn token and author
  content-aparat-check        Probe the stored Aparat browser session
  content-channels            Show the automatic channel (Bale/Eitaa/LinkedIn/Aparat) state
  content-configure           Reconfigure Content Bot settings (installer wizard)

Operator panel:
  panel-status                Panel URL, profile and token state (no secrets)
  panel-enable                Enable the panel profile and start the console
  panel-disable               Stop the panel and disable its profile
  panel-token                 Print the operator token (creates one if missing)
  panel-rotate-token          Replace the operator token and restart the panel
  panel-build                 Build the panel image locally from panel/Dockerfile
  domains                     Public host names, .env keys, LAN targets and proxy blocks

Shared object storage (S3):
  s3-status                   Backend, endpoints, bucket and per-service state (no secrets)
  s3-enable [--rustfs|--external] [--bind-ip IP]
                              Enable the shared S3 block and start the bundled RustFS server
  s3-disable                  Stop the bundled server and switch services back to local storage
  s3-verify [--create-bucket] Prove the endpoint, credentials and bucket with signed requests
  s3-keys [--show-secrets|--rotate]
                              Show or rotate the RustFS credentials shared with the stack
  s3-guide                    Public-domain route and external-provider checklist

Instagram media host (public address the Meta Graph API downloads media from):
  instagram-media-status      Show the public media URL and profile state
  instagram-media-enable [--nginx-only|--named|--quick]
                              Serve data/content-bot/media publicly (profile "ig-media")
  instagram-media-disable     Stop the public media host and disable its profile
  instagram-media-tunnel-off  Stop only the public tunnel (keep nginx for a proxy)
  instagram-media-verify      Check that the media URL is downloadable, including by Meta

Media Studio automation:
  media-status                Media Studio configuration summary (no secrets)
  media-guide                 Print the Media Studio setup and API guide pointer
  media-configure             Reconfigure Media Studio settings (installer wizard)

NotebookLM video automation:
  notebooklm-status           NotebookLM worker, session mode and bot link (no secrets)
  notebooklm-guide            Print the NotebookLM guide pointer
  notebooklm-login [SECONDS]  Open NotebookLM in the worker browser and verify the sign-in
  notebooklm-login-google     Attempt automated sign-in with Google credentials from .env
  notebooklm-enable           Enable the profile, store the shared token and start the worker
  notebooklm-disable          Stop the worker and disable its profile
  notebooklm-import-session   Import a session JSON file into the browser profile

Backup and restore automation:
  backup [--only SECTION[,...]] [--destination DIR] [--label NAME]
                              Full-stack or per-section backup archive
  backup-sections             List the section names accepted by --only
  backup-list                 List archives with the sections they contain
  restore ARCHIVE [--no-start] [--no-relocate]
                              Restore a full or partial archive; host paths in
                              a restored .env are rewritten to this checkout

Content pipeline automation (Content Bot + Media Studio on one server):
  pipeline-status             Combined Content Bot, Media Studio, and API link status

Advanced commands remain backward compatible. Use the interactive groups for
normal administration; use direct commands for automation and scripts.
EOF
}

case "${1:-}" in
  -h|--help|help) usage; exit 0 ;;
  "") set -- menu ;;
esac

[[ -f "$ENV_FILE" ]] || {
  printf 'Not configured. Run ./install.sh first.\n' >&2
  exit 1
}

if [[ "${1:-}" == migrate-hermes-permissions ]]; then
  # This host-side recovery must remain usable while Docker or Hermes is down.
  DOCKER=(docker)
elif docker info >/dev/null 2>&1; then
  DOCKER=(docker)
elif command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then
  DOCKER=(sudo docker)
else
  printf 'Cannot access the Docker daemon.\n' >&2
  exit 1
fi

compose() {
  "${DOCKER[@]}" compose -f "$ROOT_DIR/docker-compose.yml" --env-file "$ENV_FILE" "$@"
}
ops() { "$ROOT_DIR/scripts/stack-ops.sh" "$@"; }

uninstall_stack() {
  local purge=false answer dir
  case "${1:-}" in
    "") ;;
    --purge) purge=true ;;
    *)
      printf 'Usage: ./manage.sh uninstall [--purge]\n' >&2
      return 2
      ;;
  esac

  if [[ "$purge" == true ]]; then
    printf '%s\n' 'WARNING: --purge permanently deletes local stack configuration, secrets, and runtime data.'
    printf '%s\n' 'The Git/source directory and backups stored outside this repository are kept.'
    read -r -p 'Type PURGE to continue: ' answer
    [[ "$answer" == PURGE ]] || { printf 'Uninstall cancelled.\n'; return 0; }
  else
    printf '%s\n' 'This removes stack containers and the Compose network.'
    printf '%s\n' 'Your .env, Hermes/n8n/OpenWebUI data, secrets, and source files will be kept.'
    read -r -p 'Continue? [y/N]: ' answer
    [[ "$answer" =~ ^[Yy]$ ]] || { printf 'Uninstall cancelled.\n'; return 0; }
  fi

  # Enable every Compose profile so services started under optional profiles are
  # included even if COMPOSE_PROFILES has changed since installation.
  compose --profile '*' down --remove-orphans
  printf 'Stack containers and network removed.\n'

  if [[ "$purge" == true ]]; then
    rm -f -- "$ENV_FILE"

    for dir in 9router omniroute caddy hermes n8n open-webui rustfs stack-secrets; do
      if [[ -d "$ROOT_DIR/data/$dir" ]]; then
        find "$ROOT_DIR/data/$dir" -mindepth 1 -maxdepth 1 ! -name '.gitkeep' -exec rm -rf -- {} +
      fi
    done

    rm -rf -- "$ROOT_DIR/data/smart-router"

    if [[ -d "$ROOT_DIR/data/stack-state" ]]; then
      find "$ROOT_DIR/data/stack-state" -mindepth 1 -maxdepth 1 ! -name 'ops.lock' -exec rm -rf -- {} +
      : > "$ROOT_DIR/data/stack-state/ops.lock"
    fi

    printf '%s\n' 'Local stack configuration, secrets, and runtime data purged.'
    printf '%s\n' 'Source files were kept. Run ./install.sh to install again.'
  else
    printf '%s\n' 'Local data was preserved. Run ./manage.sh start to bring the stack back.'
  fi
}

valid_ids() { [[ "$1" =~ ^[0-9]+(,[0-9]+)*$ ]]; }

router_local_base_url() {
  local bind port
  bind="$(env_value "$ENV_FILE" SMART_ROUTER_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" SMART_ROUTER_PORT)"; port="${port:-8787}"
  case "$bind" in
    0.0.0.0|'') bind=127.0.0.1 ;;
  esac
  printf 'http://%s:%s' "$bind" "$port"
}

router_api_get() {
  local path="$1" key="${2:-}" base
  base="$(router_local_base_url)"
  if command -v curl >/dev/null 2>&1; then
    if [[ -n "$key" ]]; then
      curl -fsS --connect-timeout 3 --max-time 10 -H "Authorization: Bearer $key" "$base$path"
    else
      curl -fsS --connect-timeout 3 --max-time 10 "$base$path"
    fi
  else
    # urllib fallback keeps management usable on minimal hosts.
    ROUTER_URL="$base$path" ROUTER_KEY="$key" python3 - <<'PYROUTER'
import os, urllib.request
req=urllib.request.Request(os.environ['ROUTER_URL'])
if os.environ.get('ROUTER_KEY'):
    req.add_header('Authorization','Bearer '+os.environ['ROUTER_KEY'])
with urllib.request.urlopen(req, timeout=10) as r:
    print(r.read().decode(), end='')
PYROUTER
  fi
}

router_api_request() {
  local method="$1" path="$2" key="${3:-}" data="${4:-}" base
  base="$(router_local_base_url)"
  if command -v curl >/dev/null 2>&1; then
    local args=(-fsS --connect-timeout 3 --max-time 10 -X "$method")
    [[ -n "$key" ]] && args+=(-H "Authorization: Bearer $key")
    if [[ -n "$data" ]]; then
      args+=(-H 'Content-Type: application/json' --data "$data")
    fi
    curl "${args[@]}" "$base$path"
  else
    ROUTER_URL="$base$path" ROUTER_KEY="$key" ROUTER_METHOD="$method" ROUTER_DATA="$data" python3 - <<'PYROUTER_MUTATE'
import os, urllib.request
data=os.environ.get('ROUTER_DATA','').encode() or None
req=urllib.request.Request(os.environ['ROUTER_URL'], data=data, method=os.environ['ROUTER_METHOD'])
if os.environ.get('ROUTER_KEY'):
    req.add_header('Authorization','Bearer '+os.environ['ROUTER_KEY'])
if data is not None:
    req.add_header('Content-Type','application/json')
with urllib.request.urlopen(req, timeout=10) as r:
    print(r.read().decode(), end='')
PYROUTER_MUTATE
  fi
}

router_runtime_set() {
  local json="$1" key response
  key="$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)"
  for _ in {1..30}; do
    if response="$(router_api_request PUT /control/api/system "$key" "$json" 2>/dev/null)"; then
      printf '%s' "$response"
      return 0
    fi
    sleep 1
  done
  return 1
}

pretty_json() {
  python3 -m json.tool 2>/dev/null || cat
}

menu_title() {
  printf '\n\033[1;36m%s\033[0m\n' "$1"
  printf '%s\n' '============================================================'
}

menu_pause() {
  [[ -r /dev/tty ]] || return 0
  read -r -p 'Press Enter to continue...' _ </dev/tty || true
}

services_menu() {
  local choice service
  while true; do
    menu_title 'Services & Logs'
    printf '%s\n' '1) Status                 Show all containers and ports'
    printf '%s\n' '2) Health                 Run v0.5.9 service health checks'
    printf '%s\n' '3) Start                  Start selected stack services'
    printf '%s\n' '4) Stop                   Stop running stack services'
    printf '%s\n' '5) Restart                Restart running stack services'
    printf '%s\n' '6) Follow logs            Choose one service or all'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) "$ROOT_DIR/manage.sh" status; menu_pause ;;
      2) "$ROOT_DIR/manage.sh" health; menu_pause ;;
      3) "$ROOT_DIR/manage.sh" start; menu_pause ;;
      4) "$ROOT_DIR/manage.sh" stop; menu_pause ;;
      5) "$ROOT_DIR/manage.sh" restart; menu_pause ;;
      6)
        printf '%s\n' 'Log choices:'
        printf '%s\n' '  1) all          2) Hermes       3) backend gateway'
        printf '%s\n' '  4) Smart Router 5) Open WebUI   6) n8n          7) Caddy'
        printf '%s\n' '  8) Content Bot  9) Media Studio  10) RustFS'
        read -r -p 'Choose [1]: ' service
        case "${service:-1}" in
          1) "$ROOT_DIR/manage.sh" logs || true ;;
          2) "$ROOT_DIR/manage.sh" logs hermes || true ;;
          3)
            if [[ ",$(env_value "$ENV_FILE" COMPOSE_PROFILES)," == *,omniroute,* ]]; then
              "$ROOT_DIR/manage.sh" logs omniroute || true
            else
              "$ROOT_DIR/manage.sh" logs 9router || true
            fi
            ;;
          4) "$ROOT_DIR/manage.sh" logs smart-router || true ;;
          5) "$ROOT_DIR/manage.sh" logs webui || true ;;
          6) "$ROOT_DIR/manage.sh" logs n8n || true ;;
          7) "$ROOT_DIR/manage.sh" logs caddy || true ;;
          8) "$ROOT_DIR/manage.sh" logs content || true ;;
          9) "$ROOT_DIR/manage.sh" logs media || true ;;
          10) "$ROOT_DIR/manage.sh" logs rustfs || true ;;
          *) printf 'Unknown log choice.\n' >&2 ;;
        esac
        ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

router_menu() {
  local choice value file
  while true; do
    menu_title 'Hermes Smart Router v0.5.9'
    printf '%s\n' 'Observe & access'
    printf '%s\n' '  1) Status & URLs            Mode, policy, features and endpoints'
    printf '%s\n' '  2) Dashboard / Control      URLs and credential guidance'
    printf '%s\n' '  3) Telemetry summary         Choose 1h / 24h / 7d / 30d'
    printf '%s\n' '  4) Provider health           Health scores and circuit state'
    printf '%s\n' 'Routing'
    printf '%s\n' '  5) Route profiles            fast / standard / strong / coding / vision'
    printf '%s\n' '  6) Change mode               observe | route'
    printf '%s\n' '  7) Change policy             heuristic | calibrated | learned'
    printf '%s\n' '  8) Operations Center system  DB, HA, auth, OIDC and upstream state'
    printf '%s\n' 'Evaluate / tune'
    printf '%s\n' '  9) Router runtime info'
    printf '%s\n' ' 10) Calibrate from JSONL'
    printf '%s\n' ' 11) Evaluate/report JSONL'
    printf '%s\n' ' 12) Replay requests JSONL'
    printf '%s\n' '  0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) "$ROOT_DIR/manage.sh" router-status; menu_pause ;;
      2) "$ROOT_DIR/manage.sh" router-access; menu_pause ;;
      3)
        printf '%s\n' 'Telemetry window: 1) 1 hour  2) 24 hours  3) 7 days  4) 30 days'
        read -r -p 'Choose [2]: ' value
        case "${value:-2}" in 1) value=1;; 2) value=24;; 3) value=168;; 4) value=720;; *) printf 'Unknown window.\n' >&2; continue;; esac
        "$ROOT_DIR/manage.sh" router-summary "$value"; menu_pause
        ;;
      4) "$ROOT_DIR/manage.sh" router-provider-health; menu_pause ;;
      5) "$ROOT_DIR/manage.sh" router-routes; menu_pause ;;
      6)
        printf '%s\n' 'Mode choices:'
        printf '%s\n' '  1) observe  Evaluate model=auto decisions but dispatch through the observe model'
        printf '%s\n' '  2) route    Apply route profiles to model=auto requests'
        printf '%s\n' 'Explicit upstream model names pass through in both modes.'
        read -r -p 'Choose [1]: ' value
        case "${value:-1}" in 1) value=observe;; 2) value=route;; *) printf 'Unknown mode.\n' >&2; continue;; esac
        "$ROOT_DIR/manage.sh" set-router-mode "$value"; menu_pause
        ;;
      7)
        printf '%s\n' 'Policy choices:'
        printf '%s\n' '  1) heuristic   Built-in deterministic policy; safest default'
        printf '%s\n' '  2) calibrated  Uses policy/calibrated.json from your labeled data'
        printf '%s\n' '  3) learned     Uses trained learned-routing artifacts with fallback'
        read -r -p 'Choose [1]: ' value
        case "${value:-1}" in 1) value=heuristic;; 2) value=calibrated;; 3) value=learned;; *) printf 'Unknown policy.\n' >&2; continue;; esac
        "$ROOT_DIR/manage.sh" router-policy "$value"; menu_pause
        ;;
      8) "$ROOT_DIR/manage.sh" router-system; menu_pause ;;
      9) "$ROOT_DIR/manage.sh" router-info; menu_pause ;;
      10)
        read -r -p 'Labeled calibration JSONL path: ' file
        [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-calibrate "$file"
        menu_pause
        ;;
      11)
        read -r -p 'Labeled evaluation JSONL path: ' file
        [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-report "$file"
        menu_pause
        ;;
      12)
        read -r -p 'Requests JSONL path: ' file
        [[ -n "$file" ]] && "$ROOT_DIR/manage.sh" router-replay "$file"
        menu_pause
        ;;
      0) return 0 ;;
      *) printf 'Unknown Smart Router choice.\n' >&2 ;;
    esac
  done
}

hermes_menu() {
  local choice value
  while true; do
    menu_title 'Hermes Agent & Telegram'
    printf '%s\n' 'Agent'
    printf '%s\n' '  1) Restart Hermes Agent'
    printf '%s\n' '  2) Set max agent turns        10-500; 90 recommended'
    printf '%s\n' '  3) Upstream terminal tools    enable | disable'
    printf '%s\n' '  4) Update backend API key'
    printf '%s\n' 'Telegram'
    printf '%s\n' '  5) Show allowed users'
    printf '%s\n' '  6) Add allowed user'
    printf '%s\n' '  7) Replace allowed users'
    printf '%s\n' 'Dashboard'
    printf '%s\n' '  8) Show dashboard access      URL and username'
    printf '%s\n' '  9) Reveal dashboard password Trusted terminal confirmation required'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) "$ROOT_DIR/manage.sh" restart-hermes; menu_pause ;;
      2) read -r -p 'Max turns [90]: ' value; "$ROOT_DIR/manage.sh" set-agent-max-turns "${value:-90}"; menu_pause ;;
      3)
        printf '%s\n' '1) enable terminal/code_execution  2) disable terminal/code_execution'
        read -r -p 'Choose [2]: ' value
        case "${value:-2}" in 1) value=enable;; 2) value=disable;; *) printf 'Unknown choice.\n' >&2; continue;; esac
        "$ROOT_DIR/manage.sh" set-upstream-terminal "$value"; menu_pause
        ;;
      4)
        read -r -s -p 'New backend API key: ' value; printf '\n'
        [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" set-backend-api-key "$value"
        menu_pause
        ;;
      5) "$ROOT_DIR/manage.sh" show-telegram-users; menu_pause ;;
      6) read -r -p 'Numeric Telegram user ID: ' value; "$ROOT_DIR/manage.sh" add-telegram-user "$value"; menu_pause ;;
      7) read -r -p 'Complete comma-separated ID list: ' value; "$ROOT_DIR/manage.sh" set-telegram-users "$value"; menu_pause ;;
      8) "$ROOT_DIR/manage.sh" dashboard-access; menu_pause ;;
      9) "$ROOT_DIR/manage.sh" dashboard-access --show-password; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown Hermes choice.\n' >&2 ;;
    esac
  done
}

execution_menu() {
  local choice value feature name
  while true; do
    menu_title 'Execution, Sandbox & SSH'
    printf '%s\n' '1) Execution status'
    printf '%s\n' '2) Enable feature             sandbox | ssh | docker | all'
    printf '%s\n' '3) Disable feature            sandbox | ssh | docker | all'
    printf '%s\n' '4) Set execution users'
    printf '%s\n' '5) Add execution user'
    printf '%s\n' '6) Remove execution user'
    printf '%s\n' '7) Add SSH profile'
    printf '%s\n' '8) Verify SSH profile'
    printf '%s\n' '9) Change SSH profile password'
    printf '%s\n' '10) Remove SSH profile'
    printf '%s\n' '11) Configure approval bot token'
    printf '%s\n' '12) Rotate execution broker secret'
    printf '%s\n' '13) Enable Execution Admin UI'
    printf '%s\n' '14) Execution Admin status'
    printf '%s\n' '15) Show Execution Admin key (trusted terminal)'
    printf '%s\n' '16) Rotate Execution Admin key'
    printf '%s\n' '17) Disable Execution Admin UI'
    printf '%s\n' '18) PURGE execution state / SSH keys'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) "$ROOT_DIR/manage.sh" execution-status; menu_pause ;;
      2|3)
        printf '%s\n' 'Feature: 1) sandbox  2) ssh  3) docker  4) all'
        read -r -p 'Choose [1]: ' value
        case "${value:-1}" in 1) feature=sandbox;; 2) feature=ssh;; 3) feature=docker;; 4) feature=all;; *) printf 'Unknown feature.\n' >&2; continue;; esac
        if [[ "$choice" == 2 ]]; then "$ROOT_DIR/manage.sh" enable-execution "$feature"; else "$ROOT_DIR/manage.sh" disable-execution "$feature"; fi
        menu_pause
        ;;
      4) read -r -p 'Comma-separated Telegram IDs: ' value; "$ROOT_DIR/manage.sh" set-execution-users "$value"; menu_pause ;;
      5) read -r -p 'Telegram ID: ' value; "$ROOT_DIR/manage.sh" add-execution-user "$value"; menu_pause ;;
      6) read -r -p 'Telegram ID: ' value; "$ROOT_DIR/manage.sh" remove-execution-user "$value"; menu_pause ;;
      7) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" add-ssh-profile "$name"; menu_pause ;;
      8) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" verify-ssh-profile "$name"; menu_pause ;;
      9) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" set-ssh-profile-password "$name"; menu_pause ;;
      10) read -r -p 'SSH profile name: ' name; "$ROOT_DIR/manage.sh" remove-ssh-profile "$name"; menu_pause ;;
      11) "$ROOT_DIR/manage.sh" set-execution-approval-bot-token; menu_pause ;;
      12) "$ROOT_DIR/manage.sh" rotate-execution-broker-secret; menu_pause ;;
      13) "$ROOT_DIR/manage.sh" enable-execution-admin; menu_pause ;;
      14) "$ROOT_DIR/manage.sh" execution-admin-status; menu_pause ;;
      15) "$ROOT_DIR/manage.sh" show-execution-admin-key; menu_pause ;;
      16) "$ROOT_DIR/manage.sh" rotate-execution-admin-key; menu_pause ;;
      17) "$ROOT_DIR/manage.sh" disable-execution-admin; menu_pause ;;
      18) "$ROOT_DIR/manage.sh" purge-execution; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown execution choice.\n' >&2 ;;
    esac
  done
}

maintenance_menu() {
  local choice value
  while true; do
    menu_title 'Maintenance, Backup & Recovery'
    printf '%s\n' '1) Update official images and recreate services'
    printf '%s\n' '2) Create backup'
    printf '%s\n' '3) Create a section backup (env, content, panel, ...)'
    printf '%s\n' '4) List backups'
    printf '%s\n' '5) Restore backup archive'
    printf '%s\n' '6) Roll back last update/state'
    printf '%s\n' '7) Version information'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) read -r -p 'Pull and recreate selected services? [y/N]: ' value; [[ "$value" =~ ^[Yy]$ ]] && "$ROOT_DIR/manage.sh" update; menu_pause ;;
      2) "$ROOT_DIR/manage.sh" backup; menu_pause ;;
      3) "$ROOT_DIR/manage.sh" backup-sections; read -r -p 'Sections (comma separated): ' value; [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" backup --only "$value"; menu_pause ;;
      4) "$ROOT_DIR/manage.sh" backup-list; menu_pause ;;
      5) read -r -p 'Backup archive path: ' value; [[ -n "$value" ]] && "$ROOT_DIR/manage.sh" restore "$value"; menu_pause ;;
      6) read -r -p 'State ID (Enter = latest): ' value; if [[ -n "$value" ]]; then "$ROOT_DIR/manage.sh" rollback "$value"; else "$ROOT_DIR/manage.sh" rollback; fi; menu_pause ;;
      7) "$ROOT_DIR/manage.sh" version; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown maintenance choice.\n' >&2 ;;
    esac
  done
}

security_menu() {
  local choice
  while true; do
    menu_title 'Security & Integrity'
    printf '%s\n' '1) Doctor / diagnostics'
    printf '%s\n' '2) Lock current image digests'
    printf '%s\n' '3) Verify pinned image policy'
    printf '%s\n' '4) Smart Router access guidance'
    printf '%s\n' '5) Reveal Smart Router local secrets (confirmation required)'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) "$ROOT_DIR/manage.sh" doctor; menu_pause ;;
      2) "$ROOT_DIR/manage.sh" lock-images; menu_pause ;;
      3) "$ROOT_DIR/manage.sh" verify-images; menu_pause ;;
      4) "$ROOT_DIR/manage.sh" router-access; menu_pause ;;
      5) "$ROOT_DIR/manage.sh" router-access --show-secrets; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown security choice.\n' >&2 ;;
    esac
  done
}

uninstall_menu() {
  local value
  menu_title 'Uninstall'
  printf '%s\n' '1) Remove containers/network only'
  printf '%s\n' '   KEEP .env, secrets, Hermes/n8n/OpenWebUI data and source files.'
  printf '%s\n' '2) PURGE local runtime data and secrets'
  printf '%s\n' '   Source files and external backups are kept; PURGE confirmation is required.'
  printf '%s\n' '0) Cancel'
  read -r -p 'Choose [0]: ' value
  case "${value:-0}" in
    1) "$ROOT_DIR/manage.sh" uninstall ;;
    2) "$ROOT_DIR/manage.sh" uninstall --purge ;;
    0) ;;
    *) printf 'Unknown uninstall choice.\n' >&2 ;;
  esac
}

interactive_menu() {
  local choice
  while true; do
    menu_title 'Hermes Linux Stack Manager v0.5.9'
    printf '%s\n' 'Quick administration — choose a group; direct CLI commands still work.'
    printf '\n%s\n' '1) Overview & health          Status, health, version, diagnostics'
    printf '%s\n'   '2) Services & logs            Start/stop/restart and service logs'
    printf '%s\n'   '3) Smart Router               Routing, dashboard, profiles, policy, health'
    printf '%s\n'   '4) Hermes Agent & Telegram    Agent and messaging settings'
    printf '%s\n'   '5) n8n & MCP                  Provisioning, Instance MCP, Trigger MCP'
    printf '%s\n'   '6) Execution & SSH            Sandbox, Docker, SSH profiles, approvals'
    printf '%s\n'   '7) Content Bot                Status, publishing docs, platform setup'
    printf '%s\n'   '8) Media Studio               Media jobs, sessions and Google setup'
    printf '%s\n'   '9) Maintenance & recovery     Updates, backup, restore, rollback'
    printf '%s\n'   '10) Security & integrity      Doctor, image pins, access credentials'
    printf '%s\n'   '11) Reconfigure installation  Run the v0.5.9 wizard again'
    printf '%s\n'   '12) Operator panel            Web console for status, config, logs, actions'
    printf '%s\n'   '13) Object storage (S3)       RustFS or an external S3 endpoint'
    printf '%s\n'   '14) NotebookLM video          Worker status, sign-in and enable/disable'
    printf '%s\n'   '15) Uninstall                 Safe remove or explicit purge'
    printf '%s\n'   '0) Exit'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1)
        "$ROOT_DIR/manage.sh" status
        printf '\n'
        "$ROOT_DIR/manage.sh" health || true
        printf '\n'
        "$ROOT_DIR/manage.sh" version || true
        menu_pause
        ;;
      2) services_menu ;;
      3) router_menu ;;
      4) hermes_menu ;;
      5) n8n_menu ;;
      6) execution_menu ;;
      7) content_menu ;;
      8) media_menu ;;
      9) maintenance_menu ;;
      10) security_menu ;;
      11) exec "$ROOT_DIR/install.sh" ;;
      12) panel_menu ;;
      13) storage_menu ;;
      14) notebooklm_menu ;;
      15) uninstall_menu ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

replace_env_value() {
  local file="$1" key="$2" value="$3" tmp
  [[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || {
    printf 'Unsafe environment key: %s\n' "$key" >&2
    return 1
  }
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  if ! printf '%s' "$value" | python3 /dev/fd/3 "$file" "$key" 3<<'PY' > "$tmp"
import sys

path, key = sys.argv[1:]
value = sys.stdin.read()
lines = open(path, encoding="utf-8").read().splitlines()
indexes = [index for index, line in enumerate(lines) if line.startswith(f"{key}=")]
if len(indexes) > 1:
    raise SystemExit(f"Duplicate {key} entries are unsafe")
replacement = f"{key}={value}"
if indexes:
    lines[indexes[0]] = replacement
else:
    lines.append(replacement)
print("\n".join(lines) + "\n", end="")
PY
  then
    rm -f -- "$tmp"
    return 1
  fi
  chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  mv "$tmp" "$file"
}

remove_env_values() {
  local file="$1" tmp key pattern=""
  shift
  for key in "$@"; do
    [[ -z "$pattern" ]] || pattern+="|"
    pattern+="${key}="
  done
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  grep -v -E "^(${pattern})" "$file" > "$tmp" || true
  chmod --reference="$file" "$tmp"
  mv "$tmp" "$file"
}

n8n_mcp_mode() {
  local mode legacy
  mode="$(env_value "$ENV_FILE" N8N_MCP_MODE)" || return 1
  if [[ -z "$mode" ]]; then
    legacy="$(env_value "$HERMES_ENV" N8N_MCP_TOKEN)" || return 1
    if [[ -n "$legacy" ]]; then mode=trigger; else mode=off; fi
  fi
  [[ "$mode" == instance || "$mode" == trigger || "$mode" == off ]] || {
    printf 'N8N_MCP_MODE must be instance, trigger, or off.\n' >&2
    return 1
  }
  printf '%s' "$mode"
}

migrate_legacy_trigger_env() {
  local legacy
  legacy="$(env_value "$HERMES_ENV" N8N_MCP_TOKEN)" || return 1
  if [[ -n "$legacy" && -z "$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)" ]]; then
    replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN "$legacy"
  fi
  replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_URL '"http://n8n:5678/mcp/hermes"'
  replace_env_value "$HERMES_ENV" N8N_INSTANCE_MCP_URL '"http://n8n:5678/mcp-server/http"'
}

finish_legacy_trigger_env_migration() {
  remove_env_values "$HERMES_ENV" N8N_MCP_URL N8N_MCP_PATH N8N_MCP_TOKEN
}

render_managed_n8n_mcp_entry() {
  local mode="$1"
  case "$mode" in
    instance) url_var=N8N_INSTANCE_MCP_URL; token_var=N8N_INSTANCE_MCP_TOKEN ;;
    trigger) url_var=N8N_TRIGGER_MCP_URL; token_var=N8N_TRIGGER_MCP_TOKEN ;;
    off) return 0 ;;
  esac
  printf '%s\n' \
    '  # >>> hermes-stack n8n mcp (managed) >>>' \
    '  n8n:' \
    "    url: \"\${$url_var}\"" \
    '    headers:' \
    "      Authorization: \"Bearer \${$token_var}\"" \
    '  # <<< hermes-stack n8n mcp (managed) <<<'
}

set_hermes_n8n_mcp_entry() {
  local mode="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp entry
  [[ -f "$file" ]] || { printf 'Hermes config is missing.\n' >&2; return 1; }
  entry="$(render_managed_n8n_mcp_entry "$mode")"
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  if ! python3 - "$file" "$mode" "$entry" > "$tmp" <<'PY'
import re
import sys

path, mode, entry = sys.argv[1:]
lines = open(path, encoding="utf-8").read().splitlines()
opens = [i for i, line in enumerate(lines) if line == "  # >>> hermes-stack n8n mcp (managed) >>>"]
closes = [i for i, line in enumerate(lines) if line == "  # <<< hermes-stack n8n mcp (managed) <<<"]
top_opens = [i for i, line in enumerate(lines) if line == "# >>> hermes-stack n8n mcp (managed) >>>"]
top_closes = [i for i, line in enumerate(lines) if line == "# <<< hermes-stack n8n mcp (managed) <<<"]
if len(opens) != len(closes) or len(top_opens) != len(top_closes) or len(opens) + len(top_opens) > 1:
    raise SystemExit("Hermes config has incomplete or duplicate managed n8n MCP markers")
replacement = entry.splitlines() if entry else []
if top_opens:
    start, end = top_opens[0], top_closes[0]
    block = ["mcp_servers:", *replacement] if replacement else []
    lines[start:end + 1] = block
elif opens:
    start, end = opens[0], closes[0]
    lines[start:end + 1] = replacement
elif replacement:
    roots = [i for i, line in enumerate(lines) if re.fullmatch(r"mcp_servers:\s*", line)]
    if len(roots) > 1:
        raise SystemExit("Duplicate top-level mcp_servers sections are unsafe")
    if roots:
        lines[roots[0] + 1:roots[0] + 1] = replacement
    else:
        if lines and lines[-1]: lines.append("")
        lines.extend(["mcp_servers:", *replacement])
print("\n".join(lines) + "\n", end="")
PY
  then
    rm -f -- "$tmp"
    return 1
  fi
  chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  mv "$tmp" "$file"
}

enable_hermes_execution_plugin() {
  local file="$ROOT_DIR/data/hermes/config.yaml" tmp
  [[ -f "$file" ]] || { printf 'Hermes config is missing.\n' >&2; return 1; }
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  if ! python3 - "$file" > "$tmp" <<'PY'
import re
import sys

lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
starts = [i for i, line in enumerate(lines) if re.fullmatch(r"plugins:\s*", line)]
if len(starts) > 1:
    raise SystemExit("Duplicate top-level plugins sections are unsafe")
if not starts:
    if lines and lines[-1]:
        lines.append("")
    lines.extend(["plugins:", "  enabled:", "    - stack-execution-policy"])
else:
    start = starts[0]
    end = next((i for i in range(start + 1, len(lines)) if re.match(r"^[^\s#]", lines[i])), len(lines))
    if not any(re.fullmatch(r"\s*-\s*stack-execution-policy\s*", line) for line in lines[start + 1:end]):
        enabled = next((i for i in range(start + 1, end) if re.fullmatch(r"  enabled:\s*", lines[i])), None)
        if enabled is None:
            lines[end:end] = ["  enabled:", "    - stack-execution-policy"]
        else:
            lines.insert(enabled + 1, "    - stack-execution-policy")
print("\n".join(lines) + "\n", end="")
PY
  then
    rm -f -- "$tmp"
    return 1
  fi
  chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  mv "$tmp" "$file"
}

restart_hermes() {
  compose up -d --force-recreate hermes
}

hermes_dashboard_url() {
  local bind port
  bind="$(env_value "$ENV_FILE" HERMES_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" HERMES_DASHBOARD_PORT)"; port="${port:-9119}"
  [[ "$bind" != 0.0.0.0 ]] || bind=127.0.0.1
  printf 'http://%s:%s' "$bind" "$port"
}

hermes_dashboard_access() {
  local show_password="${1:-}" enabled username password answer mode
  [[ -z "$show_password" || "$show_password" == --show-password ]] || {
    printf 'Usage: ./manage.sh dashboard-access [--show-password]\n' >&2
    return 2
  }
  enabled="$(env_value "$ENV_FILE" HERMES_DASHBOARD)"
  [[ "$enabled" == 1 ]] || {
    printf 'Hermes dashboard is disabled. Run ./manage.sh configure to enable it.\n' >&2
    return 1
  }
  username="$(env_value "$ENV_FILE" HERMES_DASHBOARD_BASIC_AUTH_USERNAME)"
  printf 'Dashboard: %s\n' "$(hermes_dashboard_url)"
  printf 'Username:  %s\n' "${username:-NOT CONFIGURED}"
  if [[ -z "$show_password" ]]; then
    printf 'Use ./manage.sh dashboard-access --show-password only on a trusted terminal.\n'
    return 0
  fi
  [[ -r /dev/tty && -w /dev/tty ]] || {
    printf 'A controlling terminal is required to reveal the dashboard password.\n' >&2
    return 1
  }
  [[ -f "$HERMES_DASHBOARD_ACCESS_FILE" && ! -L "$HERMES_DASHBOARD_ACCESS_FILE" ]] || {
    printf 'Dashboard credential file is missing or unsafe; reconfigure Hermes to rotate credentials.\n' >&2
    return 1
  }
  mode="$(stat -c '%a' "$HERMES_DASHBOARD_ACCESS_FILE")"
  [[ "$mode" == 600 ]] || {
    printf 'Refusing to read dashboard credentials with unsafe mode %s; expected 600.\n' "$mode" >&2
    return 1
  }
  read -r -p 'Reveal the Hermes dashboard password on this terminal? [y/N]: ' answer </dev/tty
  [[ "$answer" =~ ^[Yy]$ ]] || { printf 'Password not shown.\n'; return 0; }
  password="$(env_value "$HERMES_DASHBOARD_ACCESS_FILE" HERMES_DASHBOARD_PASSWORD)"
  [[ -n "$password" ]] || { printf 'Dashboard password is missing; reconfigure Hermes to rotate credentials.\n' >&2; return 1; }
  printf 'Password:  %s\n' "$password"
}

hermes_uid_gid() {
  local uid gid
  uid="$(env_value "$ENV_FILE" HERMES_UID)"
  gid="$(env_value "$ENV_FILE" HERMES_GID)"
  uid="${uid:-10000}"
  gid="${gid:-10000}"
  [[ "$uid" =~ ^[1-9][0-9]*$ ]] || {
    printf 'HERMES_UID must be a non-root numeric uid in %s.\n' "$ENV_FILE" >&2
    return 1
  }
  [[ "$gid" =~ ^[1-9][0-9]*$ ]] || {
    printf 'HERMES_GID must be a non-root numeric gid in %s.\n' "$ENV_FILE" >&2
    return 1
  }
  printf '%s:%s' "$uid" "$gid"
}

hermes_permission_error() {
  printf 'Could not repair data/hermes/logs for the Hermes gateway uid.\n' >&2
  if [[ "$(id -u)" != 0 ]]; then
    printf 'Rerun with: sudo ./manage.sh migrate-hermes-permissions\n' >&2
  fi
  return 1
}

migrate_hermes_permissions() {
  local dry_run="$1" logs_dir desired uid gid changed=false path owner mode target_mode label
  logs_dir="$ROOT_DIR/data/hermes/logs"
  desired="$(hermes_uid_gid)" || return 1
  uid="${desired%%:*}"
  gid="${desired##*:}"

  if [[ -L "$logs_dir" ]]; then
    printf 'Refusing unsafe Hermes logs symlink: %s\n' "$logs_dir" >&2
    return 1
  fi

  if [[ ! -d "$logs_dir" ]]; then
    if [[ "$dry_run" == true ]]; then
      printf '[dry-run] would create %s with owner %s and mode 0700\n' "$logs_dir" "$desired"
      return 0
    fi
    install -d -m 0700 "$logs_dir" || hermes_permission_error
    chown "$uid:$gid" "$logs_dir" || hermes_permission_error
    changed=true
  fi

  while IFS= read -r -d '' path; do
    if [[ -d "$path" ]]; then
      target_mode=700
      label=directory
    else
      target_mode=600
      label=file
    fi
    owner="$(stat -c '%u:%g' "$path")"
    mode="$(stat -c '%a' "$path")"
    if [[ "$owner" != "$desired" ]]; then
      if [[ "$dry_run" == true ]]; then
        printf '[dry-run] would chown %s to %s (current %s)\n' "${path#$ROOT_DIR/}" "$desired" "$owner"
      else
        chown "$uid:$gid" "$path" || hermes_permission_error
        changed=true
      fi
    fi
    if [[ "$mode" != "$target_mode" ]]; then
      if [[ "$dry_run" == true ]]; then
        printf '[dry-run] would chmod %s %s to 0%s (current %s)\n' \
          "$label" "${path#$ROOT_DIR/}" "$target_mode" "$mode"
      else
        chmod "$target_mode" "$path" || hermes_permission_error
        changed=true
      fi
    fi
  done < <(find "$logs_dir" \( -type d -o -type f \) -print0)

  if [[ "$dry_run" == true ]]; then
    printf '[dry-run] checked %s for owner %s and restrictive modes\n' "$logs_dir" "$desired"
  elif [[ "$changed" == true ]]; then
    printf 'Hermes log permissions repaired under %s (owner %s, directories 0700, files 0600).\n' \
      "$logs_dir" "$desired"
  else
    printf 'Hermes log permissions already correct under %s.\n' "$logs_dir"
  fi
}

check_hermes_log_permissions() {
  local logs_dir="$ROOT_DIR/data/hermes/logs" desired issue_count=0 path owner mode expected_mode
  desired="$(hermes_uid_gid)" || return 1

  if [[ -L "$logs_dir" ]]; then
    printf 'WARNING: data/hermes/logs is an unsafe symlink. Replace it with a directory.\n'
    return 0
  fi
  if [[ ! -e "$logs_dir" ]]; then
    printf 'Hermes logs: not created yet (hermes-init will prepare it on startup)\n'
    return 0
  fi
  if [[ ! -d "$logs_dir" ]]; then
    printf 'WARNING: data/hermes/logs is not a directory.\n'
    printf '         Remove or relocate it, then run ./manage.sh migrate-hermes-permissions.\n'
    return 0
  fi

  while IFS= read -r -d '' path; do
    if [[ -d "$path" ]]; then expected_mode=700; else expected_mode=600; fi
    owner="$(stat -c '%u:%g' "$path")"
    mode="$(stat -c '%a' "$path")"
    if [[ "$owner" != "$desired" || "$mode" != "$expected_mode" ]]; then
      printf 'WARNING: %s has owner/mode %s/%s; expected %s/%s.\n' \
        "${path#$ROOT_DIR/}" "$owner" "$mode" "$desired" "$expected_mode"
      issue_count=$((issue_count + 1))
    fi
  done < <(find "$logs_dir" \( -type d -o -type f \) -print0)

  if (( issue_count > 0 )); then
    printf '         Run ./manage.sh migrate-hermes-permissions to repair data/hermes/logs.\n'
  else
    printf 'Hermes logs: owner/mode is compatible with Hermes gateway uid %s\n' "$desired"
  fi
}

# agent.max_turns in config.yaml is authoritative: the gateway bridges it into
# HERMES_MAX_ITERATIONS, which produces the "Iteration budget exhausted" notice.
AGENT_MAX_TURNS_MIN=10
AGENT_MAX_TURNS_MAX=500

hermes_agent_max_turns() {
  local file="$ROOT_DIR/data/hermes/config.yaml"
  [[ -f "$file" ]] || return 0
  python3 - "$file" <<'PY'
import re, sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
in_agent = False
for line in lines:
    if re.fullmatch(r"agent:\s*", line):
        in_agent = True
        continue
    if in_agent:
        if line and not line[0].isspace():
            in_agent = False
            continue
        match = re.fullmatch(r"\s+max_turns:\s*(\d+)\s*", line)
        if match:
            print(match.group(1))
            break
PY
}

set_hermes_agent_max_turns() {
  local value="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp
  [[ -f "$file" && ! -L "$file" ]] || {
    printf 'Hermes config is missing or unsafe.\n' >&2
    return 1
  }
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  if ! python3 - "$file" "$value" > "$tmp" <<'PY'
import re, sys
path, value = sys.argv[1:]
lines = open(path, encoding="utf-8").read().splitlines()
roots = [i for i, line in enumerate(lines) if re.fullmatch(r"agent:\s*", line)]
if len(roots) > 1:
    raise SystemExit("Duplicate top-level agent sections are unsafe")
if roots:
    start = roots[0]
    end = start + 1
    while end < len(lines) and (not lines[end] or lines[end][0].isspace()):
        end += 1
    body = lines[start + 1:end]
    replaced = False
    for i, line in enumerate(body):
        if re.fullmatch(r"(\s+)max_turns:\s*\d+\s*", line):
            indent = re.match(r"\s+", line).group(0)
            body[i] = f"{indent}max_turns: {value}"
            replaced = True
            break
    if not replaced:
        body.insert(0, f"  max_turns: {value}")
    lines[start + 1:end] = body
else:
    if lines and lines[-1]:
        lines.append("")
    lines.extend(["agent:", f"  max_turns: {value}"])
print("\n".join(lines) + "\n", end="")
PY
  then
    rm -f -- "$tmp"
    return 1
  fi
  chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  mv "$tmp" "$file"
}

# Upstream terminal/code_execution run as the gateway uid inside hermes-agent,
# which owns /opt/data/.env. Enabling them is a deliberate local trade of
# isolation for capability, so it lives behind an explicit command.
set_upstream_terminal() {
  local state="$1" file="$ROOT_DIR/data/hermes/config.yaml" tmp
  [[ -f "$file" && ! -L "$file" ]] || {
    printf 'Hermes config is missing or unsafe.\n' >&2
    return 1
  }
  tmp="$(mktemp "$file.tmp.XXXXXX")"
  if ! python3 - "$file" "$state" > "$tmp" <<'PY'
import re, sys
path, state = sys.argv[1:]
lines = open(path, encoding="utf-8").read().splitlines()
roots = [i for i, line in enumerate(lines) if re.fullmatch(r"agent:\s*", line)]
if len(roots) > 1:
    raise SystemExit("Duplicate top-level agent sections are unsafe")
names = ("terminal", "code_execution")
if roots:
    start = roots[0]
    end = start + 1
    while end < len(lines) and (not lines[end] or lines[end][0].isspace()):
        end += 1
else:
    if lines and lines[-1]:
        lines.append("")
    lines.append("agent:")
    start, end = len(lines) - 1, len(lines)
# Drop any existing disabled_toolsets block, preserving every other agent key.
key = next((i for i in range(start + 1, end)
            if re.fullmatch(r"\s+disabled_toolsets:.*", lines[i])), None)
if key is not None:
    stop = key + 1
    while stop < end and re.fullmatch(r"\s+-\s*[A-Za-z0-9_-]+\s*", lines[stop]):
        stop += 1
    del lines[key:stop]
    end -= stop - key
block = ["  disabled_toolsets: []"] if state == "enabled" else \
        ["  disabled_toolsets:", *(f"    - {name}" for name in names)]
# Append after the section's last real key, not after the blank lines that
# separate it from the next section, so the file stays readable.
insert_at = end
while insert_at > start + 1 and not lines[insert_at - 1].strip():
    insert_at -= 1
lines[insert_at:insert_at] = block
print("\n".join(lines) + "\n", end="")
PY
  then
    rm -f -- "$tmp"
    return 1
  fi
  chmod --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  chown --reference="$file" "$tmp" || { rm -f -- "$tmp"; return 1; }
  mv "$tmp" "$file"
}

content_status() {
  local profiles token channel users writer model scheduler daily_time ig_id ig_url
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," != *,content,* ]]; then
    printf 'Content Bot is not enabled in COMPOSE_PROFILES. Run ./manage.sh content-configure to enable it.\n'
    return 1
  fi
  token="$(env_value "$ENV_FILE" CONTENT_BOT_TOKEN)"
  channel="$(env_value "$ENV_FILE" CONTENT_TELEGRAM_CHANNEL)"
  users="$(env_value "$ENV_FILE" CONTENT_TELEGRAM_USERS)"
  writer="$(env_value "$ENV_FILE" CONTENT_WRITER_BASE_URL)"
  model="$(env_value "$ENV_FILE" CONTENT_WRITER_MODEL)"
  scheduler="$(env_value "$ENV_FILE" CONTENT_SCHEDULER_ENABLED)"
  ig_id="$(env_value "$ENV_FILE" INSTAGRAM_BUSINESS_ID)"
  ig_url="$(ig_media_public_url)"
  daily_time="$(sed -n 's/^  daily_proposal_time: //p' \
    "$ROOT_DIR/data/content-manager/config/editorial-policy.yaml" 2>/dev/null | head -n1 | tr -d '"')"
  printf 'Content Bot status\n'
  if [[ -n "$token" ]]; then
    printf '  Telegram bot token: stored (secret not shown)\n'
  else
    printf '  Telegram bot token: NOT stored; reconfigure with ./manage.sh content-configure\n'
  fi
  printf '  Publish channel: %s\n' "${channel:-not configured}"
  printf '  Operator users: %s\n' "${users:-none}"
  printf '  Writer endpoint: %s\n' "${writer:-not configured}"
  printf '  Writer model: %s\n' "${model:-auto}"
  printf '  Daily scheduler: %s at %s\n' "${scheduler:-true}" "${daily_time:-08:00}"
  if [[ -n "$ig_id" && -n "$(env_value "$ENV_FILE" INSTAGRAM_ACCESS_TOKEN)" ]]; then
    printf '  Instagram: enabled (business %s, public media URL %s)\n' "$ig_id" "${ig_url:-not set}"
  else
    printf '  Instagram: not configured (set INSTAGRAM_BUSINESS_ID / INSTAGRAM_ACCESS_TOKEN)\n'
  fi
  ig_auto="$(env_value "$ENV_FILE" INSTAGRAM_AUTO_PUBLISH)"
  case "$(printf '%s' "$ig_auto" | tr '[:upper:]' '[:lower:]')" in
    0|false|no|off)
      printf '  Instagram auto publish: off (manual Post package flow)\n'
      ;;
    *)
      printf '  Instagram auto publish: on\n'
      ;;
  esac
  printf '  Automatic channels:\n'
  printf '    %s\n' "$(content_channel_state bale)"
  printf '    %s\n' "$(content_channel_state eitaa)"
  printf '    %s\n' "$(content_linkedin_state)"
  printf '  Editorial policy: data/content-manager/config/editorial-policy.yaml\n'
  printf '  Discovery sources: data/content-manager/config/sources.yaml\n'
  printf '  Guide: docs/CONTENT-PRODUCTION-GUIDE.md\n'
}

content_linkedin_author() {
  # Builds the author URN the same way the bot builds it.
  local kind="$1" urn="$2" person="$3" org="$4"
  if [[ -n "$urn" ]]; then
    if [[ "$urn" == urn:* ]]; then
      printf '%s' "$urn"
    else
      printf 'urn:li:%s:%s' "$kind" "$urn"
    fi
    return 0
  fi
  if [[ "$kind" == organization ]]; then
    [[ -n "$org" ]] && printf 'urn:li:organization:%s' "$org"
    return 0
  fi
  [[ -n "$person" ]] && printf 'urn:li:person:%s' "$person"
  return 0
}

content_linkedin_state() {
  # One line describing the LinkedIn account (no secrets).
  local token kind account author person org
  token="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN)"
  kind="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE)"
  account="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT)"
  author="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN)"
  person="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID)"
  org="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID)"
  if [[ -n "$token" ]] && [[ -n "$author" || -n "$person" || -n "$org" ]]; then
    printf 'LinkedIn: automatic publishing ready (account %s, %s)' \
      "${account:-personal}" "${kind:-person}"
  elif [[ -n "$token" ]]; then
    printf 'LinkedIn: token stored, the author URN or id is still empty'
  else
    printf 'LinkedIn: not configured (CONTENT_LINKEDIN_ACCESS_TOKEN is empty)'
  fi
}

content_linkedin_verify() {
  # Reserves one image upload slot: validates the token, the pinned version,
  # and the author permission without publishing anything.
  local base="$1" version="$2" token="$3" author="$4" kind="$5"
  python3 - "$base" "$version" "$token" "$author" "$kind" <<'PY'
import json
import sys
import urllib.error
import urllib.request

base, version, token, author, kind = sys.argv[1:6]
url = f"{base.rstrip('/')}/rest/images?action=initializeUpload"
payload = json.dumps({"initializeUploadRequest": {"owner": author}}).encode("utf-8")
request = urllib.request.Request(
    url,
    data=payload,
    method="POST",
    headers={
        "Authorization": f"Bearer {token}",
        "LinkedIn-Version": version,
        "X-Restli-Protocol-Version": "2.0.0",
        "Content-Type": "application/json",
    },
)
try:
    with urllib.request.urlopen(request, timeout=30) as response:
        status = response.status
        body = response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as error:
    status = error.code
    body = error.read().decode("utf-8", "replace")
except Exception as error:  # noqa: BLE001 - any transport failure is reported
    print(f"unreachable ({error})")
    raise SystemExit(1)
try:
    data = json.loads(body or "{}")
except ValueError:
    data = {}
if status < 300 and (data.get("value") or {}).get("uploadUrl"):
    print(f"HTTP {status}: token and author accepted ({author})")
    raise SystemExit(0)
note = str(data.get("message") or "").strip()
if status == 401:
    print("HTTP 401: the token is expired or revoked; mint a new one.")
elif status == 403 and kind == "organization":
    print("HTTP 403: the token cannot post as this page (missing")
    print("w_organization_social, or the member is not one of its admins).")
elif status == 403:
    print("HTTP 403: the token cannot post as this member (missing")
    print("w_member_social, or the id is not the token owner's sub).")
else:
    print(f"HTTP {status}: {note or 'unexpected answer'}")
raise SystemExit(1)
PY
}

content_connect_linkedin() {
  local usage="Usage: ./manage.sh content-connect-linkedin [--token TOKEN] [--type person|organization] [--account KEY] [--person-id ID] [--organization-id ID] [--author-urn URN] [--verify] [--no-apply]"
  local token="" kind="" account="" person="" org="" urn="" base="" version=""
  local token_arg="" kind_arg="" account_arg="" person_arg="" org_arg="" urn_arg="" answer
  local verify=false apply=true arg author waited
  while (($#)); do
    arg="$1"; shift
    case "$arg" in
      --token)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        token_arg="$1"; shift ;;
      --type|--kind)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        kind_arg="$1"; shift ;;
      --account|--name)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        account_arg="$1"; shift ;;
      --person-id)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        person_arg="$1"; shift ;;
      --organization-id|--org-id)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        org_arg="$1"; shift ;;
      --author-urn|--author)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        urn_arg="$1"; shift ;;
      --verify) verify=true ;;
      --no-apply) apply=false ;;
      -h|--help) printf '%s\n' "$usage"; return 0 ;;
      *) printf 'Unknown option: %s\n%s\n' "$arg" "$usage" >&2; return 2 ;;
    esac
  done
  token="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN)"
  kind="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE)"; kind="${kind:-person}"
  account="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT)"; account="${account:-personal}"
  person="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID)"
  org="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID)"
  urn="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN)"
  base="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_API_BASE)"; base="${base:-https://api.linkedin.com}"
  version="$(env_value "$ENV_FILE" CONTENT_LINKEDIN_API_VERSION)"; version="${version:-202601}"
  printf 'LinkedIn setup (automatic publishing)\n'
  printf '  1. Create the app and request Share on LinkedIn (personal) or the\n'
  printf '     Community Management API (company page).\n'
  printf '  2. Mint the 3-legged token; it lives about 60 days.\n'
  printf '  3. Find the author: urn:li:person:<sub> from /v2/userinfo, or\n'
  printf '     urn:li:organization:<id> from the page admin URL.\n'
  printf '  Guide: docs/LINKEDIN-SETUP.md\n'
  [[ -n "$token_arg" ]] && token="$token_arg"
  [[ -n "$kind_arg" ]] && kind="$kind_arg"
  [[ -n "$account_arg" ]] && account="$account_arg"
  [[ -n "$person_arg" ]] && person="$person_arg"
  [[ -n "$org_arg" ]] && org="$org_arg"
  [[ -n "$urn_arg" ]] && urn="$urn_arg"
  if [[ -z "$token_arg$kind_arg$account_arg$person_arg$org_arg$urn_arg" && -t 0 ]]; then
    printf 'Leave an answer empty to keep the stored value.\n'
    read -r -s -p 'LinkedIn access token: ' answer
    printf '\n'
    [[ -n "$answer" ]] && token="$answer"
    read -r -p "Account type person/organization [${kind}]: " answer
    [[ -n "$answer" ]] && kind="$answer"
    read -r -p "Account key (button label suffix) [${account}]: " answer
    [[ -n "$answer" ]] && account="$answer"
    read -r -p 'Author URN, person id, or organization id: ' answer
    if [[ -n "$answer" ]]; then
      if [[ "$answer" == urn:* ]]; then
        urn="$answer"
      elif [[ "$kind" == organization ]]; then
        org="$answer"
      else
        person="$answer"
      fi
    fi
  fi
  case "$kind" in
    person|organization) ;;
    *)
      printf 'Unknown account type: %s (use person or organization).\n' "$kind" >&2
      return 2 ;;
  esac
  if [[ -z "$token" ]]; then
    printf '  Current state: %s\n' "$(content_linkedin_state)"
    printf 'Nothing stored yet. Run one of:\n'
    printf '  ./manage.sh content-connect-linkedin --token <TOKEN> --person-id <ID>\n'
    printf '  ./manage.sh content-connect-linkedin --token <TOKEN> --organization-id <ID> --type organization\n'
    return 1
  fi
  author="$(content_linkedin_author "$kind" "$urn" "$person" "$org")"
  if [[ -z "$author" ]]; then
    printf 'The author is still empty: pass --person-id, --organization-id, or --author-urn.\n' >&2
    return 1
  fi
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCESS_TOKEN "$token"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT "$account"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ACCOUNT_TYPE "$kind"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_AUTHOR_URN "$urn"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_PERSON_ID "$person"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_ORGANIZATION_ID "$org"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_API_BASE "$base"
  replace_env_value "$ENV_FILE" CONTENT_LINKEDIN_API_VERSION "$version"
  printf 'Stored the LinkedIn token, account %s, and author %s in .env.\n' "$account" "$author"
  printf '  Current state: %s\n' "$(content_linkedin_state)"
  if [[ "$verify" == true ]]; then
    printf 'Token check: '
    content_linkedin_verify "$base" "$version" "$token" "$author" "$kind" \
      || printf 'The check did not pass; see docs/LINKEDIN-SETUP.md.\n'
  fi
  if [[ "$apply" == true ]]; then
    if content_container_running; then
      printf 'Applying the configuration to content-bot...\n'
      compose up -d content-bot >/dev/null
      waited=0
      while ((waited < 30)) && ! content_container_running; do
        sleep 2
        waited=$((waited + 2))
      done
    else
      printf 'content-bot is not running; it reads the new values on its next start.\n'
    fi
  fi
  printf 'Open a draft in Telegram: "More platforms..." shows LinkedIn (auto) once the\n'
  printf 'bot restarted with the new values. Check with ./manage.sh content-channels\n'
}

content_connect_instagram() {
  printf '%s\n' 'Instagram/Meta setup checklist (official Graph API):'
  printf '%s\n' '  1. Convert the Instagram account to Business/Creator and link it to a Facebook Page.'
  printf '%s\n' '  2. Create a Meta Business app: https://developers.facebook.com/apps/creation/'
  printf '%s\n' '  3. Add the Instagram Graph API product and connect the Instagram account.'
  printf '%s\n' '  4. Grant instagram_basic and instagram_content_publish and generate a long-lived token.'
  printf '%s\n' 'Step-by-step guide: docs/INSTAGRAM-SETUP.md'
  printf '%s\n' 'Then set INSTAGRAM_BUSINESS_ID and INSTAGRAM_ACCESS_TOKEN in .env, give the media'
  printf '%s\n' 'files a public address with ./manage.sh instagram-media-enable (or pin a stable'
  printf '%s\n' 'hostname in data/content-bot/media-base-url.txt), and restart the bot:'
  printf '%s\n' '  ./manage.sh restart content'
  printf '%s\n' 'While the Graph API cannot publish, set INSTAGRAM_AUTO_PUBLISH=false and use the'
  printf '%s\n' 'Post package action in the operator console for a manual upload from the app.'
}

content_configure() {
  printf 'Reconfiguring Content Bot settings. Existing components, data, and bind IPs are preserved.\n'
  exec "$ROOT_DIR/install.sh" --content-reconfigure
}

content_container_running() {
  [[ "$("${DOCKER[@]}" inspect -f '{{.State.Running}}' content-bot 2>/dev/null)" == true ]]
}

content_channel_keys() {
  # Emits: <token key> <chat key> <api base key> <label> <default api base>
  case "${1:-}" in
    bale)
      printf '%s\n' 'CONTENT_BALE_TOKEN CONTENT_BALE_CHAT_ID CONTENT_BALE_API_BASE Bale https://tapi.bale.ai'
      ;;
    eitaa)
      printf '%s\n' 'CONTENT_EITAA_TOKEN CONTENT_EITAA_CHAT_ID CONTENT_EITAA_API_BASE Eitaa https://eitaayar.ir/api'
      ;;
    *)
      printf 'Unknown channel: %s\n' "${1:-}" >&2
      return 2
      ;;
  esac
}

content_channel_state() {
  # One line describing whether a channel can publish right now (no secrets).
  local kind="$1" token_key chat_key base_key label base_default token chat
  read -r token_key chat_key base_key label base_default <<<"$(content_channel_keys "$kind")"
  token="$(env_value "$ENV_FILE" "$token_key")"
  chat="$(env_value "$ENV_FILE" "$chat_key")"
  if [[ -n "$token" && -n "$chat" ]]; then
    printf '%s: automatic publishing ready (chat %s)' "$label" "$chat"
  elif [[ -n "$token" ]]; then
    printf '%s: token stored, %s is still empty' "$label" "$chat_key"
  elif [[ -n "$chat" ]]; then
    printf '%s: chat id stored, %s is still empty' "$label" "$token_key"
  else
    printf '%s: not configured (%s and %s are empty)' "$label" "$token_key" "$chat_key"
  fi
}

content_aparat_state() {
  # One line describing whether the Aparat session can upload right now.
  local token cookie
  token="$(env_value "$ENV_FILE" CONTENT_APARAT_TOKEN)"
  cookie="$(env_value "$ENV_FILE" CONTENT_APARAT_COOKIE)"
  if [[ -n "$token" ]]; then
    printf 'Aparat: session token stored (video drafts publish when picked)'
  elif [[ -n "$cookie" ]]; then
    printf 'Aparat: session cookie stored (video drafts publish when picked)'
  else
    printf 'Aparat: not configured (CONTENT_APARAT_TOKEN and CONTENT_APARAT_COOKIE are empty)'
  fi
}

content_aparat_check() {
  # Probe the stored session the way the bot does; no video is uploaded.
  if ! content_container_running; then
    printf 'Start the stack first (./manage.sh start) to probe the Aparat session.\n' >&2
    return 1
  fi
  "${DOCKER[@]}" exec -i content-bot python - <<'PYCHECK'
from content_bot.aparat import AparatError, client_from_settings
from content_bot.config import BotSettings

settings = BotSettings.from_env()
if not settings.aparat_enabled:
    print("No session is stored: set CONTENT_APARAT_TOKEN or CONTENT_APARAT_COOKIE.")
    raise SystemExit(1)
client = client_from_settings(settings)
try:
    server = client.probe()
except AparatError as error:
    print(f"Aparat refused the session: {error}")
    raise SystemExit(1)
print(f"Aparat session is valid (upload server {server}).")
PYCHECK
}

content_channels_status() {
  printf 'Automatic channels (tokens are never printed)\n'
  printf '  - %s\n' "$(content_channel_state bale)"
  printf '  - %s\n' "$(content_channel_state eitaa)"
  printf '  - %s\n' "$(content_linkedin_state)"
  printf '  - %s\n' "$(content_aparat_state)"
  printf '  A configured channel appears as "<name> (auto)" on a draft; uploads\n'
  printf '  publish the moment it is picked. Guides: docs/BALE-EITAA-SETUP.md,\n'
  printf '  docs/LINKEDIN-SETUP.md, and docs/APARAT-SETUP.md\n'
}

content_channel_get_me() {
  # Token check against the provider. Bale keeps the bot prefix, Eitaa does not.
  local kind="$1" base="$2" token="$3" url
  if [[ "$kind" == bale ]]; then
    url="$base/bot$token/getMe"
  else
    url="$base/$token/getMe"
  fi
  python3 - "$url" <<'PY'
import json
import sys
import urllib.error
import urllib.request

url = sys.argv[1]
try:
    with urllib.request.urlopen(url, timeout=20) as response:
        status = response.status
        body = response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as error:
    status = error.code
    body = error.read().decode("utf-8", "replace")
except Exception as error:  # noqa: BLE001 - any transport failure is reported
    print(f"unreachable ({error})")
    raise SystemExit(1)
try:
    payload = json.loads(body)
except ValueError:
    print(f"HTTP {status}: {body[:160]}")
    raise SystemExit(1)
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
name = result.get("username") or result.get("first_name") or ""
ok = payload.get("ok")
print(f"HTTP {status}: ok={ok}{f' ({name})' if name else ''}")
if not ok:
    raise SystemExit(1)
PY
}

content_channel_test_message() {
  # Publishes one line through the exact code path the bot uses.
  local kind="$1"
  "${DOCKER[@]}" exec -i content-bot python - "$kind" <<'PY'
import sys

from content_bot.channels import build_channels
from content_bot.config import BotSettings

kind = sys.argv[1]
channel = build_channels(BotSettings.from_env()).get(kind)
if channel is None:
    print(f"No token or chat id is stored for {kind} inside the container.")
    raise SystemExit(1)
try:
    channel.send_text("Locallab Content Manager test message.")
except Exception as error:  # noqa: BLE001 - surface the provider message
    print(f"Publish failed: {error}")
    raise SystemExit(1)
print(f"Published to {channel.label}.")
PY
}

content_connect_channel() {
  local kind="$1"; shift
  local token_key chat_key base_key label base_default token chat base
  local token_arg="" chat_arg="" verify=false run_test=false apply=true arg answer
  read -r token_key chat_key base_key label base_default <<<"$(content_channel_keys "$kind")"
  local usage="Usage: ./manage.sh content-connect-$kind [--token TOKEN] [--chat-id @channel] [--verify] [--test] [--no-apply]"
  while (($#)); do
    arg="$1"; shift
    case "$arg" in
      --token)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        token_arg="$1"; shift ;;
      --chat-id|--chat|--channel)
        (($#)) || { printf '%s\n' "$usage" >&2; return 2; }
        chat_arg="$1"; shift ;;
      --verify) verify=true ;;
      --test) run_test=true ;;
      --no-apply) apply=false ;;
      -h|--help) printf '%s\n' "$usage"; return 0 ;;
      *) printf 'Unknown option: %s\n%s\n' "$arg" "$usage" >&2; return 2 ;;
    esac
  done
  token="$(env_value "$ENV_FILE" "$token_key")"
  chat="$(env_value "$ENV_FILE" "$chat_key")"
  base="$(env_value "$ENV_FILE" "$base_key")"
  printf '%s setup (automatic publishing)\n' "$label"
  printf '  1. Create the bot with the provider and copy its token.\n'
  printf '  2. Add the bot to the channel and make it an administrator.\n'
  printf '  3. Use the channel @username (the public link) or its numeric id.\n'
  printf '  Guide: docs/BALE-EITAA-SETUP.md\n'
  [[ -n "$token_arg" ]] && token="$token_arg"
  [[ -n "$chat_arg" ]] && chat="$chat_arg"
  if [[ -z "$token_arg" && -z "$chat_arg" && -t 0 ]]; then
    printf 'Leave an answer empty to keep the stored value.\n'
    read -r -s -p "$label bot token: " answer
    printf '\n'
    [[ -n "$answer" ]] && token="$answer"
    read -r -p "$label channel id (for example @locallab): " answer
    [[ -n "$answer" ]] && chat="$answer"
  fi
  if [[ -z "$token" || -z "$chat" ]]; then
    printf '  Current state: %s\n' "$(content_channel_state "$kind")"
    printf 'Nothing stored yet. Run one of:\n'
    printf '  ./manage.sh content-connect-%s --token <TOKEN> --chat-id @<channel>\n' "$kind"
    printf '  ./manage.sh content-connect-%s --token <TOKEN> --chat-id @<channel> --verify --test\n' "$kind"
    return 1
  fi
  if [[ -z "$base" ]]; then
    base="$base_default"
    replace_env_value "$ENV_FILE" "$base_key" "$base"
  fi
  replace_env_value "$ENV_FILE" "$token_key" "$token"
  replace_env_value "$ENV_FILE" "$chat_key" "$chat"
  printf 'Stored %s, %s, and %s in .env.\n' "$token_key" "$chat_key" "$base_key"
  printf '  Current state: %s\n' "$(content_channel_state "$kind")"
  if [[ "$verify" == true ]]; then
    printf 'Token check: '
    content_channel_get_me "$kind" "$base" "$token" \
      || printf 'The check did not pass; run it again with --test to see the provider error.\n'
  fi
  if [[ "$apply" == true ]]; then
    if content_container_running; then
      printf 'Applying the configuration to content-bot...\n'
      compose up -d content-bot >/dev/null
      local waited=0
      while ((waited < 30)) && ! content_container_running; do
        sleep 2
        waited=$((waited + 2))
      done
    else
      printf 'content-bot is not running; it reads the new values on its next start.\n'
    fi
  fi
  if [[ "$run_test" == true ]]; then
    if content_container_running; then
      printf 'Publishing a test message through the stack...\n'
      content_channel_test_message "$kind" || true
    else
      printf 'Start the stack first (./manage.sh start) to run --test.\n' >&2
    fi
  fi
  printf 'Open a draft in Telegram: "More platforms..." shows %s (auto) once the\n' "$label"
  printf 'bot restarted with the new values. Check with ./manage.sh content-channels\n'
}


content_menu() {
  local choice
  while true; do
    printf '\nContent Bot Manager\n'
    printf '%s\n' '==================='
    printf '%s\n' '1) Show Content Bot status'
    printf '%s\n' '2) Instagram/Meta setup checklist'
    printf '%s\n' '3) Follow Content Bot logs'
    printf '%s\n' '4) Reconfigure Content Bot settings'
    printf '%s\n' '5) Instagram media host status'
    printf '%s\n' '6) Enable the Instagram media host'
    printf '%s\n' '7) Automatic channel status (Bale, Eitaa, LinkedIn)'
    printf '%s\n' '8) Bale channel setup'
    printf '%s\n' '9) Eitaa channel setup'
    printf '%s\n' '10) LinkedIn setup'
    printf '%s\n' '0) Back'
    read -r -p 'Choose: ' choice
    case "$choice" in
      1) content_status || true ;;
      2) content_connect_instagram ;;
      3) compose logs -f --tail=100 content-bot ;;
      4) content_configure ;;
      5) ig_media_status || true ;;
      6) ig_media_enable || true ;;
      7) content_channels_status || true ;;
      8) content_connect_channel bale || true ;;
      9) content_connect_channel eitaa || true ;;
      10) content_connect_linkedin || true ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

media_status() {
  local profiles drivers mode writer model token set freeze video video_model
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," != *,media,* ]]; then
    printf 'Media Studio is not enabled in COMPOSE_PROFILES. Run ./manage.sh media-configure to enable it.\n'
    return 1
  fi
  drivers="$(env_value "$ENV_FILE" MEDIA_STUDIO_DRIVERS)"
  mode="$(env_value "$ENV_FILE" MEDIA_STUDIO_SESSION_MODE)"
  writer="$(env_value "$ENV_FILE" MEDIA_STUDIO_WRITER_BASE_URL)"
  [[ -n "$writer" ]] || writer="$(env_value "$ENV_FILE" CONTENT_WRITER_BASE_URL)"
  token="$(env_value "$ENV_FILE" MEDIA_STUDIO_API_TOKEN)"
  freeze="$(env_value "$ENV_FILE" MEDIA_STUDIO_FREEZE_ON_READY)"
  printf 'Media Studio status\n'
  printf '  Enabled drivers: %s\n' "${drivers:-api-image,api-video,flow-video,video-edit}"
  printf '  Session mode: %s\n' "${mode:-persistent}"
  printf '  Writer endpoint: %s\n' "${writer:-not configured}"
  video="$(env_value "$ENV_FILE" MEDIA_STUDIO_VIDEO_BASE_URL)"
  [[ -n "$video" ]] || video="$writer"
  video_model="$(env_value "$ENV_FILE" MEDIA_STUDIO_VIDEO_MODEL)"
  printf '  Video endpoint: %s\n' "${video:-not configured}"
  printf '  Video model: %s\n' "${video_model:-not set (api-video jobs fail until MEDIA_STUDIO_VIDEO_MODEL is stored)}"
  printf '  API token: %s\n' "$([[ -n "$token" ]] && printf 'stored (secret not shown)' || printf 'not set (localhost only)')"
  case "$writer" in
    *smart-router*)
      printf '  Warning: the writer endpoint is the stack chat gateway; image jobs need an\n'
      printf '           OpenAI-compatible image API instead. See the troubleshooting\n'
      printf '           section of docs/MEDIA-STUDIO.md.\n'
      ;;
  esac
  if [[ -n "$video_model" && "$video_model" != */* ]]; then
    printf '  Warning: the video model has no provider prefix; a chat combo such as ai or\n'
    printf '           ai-strong cannot generate video. Use a provider model such as\n'
    printf '           xai/grok-imagine-video. See docs/MEDIA-STUDIO.md.\n'
  fi
  printf '  Flow freeze on ready: %s\n' "${freeze:-true}"
  printf '  Guide: docs/MEDIA-STUDIO.md\n'
  printf '  API base: http://127.0.0.1:%s (when enabled)\n' "$(env_value "$ENV_FILE" MEDIA_STUDIO_PORT | sed 's/^$/8850/')"
}

pipeline_status() {
  local profiles content_enabled media_enabled media_url media_token bot_token
  local image_driver video_driver media_drivers
  local nlm_url nlm_token nlm_bot_token nlm_mode
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  content_enabled=false
  media_enabled=false
  [[ ",$profiles," == *,content,* ]] && content_enabled=true
  [[ ",$profiles," == *,media,* ]] && media_enabled=true
  if [[ "$content_enabled" != true && "$media_enabled" != true ]]; then
    printf 'The content pipeline is not installed here (neither content nor media is enabled).\n'
    printf 'Install both on one server with ./install.sh option 7, or enable them separately with\n'
    printf './manage.sh content-configure and ./manage.sh media-configure.\n'
    return 1
  fi
  printf '\nContent production pipeline\n'
  if [[ "$content_enabled" == true ]]; then
    content_status || true
  else
    printf '  Content Bot: not installed on this host (media-only server).\n'
  fi
  if [[ "$media_enabled" == true ]]; then
    media_status || true
  else
    printf '  Media Studio: not installed on this host (content-only server).\n'
  fi
  if notebooklm_enabled; then
    nlm_url="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_URL)"
    [[ -n "$nlm_url" ]] || nlm_url="http://notebooklm-worker:$(notebooklm_port)"
    nlm_token="$(env_value "$ENV_FILE" NOTEBOOKLM_API_TOKEN)"
    nlm_bot_token="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_TOKEN)"
    nlm_mode="$(env_value "$ENV_FILE" NOTEBOOKLM_SESSION_MODE)"
    printf '\nContent Bot -> NotebookLM worker link\n'
    printf '  Worker URL: %s\n' "$nlm_url"
    if [[ -n "$nlm_token" && "$nlm_bot_token" == "$nlm_token" ]]; then
      printf '  API token: synced (secret not shown)\n'
    elif [[ -z "$nlm_bot_token" && -z "$nlm_token" ]]; then
      printf '  API token: not set; run ./manage.sh notebooklm-enable\n'
    else
      printf '  API token: NOT synced; the worker rejects the bot. Run ./manage.sh notebooklm-enable.\n'
    fi
    printf '  Session mode: %s (verify with ./manage.sh notebooklm-login 15)\n' "${nlm_mode:-persistent}"
    if [[ "$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_ENABLED)" == false ]]; then
      printf '  WARNING: CONTENT_NOTEBOOKLM_ENABLED=false, so the bot hides the button.\n'
    fi
  fi
  if [[ "$content_enabled" == true && "$media_enabled" == true ]]; then
    media_url="$(env_value "$ENV_FILE" CONTENT_MEDIA_STUDIO_URL)"
    [[ -n "$media_url" ]] || media_url="http://media-studio:8850"
    media_token="$(env_value "$ENV_FILE" MEDIA_STUDIO_API_TOKEN)"
    bot_token="$(env_value "$ENV_FILE" CONTENT_MEDIA_STUDIO_TOKEN)"
    image_driver="$(env_value "$ENV_FILE" CONTENT_MEDIA_IMAGE_DRIVER)"
    [[ -n "$image_driver" ]] || image_driver="api-image"
    video_driver="$(env_value "$ENV_FILE" CONTENT_MEDIA_VIDEO_DRIVER)"
    [[ -n "$video_driver" ]] || video_driver="flow-video"
    media_drivers="$(env_value "$ENV_FILE" MEDIA_STUDIO_DRIVERS)"
    media_drivers="${media_drivers//[[:space:]]/}"
    printf '\nContent Bot -> Media Studio link\n'
    printf '  Media Studio URL: %s\n' "$media_url"
    if [[ "$bot_token" == "$media_token" ]]; then
      if [[ -n "$bot_token" ]]; then
        printf '  API token: synced (secret not shown)\n'
      else
        printf '  API token: not set (localhost only)\n'
      fi
    else
      printf '  API token: NOT synced; run ./manage.sh media-configure after rotating the Media Studio token\n'
    fi
    printf '  Bot image driver: %s\n' "$image_driver"
    printf '  Bot video driver: %s\n' "$video_driver"
    if [[ -n "$media_drivers" ]]; then
      if [[ ",$media_drivers," != *",$image_driver,"* ]]; then
        printf '  WARNING: Media Studio drivers (%s) do not enable the bot image driver %s.\n' \
          "$media_drivers" "$image_driver"
      fi
      if [[ -n "$video_driver" && ",$media_drivers," != *",$video_driver,"* ]]; then
        printf '  WARNING: Media Studio drivers (%s) do not enable the bot video driver %s.\n' \
          "$media_drivers" "$video_driver"
      fi
    fi
  fi
}

media_guide() {
  printf '%s\n' 'Media Studio guide: docs/MEDIA-STUDIO.md'
  printf '%s\n' 'Flow unlock on a laptop only: docs/FLOW-UNLOCK-STANDALONE.md'
  printf '%s\n' 'Standalone extension folder: extensions/locallab-flow-unlock/ (load as an unpacked Chrome extension)'
  printf '%s\n' 'Reconfigure: ./manage.sh media-configure'
}

media_configure() {
  printf 'Reconfiguring Media Studio settings. Existing components, data, and bind IPs are preserved.\n'
  exec "$ROOT_DIR/install.sh" --media-reconfigure
}

media_menu() {
  local choice
  while true; do
    printf '\nMedia Studio Manager\n'
    printf '%s\n' '====================='
    printf '%s\n' '1) Show Media Studio status'
    printf '%s\n' '2) Print setup and API guide'
    printf '%s\n' '3) Follow Media Studio logs'
    printf '%s\n' '4) Check API health'
    printf '%s\n' '5) Reconfigure Media Studio settings'
    printf '%s\n' '0) Back'
    read -r -p 'Choose: ' choice
    case "$choice" in
      1) media_status || true ;;
      2) media_guide ;;
      3) compose logs -f --tail=100 media-studio ;;
      4)
        if [[ -z "$(compose ps -q media-studio)" ]]; then
          printf 'Media Studio container is not running. Start it with ./manage.sh start.\n'
        else
          compose exec -T media-studio python -c             'import json, urllib.request; print(json.load(urllib.request.urlopen("http://127.0.0.1:8850/healthz", timeout=5)))'             || printf 'Media Studio API did not answer on :8850.\n'
        fi
        ;;
      5) media_configure ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

# ---------------------------------------------------------- NotebookLM video

notebooklm_enabled() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,notebooklm,* ]]
}

notebooklm_add_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,notebooklm,* ]]; then
    return 0
  fi
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}notebooklm"
  printf 'Enabled the "notebooklm" compose profile.\n'
}

notebooklm_remove_profile() {
  local profiles entry filtered=""
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  local entries=()
  IFS=',' read -r -a entries <<< "$profiles"
  for entry in "${entries[@]}"; do
    [[ -n "$entry" && "$entry" != notebooklm ]] || continue
    filtered="${filtered:+$filtered,}$entry"
  done
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$filtered"
  printf 'Disabled the "notebooklm" compose profile.\n'
}

notebooklm_port() {
  local port
  port="$(env_value "$ENV_FILE" NOTEBOOKLM_PORT)"
  printf '%s' "${port:-8860}"
}

notebooklm_token() {
  local token
  token="$(env_value "$ENV_FILE" NOTEBOOKLM_API_TOKEN)"
  printf '%s' "$token"
}

notebooklm_ensure_token() {
  # The bot and the worker must present the same bearer token; generate one
  # when the operator has not stored one yet.
  local token
  token="$(notebooklm_token)"
  if [[ -z "$token" ]]; then
    token="$(random_hex 24)"
    replace_env_value "$ENV_FILE" NOTEBOOKLM_API_TOKEN "$token"
    printf 'Generated the shared NotebookLM API token.\n'
  fi
  replace_env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_TOKEN "$token"
  printf '%s' "$token"
}

notebooklm_ensure_data_dir() {
  # The image keeps its own unprivileged identity (10006), so a root-enabled
  # stack owns the bind mount by that uid while an unprivileged operator maps
  # the container to the invoking user. Either way the container can write its
  # job store, uploads, and browser profile.
  local run_as uid gid
  run_as="$(env_value "$ENV_FILE" NOTEBOOKLM_RUN_AS)"
  if [[ -z "$run_as" ]]; then
    if [[ "$(id -u)" == 0 ]]; then
      run_as=10006:10006
    else
      run_as="$(id -u):$(id -g)"
    fi
    replace_env_value "$ENV_FILE" NOTEBOOKLM_RUN_AS "$run_as"
    printf 'Stored NOTEBOOKLM_RUN_AS=%s.\n' "$run_as"
  fi
  uid="${run_as%%:*}"
  gid="${run_as##*:}"
  install -d -m 0700 "$ROOT_DIR/data/notebooklm-worker"
  if [[ "$(id -u)" == 0 ]]; then
    chown -R "$uid:$gid" "$ROOT_DIR/data/notebooklm-worker"
  fi
}

notebooklm_status() {
  local mode url token profile session_default bot_url bot_enabled worker_state cdp_url
  token="$(notebooklm_token)"
  mode="$(env_value "$ENV_FILE" NOTEBOOKLM_SESSION_MODE)"
  profile="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_BROWSER_PROFILE)"
  session_default="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_DEFAULT_PROFILE)"
  bot_enabled="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_ENABLED)"
  url="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_URL)"
  [[ -n "$url" ]] || url="http://notebooklm-worker:$(notebooklm_port)"
  bot_url="$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_URL)"
  [[ -n "$bot_url" ]] || bot_url="http://notebooklm-worker:$(notebooklm_port)"
  printf 'NotebookLM video provider\n'
  if notebooklm_enabled; then
    printf '  Compose profile: enabled (profile "notebooklm")\n'
  else
    printf '  Compose profile: not enabled; run ./manage.sh notebooklm-enable to start it\n'
  fi
  printf '  Worker URL: %s\n' "$bot_url"
  printf '  Session mode: %s\n' "${mode:-persistent}"
  case "${mode:-persistent}" in
    cdp)
      cdp_url="$(env_value "$ENV_FILE" NOTEBOOKLM_CDP_URL)"
      [[ -n "$cdp_url" ]] || cdp_url=http://host.docker.internal:9222
      printf '  Session source: %s (a signed-in Chrome on the Docker host)\n' "$cdp_url"
      ;;
    *)
      printf '  Browser profile: %s\n' "${profile:-/data/notebooklm-browser-profile}"
      ;;
  esac
  printf '  API token: %s\n' "$([[ -n "$token" ]] && printf 'stored (secret not shown)' || printf 'not set; run ./manage.sh notebooklm-enable')"
  printf '  Default profile: %s\n' "${session_default:-technical_fa}"
  if [[ "$bot_enabled" == false ]]; then
    printf '  Bot button: disabled (CONTENT_NOTEBOOKLM_ENABLED=false)\n'
  else
    printf '  Bot button: offered on the media question while the worker answers\n'
  fi
  worker_state="$("${DOCKER[@]}" inspect -f '{{.State.Running}}' notebooklm-worker 2>/dev/null || true)"
  if [[ "$worker_state" == true ]]; then
    printf '  Container: running\n'
    printf '  Session probe: run ./manage.sh notebooklm-login 15 to verify the sign-in\n'
  else
    printf '  Container: not running\n'
  fi
  printf '  Data: data/notebooklm-worker (jobs, videos, failure screenshots)\n'
  printf '  Guide: docs/NOTEBOOKLM-STUDIO.md\n'
}

notebooklm_guide() {
  printf '%s\n' 'NotebookLM video guide: docs/NOTEBOOKLM-STUDIO.md'
  printf '%s\n' 'Sign-in check: ./manage.sh notebooklm-login 15'
  printf '%s\n' 'Enable the worker: ./manage.sh notebooklm-enable'
  printf '%s\n' 'Content Bot side: ./manage.sh content-status'
}

notebooklm_enable() {
  notebooklm_add_profile >/dev/null
  notebooklm_ensure_token >/dev/null
  notebooklm_ensure_data_dir
  if [[ -z "$(env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_URL)" ]]; then
    replace_env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_URL "http://notebooklm-worker:$(notebooklm_port)"
  fi
  replace_env_value "$ENV_FILE" CONTENT_NOTEBOOKLM_ENABLED true
  printf 'Starting the NotebookLM worker.\n'
  compose up -d --no-deps notebooklm-worker
  if [[ -n "$(compose ps -q content-bot 2>/dev/null)" ]]; then
    printf 'Restarting content-bot so it reads the NotebookLM URL and token.\n'
    compose up -d --no-deps content-bot
  fi
  printf 'NotebookLM worker is enabled. Next steps:\n'
  printf '  1) Sign in to https://notebooklm.google.com in the browser the worker uses.\n'
  printf '  2) Verify it with ./manage.sh notebooklm-login 15\n'
  printf '  3) The bot offers "NotebookLM video" on the next draft; docs/NOTEBOOKLM-STUDIO.md\n'
  printf '     covers profiles, durations and failure screenshots.\n'
}

notebooklm_disable() {
  "${DOCKER[@]}" rm -sf notebooklm-worker >/dev/null 2>&1 || true
  notebooklm_remove_profile
  printf 'NotebookLM worker stopped. The bot hides the button after the next restart:\n'
  printf '  ./manage.sh restart content-bot\n'
}

notebooklm_login() {
  # Opens NotebookLM in the browser the worker drives and reports the sign-in
  # state; nothing is stored, the operator signs in by hand.
  local seconds="${1:-600}" mode
  if ! notebooklm_enabled; then
    printf 'NotebookLM is not enabled here. Run ./manage.sh notebooklm-enable first.\n' >&2
    return 1
  fi
  if [[ -z "$("${DOCKER[@]}" ps -q notebooklm-worker 2>/dev/null)" ]]; then
    printf 'The notebooklm-worker container is not running. Start it with ./manage.sh start.\n' >&2
    return 1
  fi
  mode="$(env_value "$ENV_FILE" NOTEBOOKLM_SESSION_MODE)"
  printf 'Waiting up to %s seconds for a signed-in NotebookLM session.\n' "$seconds"
  printf 'Sign in to https://notebooklm.google.com in the Chrome the worker drives;\n'
  printf 'this command only reports the state and never stores a password.\n'
  if [[ "${mode:-persistent}" == persistent ]]; then
    printf 'Persistent mode runs the browser inside the container, so it needs a display there;\n'
    printf 'see docs/NOTEBOOKLM-STUDIO.md for that session mode.\n'
    compose run --rm -e NOTEBOOKLM_HEADLESS=false notebooklm-worker \
      python -m app login "$seconds"
  else
    compose exec -T notebooklm-worker python -m app login "$seconds"
  fi
}

notebooklm_login_google() {
  # Automated Google sign-in with credentials from .env
  if ! notebooklm_enabled; then
    printf 'NotebookLM is not enabled here. Run ./manage.sh notebooklm-enable first.\n' >&2
    return 1
  fi
  printf 'Attempting automated Google sign-in with credentials from .env...\n'
  compose run --rm --no-deps notebooklm-worker python -m app login-google
  return $?
}

notebooklm_import_session() {
  # Import a session JSON file into the browser profile
  local session_file="${1:-}"
  if [[ -z "$session_file" ]]; then
    printf 'Usage: ./manage.sh notebooklm-import-session <session.json>\n' >&2
    return 2
  fi
  if [[ ! -f "$session_file" ]]; then
    printf 'File not found: %s\n' "$session_file" >&2
    return 1
  fi
  if ! notebooklm_enabled; then
    printf 'NotebookLM is not enabled here. Run ./manage.sh notebooklm-enable first.\n' >&2
    return 1
  fi
  # Copy to data dir so the container can access it
  mkdir -p "$ROOT_DIR/data/notebooklm-worker"
  cp "$session_file" "$ROOT_DIR/data/notebooklm-worker/import-session.json"
  printf 'Importing session from %s...\n' "$session_file"
  compose run --rm --no-deps notebooklm-worker python -m app import-session /data/import-session.json
  return $?
}

notebooklm_menu() {
  local choice
  while true; do
    printf '\nNotebookLM Video Manager\n'
    printf '%s\n' '========================='
    printf '%s\n' '1) Show NotebookLM status'
    printf '%s\n' '2) Sign-in check and guide'
    printf '%s\n' '3) Follow worker logs'
    printf '%s\n' '4) Verify the API health'
    printf '%s\n' '5) Enable the worker and start it'
    printf '%s\n' '6) Disable the worker and stop it'
    printf '%s\n' '0) Back'
    read -r -p 'Choose: ' choice
    case "$choice" in
      1) notebooklm_status || true ;;
      2) notebooklm_login "${NOTEBOOKLM_LOGIN_SECONDS:-600}" || true ;;
      3) compose logs -f --tail=100 notebooklm-worker ;;
      4)
        if [[ -z "$(compose ps -q notebooklm-worker)" ]]; then
          printf 'The NotebookLM worker is not running. Start it with ./manage.sh start.\n'
        else
          compose exec -T notebooklm-worker python -c \
            'import json, urllib.request; print(json.load(urllib.request.urlopen("http://127.0.0.1:8860/healthz", timeout=5)))' \
            || printf 'The NotebookLM worker API did not answer on :8860.\n'
        fi
        ;;
      5) notebooklm_enable ;;
      6) notebooklm_disable ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

# ------------------------------------------------------- Instagram media host

IG_MEDIA_LOG="$ROOT_DIR/data/content-bot/tunnel/trycloudflared.log"
IG_MEDIA_PIN="$ROOT_DIR/data/content-bot/media-base-url.txt"

ig_media_enabled() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,ig-media,* ]]
}

ig_media_tunnel_url() {
  [[ -s "$IG_MEDIA_LOG" ]] || return 0
  grep -o 'https://[a-z0-9][a-z0-9-]*\.trycloudflare\.com' "$IG_MEDIA_LOG" 2>/dev/null | tail -n1
}

ig_media_public_url() {
  local pinned stable tunnel
  pinned="$(grep -m1 -o 'https://[^[:space:]]*' "$IG_MEDIA_PIN" 2>/dev/null || true)"
  stable="$(env_value "$ENV_FILE" INSTAGRAM_MEDIA_PUBLIC_BASE_URL)"
  case "$stable" in
    *trycloudflare.com*) stable="" ;;
  esac
  [[ "$stable" == https://* ]] || stable=""
  tunnel="$(ig_media_tunnel_url || true)"
  printf '%s\n' "${pinned:-${stable:-$tunnel}}"
}

ig_media_add_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,ig-media,* ]] && return 0
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}ig-media"
  printf 'Enabled the "ig-media" compose profile.\n'
}

ig_media_add_quick_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,ig-media-quick,* ]] && return 0
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}ig-media-quick"
  printf 'Enabled the "ig-media-quick" compose profile.\n'
}

ig_media_add_named_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,ig-media-named,* ]] && return 0
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}ig-media-named"
  printf 'Enabled the "ig-media-named" compose profile.\n'
}

ig_media_remove_profile() {
  local profiles entry filtered=""
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  local entries=()
  IFS=',' read -r -a entries <<< "$profiles"
  for entry in "${entries[@]}"; do
    [[ -n "$entry" && "$entry" != ig-media && "$entry" != ig-media-named \
      && "$entry" != ig-media-quick ]] || continue
    filtered="${filtered:+$filtered,}$entry"
  done
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$filtered"
  printf 'Disabled the "ig-media", "ig-media-quick", and "ig-media-named" compose profiles.\n'
}

ig_media_remove_tunnel_profiles() {
  local profiles entry filtered=""
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  local entries=()
  IFS=',' read -r -a entries <<< "$profiles"
  for entry in "${entries[@]}"; do
    [[ -n "$entry" && "$entry" != ig-media-named && "$entry" != ig-media-quick ]] || continue
    filtered="${filtered:+$filtered,}$entry"
  done
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$filtered"
}

ig_media_named() {
  [[ -n "$(env_value "$ENV_FILE" IG_MEDIA_TUNNEL_TOKEN)" ]]
}

ig_media_status() {
  local bind port url pinned legacy
  bind="$(env_value "$ENV_FILE" IG_MEDIA_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" IG_MEDIA_PORT)"; port="${port:-8099}"
  url="$(ig_media_public_url)"
  legacy="$(pgrep -f "http\.server.*--directory .*ig-medi[a]" 2>/dev/null | head -n1 || true)"
  printf 'Instagram media host\n'
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if ig_media_enabled; then
    if [[ ",$profiles," == *,ig-media-named,* ]]; then
      printf '  Profile "ig-media": enabled (named tunnel)\n'
    elif [[ ",$profiles," == *,ig-media-quick,* ]]; then
      printf '  Profile "ig-media": enabled (quick tunnel)\n'
    else
      printf '  Profile "ig-media": enabled (nginx only; reached through the proxy that fronts %s:%s)\n' \
        "$bind" "$port"
    fi
  else
    printf '  Profile "ig-media": not enabled; run ./manage.sh instagram-media-enable\n'
  fi
  printf '  Local address: %s:%s (serves data/content-bot/media read-only)\n' "$bind" "$port"
  printf '  Pinned URL file: %s\n' \
    "$([[ -s "$IG_MEDIA_PIN" ]] && printf 'data/content-bot/media-base-url.txt' || printf 'not set')"
  printf '  Public base URL: %s\n' "${url:-not set (Instagram publishing needs one)}"
  if [[ -n "$(env_value "$ENV_FILE" INSTAGRAM_MEDIA_PUBLIC_BASE_URL)" ]]; then
    printf '  Note: INSTAGRAM_MEDIA_PUBLIC_BASE_URL is set in .env; a quick tunnel value there is ignored\n'
  fi
  if [[ -n "$legacy" ]]; then
    printf '  WARNING: a manually started media host is still running (pid %s); stop it before enabling the profile\n' "$legacy"
  fi
  if ig_media_enabled; then
    compose --profile ig-media --profile ig-media-quick --profile ig-media-named ps \
      ig-media ig-media-tunnel ig-media-named-tunnel 2>/dev/null || true
  fi
  printf '  Guide: docs/INSTAGRAM-SETUP.md\n'
}

ig_media_enable() {
  local mode="auto" port legacy url waited=0 bind
  case "${1:-}" in
    --nginx-only) mode="nginx" ;;
    --named) mode="named" ;;
    --quick) mode="quick" ;;
    "") ;;
    *) printf 'Usage: ./manage.sh instagram-media-enable [--nginx-only|--named|--quick]\n' >&2; return 2 ;;
  esac
  port="$(env_value "$ENV_FILE" IG_MEDIA_PORT)"; port="${port:-8099}"
  bind="$(env_value "$ENV_FILE" IG_MEDIA_BIND_IP)"; bind="${bind:-127.0.0.1}"
  legacy="$(pgrep -f "http\.server.*--directory .*ig-medi[a]" 2>/dev/null | head -n1 || true)"
  if [[ -n "$legacy" ]]; then
    printf 'A manually started media host is running (pid %s).\n' "$legacy"
    printf 'Stop it first, for example: pkill -f "http.server.*--directory .*ig-media"\n'
    printf 'and stop the cloudflared process that points at 127.0.0.1:%s.\n' "$port"
    return 1
  fi
  install -d -m 0755 "$ROOT_DIR/data/content-bot/tunnel"
  if [[ "$mode" == nginx ]]; then
    ig_media_add_profile
    compose --profile ig-media up -d ig-media
    printf '\nMedia host started without a tunnel; it serves data/content-bot/media on %s:%s.\n' "$bind" "$port"
    printf 'Point the reverse proxy at http://%s:%s and set INSTAGRAM_MEDIA_PUBLIC_BASE_URL\n' "$bind" "$port"
    printf '(or data/content-bot/media-base-url.txt) to the public base it answers on.\n'
    return 0
  fi
  if [[ "$mode" == named ]] && ! ig_media_named; then
    printf 'IG_MEDIA_TUNNEL_TOKEN is empty; add the named tunnel token first or use --quick.\n' >&2
    return 1
  fi
  if ig_media_named && [[ "$mode" != quick ]]; then
    ig_media_add_profile
    ig_media_add_named_profile
    compose --profile ig-media --profile ig-media-named up -d ig-media ig-media-named-tunnel
    printf '\nNamed tunnel started; it keeps the hostname configured in Cloudflare.\n'
    printf 'Point INSTAGRAM_MEDIA_PUBLIC_BASE_URL or data/content-bot/media-base-url.txt at it\n'
    printf 'if that is not done yet, then check ./manage.sh instagram-media-status.\n'
    return 0
  fi
  ig_media_add_profile
  ig_media_add_quick_profile
  compose --profile ig-media up -d ig-media
  # A quick tunnel hostname is only trustworthy when it was written after this
  # start, so the log is rotated AND the tunnel is recreated: a running
  # cloudflared keeps appending to the file it already has open.
  if [[ -s "$IG_MEDIA_LOG" ]]; then
    mv "$IG_MEDIA_LOG" "$IG_MEDIA_LOG.previous"
  fi
  compose --profile ig-media --profile ig-media-quick up -d --force-recreate ig-media-tunnel
  printf '\nWaiting for the quick tunnel hostname'
  while (( waited < 60 )); do
    url="$(ig_media_tunnel_url)"
    if [[ -n "$url" ]]; then
      printf '\nInstagram media host is live.\n'
      printf '  Public base URL: %s/media\n' "$url"
      printf '  The Content Bot picks this up automatically; verify with ./manage.sh instagram-media-status\n'
      return 0
    fi
    printf '.'
    sleep 3
    waited=$(( waited + 3 ))
  done
  printf '\nThe tunnel did not report a hostname yet; check ./manage.sh logs ig-media-tunnel\n' >&2
  return 1
}

ig_media_verify() {
  local url file code
  url="$(ig_media_public_url)"
  [[ -n "$url" ]] || { printf 'No public media base URL is configured yet.\n' >&2; return 1; }
  file="$(find "$ROOT_DIR/data/content-bot/media" -maxdepth 1 -type f \
    \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) \
    -printf '%f\n' 2>/dev/null | head -n1)"
  [[ -n "$file" ]] || { printf 'No image under data/content-bot/media to test with.\n' >&2; return 1; }
  printf 'Public media URL under test: %s/media/%s\n' "$url" "$file"
  code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$url/media/$file" || true)"
  printf '  download from this host: %s\n' "${code:-no response}"
  [[ "$code" == 200 ]] || printf '  WARNING: the media URL did not answer 200 from here.\n'
  printf 'Asking the Instagram Graph API to download the same file (no publish):\n'
  python3 - "$ROOT_DIR" "$url/media/$file" <<'VERIFY'
import json, sys, time, urllib.error, urllib.parse, urllib.request
from pathlib import Path

root = Path(sys.argv[1])
media_url = sys.argv[2]
env = {}
for line in (root / ".env").read_text(encoding="utf-8").splitlines():
    if "=" in line and not line.strip().startswith("#"):
        key, value = line.split("=", 1)
        env[key.strip()] = value.strip().strip('"').strip("'")
stored = {}
path = root / "data/content-bot/instagram-token.json"
if path.is_file():
    stored = json.loads(path.read_text(encoding="utf-8"))

token = stored.get("access_token") or env.get("INSTAGRAM_ACCESS_TOKEN", "")
account = env.get("INSTAGRAM_BUSINESS_ID", "")
api = env.get("INSTAGRAM_API_BASE", "https://graph.facebook.com").rstrip("/")
version = env.get("INSTAGRAM_API_VERSION", "v26.0")
if not token or not account:
    print("  skipped: INSTAGRAM_BUSINESS_ID and an access token are required")
    raise SystemExit(1)


def call(url, payload=None):
    data = urllib.parse.urlencode(payload).encode() if payload else None
    request = urllib.request.Request(url, data=data)
    try:
        with urllib.request.urlopen(request, timeout=180) as response:
            return json.loads(response.read().decode())
    except urllib.error.HTTPError as error:
        try:
            return json.loads(error.read().decode())
        except ValueError:
            return {"error": {"message": f"HTTP {error.code}"}}
    except Exception as error:  # noqa: BLE001 - surfaced to the operator
        return {"error": {"message": str(error)}}


created = call(
    f"{api}/{version}/{account}/media",
    {
        "image_url": media_url,
        "caption": "media connectivity check",
        "access_token": token,
    },
)
container = created.get("id")
if not container:
    message = (created.get("error") or {}).get("message", json.dumps(created)[:200])
    print(f"  the Graph API refused the media URL: {message}")
    raise SystemExit(1)

status = "IN_PROGRESS"
for _ in range(12):
    result = call(
        f"{api}/{version}/{container}"
        f"?fields=status_code&access_token={urllib.parse.quote(token)}"
    )
    status = result.get("status_code", status)
    if status != "IN_PROGRESS":
        break
    time.sleep(5)
print(f"  Instagram downloaded the file: {status}")
print("  the container is discarded and was never published")
raise SystemExit(0 if status == "FINISHED" else 1)
VERIFY
}

ig_media_tunnel_off() {
  # "ig-media" must stay on the profile list: the tunnel services depend on it.
  compose --profile ig-media --profile ig-media-quick --profile ig-media-named stop \
    ig-media-tunnel ig-media-named-tunnel >/dev/null 2>&1 || true
  ig_media_remove_tunnel_profiles
  printf 'Tunnel stopped. nginx keeps serving data/content-bot/media on %s:%s for a\n' \
    "$(env_value "$ENV_FILE" IG_MEDIA_BIND_IP | sed 's/^$/127.0.0.1/')" \
    "$(env_value "$ENV_FILE" IG_MEDIA_PORT | sed 's/^$/8099/')"
  printf 'reverse proxy or a named tunnel; run ./manage.sh instagram-media-enable to bring it back.\n'
}

ig_media_disable() {
  compose --profile ig-media --profile ig-media-quick --profile ig-media-named stop \
    ig-media-tunnel ig-media-named-tunnel ig-media >/dev/null 2>&1 || true
  ig_media_remove_profile
  printf 'Public media host stopped. data/content-bot/media is untouched.\n'
}

# ------------------------------------------------------------- operator panel

PANEL_DIR="$ROOT_DIR/data/panel"
PANEL_TOKEN_PATH="$PANEL_DIR/token"

panel_enabled() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,panel,* ]]
}

panel_url() {
  local bind port
  bind="$(env_value "$ENV_FILE" PANEL_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" PANEL_PORT)"; port="${port:-8899}"
  case "$bind" in 0.0.0.0|::|"[::]") bind=127.0.0.1 ;; esac
  printf 'http://%s:%s/\n' "$bind" "$port"
}

panel_token() {
  local token
  install -d -m 0700 "$PANEL_DIR"
  if [[ ! -s "$PANEL_TOKEN_PATH" ]]; then
    ( umask 077; random_hex 32 > "$PANEL_TOKEN_PATH" )
    printf 'Created a new operator token.\n' >&2
  fi
  chmod 600 "$PANEL_TOKEN_PATH" 2>/dev/null || true
  token="$(tr -d '[:space:]' < "$PANEL_TOKEN_PATH")"
  [[ -n "$token" ]] || { printf 'Panel token file is empty: %s\n' "$PANEL_TOKEN_PATH" >&2; return 1; }
  printf '%s\n' "$token"
}

panel_rotate_token() {
  install -d -m 0700 "$PANEL_DIR"
  ( umask 077; random_hex 32 > "$PANEL_TOKEN_PATH" )
  chmod 600 "$PANEL_TOKEN_PATH" 2>/dev/null || true
  compose up -d --no-deps --force-recreate panel >/dev/null 2>&1 || true
  printf 'Operator token rotated. Panel sessions are invalid; sign in again.\n'
}

panel_add_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,panel,* ]]; then
    return 0
  fi
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}panel"
  printf 'Enabled the "panel" compose profile.\n'
}

panel_remove_profile() {
  local profiles entry filtered=""
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  local entries=()
  IFS=',' read -r -a entries <<< "$profiles"
  for entry in "${entries[@]}"; do
    [[ -n "$entry" && "$entry" != panel ]] || continue
    filtered="${filtered:+$filtered,}$entry"
  done
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$filtered"
  printf 'Disabled the "panel" compose profile.\n'
}

panel_status() {
  local actions secure
  if panel_enabled; then
    printf 'Operator panel: enabled (profile "panel")\n'
  else
    printf 'Operator panel: not enabled; run ./manage.sh panel-enable to start it\n'
  fi
  printf '  URL: %s\n' "$(panel_url)"
  printf '  Token: %s\n' "$([[ -s "$PANEL_TOKEN_PATH" ]] && printf 'stored at data/panel/token (secret not shown)' || printf 'not created yet; run ./manage.sh panel-token')"
  actions="$(env_value "$ENV_FILE" PANEL_ACTIONS_ENABLED)"
  printf '  Actions: %s\n' "${actions:-true}"
  secure="$(env_value "$ENV_FILE" PANEL_COOKIE_SECURE)"
  printf '  Secure cookie: %s\n' "${secure:-false}"
  if panel_enabled; then
    compose ps panel 2>/dev/null || true
  fi
  printf '  Bind: loopback only by default; set PANEL_BIND_IP for a trusted network or use a reverse proxy\n'
}

panel_enable() {
  panel_add_profile
  panel_token >/dev/null
  install -d -m 0700 "$PANEL_DIR/backups"
  # The panel mounts the backup directory to list and create archives.
  [[ -d "$ROOT_DIR-backups" ]] || install -d -m 0700 "$ROOT_DIR-backups"
  compose up -d panel
  printf '\nOperator panel: %s\n' "$(panel_url)"
  printf 'Sign in with the token printed by ./manage.sh panel-token\n'
}

panel_disable() {
  compose stop panel >/dev/null 2>&1 || true
  panel_remove_profile
  printf 'Containers stopped. The operator token stays in data/panel/token.\n'
}

panel_build() {
  local repository tag
  repository="$(env_value "$ENV_FILE" PANEL_IMAGE_REPOSITORY)"; repository="${repository:-afsharidevops/content-panel}"
  tag="$(env_value "$ENV_FILE" PANEL_IMAGE_TAG)"; tag="${tag:-0.6.0}"
  "${DOCKER[@]}" build -t "$repository:$tag" -f "$ROOT_DIR/panel/Dockerfile" "$ROOT_DIR"
  printf 'Built %s:%s from panel/Dockerfile\n' "$repository" "$tag"
}

panel_menu() {
  local choice
  while true; do
    printf '\nOperator Panel\n'
    printf '%s\n' '=============='
    printf '%s\n' '1) Enable and start the panel'
    printf '%s\n' '2) Show panel status and URL'
    printf '%s\n' '3) Show the operator token'
    printf '%s\n' '4) Rotate the operator token'
    printf '%s\n' '5) Follow panel logs'
    printf '%s\n' '6) Build the panel image locally'
    printf '%s\n' '7) Disable and stop the panel'
    printf '%s\n' '8) Public routes          Domains, LAN targets and proxy blocks for the console links'
    printf '%s\n' '0) Back'
    read -r -p 'Choose: ' choice
    case "$choice" in
      1) panel_enable; menu_pause ;;
      2) panel_status; menu_pause ;;
      3) panel_token; menu_pause ;;
      4) panel_rotate_token; menu_pause ;;
      5) compose logs -f --tail=100 panel ;;
      6) panel_build; menu_pause ;;
      7) panel_disable; menu_pause ;;
      8) domains; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

# --- Shared S3-compatible object storage -------------------------------------
# One endpoint backs every service that understands object storage: the
# bundled RustFS server (profile "rustfs") or any external S3-compatible
# provider. docs/S3-STORAGE.md documents the consumer matrix, the public
# domain route, and the external-provider checklist.

s3_data_dir() { printf '%s/data/rustfs' "$ROOT_DIR"; }

s3_backend() {
  local backend
  backend="$(env_value "$ENV_FILE" S3_STORAGE_BACKEND)"
  printf '%s' "${backend:-off}"
}

s3_rustfs_enabled() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  [[ ",$profiles," == *,rustfs,* ]]
}

s3_rustfs_add_profile() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,rustfs,* ]]; then
    return 0
  fi
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "${profiles:+$profiles,}rustfs"
  printf 'Enabled the "rustfs" compose profile.\n'
}

s3_rustfs_remove_profile() {
  local profiles entry filtered="" entries=()
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  IFS=',' read -r -a entries <<< "$profiles"
  for entry in "${entries[@]}"; do
    [[ -n "$entry" && "$entry" != rustfs ]] || continue
    filtered="${filtered:+$filtered,}$entry"
  done
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$filtered"
}

s3_bind_port() {
  local bind port
  bind="$(env_value "$ENV_FILE" RUSTFS_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" RUSTFS_PORT)"; port="${port:-9000}"
  case "$bind" in 0.0.0.0|::|"[::]") bind=127.0.0.1 ;; esac
  printf '%s %s\n' "$bind" "$port"
}

# Address that tools on the Docker host use. Inside the stack network the
# services reach the bundled server as http://rustfs:9000, which never
# resolves from the host, so the operator-facing checks need this value.
s3_host_endpoint() {
  local override endpoint bind port
  # The caller's environment wins so a tool that runs inside the stack network
  # (for example the operator panel) can point the check at the in-network
  # endpoint without editing .env.
  override="${S3_HOST_ENDPOINT_URL:-$(env_value "$ENV_FILE" S3_HOST_ENDPOINT_URL)}"
  if [[ -n "$override" ]]; then
    printf '%s' "${override%/}"
    return 0
  fi
  if [[ "$(s3_backend)" == rustfs ]]; then
    read -r bind port < <(s3_bind_port)
    printf 'http://%s:%s' "$bind" "$port"
    return 0
  fi
  endpoint="$(env_value "$ENV_FILE" S3_ENDPOINT_URL)"
  printf '%s' "${endpoint%/}"
}

s3_container_endpoint() {
  local endpoint
  endpoint="$(env_value "$ENV_FILE" S3_ENDPOINT_URL)"
  if [[ -z "$endpoint" && "$(s3_backend)" == rustfs ]]; then
    endpoint="http://rustfs:9000"
  fi
  printf '%s' "${endpoint%/}"
}

s3_console_url() {
  local bind port prefix
  bind="$(env_value "$ENV_FILE" RUSTFS_CONSOLE_BIND_IP)"; bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" RUSTFS_CONSOLE_PORT)"; port="${port:-9001}"
  prefix="$(env_value "$ENV_FILE" RUSTFS_CONSOLE_PREFIX)"; prefix="${prefix:-/rustfs/console}"
  case "$bind" in 0.0.0.0|::|"[::]") bind=127.0.0.1 ;; esac
  printf 'http://%s:%s%s/' "$bind" "$port" "${prefix%/}"
}

# The RustFS image runs unprivileged, so the bind-mounted directories must be
# writable by RUSTFS_UID/RUSTFS_GID. install.sh maps those to the invoking
# user for unprivileged installs.
s3_prepare_dirs() {
  local uid gid owner dir
  uid="$(env_value "$ENV_FILE" RUSTFS_UID)"; uid="${uid:-10001}"
  gid="$(env_value "$ENV_FILE" RUSTFS_GID)"; gid="${gid:-10001}"
  for dir in "$(s3_data_dir)/data" "$(s3_data_dir)/logs"; do
    install -d -m 0700 "$dir"
    owner="$(stat -c '%u:%g' "$dir")"
    if [[ "$owner" != "$uid:$gid" ]]; then
      if ! chown "$uid:$gid" "$dir" 2>/dev/null; then
        printf 'WARNING: %s is owned by %s but RustFS runs as %s. Run: sudo chown %s:%s %s\n' \
          "${dir#$ROOT_DIR/}" "$owner" "$uid:$gid" "$uid" "$gid" "${dir#$ROOT_DIR/}" >&2
      fi
    fi
  done
}

s3_recreate_consumers() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,open-webui,* ]]; then
    compose up -d --no-deps --force-recreate open-webui >/dev/null
    printf 'Recreated Open WebUI with the updated storage settings.\n'
  fi
}

s3_wait_healthy() {
  local endpoint attempt
  endpoint="$(s3_host_endpoint)"
  for attempt in $(seq 1 30); do
    if curl -fsS --connect-timeout 2 --max-time 4 "$endpoint/health" >/dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}

s3_curl_supported() {
  curl --help all 2>/dev/null | grep -q -- '--aws-sigv4'
}

# Signed S3 request. Prints "HTTP_CODE" on stdout and the response body on
# stderr so callers can decide on retries without leaking the secret.
s3_signed_request() {
  local method="$1" url="$2" key="$3" secret="$4" region="$5" body="${6:-}"
  local args=(-sS -o /dev/stdout -w '%{http_code}' -X "$method"
    --aws-sigv4 "aws:amz:${region}:s3" --user "${key}:${secret}")
  if [[ -n "$body" ]]; then
    printf '%s' "$body" | curl "${args[@]}" --data-binary @- "$url"
  else
    curl "${args[@]}" "$url"
  fi
}

s3_ensure_bucket() {
  local endpoint bucket key secret region response code
  endpoint="$(s3_host_endpoint)"
  bucket="$(env_value "$ENV_FILE" S3_BUCKET)"; bucket="${bucket:-locallab}"
  key="$(env_value "$ENV_FILE" S3_ACCESS_KEY_ID)"
  secret="$(env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY)"
  region="$(env_value "$ENV_FILE" S3_REGION)"; region="${region:-us-east-1}"
  [[ -n "$endpoint" && -n "$key" && -n "$secret" ]] || return 1
  response="$(s3_signed_request GET "$endpoint/" "$key" "$secret" "$region" 2>/dev/null)"
  code="${response: -3}"
  [[ "$code" == 200 ]] || return 1
  if [[ "${response%???}" == *"<Name>${bucket}</Name>"* ]]; then
    printf 'Bucket "%s" already exists.\n' "$bucket"
    return 0
  fi
  response="$(s3_signed_request PUT "$endpoint/${bucket}" "$key" "$secret" "$region" 2>/dev/null)"
  code="${response: -3}"
  if [[ "$code" == 200 ]]; then
    printf 'Created bucket "%s".\n' "$bucket"
    return 0
  fi
  printf 'Could not create bucket "%s" (HTTP %s).\n' "$bucket" "$code" >&2
  return 1
}

s3_status() {
  local backend bucket region key secret profiles consumer public_base
  backend="$(s3_backend)"
  bucket="$(env_value "$ENV_FILE" S3_BUCKET)"; bucket="${bucket:-locallab}"
  region="$(env_value "$ENV_FILE" S3_REGION)"; region="${region:-us-east-1}"
  key="$(env_value "$ENV_FILE" S3_ACCESS_KEY_ID)"
  secret="$(env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY)"
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  printf 'Shared object storage\n'
  printf '  Backend:            %s\n' "$backend"
  printf '  Endpoint (stack):   %s\n' "$(s3_container_endpoint)"
  printf '  Endpoint (host):    %s\n' "$(s3_host_endpoint)"
  printf '  Bucket / region:    %s / %s\n' "$bucket" "$region"
  printf '  Credentials:        %s\n' \
    "$([[ -n "$key" && -n "$secret" ]] && printf 'configured (S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY)' || printf 'not configured; run ./manage.sh s3-enable')"
  public_base="$(env_value "$ENV_FILE" S3_PUBLIC_BASE_URL)"
  printf '  Public API URL:     %s\n' "${public_base:--}"
  if [[ "$backend" == rustfs ]]; then
    printf '  RustFS profile:     %s\n' "$(s3_rustfs_enabled && printf 'enabled' || printf 'missing; run ./manage.sh s3-enable --rustfs')"
    printf '  RustFS bind:        %s\n' "$(s3_bind_port | tr ' ' ':')"
    printf '  Console:            %s\n' "$(s3_console_url)"
    printf '  Data directory:     %s\n' "$(s3_data_dir | sed "s|^$ROOT_DIR/||")"
    if s3_rustfs_enabled; then
      compose ps rustfs 2>/dev/null || true
    fi
  fi
  printf '  Consumers:\n'
  if [[ ",$profiles," == *,open-webui,* ]]; then
    consumer="$(env_value "$ENV_FILE" OPENWEBUI_STORAGE_PROVIDER)"; consumer="${consumer:-local}"
    printf '    open-webui        %s\n' "$([[ "$consumer" == s3 ]] && printf 's3 (uploads and generated files in the bucket)' || printf 'local disk (set storage to s3 with ./manage.sh s3-enable)')"
  else
    printf '    open-webui        not installed\n'
  fi
  printf '    content-bot       local disk (no S3 driver yet; documented in docs/S3-STORAGE.md)\n'
  printf '    media-studio      local disk (no S3 driver yet; documented in docs/S3-STORAGE.md)\n'
  printf '    n8n               external binary storage requires n8n Enterprise; n8n still stores workflows locally\n'
  printf '    hermes-agent      no object-storage integration\n'
  printf '  Verify:             ./manage.sh s3-verify\n'
}

s3_prompt_value() {
  local label="$1" key="$2" secret_input="${3:-false}" value
  if [[ ! -t 0 ]]; then
    printf 'Set %s in .env first, or run this command from an interactive terminal.\n' "$key" >&2
    return 1
  fi
  if [[ "$secret_input" == true ]]; then
    read -r -s -p "$label: " value
    printf '\n'
  else
    read -r -p "$label: " value
  fi
  [[ -n "$value" ]] || { printf '%s must not be empty.\n' "$key" >&2; return 1; }
  replace_env_value "$ENV_FILE" "$key" "$value"
  printf 'Stored %s in .env.\n' "$key" >&2
}

s3_enable() {
  local mode="" bind_ip="" arg endpoint key secret bucket region bind port rustfs_uid rustfs_gid rustfs_data_owner
  while (($#)); do
    arg="$1"; shift
    case "$arg" in
      --rustfs|rustfs) mode=rustfs ;;
      --external|external) mode=external ;;
      --bind-ip)
        (($#)) || { printf 'Usage: ./manage.sh s3-enable [--rustfs|--external] [--bind-ip IP]\n' >&2; return 2; }
        bind_ip="$1"; shift ;;
      *) printf 'Usage: ./manage.sh s3-enable [--rustfs|--external] [--bind-ip IP]\n' >&2; return 2 ;;
    esac
  done
  [[ -n "$mode" ]] || mode="$(s3_backend)"
  [[ "$mode" == rustfs || "$mode" == external ]] || mode=rustfs

  replace_env_value "$ENV_FILE" S3_FORCE_PATH_STYLE true

  if [[ "$mode" == rustfs ]]; then
    if [[ -n "$bind_ip" ]]; then
      replace_env_value "$ENV_FILE" RUSTFS_BIND_IP "$bind_ip"
    fi
    # The container identity must match the owner of data/rustfs. Root-managed
    # stacks keep the image identity (10001:10001); an unprivileged operator
    # cannot chown the bind mounts, so the container is pinned to that operator
    # instead of the image default.
    configured_rustfs_uid="$(env_value "$ENV_FILE" RUSTFS_UID)"
    rustfs_uid="$configured_rustfs_uid"
    rustfs_gid="$(env_value "$ENV_FILE" RUSTFS_GID)"
    if [[ "$(id -u)" != 0 && ( -z "$rustfs_uid" || "$rustfs_uid" == 10001 ) ]]; then
      # Only take over the image default when the tree is missing or already
      # owned by the caller; a root-managed tree keeps its configured identity.
      rustfs_data_owner="$(stat -c '%u' "$(s3_data_dir)/data" 2>/dev/null || true)"
      if [[ -z "$rustfs_data_owner" || "$rustfs_data_owner" == "$(id -u)" ]]; then
        rustfs_uid="$(id -u)"
        rustfs_gid="$(id -g)"
      fi
    fi
    rustfs_uid="${rustfs_uid:-10001}"
    rustfs_gid="${rustfs_gid:-10001}"
    if [[ "$rustfs_uid" != "$configured_rustfs_uid" ]]; then
      replace_env_value "$ENV_FILE" RUSTFS_UID "$rustfs_uid"
      replace_env_value "$ENV_FILE" RUSTFS_GID "$rustfs_gid"
      printf 'Pinned RUSTFS_UID/RUSTFS_GID to %s:%s in .env.\n' "$rustfs_uid" "$rustfs_gid"
    fi
    key="$(env_value "$ENV_FILE" RUSTFS_ACCESS_KEY)"
    case "$key" in
      ""|CHANGE_ME)
        key="locallab-$(random_hex 6)"
        replace_env_value "$ENV_FILE" RUSTFS_ACCESS_KEY "$key"
        ;;
    esac
    secret="$(env_value "$ENV_FILE" RUSTFS_SECRET_KEY)"
    case "$secret" in
      ""|CHANGE_ME)
        secret="$(random_hex 16)"
        replace_env_value "$ENV_FILE" RUSTFS_SECRET_KEY "$secret"
        ;;
    esac
    bucket="$(env_value "$ENV_FILE" S3_BUCKET)"; bucket="${bucket:-locallab}"
    region="$(env_value "$ENV_FILE" RUSTFS_REGION)"; region="${region:-us-east-1}"
    read -r bind port < <(s3_bind_port)
    replace_env_value "$ENV_FILE" S3_STORAGE_BACKEND rustfs
    replace_env_value "$ENV_FILE" S3_ENDPOINT_URL "http://rustfs:9000"
    replace_env_value "$ENV_FILE" S3_ACCESS_KEY_ID "$key"
    replace_env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY "$secret"
    replace_env_value "$ENV_FILE" S3_BUCKET "$bucket"
    replace_env_value "$ENV_FILE" S3_REGION "$region"
    replace_env_value "$ENV_FILE" S3_HOST_ENDPOINT_URL "http://$bind:$port"
    replace_env_value "$ENV_FILE" OPENWEBUI_STORAGE_PROVIDER s3
    s3_rustfs_add_profile
    s3_prepare_dirs
    compose up -d --no-deps rustfs
    if s3_wait_healthy; then
      s3_ensure_bucket || printf 'WARNING: the bucket could not be created yet; rerun ./manage.sh s3-verify --create-bucket.\n' >&2
    else
      printf 'WARNING: the RustFS API did not answer yet; check ./manage.sh logs rustfs.\n' >&2
    fi
    s3_recreate_consumers
    printf '\nObject storage: RustFS (profile "rustfs")\n'
    printf '  S3 API:  %s\n' "$(s3_host_endpoint)"
    printf '  Console: %s\n' "$(s3_console_url)"
    printf '  Bucket:  %s (region %s)\n' "$bucket" "$region"
    printf '  Credentials stay in .env; print them with ./manage.sh s3-keys --show-secrets\n'
    printf '  Publish it with domain: ./manage.sh s3-guide\n'
    return 0
  fi

  # Values that belong to the bundled server never count as external ones.
  endpoint="$(env_value "$ENV_FILE" S3_ENDPOINT_URL)"
  case "$endpoint" in "http://rustfs:9000"|"http://rustfs:9000/") endpoint="" ;; esac
  [[ -n "$endpoint" ]] || s3_prompt_value 'S3 endpoint URL (for example https://s3.eu-central-1.amazonaws.com)' S3_ENDPOINT_URL
  key="$(env_value "$ENV_FILE" S3_ACCESS_KEY_ID)"
  case "$key" in
    ""|CHANGE_ME|"$(env_value "$ENV_FILE" RUSTFS_ACCESS_KEY)") s3_prompt_value 'S3 access key id' S3_ACCESS_KEY_ID ;;
  esac
  secret="$(env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY)"
  case "$secret" in
    ""|CHANGE_ME|"$(env_value "$ENV_FILE" RUSTFS_SECRET_KEY)") s3_prompt_value 'S3 secret access key' S3_SECRET_ACCESS_KEY true ;;
  esac
  bucket="$(env_value "$ENV_FILE" S3_BUCKET)"; [[ -n "$bucket" ]] || s3_prompt_value 'S3 bucket name' S3_BUCKET
  region="$(env_value "$ENV_FILE" S3_REGION)"; [[ -n "$region" ]] || s3_prompt_value 'S3 region' S3_REGION
  replace_env_value "$ENV_FILE" S3_STORAGE_BACKEND external
  replace_env_value "$ENV_FILE" S3_HOST_ENDPOINT_URL ""
  replace_env_value "$ENV_FILE" OPENWEBUI_STORAGE_PROVIDER s3
  s3_rustfs_remove_profile
  compose --profile rustfs rm -sf rustfs >/dev/null 2>&1 || true
  s3_recreate_consumers
  printf '\nObject storage: external S3 endpoint\n'
  printf '  Bucket: %s (region %s)\n' "$(env_value "$ENV_FILE" S3_BUCKET)" "$(env_value "$ENV_FILE" S3_REGION)"
  printf '  Verify the endpoint and credentials with ./manage.sh s3-verify\n'
}

s3_disable() {
  local running
  replace_env_value "$ENV_FILE" S3_STORAGE_BACKEND off
  replace_env_value "$ENV_FILE" OPENWEBUI_STORAGE_PROVIDER local
  running="$(compose --profile rustfs ps -q rustfs 2>/dev/null || true)"
  if [[ -n "$running" ]]; then
    compose --profile rustfs rm -sf rustfs >/dev/null 2>&1 || true
    printf 'Stopped the bundled RustFS container; its data stays in %s.\n' "$(s3_data_dir | sed "s|^$ROOT_DIR/||")"
  fi
  s3_rustfs_remove_profile
  s3_recreate_consumers
  printf 'Object storage disabled; services fall back to their local storage.\n'
}

s3_verify() {
  local create_bucket=false arg endpoint bucket key secret region response code
  while (($#)); do
    arg="$1"; shift
    case "$arg" in
      --create-bucket) create_bucket=true ;;
      *) printf 'Usage: ./manage.sh s3-verify [--create-bucket]\n' >&2; return 2 ;;
    esac
  done
  [[ "$(s3_backend)" != off ]] || {
    printf 'Object storage is off. Run ./manage.sh s3-enable --rustfs (or --external) first.\n' >&2
    return 1
  }
  s3_curl_supported || {
    printf 'This curl lacks --aws-sigv4 support, so signed S3 requests cannot be made here.\n' >&2
    printf 'Run the check from a host with curl 7.75+ or use the provider console.\n' >&2
    return 1
  }
  endpoint="$(s3_host_endpoint)"
  bucket="$(env_value "$ENV_FILE" S3_BUCKET)"; bucket="${bucket:-locallab}"
  key="$(env_value "$ENV_FILE" S3_ACCESS_KEY_ID)"
  secret="$(env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY)"
  region="$(env_value "$ENV_FILE" S3_REGION)"; region="${region:-us-east-1}"
  [[ -n "$endpoint" ]] || { printf 'No S3 endpoint is configured.\n' >&2; return 1; }
  [[ -n "$key" && -n "$secret" ]] || { printf 'S3 credentials are missing; run ./manage.sh s3-enable.\n' >&2; return 1; }

  printf 'Endpoint: %s\n' "$endpoint"
  response="$(s3_signed_request GET "$endpoint/" "$key" "$secret" "$region" 2>/dev/null || true)"
  code="${response: -3}"
  if [[ "$code" != 200 ]]; then
    printf 'FAIL: signed request was rejected (HTTP %s). Check the endpoint, region, and credentials.\n' "$code" >&2
    return 1
  fi
  printf 'OK: signed request accepted; the credentials are valid.\n'
  if [[ "${response%???}" == *"<Name>${bucket}</Name>"* ]]; then
    printf 'OK: bucket "%s" exists.\n' "$bucket"
  elif [[ "$create_bucket" == true ]]; then
    response="$(s3_signed_request PUT "$endpoint/${bucket}" "$key" "$secret" "$region" 2>/dev/null || true)"
    code="${response: -3}"
    if [[ "$code" == 200 ]]; then
      printf 'OK: created bucket "%s".\n' "$bucket"
    else
      printf 'FAIL: could not create bucket "%s" (HTTP %s).\n' "$bucket" "$code" >&2
      return 1
    fi
  else
    printf 'Bucket "%s" is missing; rerun with --create-bucket to create it.\n' "$bucket" >&2
    return 1
  fi
  printf 'Object storage verification passed.\n'
}

s3_keys() {
  local key secret
  [[ "$(s3_backend)" == rustfs ]] || {
    printf 'These credentials belong to the bundled RustFS server; the active backend is "%s".\n' "$(s3_backend)" >&2
    return 1
  }
  key="$(env_value "$ENV_FILE" RUSTFS_ACCESS_KEY)"
  secret="$(env_value "$ENV_FILE" RUSTFS_SECRET_KEY)"
  printf 'RustFS access key id: %s\n' "${key:-<not set; run ./manage.sh s3-enable --rustfs>}"
  case "${1:-}" in
    --show-secrets)
      [[ -r /dev/tty && -w /dev/tty ]] || { printf 'A controlling terminal is required to reveal secrets.\n' >&2; return 1; }
      read -r -p 'Reveal the object-storage secret on this terminal? [y/N]: ' answer </dev/tty
      [[ "$answer" =~ ^[Yy]$ ]] || { printf 'Secret not shown.\n'; return 0; }
      printf 'RUSTFS_SECRET_KEY=%s\n' "$secret"
      printf 'S3_ACCESS_KEY_ID=%s\n' "$key"
      printf 'S3_SECRET_ACCESS_KEY=%s\n' "$secret"
      ;;
    --rotate)
      key="locallab-$(random_hex 6)"
      secret="$(random_hex 16)"
      replace_env_value "$ENV_FILE" RUSTFS_ACCESS_KEY "$key"
      replace_env_value "$ENV_FILE" RUSTFS_SECRET_KEY "$secret"
      replace_env_value "$ENV_FILE" S3_ACCESS_KEY_ID "$key"
      replace_env_value "$ENV_FILE" S3_SECRET_ACCESS_KEY "$secret"
      compose up -d --no-deps --force-recreate rustfs >/dev/null
      if s3_wait_healthy; then
        s3_ensure_bucket || true
      fi
      s3_recreate_consumers
      printf 'Rotated the RustFS credentials; new access key id: %s\n' "$key"
      printf 'Print it with ./manage.sh s3-keys --show-secrets when you need to configure a client.\n'
      ;;
    "")
      printf 'Use --show-secrets to print the credentials or --rotate to replace them.\n'
      ;;
    *) printf 'Usage: ./manage.sh s3-keys [--show-secrets|--rotate]\n' >&2; return 2 ;;
  esac
}

s3_guide() {
  local lan_ip bind base_domain api_host console_host
  lan_ip="$(ip -4 route get 1.1.1.1 2>/dev/null \
    | awk '{ for (i=1; i<=NF; i++) if ($i == "src") { print $(i+1); exit } }')"
  bind="$(s3_bind_port)"
  # Prefer the host names already recorded in .env, then the shared base
  # domain from the installer, and only then the placeholder zone.
  base_domain="$(env_value "$ENV_FILE" STACK_BASE_DOMAIN)"
  [[ "$base_domain" == "CHANGE_ME" ]] && base_domain=""
  base_domain="${base_domain:-stack.example.com}"
  api_host="$(url_host "$(env_value "$ENV_FILE" S3_PUBLIC_BASE_URL)")"
  console_host="$(url_host "$(env_value "$ENV_FILE" S3_PUBLIC_CONSOLE_URL)")"
  api_host="${api_host:-s3.$base_domain}"
  console_host="${console_host:-console.$base_domain}"
  printf 'Object storage guide: docs/S3-STORAGE.md\n'
  printf '\nPublic domain route (ArvanCloud -> router reverse proxy -> this stack):\n'
  printf '  1) DNS: create %s (and %s if you\n' "$api_host" "$console_host"
  printf '     want the console) in ArvanCloud, proxied as usual.\n'
  printf '  2) The stack must listen on the LAN address instead of loopback:\n'
  printf '     ./manage.sh s3-enable --rustfs --bind-ip %s\n' "${lan_ip:-<LAN-IP>}"
  printf '     (console bind: set RUSTFS_CONSOLE_BIND_IP=%s in .env the same way)\n' "${lan_ip:-<LAN-IP>}"
  printf '  3) On the MikroTik Caddy container add:\n'
  printf '     %s {\n         encode zstd gzip\n         reverse_proxy %s:%s\n     }\n' \
    "$api_host" "${lan_ip:-<STACK-LAN-IP>}" "$(printf '%s' "$bind" | awk '{print $2}')"
  printf '     For the console, publish port 9001 under its own host name. Sign-in sends a\n'
  printf '     signed POST to "/" (Action=AssumeRole) on that host, so a blanket\n'
  printf '     redirect on "/" breaks login. Publish it with no redirect at all:\n'
  printf '       %s {\n           encode zstd gzip\n           reverse_proxy %s:9001\n       }\n' \
    "$console_host" "${lan_ip:-<STACK-LAN-IP>}"
  printf '     or keep the friendly root redirect and restrict it to GET:\n'
  printf '       @console_root {\n           method GET\n           path /\n       }\n'
  printf '       redir @console_root /rustfs/console/ 302\n'
  printf '  4) Record the public origin for the stack: S3_PUBLIC_BASE_URL=https://%s in .env\n' "$api_host"
  printf '     For the console add S3_PUBLIC_CONSOLE_URL=https://%s/rustfs/console and\n' "$console_host"
  printf '     RUSTFS_CONSOLE_BIND_IP=%s, then restart rustfs.\n' "${lan_ip:-<LAN-IP>}"
  printf '\nSecurity: keep the console off the public internet unless you accept the risk; the S3 API\n'
  printf 'must always sit behind strong credentials. ./manage.sh s3-keys --rotate replaces them.\n'
  printf 'External provider instead of RustFS: ./manage.sh s3-enable --external\n'
}

# One service in the public-routes checklist: the host name (recorded or
# suggested), the .env key the operator console reads for its links, the LAN
# bind the router proxy must reach, and a ready-to-paste proxy block.
domain_route() {
  local label="$1" key="$2" prefix="$3" record_path="$4" bind_key="$5" port_key="$6" default_port="$7" note="$8" kind="${9-}"
  local recorded host host_status bind port proxy_host
  recorded="$(env_value "$ENV_FILE" "$key")"
  host="$(url_host "$recorded")"
  if [[ -n "$host" ]]; then
    host_status="recorded"
  else
    host="$prefix.$base_domain"
    host_status="suggested"
  fi
  bind="$(env_value "$ENV_FILE" "$bind_key")"
  bind="${bind:-127.0.0.1}"
  port="$(env_value "$ENV_FILE" "$port_key")"
  port="${port:-$default_port}"
  printf '\n%s\n' "$label"
  printf '  Host:   %s (%s)\n' "$host" "$host_status"
  if [[ -n "$recorded" ]]; then
    printf '  Record: %s=%s\n' "$key" "$recorded"
  else
    printf '  Record: %s=https://%s%s\n' "$key" "$host" "$record_path"
  fi
  printf '  Target: %s:%s\n' "$bind" "$port"
  proxy_host="$bind"
  if [[ "$bind" == "127.0.0.1" || "$bind" == "::1" ]]; then
    proxy_host="${lan_ip:-<LAN-IP>}"
    printf '  Bind:   %s is loopback, so the router proxy cannot reach it yet; set %s=%s in .env.\n' \
      "$bind_key" "$bind_key" "$proxy_host"
  fi
  printf '  Caddy:\n'
  printf '    %s {\n        encode zstd gzip\n' "$host"
  if [[ "$kind" == console ]]; then
    printf '        @console_root {\n            method GET\n            path /\n        }\n'
    printf '        redir @console_root /rustfs/console/ 302\n'
  fi
  printf '        reverse_proxy %s:%s\n    }\n' "$proxy_host" "$port"
  [[ -n "$note" ]] && printf '  Note:   %s\n' "$note"
}

# The console links (Overview -> Endpoints) follow the public URLs recorded
# here: a service with a public host links through the domain from anywhere,
# and a service without one falls back to the LAN bind address. Changing a
# value needs no restart for the panel, which reads .env on every request.
domains() {
  local base_domain recorded_base
  lan_ip="$(ip -4 route get 1.1.1.1 2>/dev/null \
    | awk '{ for (i=1; i<=NF; i++) if ($i == "src") { print $(i+1); exit } }')"
  recorded_base="$(env_value "$ENV_FILE" STACK_BASE_DOMAIN)"
  [[ "$recorded_base" == "CHANGE_ME" ]] && recorded_base=""
  base_domain="${recorded_base:-example.com}"
  printf 'Public service routes (ArvanCloud DNS -> router reverse proxy -> this stack)\n'
  if [[ -n "$recorded_base" ]]; then
    printf 'Base domain: %s (STACK_BASE_DOMAIN)\n' "$base_domain"
  else
    printf 'Base domain: %s (placeholder; set STACK_BASE_DOMAIN in .env to name the services)\n' "$base_domain"
  fi
  printf 'The operator console links through these host names when a public URL is recorded\n'
  printf 'and falls back to the LAN address otherwise.\n'
  domain_route 'Operator panel' PANEL_PUBLIC_URL panel '' \
    PANEL_BIND_IP PANEL_PORT 8899 \
    'Set PANEL_COOKIE_SECURE=true in .env once the HTTPS hostname is in front; recreate the panel: docker compose up -d panel'
  domain_route 'Smart Router dashboard' SMART_ROUTER_PUBLIC_URL sr '/dashboard' \
    SMART_ROUTER_BIND_IP SMART_ROUTER_PORT 8787 \
    'Keep the OpenAI-compatible API and /metrics private; publish only the dashboard if needed.'
  domain_route 'Media Studio API' MEDIA_STUDIO_PUBLIC_URL studio '' \
    MEDIA_STUDIO_BIND_IP MEDIA_STUDIO_PORT 8850 \
    'Token-protected job API; publish it only for remote operators.'
  domain_route 'n8n editor' N8N_PUBLIC_URL n8n '' \
    N8N_BIND_IP N8N_PORT 5678 \
    'Keep the MCP endpoints on loopback when only the editor is published.'
  domain_route 'Instagram media host' INSTAGRAM_MEDIA_PUBLIC_BASE_URL media '' \
    IG_MEDIA_BIND_IP IG_MEDIA_PORT 8099 \
    'Instagram needs HTTPS; expose only /media/, and keep media-base-url.txt in sync for a stable hostname.'
  domain_route 'S3 API' S3_PUBLIC_BASE_URL s3 '' \
    RUSTFS_BIND_IP RUSTFS_PORT 9000 \
    'Strong credentials are required before this host is public: ./manage.sh s3-keys --rotate'
  domain_route 'RustFS console' S3_PUBLIC_CONSOLE_URL console '/rustfs/console' \
    RUSTFS_CONSOLE_BIND_IP RUSTFS_CONSOLE_PORT 9001 \
    'Keep the console off the public internet unless you accept the risk; the root redirect must stay GET-only because sign-in POSTs to /.' \
    console
  printf '\nPrivate by design: the router dashboards (NINEROUTER_PUBLIC_BASE_URL or\n'
  printf 'OMNIROUTE_PUBLIC_BASE_URL), the Hermes dashboard, and the OpenAI-compatible API\n'
  printf 'stay on the LAN or a VPN unless you add access control in front of them.\n'
  printf 'Object storage details: ./manage.sh s3-guide (docs/S3-STORAGE.md)\n'
  printf 'Panel links and reverse-proxy notes: docs/PANEL.md\n'
}

storage_menu() {
  local choice
  while true; do
    menu_title 'Object Storage (S3)'
    printf '%s\n' '1) Status                  Backend, endpoints, bucket and consumers'
    printf '%s\n' '2) Enable RustFS           Bundled S3 server for the stack'
    printf '%s\n' '3) Use an external S3      Point the stack at another provider'
    printf '%s\n' '4) Verify                  Signed request, credentials and bucket'
    printf '%s\n' '5) Create the bucket       Only when it is still missing'
    printf '%s\n' '6) Credentials             Show or rotate the RustFS keys'
    printf '%s\n' '7) Public domain guide     ArvanCloud, router proxy and bind addresses'
    printf '%s\n' '8) Follow RustFS logs'
    printf '%s\n' '9) Disable                 Back to local storage'
    printf '%s\n' '0) Back'
    read -r -p 'Choose [0]: ' choice
    case "${choice:-0}" in
      1) s3_status; menu_pause ;;
      2) s3_enable --rustfs; menu_pause ;;
      3) s3_enable --external; menu_pause ;;
      4) s3_verify; menu_pause ;;
      5) s3_verify --create-bucket; menu_pause ;;
      6)
        printf '%s\n' '1) Show access key id  2) Reveal credentials  3) Rotate'
        read -r -p 'Choose [1]: ' choice
        case "${choice:-1}" in
          1) s3_keys ;;
          2) s3_keys --show-secrets ;;
          3) s3_keys --rotate ;;
          *) printf 'Unknown choice.\n' >&2 ;;
        esac
        menu_pause
        ;;
      7) s3_guide; menu_pause ;;
      8) compose logs -f --tail=100 rustfs ;;
      9) s3_disable; menu_pause ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

# Extracts the host name from a recorded public URL such as
# https://s3.stack.example.com (empty input returns an empty string).
url_host() {
  local url="${1-}" host
  host="${url#*//}"
  host="${host%%/*}"
  printf '%s' "$host"
}

env_value() {
  local file="$1" key="$2" value="" count
  [[ -f "$file" ]] || return 0
  count="$(grep -c "^${key}=" "$file" || true)"
  if (( count > 1 )); then
    printf 'Duplicate %s entries in %s are unsafe; keep exactly one value.\n' \
      "$key" "${file#$ROOT_DIR/}" >&2
    return 1
  fi
  value="$(sed -n "s/^${key}=//p" "$file")"
  if [[ "$value" == \"*\" || "$value" == \'*\' ]]; then
    value="${value:1:${#value}-2}"
  fi
  printf '%s' "$value"
}


n8n_status() {
  local profiles mode public_url
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," != *,n8n,* ]]; then
    printf 'n8n is not enabled in COMPOSE_PROFILES. Run ./manage.sh configure to enable it.\n'
    return 1
  fi
  mode="$(n8n_mcp_mode 2>/dev/null || printf invalid)"
  public_url="$(env_value "$ENV_FILE" N8N_PUBLIC_URL)"
  if [[ -z "$public_url" ]]; then
    public_url="http://$(env_value "$ENV_FILE" N8N_BIND_IP):$(env_value "$ENV_FILE" N8N_PORT)"
  fi
  printf 'n8n provisioning status\n'
  printf '  URL: %s\n' "$public_url"
  printf '  MCP mode: %s\n' "$mode"
  if [[ -n "$(n8n_api_key 2>/dev/null || true)" ]]; then
    printf '  owner API key: stored (secret not shown)\n'
  else
    printf '  owner API key: NOT stored; managed workflow reconciliation is pending\n'
  fi
  if [[ -f "$HERMES_ENV" && -n "$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN 2>/dev/null || true)" ]]; then
    printf '  Instance MCP token: stored (secret not shown)\n'
  else
    printf '  Instance MCP token: not stored\n'
  fi
  if [[ -f "$HERMES_ENV" && -n "$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN 2>/dev/null || true)" ]]; then
    printf '  Trigger MCP token: stored (secret not shown)\n'
  else
    printf '  Trigger MCP token: not stored\n'
  fi
  if [[ -f "$N8N_BOOTSTRAP_STATE" && ! -L "$N8N_BOOTSTRAP_STATE" ]]; then
    printf '  managed n8n state: present\n'
  else
    printf '  managed n8n state: pending\n'
  fi
  case "$mode" in
    instance)
      printf '  Instance setup: Settings -> Instance-level MCP -> Enable MCP access -> Connection details -> Access Token\n'
      ;;
    trigger)
      printf '  Trigger setup: stack-managed MCP Server Trigger workflow\n'
      ;;
  esac
}

n8n_menu() {
  local choice value
  while true; do
    printf '\nn8n Provisioning / MCP Manager\n'
    printf '%s\n' '=============================='
    printf '%s\n' '1) Show provisioning / MCP status'
    printf '%s\n' '2) Store or replace owner API key'
    printf '%s\n' '3) Store or replace Instance-level MCP token'
    printf '%s\n' '4) Select MCP mode (instance / trigger / off)'
    printf '%s\n' '5) Bootstrap / reconcile stack-owned n8n objects'
    printf '%s\n' '6) Verify current n8n + MCP configuration'
    printf '%s\n' '7) Rotate retained Trigger-mode bearer token'
    printf '%s\n' '8) Remove stored owner API key'
    printf '%s\n' '9) Remove stored Instance MCP token (when mode is not instance)'
    printf '%s\n' '0) Back'
    read -r -p 'Choose: ' choice
    case "$choice" in
      1) "$ROOT_DIR/manage.sh" n8n-status ;;
      2)
        printf '%s\n' 'Create the key in n8n as the owner/admin, then paste it at the hidden prompt.'
        "$ROOT_DIR/manage.sh" set-n8n-api-key
        ;;
      3)
        printf '%s\n' 'In n8n: Settings -> Instance-level MCP -> enable access -> Connection details -> Access Token.'
        printf '%s\n' 'Generate/copy the token, then paste it at the hidden prompt.'
        "$ROOT_DIR/manage.sh" set-n8n-instance-mcp-token
        ;;
      4)
        printf '%s\n' '  instance - n8n built-in Instance-level MCP endpoint'
        printf '%s\n' '  trigger  - stack-managed MCP Server Trigger workflow'
        printf '%s\n' '  off      - disconnect Hermes from n8n MCP'
        read -r -p 'Mode (instance/trigger/off): ' value
        "$ROOT_DIR/manage.sh" set-n8n-mcp-mode "$value"
        ;;
      5) "$ROOT_DIR/manage.sh" bootstrap-n8n ;;
      6) "$ROOT_DIR/manage.sh" verify-n8n ;;
      7) "$ROOT_DIR/manage.sh" rotate-n8n-trigger-token ;;
      8) "$ROOT_DIR/manage.sh" remove-n8n-bootstrap-key ;;
      9) "$ROOT_DIR/manage.sh" remove-n8n-instance-mcp-token ;;
      0) return 0 ;;
      *) printf 'Unknown choice.\n' >&2 ;;
    esac
  done
}

require_profiles() {
  local profiles required
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  for required in "$@"; do
    [[ ",$profiles," == *",$required,"* ]] || {
      printf '%s is not selected. Run ./manage.sh configure first.\n' "$required" >&2
      exit 1
    }
  done
}

require_router_backend() {
  local profiles
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," != *,9router,* && ",$profiles," != *,omniroute,* ]]; then
    printf 'This command requires a router backend (9router or OmniRoute). Run ./manage.sh configure first.\n' >&2
    exit 1
  fi
}

random_hex() {
  if command -v openssl >/dev/null 2>&1; then
    openssl rand -hex "${1:-32}"
  else
    od -An -N "${1:-32}" -tx1 /dev/urandom | tr -d ' \n'
  fi
}

# The reconciler and verifier containers run --cap-drop ALL, so their uid has no
# CAP_DAC_OVERRIDE and cannot traverse a mode-0700 /state owned by anyone else.
# Keep the directory and its secrets owned by whoever invokes manage.sh.
ensure_stack_secrets_dir() {
  local owner
  if [[ -e "$STACK_SECRETS_DIR" || -L "$STACK_SECRETS_DIR" ]]; then
    [[ -d "$STACK_SECRETS_DIR" && ! -L "$STACK_SECRETS_DIR" ]] || {
      printf 'Refusing unsafe data/stack-secrets path; expected a real directory.\n' >&2
      return 1
    }
  else
    mkdir -p "$STACK_SECRETS_DIR"
  fi
  chmod 700 "$STACK_SECRETS_DIR"
  owner="$(stat -c '%u:%g' "$STACK_SECRETS_DIR")"
  if [[ "$owner" != "$(id -u):$(id -g)" ]]; then
    chown "$(id -u):$(id -g)" "$STACK_SECRETS_DIR" 2>/dev/null || {
      printf 'data/stack-secrets is owned by %s; rerun as that user or fix its ownership.\n' \
        "$owner" >&2
      return 1
    }
  fi
}

execution_root() { printf '%s/execution' "$STACK_SECRETS_DIR"; }

execution_hermes_uid() {
  local uid
  uid="$(env_value "$ENV_FILE" HERMES_UID)"
  uid="${uid:-10000}"
  [[ "$uid" =~ ^[1-9][0-9]*$ ]] || {
    printf 'HERMES_UID must be a non-root numeric uid before configuring execution.\n' >&2
    return 1
  }
  printf '%s' "$uid"
}

ensure_execution_paths() {
  local root file hermes_uid
  ensure_stack_secrets_dir || return 1
  hermes_uid="$(execution_hermes_uid)" || return 1
  root="$(execution_root)"
  [[ ! -L "$root" ]] || { printf 'Refusing unsafe execution symlink: %s\n' "$root" >&2; return 1; }
  install -d -m 0700 "$root"
  for file in "$root/docker-state" "$root/ssh-state" "$root/approver-state" "$root/admin-state" "$root/ssh"; do
    [[ ! -L "$file" ]] || { printf 'Refusing unsafe execution symlink: %s\n' "$file" >&2; return 1; }
    install -d -o 10003 -g 10003 -m 0700 "$file"
    chown 10003:10003 "$file"
    chmod 700 "$file"
  done
  for file in "$root/control-secret" "$root/users" "$root/features" "$root/policy-generation"; do
    [[ ! -L "$file" && ( ! -e "$file" || -f "$file" ) ]] || {
      printf 'Refusing unsafe execution policy path: %s\n' "$file" >&2; return 1;
    }
    if [[ ! -e "$file" ]]; then
      install -o "$hermes_uid" -g 10003 -m 0660 /dev/null "$file"
      case "${file##*/}" in
        features) printf '%s\n' "$(env_value "$ENV_FILE" EXECUTION_FEATURES)" > "$file" ;;
        policy-generation) printf '%s\n' "$(env_value "$ENV_FILE" EXECUTION_POLICY_GENERATION | sed 's/^$/0/')" > "$file" ;;
      esac
    fi
    chown "$hermes_uid:10003" "$file"
    chmod 660 "$file"
  done
  for file in "$root/approval-request-secret" "$root/approval-signing-key.pem" \
    "$root/approval-public-key.pem" "$root/approval-bot-token" \
    "$root/ssh-profile-integrity-secret" "$root/admin-key"; do
    [[ ! -L "$file" && ( ! -e "$file" || -f "$file" ) ]] || {
      printf 'Refusing unsafe execution policy path: %s\n' "$file" >&2; return 1;
    }
    [[ -e "$file" ]] || install -o 10003 -g 10003 -m 0600 /dev/null "$file"
    chown 10003:10003 "$file"
    chmod 600 "$file"
  done
  for file in "$root/telegram-allowed-users" "$root/hermes-bot-token.sha256"; do
    [[ ! -L "$file" && ( ! -e "$file" || -f "$file" ) ]] || {
      printf 'Refusing unsafe execution admin metadata path: %s\n' "$file" >&2; return 1;
    }
    [[ -e "$file" ]] || install -o "$hermes_uid" -g 10003 -m 0640 /dev/null "$file"
    chown "$hermes_uid:10003" "$file"
    chmod 640 "$file"
  done
  install -d -o 10002 -g 10002 -m 0700 "$ROOT_DIR/data/execution-workspace"
}

execution_features() {
  local path="$(execution_root)/features"
  [[ -f "$path" ]] && { tr -d '[:space:]' < "$path"; return; }
  env_value "$ENV_FILE" EXECUTION_FEATURES
}

write_execution_features() {
  local features="$1" root tmp hermes_uid
  ensure_execution_paths || return 1
  hermes_uid="$(execution_hermes_uid)"; root="$(execution_root)"
  tmp="$(mktemp "$root/features.tmp.XXXXXX")"
  printf '%s\n' "$features" > "$tmp"; chown "$hermes_uid:10003" "$tmp"; chmod 660 "$tmp"; mv "$tmp" "$root/features"
  replace_env_value "$ENV_FILE" EXECUTION_FEATURES "$features"
}
execution_users() { [[ -f "$(execution_root)/users" ]] && tr -d '[:space:]' < "$(execution_root)/users" || true; }

telegram_users() {
  local value
  value="$(env_value "$HERMES_ENV" TELEGRAM_ALLOWED_USERS)"
  value="${value#[}"; value="${value%]}"; value="${value//\"/}"; value="${value// /}"
  printf '%s' "$value"
}

sync_execution_admin_metadata() {
  local root hermes_uid tmp token
  ensure_execution_paths || return 1
  root="$(execution_root)"; hermes_uid="$(execution_hermes_uid)"
  tmp="$(mktemp "$root/telegram-allowed-users.tmp.XXXXXX")"
  printf '%s\n' "$(telegram_users)" > "$tmp"; chown "$hermes_uid:10003" "$tmp"; chmod 640 "$tmp"; mv "$tmp" "$root/telegram-allowed-users"
  token="$(env_value "$HERMES_ENV" TELEGRAM_BOT_TOKEN)"
  tmp="$(mktemp "$root/hermes-bot-token.sha256.tmp.XXXXXX")"
  if [[ -n "$token" ]]; then printf '%s' "$token" | sha256sum | awk '{print $1}' > "$tmp"; else : > "$tmp"; fi
  chown "$hermes_uid:10003" "$tmp"; chmod 640 "$tmp"; mv "$tmp" "$root/hermes-bot-token.sha256"
}

execution_users_valid() {
  local users="$1" allowed user
  valid_ids "$users" || return 1
  allowed=",$(telegram_users),"
  IFS=, read -ra entries <<< "$users"
  for user in "${entries[@]}"; do [[ "$allowed" == *",$user,"* ]] || return 1; done
}

write_execution_users() {
  local users="$1" root tmp hermes_uid
  ensure_execution_paths || return 1
  hermes_uid="$(execution_hermes_uid)"
  root="$(execution_root)"
  tmp="$(mktemp "$root/users.tmp.XXXXXX")"
  printf '%s\n' "$users" > "$tmp"
  chown "$hermes_uid:10003" "$tmp"
  chmod 640 "$tmp"
  mv "$tmp" "$root/users"
}

rotate_execution_generation() {
  local current workspace root tmp hermes_uid
  ensure_execution_paths || return 1
  root="$(execution_root)"; hermes_uid="$(execution_hermes_uid)"
  current="$(tr -d '[:space:]' < "$root/policy-generation" 2>/dev/null || true)"; current="${current:-$(env_value "$ENV_FILE" EXECUTION_POLICY_GENERATION)}"; current="${current:-0}"
  workspace="$(env_value "$ENV_FILE" EXECUTION_WORKSPACE_GENERATION)"; workspace="${workspace:-0}"
  [[ "$current" =~ ^[0-9]+$ ]] || current=0
  [[ "$workspace" =~ ^[0-9]+$ ]] || workspace=0
  current="$((current + 1))"
  tmp="$(mktemp "$root/policy-generation.tmp.XXXXXX")"; printf '%s\n' "$current" > "$tmp"; chown "$hermes_uid:10003" "$tmp"; chmod 660 "$tmp"; mv "$tmp" "$root/policy-generation"
  replace_env_value "$ENV_FILE" EXECUTION_POLICY_GENERATION "$current"
  replace_env_value "$ENV_FILE" EXECUTION_WORKSPACE_GENERATION "$((workspace + 1))"
}

sync_execution_profiles() {
  local features profiles base admin_enabled
  features="$(execution_features)"
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  admin_enabled="$(env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED)"
  base="$(printf '%s' "$profiles" | tr ',' '\n' | grep -v -E '^execution-(docker|ssh|approval|admin)$' | paste -sd, -)"
  if [[ -n "$features" ]]; then base="${base:+$base,}execution-approval"; fi
  [[ ",$features," == *,local,* || ",$features," == *,docker,* ]] \
    && base="${base:+$base,}execution-docker"
  [[ ",$features," == *,ssh,* ]] && base="${base:+$base,}execution-ssh"
  [[ "$admin_enabled" == true ]] && base="${base:+$base,}execution-admin"
  replace_env_value "$ENV_FILE" COMPOSE_PROFILES "$base"
}

apply_execution_features() {
  local features="$1"
  write_execution_features "$features"
  rotate_execution_generation
  sync_execution_profiles
  if [[ -n "$features" ]]; then
    compose pull execution-approver execution-docker-broker execution-ssh-broker
  fi
  compose up -d --no-build --remove-orphans
}

set_execution_feature() {
  local requested="$1" enabled="$2" current item output=""
  [[ "$requested" == sandbox ]] && requested=local
  current="$(execution_features)"
  for item in local ssh docker; do
    if [[ "$enabled" == true && ( "$requested" == all || "$requested" == "$item" ) ]]; then
      [[ ",$current," == *",$item,"* ]] || output="${output:+$output,}$item"
    elif [[ "$enabled" != true && ( "$requested" == all || "$requested" == "$item" ) ]]; then
      continue
    elif [[ ",$current," == *",$item,"* ]]; then
      output="${output:+$output,}$item"
    fi
  done
  printf '%s' "$output"
}

write_n8n_bootstrap_key() {
  local key="$1" tmp
  ensure_stack_secrets_dir || return 1
  if [[ -e "$N8N_BOOTSTRAP_ENV" && ( ! -f "$N8N_BOOTSTRAP_ENV" || -L "$N8N_BOOTSTRAP_ENV" ) ]]; then
    printf 'Refusing unsafe n8n bootstrap secret path.\n' >&2
    return 1
  fi
  tmp="$(mktemp "$STACK_SECRETS_DIR/n8n-bootstrap.env.tmp.XXXXXX")"
  printf 'N8N_API_KEY=%s\n' "$key" > "$tmp"
  chmod 600 "$tmp"
  mv "$tmp" "$N8N_BOOTSTRAP_ENV"
}

n8n_api_key() {
  env_value "$N8N_BOOTSTRAP_ENV" N8N_API_KEY
}

n8n_api_check() {
  local key="$1" image env_file status
  image_repo="$(env_value "$ENV_FILE" N8N_IMAGE_REPOSITORY)"; image_repo="${image_repo:-n8nio/n8n}"
  image_tag="$(env_value "$ENV_FILE" N8N_IMAGE_TAG)"; image_tag="${image_tag:-latest}"
  image="$image_repo:$image_tag"
  ensure_stack_secrets_dir || return 1
  env_file="$(mktemp "$STACK_SECRETS_DIR/n8n-api-check.env.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$env_file")
  chmod 600 "$env_file"
  printf 'N8N_API_KEY=%s\n' "$key" > "$env_file"
  if "${DOCKER[@]}" run --rm --network hermes-9router-net \
    --read-only --cap-drop ALL --security-opt no-new-privileges \
    --env-file "$env_file" --entrypoint node "$image" -e '
      fetch("http://n8n:5678/api/v1/workflows?limit=1", {
        headers: {"X-N8N-API-KEY": process.env.N8N_API_KEY},
      }).then(async response => {
        if (!response.ok) throw new Error(`n8n API returned ${response.status}`);
        return response.json();
      }).then(() => process.exit(0)).catch(error => {
        console.error(error.message); process.exit(1);
      });'; then
    status=0
  else
    status=$?
  fi
  rm -f -- "$env_file"
  return "$status"
}

n8n_instance_mcp_check() {
  local token="$1" image env_file status
  image_repo="$(env_value "$ENV_FILE" N8N_IMAGE_REPOSITORY)"; image_repo="${image_repo:-n8nio/n8n}"
  image_tag="$(env_value "$ENV_FILE" N8N_IMAGE_TAG)"; image_tag="${image_tag:-latest}"
  image="$image_repo:$image_tag"
  ensure_stack_secrets_dir || return 1
  env_file="$(mktemp "$STACK_SECRETS_DIR/n8n-instance-mcp-check.env.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$env_file")
  chmod 600 "$env_file"
  printf 'N8N_INSTANCE_MCP_TOKEN=%s\n' "$token" > "$env_file"
  if "${DOCKER[@]}" run --rm --network hermes-9router-net \
    --read-only --cap-drop ALL --security-opt no-new-privileges \
    --env-file "$env_file" --entrypoint node "$image" -e '
      const url = "http://n8n:5678/mcp-server/http";
      const initialize = {jsonrpc:"2.0",id:1,method:"initialize",params:{
        protocolVersion:"2025-03-26",capabilities:{},
        clientInfo:{name:"hermes-n8n-token-validator",version:"1"}}};
      const baseHeaders = {Accept:"application/json, text/event-stream","Content-Type":"application/json"};
      const fail = message => { console.error(message); process.exit(1); };
      const messages = async response => {
        const text = await response.text();
        if (!text.trim()) return [];
        if ((response.headers.get("content-type") || "").includes("text/event-stream")) {
          return text.split(/\r?\n/).filter(line => line.startsWith("data:"))
            .map(line => line.slice(5).trim()).filter(value => value && value !== "[DONE]")
            .map(value => JSON.parse(value));
        }
        return [JSON.parse(text)];
      };
      (async () => {
        let session;
        let failure;
        try {
          const anonymous = await fetch(url,{method:"POST",headers:baseHeaders,
            body:JSON.stringify(initialize),redirect:"manual",signal:AbortSignal.timeout(15000)});
          await anonymous.body?.cancel();
          if (![401,403].includes(anonymous.status)) throw new Error("Instance MCP did not reject an unauthenticated request");
          const request = async body => {
            const response = await fetch(url,{method:"POST",headers:{...baseHeaders,
              Authorization:`Bearer ${process.env.N8N_INSTANCE_MCP_TOKEN}`,
              ...(session?{"Mcp-Session-Id":session}:{})},body:JSON.stringify(body),
              redirect:"manual",signal:AbortSignal.timeout(15000)});
            if (!response.ok) throw new Error(`Instance MCP returned HTTP ${response.status}`);
            session = response.headers.get("mcp-session-id") || session;
            return messages(response);
          };
          const initialized = await request(initialize);
          if (!initialized.some(item => item?.id === 1 && item?.result?.protocolVersion)) throw new Error("Instance MCP initialize failed");
          await request({jsonrpc:"2.0",method:"notifications/initialized",params:{}});
          const listed = await request({jsonrpc:"2.0",id:2,method:"tools/list",params:{}});
          const tools = listed.find(item => item?.id === 2)?.result?.tools;
          // n8n adds Instance MCP capabilities over time. Require the stable
          // workflow core so a valid token is not rejected only because newer,
          // version-gated tools are absent from this installed n8n image.
          for (const name of ["search_workflows","get_workflow_details","execute_workflow"]) {
            if (!Array.isArray(tools) || !tools.some(tool => tool?.name === name)) throw new Error(`Instance MCP core tool ${name} is missing`);
          }
        } catch (error) {
          failure = error;
        }
        if (session) {
          try {
            const closed = await fetch(url,{method:"DELETE",headers:{
              Authorization:`Bearer ${process.env.N8N_INSTANCE_MCP_TOKEN}`,"Mcp-Session-Id":session},
              redirect:"manual",signal:AbortSignal.timeout(15000)});
            if (!closed.ok) throw new Error(`Instance MCP session close returned HTTP ${closed.status}`);
          } catch (error) {
            failure ||= error;
          }
        }
        if (failure) throw failure;
      })().catch(error => fail(error.message));'; then
    status=0
  else
    status=$?
  fi
  rm -f -- "$env_file"
  return "$status"
}

write_omniroute_n8n_router_key() {
  local key="$1" id="$2" tmp
  ensure_stack_secrets_dir || return 1
  tmp="$(mktemp "$STACK_SECRETS_DIR/omniroute-n8n-router.env.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$tmp")
  chmod 600 "$tmp"
  {
    printf 'OMNIROUTE_N8N_API_KEY=%s\n' "$key"
    printf 'OMNIROUTE_N8N_API_KEY_ID=%s\n' "$id"
  } > "$tmp"
  mv "$tmp" "$OMNIROUTE_N8N_KEY_ENV"
  chmod 600 "$OMNIROUTE_N8N_KEY_ENV"
}

stored_omniroute_n8n_router_key() {
  [[ -f "$OMNIROUTE_N8N_KEY_ENV" && ! -L "$OMNIROUTE_N8N_KEY_ENV" ]] || return 0
  chmod 600 "$OMNIROUTE_N8N_KEY_ENV"
  env_value "$OMNIROUTE_N8N_KEY_ENV" OMNIROUTE_N8N_API_KEY
}

validate_omniroute_n8n_router_key() {
  local key="$1"
  [[ -n "$key" ]] || return 1
  if printf '%s' "$key" | compose exec -T omniroute node -e '
    let key="";
    process.stdin.setEncoding("utf8");
    process.stdin.on("data", chunk => key += chunk);
    process.stdin.on("end", async () => {
      try {
        const response = await fetch("http://127.0.0.1:20129/v1/models", {
          headers: {Authorization: `Bearer ${key}`},
          signal: AbortSignal.timeout(10000),
        });
        process.exit(response.ok ? 0 : 1);
      } catch {
        process.exit(1);
      }
    });'; then
    return 0
  fi
  return 1
}

create_omniroute_n8n_router_key() {
  local output key id
  output="$(compose exec -T \
    -e 'HERMES_N8N_SERVICE_KEY_NAME=n8n (content-manager stack)' \
    omniroute node -e '
      (async () => {
        const managementKey = process.env.OMNIROUTE_API_KEY || "";
        if (!managementKey) {
          throw new Error("OMNIROUTE_API_KEY management bootstrap credential is missing");
        }
        const response = await fetch("http://127.0.0.1:20128/api/keys", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${managementKey}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({name: process.env.HERMES_N8N_SERVICE_KEY_NAME}),
          signal: AbortSignal.timeout(15000),
        });
        const data = await response.json().catch(() => ({}));
        if (!response.ok || !data.key || !data.id) {
          throw new Error(`OmniRoute key provisioning returned HTTP ${response.status}`);
        }
        process.stdout.write(`OMNIROUTE_N8N_API_KEY=${data.key}\n`);
        process.stdout.write(`OMNIROUTE_N8N_API_KEY_ID=${data.id}\n`);
      })().catch(error => {
        console.error(error.message);
        process.exit(1);
      });
    ')" || {
      printf '%s\n' \
        'OmniRoute could not auto-provision the dedicated n8n API key.' \
        'Ensure the current OmniRoute image supports management POST /api/keys and OMNIROUTE_MANAGEMENT_API_KEY is configured.' >&2
      return 1
    }

  key="$(sed -n 's/^OMNIROUTE_N8N_API_KEY=//p' <<< "$output" | tail -n1)"
  id="$(sed -n 's/^OMNIROUTE_N8N_API_KEY_ID=//p' <<< "$output" | tail -n1)"
  [[ -n "$key" && -n "$id" ]] || {
    printf 'OmniRoute did not return the dedicated n8n API key.\n' >&2
    return 1
  }

  write_omniroute_n8n_router_key "$key" "$id" || return 1
  printf '%s' "$key"
}

provision_n8n_router_key() {
  local profiles output key
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,smart-router,* ]]; then
    key="$(env_value "$ENV_FILE" SMART_ROUTER_CLIENT_API_KEY)"
    [[ -n "$key" ]] || { printf 'SMART_ROUTER_CLIENT_API_KEY is missing.\n' >&2; return 1; }
    printf '%s' "$key"
    return 0
  fi
  if [[ ",$profiles," == *,omniroute,* ]]; then
    key="$(stored_omniroute_n8n_router_key)"
    if [[ -n "$key" ]] && validate_omniroute_n8n_router_key "$key"; then
      printf '%s' "$key"
      return 0
    fi
    create_omniroute_n8n_router_key
    return $?
  fi
  output="$(compose exec -T -e PROVISION_HERMES=false -e PROVISION_OPENWEBUI=false \
    -e PROVISION_SMART_ROUTER=false -e PROVISION_N8N=true nine-router \
    node --input-type=module < "$ROOT_DIR/scripts/bootstrap-openwebui.mjs")"
  key="$(sed -n 's/^N8N_API_KEY=//p' <<< "$output" | tail -n1)"
  [[ -n "$key" ]] || { printf '9router did not return the dedicated n8n key.\n' >&2; return 1; }
  printf '%s' "$key"
}

run_n8n_reconciler_with_token() {
  local mcp_token="$1" previous_mcp_token="${2:-$1}" requested_mode="${3:-}" mode api_key router_key image env_file status
  local profiles router_base_url router_model previous_router_base_url state_dir state_tmp
  mode="${requested_mode:-$(n8n_mcp_mode)}"
  api_key="$(n8n_api_key)"
  [[ -n "$api_key" ]] || {
    printf 'No n8n bootstrap API key is stored. Run ./manage.sh set-n8n-api-key.\n' >&2
    return 1
  }
  router_key="$(provision_n8n_router_key)" || return 1
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,smart-router,* ]]; then
    router_base_url="http://smart-router:8080/v1"
    router_model="auto"
  elif [[ ",$profiles," == *,omniroute,* ]]; then
    router_base_url="http://omniroute:20129/v1"
    router_model="auto/best-chat"
  else
    router_base_url="http://nine-router:20128/v1"
    router_model="ai"
  fi
  previous_router_base_url="$router_base_url"
  if [[ -f "$N8N_BOOTSTRAP_STATE" ]]; then
    previous_router_base_url="$(python3 - "$N8N_BOOTSTRAP_STATE" "$router_base_url" <<'PY'
import json
import sys
try:
    state = json.load(open(sys.argv[1], encoding="utf-8"))
    print(state.get("routerBaseUrl") or sys.argv[2])
except Exception:
    print(sys.argv[2])
PY
)"
  fi
  ensure_stack_secrets_dir || return 1
  if [[ -e "$N8N_BOOTSTRAP_STATE" ]]; then
    [[ -f "$N8N_BOOTSTRAP_STATE" && ! -L "$N8N_BOOTSTRAP_STATE" ]] || {
      printf 'Refusing unsafe n8n bootstrap state path.\n' >&2
      return 1
    }
    chmod 600 "$N8N_BOOTSTRAP_STATE"
  fi
  state_dir="$(mktemp -d "$STACK_SECRETS_DIR/n8n-reconcile-state.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$state_dir")
  chmod 700 "$state_dir"
  if [[ -f "$N8N_BOOTSTRAP_STATE" ]]; then
    cp --preserve=mode,timestamps "$N8N_BOOTSTRAP_STATE" "$state_dir/n8n-bootstrap-state.json"
  fi
  env_file="$(mktemp "$STACK_SECRETS_DIR/n8n-reconcile.env.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$env_file")
  chmod 600 "$env_file"
  # The shared tool registry lives in the Content Manager config directory; it
  # is mounted read-only so the bootstrap can report the n8n entries without
  # copying the file into the container image.
  registry_args=()
  if [[ -d "$ROOT_DIR/data/content-manager/config" ]]; then
    registry_args=(-v "$ROOT_DIR/data/content-manager/config:/tools:ro")
  fi
  {
    printf 'N8N_API_URL=http://n8n:5678/api/v1\n'
    printf 'N8N_API_KEY=%s\n' "$api_key"
    printf 'N8N_MCP_MODE=%s\n' "$mode"
    [[ -n "$mcp_token" ]] && printf 'N8N_TRIGGER_MCP_TOKEN=%s\n' "$mcp_token"
    [[ -n "$previous_mcp_token" ]] && printf 'N8N_PREVIOUS_TRIGGER_MCP_TOKEN=%s\n' "$previous_mcp_token"
    printf 'NINEROUTER_API_KEY=%s\n' "$router_key"
    printf 'N8N_ROUTER_BASE_URL=%s\n' "$router_base_url"
    printf 'N8N_PREVIOUS_ROUTER_BASE_URL=%s\n' "$previous_router_base_url"
    printf 'N8N_CHAT_MODEL=%s\n' "$router_model"
    printf 'N8N_STATE_FILE=/state/n8n-bootstrap-state.json\n'
    if [[ "${#registry_args[@]}" -gt 0 ]]; then
      printf 'N8N_TOOLS_REGISTRY=/tools/tools.json\n'
    fi
  } > "$env_file"
  image_repo="$(env_value "$ENV_FILE" N8N_IMAGE_REPOSITORY)"; image_repo="${image_repo:-n8nio/n8n}"
  image_tag="$(env_value "$ENV_FILE" N8N_IMAGE_TAG)"; image_tag="${image_tag:-latest}"
  image="$image_repo:$image_tag"
  if "${DOCKER[@]}" run --rm --network hermes-9router-net \
    --user "$(id -u):$(id -g)" \
    --read-only --cap-drop ALL --security-opt no-new-privileges \
    --tmpfs /tmp:size=16m,mode=1777 \
    -v "$ROOT_DIR/scripts:/stack/scripts:ro" \
    ${registry_args[@]+"${registry_args[@]}"} \
    -v "$state_dir:/state" \
    --env-file "$env_file" \
    --entrypoint node "$image" \
    /stack/scripts/bootstrap-n8n.mjs; then
    status=0
    state_tmp="$(mktemp "$STACK_SECRETS_DIR/n8n-bootstrap-state.tmp.XXXXXX")"
    if cp "$state_dir/n8n-bootstrap-state.json" "$state_tmp"; then
      chmod 600 "$state_tmp"
      mv "$state_tmp" "$N8N_BOOTSTRAP_STATE"
    else
      rm -f -- "$state_tmp"
      status=1
    fi
  else
    status=$?
  fi
  rm -f -- "$env_file"
  rm -rf -- "$state_dir"
  return "$status"
}

run_n8n_reconciler() {
  local mode mcp_token
  mode="$(n8n_mcp_mode)" || return 1
  migrate_legacy_trigger_env || return 1
  mcp_token="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)"
  if [[ "$mode" == trigger && -z "$mcp_token" ]]; then
    printf 'No Trigger MCP token is configured. Run ./manage.sh configure and select Trigger mode.\n' >&2
    return 1
  fi
  run_n8n_reconciler_with_token "$mcp_token" "$mcp_token" "$mode"
}

run_n8n_verifier() {
  local api_key mode mcp_token="" mcp_url="" image env_file status profiles router_health_url state_dir
  [[ -f "$N8N_BOOTSTRAP_STATE" && ! -L "$N8N_BOOTSTRAP_STATE" ]] || {
    printf 'Managed n8n state is missing or unsafe; run bootstrap-n8n.\n' >&2
    return 1
  }
  mode="$(n8n_mcp_mode)" || return 1
  migrate_legacy_trigger_env || return 1
  case "$mode" in
    instance)
      mcp_token="$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN)"
      mcp_url="$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_URL)"
      ;;
    trigger)
      mcp_token="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)"
      mcp_url="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_URL)"
      ;;
  esac
  if [[ "$mode" != off && ( -z "$mcp_token" || -z "$mcp_url" ) ]]; then
    printf 'Hermes n8n %s MCP configuration is incomplete.\n' "$mode" >&2
    return 1
  fi
  api_key="$(n8n_api_key)"
  profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
  if [[ ",$profiles," == *,smart-router,* ]]; then
    router_health_url="http://smart-router:8080/ready"
  elif [[ ",$profiles," == *,omniroute,* ]]; then
    router_health_url="http://omniroute:20128/api/monitoring/health"
  else
    router_health_url="http://nine-router:20128/api/health"
  fi
  ensure_stack_secrets_dir || return 1
  env_file="$(mktemp "$STACK_SECRETS_DIR/n8n-verify.env.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$env_file")
  chmod 600 "$env_file"
  state_dir="$(mktemp -d "$STACK_SECRETS_DIR/n8n-verify-state.tmp.XXXXXX")"
  TEMP_SECRET_FILES+=("$state_dir")
  chmod 700 "$state_dir"
  cp --preserve=mode,timestamps "$N8N_BOOTSTRAP_STATE" "$state_dir/n8n-bootstrap-state.json"
  {
    printf 'N8N_API_URL=http://n8n:5678/api/v1\n'
    [[ -n "$api_key" ]] && printf 'N8N_API_KEY=%s\n' "$api_key"
    printf 'N8N_MCP_MODE=%s\n' "$mode"
    case "$mode" in
      instance)
        printf 'N8N_INSTANCE_MCP_URL=%s\n' "$mcp_url"
        printf 'N8N_INSTANCE_MCP_TOKEN=%s\n' "$mcp_token"
        ;;
      trigger)
        printf 'N8N_TRIGGER_MCP_URL=%s\n' "$mcp_url"
        printf 'N8N_TRIGGER_MCP_TOKEN=%s\n' "$mcp_token"
        ;;
    esac
    printf 'N8N_STATE_FILE=/state/n8n-bootstrap-state.json\n'
    printf 'N8N_ROUTER_HEALTH_URL=%s\n' "$router_health_url"
  } > "$env_file"
  image_repo="$(env_value "$ENV_FILE" N8N_IMAGE_REPOSITORY)"; image_repo="${image_repo:-n8nio/n8n}"
  image_tag="$(env_value "$ENV_FILE" N8N_IMAGE_TAG)"; image_tag="${image_tag:-latest}"
  image="$image_repo:$image_tag"
  if "${DOCKER[@]}" run --rm --network hermes-9router-net \
    --user "$(id -u):$(id -g)" \
    --read-only --cap-drop ALL --security-opt no-new-privileges \
    --tmpfs /tmp:size=16m,mode=1777 \
    -v "$ROOT_DIR/scripts:/stack/scripts:ro" \
    -v "$state_dir:/state:ro" \
    --env-file "$env_file" \
    --entrypoint node "$image" \
    /stack/scripts/verify-n8n.mjs; then
    status=0
  else
    status=$?
  fi
  rm -f -- "$env_file"
  rm -rf -- "$state_dir"
  return "$status"
}

check_hermes_file() {
  local path="$1" label="$2" container_path
  [[ -f "$path" ]] || return 0
  container_path="/opt/data/${path#"$ROOT_DIR/data/hermes/"}"
  if compose exec -T hermes setpriv --reuid=hermes --regid=hermes --clear-groups \
    test -r "$container_path" >/dev/null 2>&1; then
    printf '%s: readable by Hermes gateway\n' "$label"
  else
    printf 'WARNING: %s is not readable by the Hermes gateway user. Run ./manage.sh configure to repair ownership.\n' "$label"
  fi
}

command="${1:-}"
case "$command" in
  menu) interactive_menu ;;
  services|services-menu) services_menu ;;
  router|router-menu) router_menu ;;
  hermes|hermes-menu) hermes_menu ;;
  n8n|n8n-menu) n8n_menu ;;
  content|content-menu) content_menu ;;
  media|media-menu) media_menu ;;
  panel|panel-menu) panel_menu ;;
  panel-enable) panel_enable ;;
  panel-disable) panel_disable ;;
  panel-status) panel_status ;;
  panel-token) panel_token ;;
  panel-rotate-token) panel_rotate_token ;;
  panel-build) panel_build ;;
  storage|storage-menu) storage_menu ;;
  s3-status) s3_status ;;
  s3-enable) shift; s3_enable "$@" ;;
  s3-disable) s3_disable ;;
  s3-verify) shift; s3_verify "$@" ;;
  s3-keys) shift; s3_keys "${1:-}" ;;
  s3-guide) s3_guide ;;
  domains|public-routes) domains ;;
  execution|execution-menu) execution_menu ;;
  maintenance|maintenance-menu) maintenance_menu ;;
  security|security-menu) security_menu ;;
  n8n-status) n8n_status ;;
  content-status) content_status ;;
  content-connect-instagram) content_connect_instagram ;;
  content-connect-bale) shift; content_connect_channel bale "$@" ;;
  content-connect-eitaa) shift; content_connect_channel eitaa "$@" ;;
  content-connect-linkedin) shift; content_connect_linkedin "$@" ;;
  content-aparat-check) content_aparat_check ;;
  content-channels) content_channels_status ;;
  instagram-media-status) ig_media_status ;;
  instagram-media-enable) shift; ig_media_enable "${1:-}" ;;
  instagram-media-disable) ig_media_disable ;;
  instagram-media-tunnel-off) ig_media_tunnel_off ;;
  instagram-media-verify) ig_media_verify ;;
  content-configure) content_configure ;;
  media-status) media_status ;;
  media-guide) media_guide ;;
  media-configure) media_configure ;;
  notebooklm|notebooklm-menu) notebooklm_menu ;;
  notebooklm-status) notebooklm_status ;;
  notebooklm-guide) notebooklm_guide ;;
  notebooklm-enable) notebooklm_enable ;;
  notebooklm-disable) notebooklm_disable ;;
  notebooklm-login) shift; notebooklm_login "${1:-600}" ;;
  notebooklm-login-google|notebooklm_login_google) notebooklm_login_google ;;
  notebooklm-import-session) shift; notebooklm_import_session "$@" ;;
  pipeline-status) pipeline_status ;;
  uninstall)
    shift
    uninstall_stack "${1:-}"
    ;;
  start)
    # A missing token makes the panel exit; create one before a full start
    # when the optional profile is enabled.
    panel_enabled && panel_token >/dev/null
    compose up -d --build
    ;;
  stop) compose stop ;;
  restart) compose restart ;;
  update)
    shift
    ops update "$@"
    ;;
  status) if [[ "${2:-}" == --json ]]; then ops status --json; else compose ps; fi ;;
  logs)
    case "${2:-}" in
      "") compose logs -f --tail=100 ;;
      hermes) compose logs -f --tail=100 hermes ;;
      9router|nine-router) compose logs -f --tail=100 nine-router ;;
      omniroute|omni) compose logs -f --tail=100 omniroute ;;
      smart-router|router) compose logs -f --tail=100 smart-router ;;
      webui|open-webui) compose logs -f --tail=100 open-webui ;;
      n8n) compose logs -f --tail=100 n8n ;;
      content|content-bot) compose logs -f --tail=100 content-bot ;;
      media|media-studio) compose logs -f --tail=100 media-studio ;;
      notebooklm|notebooklm-worker) compose logs -f --tail=100 notebooklm-worker ;;
      caddy) compose logs -f --tail=100 caddy ;;
      rustfs|s3) compose logs -f --tail=100 rustfs ;;
      *) printf 'Choose hermes, 9router, omniroute, smart-router, webui, n8n, content, media, caddy, or rustfs.\n' >&2; exit 2 ;;
    esac
    ;;
  dashboard-access)
    shift
    hermes_dashboard_access "${1:-}"
    ;;
  migrate-hermes-permissions)
    shift
    case "$#:${1:-}" in
      0:) dry_run=false ;;
      1:--dry-run) dry_run=true ;;
      *) printf 'Usage: ./manage.sh migrate-hermes-permissions [--dry-run]\n' >&2; exit 2 ;;
    esac
    migrate_hermes_permissions "$dry_run"
    ;;
  doctor)
    compose config --quiet
    printf 'Compose configuration: valid\n'
    compose ps
    if [[ -f "$HERMES_ENV" ]]; then
      mode="$(stat -c '%a' "$HERMES_ENV")"
      printf 'Hermes secret file mode: %s\n' "$mode"
      [[ "$mode" == 600 ]] || printf 'WARNING: expected data/hermes/.env mode 600\n'
    fi
    profiles="$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_FILE")"
    if [[ "$profiles" == *hermes* ]]; then
      check_hermes_file "$ROOT_DIR/data/hermes/config.yaml" "Hermes config"
      check_hermes_file "$HERMES_ENV" "Hermes secret file"
      check_hermes_log_permissions
      if compose exec -T hermes sh -c \
        'test -f /opt/data/plugins/stack-package-policy/plugin.yaml && test ! -w /opt/data/plugins/stack-package-policy/plugin.yaml' \
        >/dev/null 2>&1; then
        printf 'Hermes package policy plugin: mounted read-only\n'
      else
        printf 'WARNING: stack-package-policy is missing or writable inside Hermes.\n'
      fi
      configured_turns="$(hermes_agent_max_turns || true)"
      effective_turns="$(compose exec -T hermes sh -lc 'printf "%s" "${HERMES_MAX_ITERATIONS:-}"' 2>/dev/null || true)"
      printf 'Hermes agent iteration budget: configured %s, effective %s\n' \
        "${configured_turns:-unset}" "${effective_turns:-unknown}"
      if [[ -n "$configured_turns" ]] && (( configured_turns <= 30 )); then
        printf 'WARNING: an iteration budget of %s stops multi-step tool tasks early. Raise it with ./manage.sh set-agent-max-turns 90.\n' \
          "$configured_turns"
      fi
      if [[ -n "$configured_turns" && -n "$effective_turns" && "$configured_turns" != "$effective_turns" ]]; then
        printf 'WARNING: the running gateway budget does not match config.yaml; recreate Hermes.\n'
      fi
      dashboard_enabled="$(env_value "$ENV_FILE" HERMES_DASHBOARD)"
      if [[ "$dashboard_enabled" == 1 ]]; then
        dashboard_user="$(env_value "$ENV_FILE" HERMES_DASHBOARD_BASIC_AUTH_USERNAME)"
        dashboard_hash="$(env_value "$ENV_FILE" HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH)"
        dashboard_secret="$(env_value "$ENV_FILE" HERMES_DASHBOARD_BASIC_AUTH_SECRET)"
        if [[ -z "$dashboard_user" || -z "$dashboard_hash" || -z "$dashboard_secret" ]]; then
          printf 'WARNING: HERMES_DASHBOARD is enabled but Basic Auth credentials are incomplete. The gateway refuses to bind a non-loopback dashboard without an auth provider. Run ./manage.sh configure to provision credentials.\n'
        else
          printf 'Hermes dashboard: Basic Auth configured (user %s)\n' "$dashboard_user"
        fi
        if [[ -f "$HERMES_DASHBOARD_ACCESS_FILE" ]]; then
          dashboard_access_mode="$(stat -c '%a' "$HERMES_DASHBOARD_ACCESS_FILE")"
          [[ "$dashboard_access_mode" == 600 ]] || printf 'WARNING: expected %s mode 600, found %s\n' "$HERMES_DASHBOARD_ACCESS_FILE" "$dashboard_access_mode"
        elif [[ -n "$dashboard_user" ]]; then
          printf 'WARNING: %s is missing; dashboard-access --show-password will not work until Hermes is reconfigured.\n' "$HERMES_DASHBOARD_ACCESS_FILE"
        fi
      fi
      if grep -q '^[[:space:]]*- stack-package-policy[[:space:]]*$' "$ROOT_DIR/data/hermes/config.yaml"; then
        printf 'Hermes package policy plugin: enabled in config\n'
      else
        printf 'WARNING: stack-package-policy is not enabled in Hermes config.\n'
      fi
      if compose exec -T hermes sh -lc '
        cd /opt/hermes
        /opt/hermes/.venv/bin/hermes plugins list --enabled --user --plain 2>/dev/null \
          | grep -Eq "enabled[[:space:]]+user[[:space:]]+[^[:space:]]+[[:space:]]+stack-package-policy"
      '; then
        printf 'Hermes package policy plugin: registered at runtime\n'
      else
        printf 'WARNING: stack-package-policy is not registered as an enabled user plugin.\n'
      fi
      if compose exec -T hermes sh -lc '
        cd /opt/hermes
        /opt/hermes/.venv/bin/hermes plugins list --enabled --user --plain 2>/dev/null \
          | grep -Eq "enabled[[:space:]]+user[[:space:]]+[^[:space:]]+[[:space:]]+stack-execution-policy"
      '; then
        printf 'Hermes execution policy plugin: registered at runtime\n'
      else
        printf 'WARNING: stack-execution-policy is not registered as an enabled user plugin.\n'
      fi
      telegram_tools="$(compose exec -T hermes sh -lc '
        cd /opt/hermes
        /opt/hermes/.venv/bin/hermes tools list --platform telegram 2>/dev/null
      ' 2>/dev/null || true)"
      if grep -Eq "enabled[[:space:]]+stack-package-policy([[:space:]]|$)" <<< "$telegram_tools"; then
        printf 'Hermes package policy toolset: enabled for Telegram\n'
      else
        printf 'WARNING: stack-package-policy is not enabled for Telegram.\n'
      fi
      if grep -Eq "enabled[[:space:]]+stack-execution-policy([[:space:]]|$)" <<< "$telegram_tools"; then
        printf 'Hermes execution policy toolset: enabled for Telegram\n'
      else
        printf 'WARNING: stack-execution-policy is not enabled for Telegram.\n'
      fi
      # Upstream terminal/code_execution are a local decision, so report their
      # real state rather than asserting one. Enabled means an approved command
      # runs as the gateway uid, which can read /opt/data/.env.
      upstream_terminal=unknown
      if [[ -n "$telegram_tools" ]]; then
        if grep -Eq "disabled[[:space:]]+terminal([[:space:]]|$)" <<< "$telegram_tools" \
          && grep -Eq "disabled[[:space:]]+code_execution([[:space:]]|$)" <<< "$telegram_tools"; then
          upstream_terminal=disabled
        else
          upstream_terminal=enabled
        fi
      fi
      if [[ "$upstream_terminal" == disabled ]]; then
        printf 'Upstream terminal/code execution: disabled (isolated stack tools only)\n'
      elif [[ "$upstream_terminal" == enabled ]]; then
        printf 'Upstream terminal/code execution: ENABLED — approved commands run as the\n'
        printf '  gateway uid inside hermes-agent and can read /opt/data/.env. Keep\n'
        printf '  approvals.mode=manual and the Telegram allowlist tight.\n'
      else
        printf 'WARNING: could not determine the upstream terminal state.\n'
      fi
    fi
    if [[ "$profiles" == *smart-router* ]]; then
      compose exec -T smart-router python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8080/health", timeout=5)'
      compose exec -T smart-router python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8080/ready", timeout=5)'
      printf 'Smart Router health/readiness: valid\n'
    fi
    if [[ -d "$STACK_SECRETS_DIR" ]]; then
      secret_dir_mode="$(stat -c '%a' "$STACK_SECRETS_DIR")"
      printf 'Stack secret directory mode: %s\n' "$secret_dir_mode"
      [[ "$secret_dir_mode" == 700 ]] || printf 'WARNING: expected data/stack-secrets mode 700\n'
      # The bootstrap containers drop all capabilities, so a mismatched owner
      # makes mode 700 unreadable to them even when manage.sh runs as root.
      secret_dir_owner="$(stat -c '%u:%g' "$STACK_SECRETS_DIR")"
      if [[ "$secret_dir_owner" == "$(id -u):$(id -g)" ]]; then
        printf 'Stack secret directory owner: matches the invoking user\n'
      else
        printf 'WARNING: data/stack-secrets is owned by %s, not %s:%s; n8n bootstrap will fail with EACCES\n' \
          "$secret_dir_owner" "$(id -u)" "$(id -g)"
      fi
      if [[ -f "$N8N_BOOTSTRAP_ENV" ]]; then
        bootstrap_mode="$(stat -c '%a' "$N8N_BOOTSTRAP_ENV")"
        printf 'n8n bootstrap secret mode: %s\n' "$bootstrap_mode"
        [[ "$bootstrap_mode" == 600 ]] || printf 'WARNING: expected n8n bootstrap secret mode 600\n'
      fi
      if [[ -d "$(execution_root)" ]]; then
        printf 'Execution features: %s\n' "$(execution_features | sed 's/^$/off/')"
        execution_gateway_uid="$(execution_hermes_uid)"
        for execution_file in "$(execution_root)/control-secret" "$(execution_root)/users"; do
          if [[ -f "$execution_file" && ! -L "$execution_file" \
            && "$(stat -c %a "$execution_file")" == 640 \
            && "$(stat -c %u "$execution_file")" == "$execution_gateway_uid" \
            && "$(stat -c %g "$execution_file")" == 10003 ]]; then
            printf 'Execution policy %s: safe owner %s, group 10003, mode 640\n' \
              "${execution_file##*/}" "$execution_gateway_uid"
          else
            printf 'WARNING: shared execution policy %s is missing or has unsafe permissions.\n' "${execution_file##*/}"
          fi
        done
        for execution_file in "$(execution_root)/approval-request-secret" \
          "$(execution_root)/approval-signing-key.pem" "$(execution_root)/approval-public-key.pem" \
          "$(execution_root)/approval-bot-token" "$(execution_root)/ssh-profile-integrity-secret"; do
          if [[ -f "$execution_file" && ! -L "$execution_file" && "$(stat -c %a "$execution_file")" == 600 ]]; then
            printf 'Execution policy %s: safe private mode 600\n' "${execution_file##*/}"
          else
            printf 'WARNING: private execution policy %s is missing, unsafe, or not mode 600.\n' "${execution_file##*/}"
          fi
        done
        users="$(execution_users)"
        if [[ -z "$users" ]] || execution_users_valid "$users"; then
          printf 'Execution user policy: valid Telegram subset\n'
        else
          printf 'WARNING: execution users are not a subset of TELEGRAM_ALLOWED_USERS.\n'
        fi
        rendered="$(compose config 2>/dev/null || true)"
        rendered_json="$(compose config --format json 2>/dev/null || true)"
        socket_owners="$(python3 -c '
import json, sys
try:
    data = json.load(sys.stdin)
except (json.JSONDecodeError, TypeError):
    raise SystemExit(0)
for name, service in (data.get("services") or {}).items():
    for volume in service.get("volumes") or []:
        source = volume.get("source", "") if isinstance(volume, dict) else str(volume).split(":", 1)[0]
        target = volume.get("target", "") if isinstance(volume, dict) else ""
        if source == "/var/run/docker.sock" or target == "/var/run/docker.sock":
            print(name)
' <<< "$rendered_json")"
        socket_count="$(grep -c . <<< "$socket_owners" || true)"
        if [[ "$socket_count" == 1 && "$socket_owners" == execution-docker-broker ]]; then
          printf 'Docker socket boundary: mounted once, Docker broker only\n'
        else
          printf 'WARNING: expected exactly one Docker socket mount on Docker broker; found %s (%s).\n' \
            "$socket_count" "${socket_owners:-none}"
        fi
        if grep -A35 '^  hermes:' <<< "$rendered" | grep -q docker.sock; then
          printf 'WARNING: Hermes has the Docker socket; remove it immediately.\n'
        fi
        if grep -A50 '^  execution-ssh-broker:' <<< "$rendered" | grep -q docker.sock; then
          printf 'WARNING: SSH broker has the Docker socket; remove it immediately.\n'
        fi
        approval_token_count="$(grep -c '/run/secrets/execution-approval-bot-token' <<< "$rendered" || true)"
        [[ "$approval_token_count" == 2 ]] \
          && printf 'Approval bot token boundary: approver mount and environment only\n' \
          || printf 'WARNING: approval bot token wiring count is unexpected: %s.\n' "$approval_token_count"
        hermes_block="$(grep -A55 '^  hermes:' <<< "$rendered")"
        if grep -q 'execution-approval\|execution-approval-bot-token' <<< "$hermes_block"; then
          printf 'WARNING: Hermes has independent approval authority; disable execution immediately.\n'
        fi
        approver_block="$(grep -A55 '^  execution-approver:' <<< "$rendered")"
        if grep -q 'docker.sock\|/profiles\|execution-ssh-profile-integrity' <<< "$approver_block"; then
          printf 'WARNING: the approver has Docker or SSH execution authority.\n'
        fi
        ssh_integrity_count="$(grep -c '/run/secrets/execution-ssh-profile-integrity' <<< "$rendered" || true)"
        [[ "$ssh_integrity_count" == 2 ]] \
          && printf 'SSH password integrity boundary: SSH broker mount and environment only\n' \
          || printf 'WARNING: SSH password integrity secret wiring count is unexpected: %s.\n' "$ssh_integrity_count"
        if grep -q '^[[:space:]]*- stack-execution-policy[[:space:]]*$' "$ROOT_DIR/data/hermes/config.yaml"; then
          printf 'Hermes execution policy plugin: enabled in config\n'
        else
          printf 'WARNING: stack-execution-policy is not enabled in Hermes config.\n'
        fi
      fi
    fi
    if [[ "$profiles" == *execution-approval* ]]; then
      compose exec -T execution-approver python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8751/health", timeout=5)'
      printf 'Independent execution approver: healthy\n'
    fi
    if [[ "$profiles" == *execution-docker* ]]; then
      compose exec -T execution-docker-broker python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8750/health", timeout=5)'
      printf 'Docker execution broker: healthy\n'
    fi
    if [[ "$profiles" == *execution-ssh* ]]; then
      compose exec -T execution-ssh-broker python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8750/health", timeout=5)'
      printf 'SSH execution broker: healthy\n'
    fi
    if [[ "$profiles" == *n8n* ]]; then
      compose exec -T n8n node -e \
        "fetch('http://127.0.0.1:5678/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
      printf 'n8n health: valid\n'
      if [[ "$profiles" == *hermes* && -f "$HERMES_ENV" ]]; then
        selected_mcp_mode="$(n8n_mcp_mode 2>/dev/null || true)"
        printf 'Hermes n8n MCP mode: %s\n' "${selected_mcp_mode:-invalid}"
        mcp_url=""
        case "$selected_mcp_mode" in
          instance)
            mcp_url="$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_URL)"
            if [[ -z "$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN)" ]]; then
              printf 'WARNING: Instance MCP mode is pending a token. Enable it in n8n and run ./manage.sh set-n8n-instance-mcp-token.\n'
            fi
            ;;
          trigger)
            mcp_url="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_URL)"
            [[ -n "$mcp_url" ]] || mcp_url="$(env_value "$HERMES_ENV" N8N_MCP_URL)"
            ;;
          off) printf 'Hermes -> n8n MCP: disabled; retained n8n objects are not deleted.\n' ;;
        esac
        if [[ -n "$mcp_url" ]]; then
          code="$(compose exec -T -e HERMES_N8N_MCP_URL="$mcp_url" hermes sh -c \
            'curl -s -o /dev/null -w "%{http_code}" --max-time 5 -X POST -H "Content-Type: application/json" --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\",\"capabilities\":{},\"clientInfo\":{\"name\":\"doctor\",\"version\":\"1\"}}}" "$HERMES_N8N_MCP_URL"' \
            2>/dev/null || true)"
          case "$code" in
            401|403) printf 'Hermes -> n8n %s MCP endpoint: reachable and rejects anonymous access (HTTP %s)\n' "$selected_mcp_mode" "$code" ;;
            404)
              if [[ "$selected_mcp_mode" == trigger ]]; then
                printf 'Hermes -> n8n Trigger MCP endpoint: HTTP 404; reconcile Trigger mode to publish it.\n'
              else
                printf 'Hermes -> n8n Instance MCP endpoint: HTTP 404; enable Instance-level MCP in n8n Settings.\n'
              fi
              ;;
            000|"") printf 'WARNING: Hermes cannot reach %s over the Docker network.\n' "$mcp_url" ;;
            *) printf 'Hermes -> n8n %s MCP endpoint: unexpected anonymous HTTP %s\n' "$selected_mcp_mode" "$code" ;;
          esac
        fi
      fi
    fi
    if [[ "$profiles" == *caddy* ]]; then
      compose run --rm --no-deps caddy caddy validate --config /etc/caddy/Caddyfile
    fi
    ;;
  configure) exec "$ROOT_DIR/install.sh" ;;
  set-router-mode)
    mode="${2:-}"
    [[ "$mode" == observe || "$mode" == route ]] || {
      printf 'Mode must be observe or route.\n' >&2
      exit 2
    }
    profiles="$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_FILE")"
    [[ ",$profiles," == *,smart-router,* ]] || {
      printf 'Smart Router is not selected. Run ./manage.sh configure first.\n' >&2
      exit 1
    }
    replace_env_value "$ENV_FILE" SMART_ROUTER_MODE "$mode"
    if [[ ",$profiles," == *,omniroute,* ]]; then
      compose up -d omniroute smart-router
    else
      compose up -d nine-router smart-router
    fi
    ready=false
    for _ in {1..60}; do
      if compose exec -T smart-router python -c \
        'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8080/ready", timeout=5)' \
        >/dev/null 2>&1; then
        ready=true
        break
      fi
      sleep 2
    done
    [[ "$ready" == true ]] || {
      printf 'Smart Router was recreated but did not become ready.\n' >&2
      exit 1
    }
    if ! router_runtime_set "{\"router_mode\":\"$mode\"}" >/dev/null; then
      printf 'WARNING: mode was written to .env, but the Operations Center runtime override could not be synchronized.\n' >&2
      printf 'If a previous UI override exists, open System and choose Reset to environment, then retry.\n' >&2
    fi
    printf 'Smart Router mode changed to %s and is ready.\n' "$mode"
    ;;
  show-telegram-users)
    [[ -f "$HERMES_ENV" ]] || { printf 'Hermes is not configured.\n' >&2; exit 1; }
    grep '^TELEGRAM_ALLOWED_USERS=' "$HERMES_ENV" || true
    ;;
  set-telegram-users)
    ids="${2:-}"
    ids="${ids//[[:space:]]/}"
    valid_ids "$ids" || { printf 'Use numeric comma-separated IDs.\n' >&2; exit 2; }
    [[ -f "$HERMES_ENV" ]] || { printf 'Hermes is not configured.\n' >&2; exit 1; }
    replace_env_value "$HERMES_ENV" TELEGRAM_ALLOWED_USERS "$ids"
    restart_hermes
    printf 'Telegram allowlist replaced: %s\n' "$ids"
    ;;
  add-telegram-user)
    new_id="${2:-}"
    [[ "$new_id" =~ ^[0-9]+$ ]] || { printf 'A numeric Telegram ID is required.\n' >&2; exit 2; }
    [[ -f "$HERMES_ENV" ]] || { printf 'Hermes is not configured.\n' >&2; exit 1; }
    current="$(sed -n 's/^TELEGRAM_ALLOWED_USERS=//p' "$HERMES_ENV" | head -n1)"
    if [[ ",$current," == *",$new_id,"* ]]; then
      printf 'Telegram ID %s is already allowed.\n' "$new_id"
      exit 0
    fi
    if [[ -n "$current" ]]; then updated="$current,$new_id"; else updated="$new_id"; fi
    replace_env_value "$HERMES_ENV" TELEGRAM_ALLOWED_USERS "$updated"
    restart_hermes
    printf 'Telegram ID %s added.\n' "$new_id"
    ;;
  restart-hermes)
    profiles="$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_FILE")"
    [[ "$profiles" == *hermes* ]] || { printf 'Hermes is not selected.\n' >&2; exit 1; }
    restart_hermes
    printf 'Hermes recreated; config.yaml and MCP tools were reloaded.\n'
    ;;
  set-agent-max-turns)
    require_profiles hermes
    turns="${2:-}"
    [[ -z "${3:-}" && "$turns" =~ ^[0-9]+$ ]] || {
      printf 'Usage: ./manage.sh set-agent-max-turns N\n' >&2
      exit 2
    }
    turns=$((10#$turns))
    (( turns >= AGENT_MAX_TURNS_MIN && turns <= AGENT_MAX_TURNS_MAX )) || {
      printf 'Choose a budget between %s and %s. 90 suits most tasks; 150 suits long exploration. An unbounded budget amplifies stuck tool loops and cost.\n' \
        "$AGENT_MAX_TURNS_MIN" "$AGENT_MAX_TURNS_MAX" >&2
      exit 2
    }
    config_file="$ROOT_DIR/data/hermes/config.yaml"
    ensure_stack_secrets_dir
    turns_backup="$(mktemp "$STACK_SECRETS_DIR/max-turns-config.backup.XXXXXX")"
    TEMP_SECRET_FILES+=("$turns_backup")
    cp --preserve=mode,ownership,timestamps "$config_file" "$turns_backup"
    set_hermes_agent_max_turns "$turns" || {
      cp --preserve=mode,ownership,timestamps "$turns_backup" "$config_file"
      printf 'Hermes config was not modified.\n' >&2
      exit 1
    }
    if ! restart_hermes; then
      cp --preserve=mode,ownership,timestamps "$turns_backup" "$config_file"
      restart_hermes || true
      printf 'Hermes failed to start with the new budget; the prior config was restored.\n' >&2
      exit 1
    fi
    effective="$(compose exec -T hermes sh -lc 'printf "%s" "${HERMES_MAX_ITERATIONS:-}"' 2>/dev/null || true)"
    if [[ -n "$effective" && "$effective" != "$turns" ]]; then
      printf 'WARNING: config.yaml requests %s turns but the gateway reports %s.\n' \
        "$turns" "$effective" >&2
    fi
    printf 'Agent iteration budget set to %s. Reaching it stops the turn safely; send a new message to continue from the summary.\n' "$turns"
    ;;
  set-upstream-terminal)
    require_profiles hermes
    state="${2:-}"
    [[ -z "${3:-}" && ( "$state" == enabled || "$state" == disabled ) ]] || {
      printf 'Usage: ./manage.sh set-upstream-terminal enabled|disabled\n' >&2
      exit 2
    }
    if [[ "$state" == enabled ]]; then
      printf 'Enabling upstream terminal and code_execution.\n'
      printf 'They run as the gateway uid inside hermes-agent, which owns /opt/data/.env:\n'
      printf '  the Telegram bot token, router backend key, API server key, and n8n Instance token.\n'
      printf 'Every call still passes the hardline floor and a manual approval prompt, but an\n'
      printf 'approved command can read those secrets, and prompt injection reaching the model\n'
      printf 'can request one. Rotating afterwards does not undo an exfiltration.\n'
      read -r -p 'Type ENABLE to confirm: ' confirm
      [[ "$confirm" == ENABLE ]] || { printf 'Unchanged.\n' >&2; exit 1; }
    fi
    config_file="$ROOT_DIR/data/hermes/config.yaml"
    ensure_stack_secrets_dir
    terminal_backup="$(mktemp "$STACK_SECRETS_DIR/terminal-config.backup.XXXXXX")"
    TEMP_SECRET_FILES+=("$terminal_backup")
    cp --preserve=mode,ownership,timestamps "$config_file" "$terminal_backup"
    set_upstream_terminal "$state" || {
      cp --preserve=mode,ownership,timestamps "$terminal_backup" "$config_file"
      printf 'Hermes config was not modified.\n' >&2
      exit 1
    }
    if ! restart_hermes; then
      cp --preserve=mode,ownership,timestamps "$terminal_backup" "$config_file"
      restart_hermes || true
      printf 'Hermes failed to start; the prior config was restored.\n' >&2
      exit 1
    fi
    printf 'Upstream terminal/code execution: %s. Run ./manage.sh doctor to confirm.\n' "$state"
    ;;
  execution-status)
    printf 'Execution features: %s\n' "$(execution_features | sed 's/^$/off/')"
    printf 'Execution users: %s\n' "$(execution_users | sed 's/^$/none/')"
    printf 'Policy generation: %s\n' "$(tr -d '[:space:]' < "$(execution_root)/policy-generation" 2>/dev/null || env_value "$ENV_FILE" EXECUTION_POLICY_GENERATION)"
    printf 'Execution admin UI: %s\n' "$(env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED | sed 's/^$/false/')"
    if [[ -d "$(execution_root)/ssh" ]]; then
      printf 'SSH profiles:'
      found=false
      for profile_dir in "$(execution_root)/ssh"/*; do
        [[ -d "$profile_dir" && ! -L "$profile_dir" ]] || continue
        profile_auth="$(python3 - "$profile_dir/profile.json" <<'PY' 2>/dev/null || printf invalid
import json,sys
print(json.load(open(sys.argv[1], encoding="utf-8")).get("auth", "publickey"))
PY
)"
        printf ' %s(%s)' "${profile_dir##*/}" "$profile_auth"; found=true
      done
      [[ "$found" == true ]] || printf ' none'
      printf '\n'
    fi
    ;;
  set-execution-users)
    users="${2:-}"
    [[ -z "${3:-}" && -n "$users" ]] || { printf 'Usage: ./manage.sh set-execution-users ID1,ID2,...\n' >&2; exit 2; }
    execution_users_valid "$users" || {
      printf 'Execution users must be numeric and a subset of TELEGRAM_ALLOWED_USERS.\n' >&2; exit 2;
    }
    write_execution_users "$users"
    rotate_execution_generation
    restart_hermes
    printf 'Execution users updated; all pending operations were revoked.\n'
    ;;
  add-execution-user)
    user="${2:-}"
    [[ -z "${3:-}" && "$user" =~ ^[0-9]+$ ]] || { printf 'Usage: ./manage.sh add-execution-user ID\n' >&2; exit 2; }
    users="$(execution_users)"
    [[ ",$users," == *",$user,"* ]] || users="${users:+$users,}$user"
    execution_users_valid "$users" || { printf 'ID must already be in TELEGRAM_ALLOWED_USERS.\n' >&2; exit 2; }
    write_execution_users "$users"; rotate_execution_generation; restart_hermes
    printf 'Execution user %s added.\n' "$user"
    ;;
  remove-execution-user)
    user="${2:-}"
    [[ -z "${3:-}" && "$user" =~ ^[0-9]+$ ]] || { printf 'Usage: ./manage.sh remove-execution-user ID\n' >&2; exit 2; }
    users="$(execution_users | tr ',' '\n' | grep -vx "$user" | paste -sd, -)"
    [[ -n "$users" ]] && write_execution_users "$users" || write_execution_users ""
    rotate_execution_generation; restart_hermes
    printf 'Execution user %s removed; pending operations revoked.\n' "$user"
    ;;
  enable-execution|disable-execution)
    requested_feature="${2:-}"
    [[ -z "${3:-}" && "$requested_feature" =~ ^(sandbox|ssh|docker|all)$ ]] || {
      printf 'Usage: ./manage.sh %s sandbox|ssh|docker|all\n' "$1" >&2; exit 2;
    }
    if [[ "$1" == enable-execution ]]; then
      features="$(set_execution_feature "$requested_feature" true)"
    else
      features="$(set_execution_feature "$requested_feature" false)"
    fi
    ensure_execution_paths
    if [[ "$1" == enable-execution ]]; then
      [[ -n "$(execution_users)" ]] || { printf 'Set execution users first.\n' >&2; exit 1; }
      [[ -s "$(execution_root)/control-secret" \
        && -s "$(execution_root)/approval-bot-token" \
        && -s "$(execution_root)/approval-request-secret" \
        && -s "$(execution_root)/approval-signing-key.pem" \
        && -s "$(execution_root)/approval-public-key.pem" ]] || {
        printf 'Configure the dedicated approval bot first: ./manage.sh set-execution-approval-bot-token\n' >&2; exit 1;
      }
      if [[ ",$features," == *,ssh,* && ! -s "$(execution_root)/ssh-profile-integrity-secret" ]]; then
        tmp="$(mktemp "$(execution_root)/ssh-profile-integrity-secret.tmp.XXXXXX")"
        random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"
        mv "$tmp" "$(execution_root)/ssh-profile-integrity-secret"
      fi
      enable_hermes_execution_plugin
      replace_env_value "$ENV_FILE" EXECUTION_DOCKER_GID "$(stat -c %g /var/run/docker.sock 2>/dev/null || printf 999)"
      replace_env_value "$ENV_FILE" EXECUTION_WORKSPACE_HOST_PATH "$ROOT_DIR/data/execution-workspace"
      [[ -n "$(env_value "$ENV_FILE" EXECUTION_SANDBOX_IMAGE)" ]] || \
        replace_env_value "$ENV_FILE" EXECUTION_SANDBOX_IMAGE \
          'python:3.13.5-slim-bookworm@sha256:4c2cf9917bd1cbacc5e9b07320025bdb7cdf2df7b0ceaccb55e9dd7e30987419'
      if [[ ",$features," == *,local,* ]]; then
        sandbox_image="$(env_value "$ENV_FILE" EXECUTION_SANDBOX_IMAGE)"
        [[ "$sandbox_image" =~ @sha256:[0-9a-f]{64}$ ]] || {
          printf 'EXECUTION_SANDBOX_IMAGE must use an immutable sha256 digest.\n' >&2
          exit 1
        }
        "${DOCKER[@]}" pull "$sandbox_image"
      fi
    fi
    apply_execution_features "$features"
    printf 'Execution features now: %s\n' "${features:-off}"
    ;;
  set-execution-approval-bot-token)
    [[ -z "${2:-}" ]] || { printf 'Do not pass Telegram tokens in argv.\n' >&2; exit 2; }
    ensure_execution_paths
    sync_execution_admin_metadata
    read -r -s -p 'Dedicated execution approval Telegram bot token: ' token
    printf '\n' >&2
    [[ "$token" =~ ^[0-9]+:[A-Za-z0-9_-]{20,}$ ]] || {
      printf 'The Telegram bot token format is invalid.\n' >&2; exit 2;
    }
    hermes_bot_token="$(env_value "$HERMES_ENV" TELEGRAM_BOT_TOKEN)"
    [[ -z "$hermes_bot_token" || "$token" != "$hermes_bot_token" ]] || {
      printf 'The execution approver must use a different Telegram bot from Hermes.\n' >&2
      exit 2
    }
    root="$(execution_root)"
    tmp="$(mktemp "$root/approval-bot-token.tmp.XXXXXX")"
    printf '%s\n' "$token" > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/approval-bot-token"
    if [[ ! -s "$root/control-secret" ]]; then
      tmp="$(mktemp "$root/control-secret.tmp.XXXXXX")"
      random_hex 32 > "$tmp"; chown "$(execution_hermes_uid):10003" "$tmp"; chmod 640 "$tmp"; mv "$tmp" "$root/control-secret"
    fi
    if [[ ! -s "$root/approval-request-secret" ]]; then
      tmp="$(mktemp "$root/approval-request-secret.tmp.XXXXXX")"
      random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/approval-request-secret"
    fi
    if [[ ! -s "$root/ssh-profile-integrity-secret" ]]; then
      tmp="$(mktemp "$root/ssh-profile-integrity-secret.tmp.XXXXXX")"
      random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/ssh-profile-integrity-secret"
    fi
    if [[ ! -s "$root/approval-signing-key.pem" || ! -s "$root/approval-public-key.pem" ]]; then
      private_tmp="$(mktemp "$root/approval-signing-key.tmp.XXXXXX")"
      public_tmp="$(mktemp "$root/approval-public-key.tmp.XXXXXX")"
      openssl genpkey -algorithm ED25519 -out "$private_tmp" >/dev/null 2>&1
      openssl pkey -in "$private_tmp" -pubout -out "$public_tmp" >/dev/null 2>&1
      chown 10003:10003 "$private_tmp" "$public_tmp"
      chmod 600 "$private_tmp" "$public_tmp"
      mv "$private_tmp" "$root/approval-signing-key.pem"
      mv "$public_tmp" "$root/approval-public-key.pem"
    fi
    rotate_execution_generation
    printf 'Dedicated execution approval bot configured without printing its token. Execution remains off until explicitly enabled.\n'
    ;;
  enable-execution-admin)
    [[ -z "${2:-}" ]] || { printf 'Usage: ./manage.sh enable-execution-admin\n' >&2; exit 2; }
    ensure_execution_paths; sync_execution_admin_metadata
    root="$(execution_root)"
    if [[ ! -s "$root/admin-key" ]]; then
      tmp="$(mktemp "$root/admin-key.tmp.XXXXXX")"; random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/admin-key"
    fi
    replace_env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED true
    [[ -n "$(env_value "$ENV_FILE" EXECUTION_ADMIN_PORT)" ]] || replace_env_value "$ENV_FILE" EXECUTION_ADMIN_PORT 8752
    sync_execution_profiles
    compose pull execution-admin
    compose up -d --no-deps --force-recreate execution-admin
    printf 'Execution admin UI enabled on %s:%s. The separate admin key was not printed.\n' "$(env_value "$ENV_FILE" EXECUTION_ADMIN_BIND_IP | sed 's/^$/127.0.0.1/')" "$(env_value "$ENV_FILE" EXECUTION_ADMIN_PORT | sed 's/^$/8752/')"
    printf 'Use ./manage.sh show-execution-admin-key from a trusted terminal when you need to connect the Operations Center page.\n'
    ;;
  configure-execution-admin-browser)
    origin="${2:-}"; bind_ip="${3:-}"
    [[ -z "${4:-}" && -n "$origin" ]] || { printf 'Usage: ./manage.sh configure-execution-admin-browser ORIGIN [PRIVATE_IPV4]\n' >&2; exit 2; }
    validation="$(python3 - "$origin" "$bind_ip" <<'EXECADMINPY'
import ipaddress,sys
from urllib.parse import urlsplit
origin=sys.argv[1].strip().rstrip('/')
bind=sys.argv[2].strip()
try:
    u=urlsplit(origin)
except Exception:
    raise SystemExit('Invalid origin.')
if u.scheme not in {'http','https'} or not u.hostname or u.username or u.password or (u.path not in {'','/'}) or u.query or u.fragment:
    raise SystemExit('Origin must be an exact http(s) origin such as http://192.168.1.20:8787.')
try:
    port=u.port
except ValueError:
    raise SystemExit('Origin port is invalid.')
if port is not None and not (1 <= port <= 65535):
    raise SystemExit('Origin port is invalid.')
if not bind:
    try: bind=str(ipaddress.ip_address(u.hostname))
    except ValueError: raise SystemExit('When ORIGIN uses DNS, pass the server private IPv4 as the second argument.')
try: ip=ipaddress.ip_address(bind)
except ValueError: raise SystemExit('Bind address must be an IPv4 address.')
if ip.version != 4: raise SystemExit('This helper currently requires IPv4.')
if ip.is_unspecified or ip.is_multicast:
    raise SystemExit('Wildcard/multicast binds are refused. Use the exact private administration address.')
if not (ip.is_private or ip.is_loopback or ip.is_link_local):
    raise SystemExit('Refusing a public Execution Admin bind. Use a private/loopback administration address or a trusted reverse proxy.')
print(origin)
print(str(ip))
EXECADMINPY
    )" || { printf '%s\n' "$validation" >&2; exit 2; }
    origin="$(printf '%s\n' "$validation" | sed -n '1p')"
    bind_ip="$(printf '%s\n' "$validation" | sed -n '2p')"
    current="$(env_value "$ENV_FILE" EXECUTION_ADMIN_ALLOWED_ORIGINS)"
    current="${current:-http://127.0.0.1:8787,http://localhost:8787}"
    allowed="$(python3 - "$current" "$origin" <<'EXECORIGINPY'
import sys
seen=[]
for item in (sys.argv[1]+','+sys.argv[2]).split(','):
    item=item.strip().rstrip('/')
    if item and item not in seen: seen.append(item)
print(','.join(seen))
EXECORIGINPY
    )"
    ensure_execution_paths; sync_execution_admin_metadata
    root="$(execution_root)"
    if [[ ! -s "$root/admin-key" ]]; then
      tmp="$(mktemp "$root/admin-key.tmp.XXXXXX")"; random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/admin-key"
    fi
    replace_env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED true
    replace_env_value "$ENV_FILE" EXECUTION_ADMIN_BIND_IP "$bind_ip"
    replace_env_value "$ENV_FILE" EXECUTION_ADMIN_ALLOWED_ORIGINS "$allowed"
    [[ -n "$(env_value "$ENV_FILE" EXECUTION_ADMIN_PORT)" ]] || replace_env_value "$ENV_FILE" EXECUTION_ADMIN_PORT 8752
    sync_execution_profiles
    compose pull execution-admin
    compose up -d --no-deps --force-recreate execution-admin
    printf 'Execution Admin browser access configured.\n'
    printf 'Bind: %s:%s\n' "$bind_ip" "$(env_value "$ENV_FILE" EXECUTION_ADMIN_PORT | sed 's/^$/8752/')"
    printf 'Allowed origin added: %s\n' "$origin"
    printf 'The separate admin key was not printed. Use ./manage.sh show-execution-admin-key in a trusted terminal.\n'
    ;;
  disable-execution-admin)
    [[ -z "${2:-}" ]] || { printf 'Usage: ./manage.sh disable-execution-admin\n' >&2; exit 2; }
    replace_env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED false
    sync_execution_profiles
    COMPOSE_PROFILES=execution-admin compose rm -sf execution-admin >/dev/null 2>&1 || true
    printf 'Execution admin UI disabled. Broker/approver execution policy is unchanged.\n'
    ;;
  execution-admin-status)
    ensure_execution_paths
    printf 'Enabled: %s\n' "$(env_value "$ENV_FILE" EXECUTION_ADMIN_ENABLED | sed 's/^$/false/')"
    printf 'Bind: %s:%s\n' "$(env_value "$ENV_FILE" EXECUTION_ADMIN_BIND_IP | sed 's/^$/127.0.0.1/')" "$(env_value "$ENV_FILE" EXECUTION_ADMIN_PORT | sed 's/^$/8752/')"
    printf 'Allowed origins: %s\n' "$(env_value "$ENV_FILE" EXECUTION_ADMIN_ALLOWED_ORIGINS | sed 's/^$/http:\/\/127.0.0.1:8787,http:\/\/localhost:8787/')"
    [[ -s "$(execution_root)/admin-key" ]] && printf 'Admin key: configured (hidden)\n' || printf 'Admin key: missing\n'
    printf 'Remote browser helper: ./manage.sh configure-execution-admin-browser ORIGIN PRIVATE_IPV4\n'
    ;;
  rotate-execution-admin-key)
    [[ -z "${2:-}" ]] || { printf 'Do not pass execution admin keys in argv.\n' >&2; exit 2; }
    ensure_execution_paths; root="$(execution_root)"
    tmp="$(mktemp "$root/admin-key.tmp.XXXXXX")"; random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$root/admin-key"
    COMPOSE_PROFILES=execution-admin compose up -d --no-deps --force-recreate execution-admin >/dev/null 2>&1 || true
    printf 'Execution admin key rotated. Existing browser sessions must reconnect.\n'
    ;;
  show-execution-admin-key)
    [[ -z "${2:-}" ]] || { printf 'Usage: ./manage.sh show-execution-admin-key\n' >&2; exit 2; }
    [[ -t 1 ]] || { printf 'Refusing to print the execution admin key to a non-interactive output.\n' >&2; exit 1; }
    ensure_execution_paths
    [[ -s "$(execution_root)/admin-key" ]] || { printf 'Admin key is not configured; run ./manage.sh enable-execution-admin first.\n' >&2; exit 1; }
    printf 'Sensitive: enter this only into Operations Center → Execution & Approvals. Do not save it in browser storage.\n'
    cat "$(execution_root)/admin-key"; printf '\n'
    ;;
  rotate-execution-broker-secret)
    [[ -z "${2:-}" ]] || { printf 'Do not pass broker secrets in argv.\n' >&2; exit 2; }
    ensure_execution_paths
    secret_file="$(execution_root)/control-secret"
    tmp="$(mktemp "$(execution_root)/control-secret.tmp.XXXXXX")"
    random_hex 32 > "$tmp"; chown "$(execution_hermes_uid):10003" "$tmp"; chmod 640 "$tmp"; mv "$tmp" "$secret_file"
    secret_file="$(execution_root)/ssh-profile-integrity-secret"
    tmp="$(mktemp "$(execution_root)/ssh-profile-integrity-secret.tmp.XXXXXX")"
    random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$secret_file"
    rotate_execution_generation
    compose up -d --force-recreate hermes execution-approver execution-docker-broker execution-ssh-broker
    printf 'Execution broker secret rotated; pending operations revoked.\n'
    ;;
  add-ssh-profile)
    name="${2:-}"
    [[ -z "${3:-}" && "$name" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ ]] || { printf 'Use a lowercase safe profile name.\n' >&2; exit 2; }
    ensure_execution_paths
    integrity_secret="$(execution_root)/ssh-profile-integrity-secret"
    if [[ ! -s "$integrity_secret" ]]; then
      tmp="$(mktemp "$(execution_root)/ssh-profile-integrity-secret.tmp.XXXXXX")"
      random_hex 32 > "$tmp"; chown 10003:10003 "$tmp"; chmod 600 "$tmp"; mv "$tmp" "$integrity_secret"
    fi
    root="$(execution_root)/ssh"; target="$root/$name"
    [[ ! -e "$target" && ! -L "$target" ]] || { printf 'Profile already exists.\n' >&2; exit 1; }
    read -r -p 'SSH host: ' host
    read -r -p 'SSH port [22]: ' port; port="${port:-22}"
    read -r -p 'SSH user: ' ssh_user
    read -r -p 'Authority [user|root|sudo-nopasswd]: ' authority
    read -r -p 'Authentication [publickey|password]: ' auth
    auth="${auth:-publickey}"
    [[ "$host" =~ ^[A-Za-z0-9._:-]+$ && "$port" =~ ^[0-9]+$ && "$port" -ge 1 && "$port" -le 65535 \
      && "$ssh_user" =~ ^[A-Za-z0-9._-]+$ && "$authority" =~ ^(user|root|sudo-nopasswd)$ \
      && "$auth" =~ ^(publickey|password)$ ]] || { printf 'Invalid profile values.\n' >&2; exit 2; }
    [[ "$ssh_user" != root || "$authority" == root ]] || { printf 'The root SSH user must use authority root.\n' >&2; exit 2; }
    stage="$(mktemp -d "$root/.${name}.tmp.XXXXXX")"; chmod 700 "$stage"
    TEMP_SECRET_FILES+=("$stage")
    if [[ "$auth" == publickey ]]; then
      if read -r -p 'Generate a dedicated Ed25519 key? [Y/n] ' answer && [[ "${answer:-y}" =~ ^[Yy]$ ]]; then
        ssh-keygen -q -t ed25519 -N '' -f "$stage/identity"
      else
        printf 'Paste an unencrypted private key, then Ctrl-D:\n' >&2
        umask 077; cat > "$stage/identity"
        ssh-keygen -y -f "$stage/identity" > "$stage/identity.pub"
      fi
      chmod 600 "$stage/identity" "$stage/identity.pub"
    fi
    ssh-keyscan -p "$port" -T 10 -- "$host" > "$stage/known_hosts.scan" 2>/dev/null || { rm -rf "$stage"; printf 'Host-key scan failed.\n' >&2; exit 1; }
    read -r -p 'Enter the independently verified SHA256 host fingerprint: ' expected
    [[ "$expected" =~ ^SHA256:[A-Za-z0-9+/]{20,}={0,2}$ ]] || { rm -rf "$stage"; printf 'Invalid host fingerprint.\n' >&2; exit 2; }
    status=0
    python3 - "$stage/known_hosts.scan" "$stage/known_hosts" "$expected" <<'PY' || status=$?
import subprocess, sys
source, target, expected = sys.argv[1:]
matches = []
for line in open(source, encoding="utf-8"):
    if not line.strip():
        continue
    completed = subprocess.run(["ssh-keygen", "-lf", "-", "-E", "sha256"], input=line,
        text=True, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    fields = completed.stdout.split()
    if completed.returncode == 0 and len(fields) > 1 and fields[1] == expected:
        matches.append(line)
if len(matches) != 1:
    raise SystemExit("The independently verified fingerprint did not select exactly one scanned host key.")
open(target, "w", encoding="utf-8").write(matches[0])
PY
    rm -f "$stage/known_hosts.scan"
    [[ "$status" -eq 0 ]] || { rm -rf "$stage"; printf 'Host fingerprint mismatch or ambiguity.\n' >&2; exit 1; }
    if [[ "$auth" == password ]]; then
      [[ -r /dev/tty && -w /dev/tty ]] || { rm -rf "$stage"; printf 'A controlling terminal is required for password input.\n' >&2; exit 1; }
      IFS= read -r -s -p 'SSH password: ' password < /dev/tty; printf '\n' > /dev/tty
      IFS= read -r -s -p 'Confirm SSH password: ' password_confirm < /dev/tty; printf '\n' > /dev/tty
      [[ -n "$password" && "$password" == "$password_confirm" && ${#password} -le 1024 \
        && "$password" != *$'\n'* && "$password" != *$'\r'* ]] || {
        unset password password_confirm; rm -rf "$stage"; printf 'Passwords are empty, mismatched, or too long.\n' >&2; exit 2;
      }
      umask 077; printf '%s' "$password" > "$stage/password"
      unset password password_confirm
      chmod 600 "$stage/password"
    fi
    revision="$(random_hex 32)"
    python3 - "$stage/profile.json" "$host" "$port" "$ssh_user" "$authority" "$expected" "$auth" "$revision" <<'PY'
import json, sys
path, host, port, user, authority, fingerprint, auth, revision = sys.argv[1:]
value = {"version": 2, "auth": auth, "credential_revision": revision, "host": host,
         "port": int(port), "user": user, "authority": authority, "fingerprint": fingerprint}
open(path, "w", encoding="utf-8").write(json.dumps(value, indent=2) + "\n")
PY
    chown -R 10003:10003 "$stage"
    chmod 700 "$stage"; find "$stage" -type f -exec chmod 600 {} +
    printf 'Pinned independently verified host fingerprint: %s\n' "$expected"
    if [[ "$auth" == publickey ]]; then
      printf 'Install this public key on %s@%s, then verify:\n' "$ssh_user" "$host"
      cat "$stage/identity.pub"
    else
      printf 'Password stored only in the protected SSH broker profile.\n'
    fi
    mv "$stage" "$target"
    rotate_execution_generation
    printf 'Profile %s stored. Run ./manage.sh verify-ssh-profile %s.\n' "$name" "$name"
    ;;
  verify-ssh-profile)
    name="${2:-}"; target="$(execution_root)/ssh/$name"
    [[ -z "${3:-}" && "$name" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ && -d "$target" && ! -L "$target" ]] || { printf 'Unknown or unsafe profile.\n' >&2; exit 2; }
    compose exec -T execution-ssh-broker python -m broker.ssh --probe "$name"
    printf 'SSH profile %s verified.\n' "$name"
    ;;
  set-ssh-profile-password)
    name="${2:-}"; target="$(execution_root)/ssh/$name"
    [[ -z "${3:-}" && "$name" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ && -d "$target" && ! -L "$target" ]] || { printf 'Unknown or unsafe profile.\n' >&2; exit 2; }
    auth="$(python3 - "$target/profile.json" <<'PY'
import json,sys
print(json.load(open(sys.argv[1], encoding="utf-8")).get("auth", "publickey"))
PY
)"
    [[ "$auth" == password ]] || { printf 'Only password profiles can use this command.\n' >&2; exit 2; }
    [[ -r /dev/tty && -w /dev/tty ]] || { printf 'A controlling terminal is required for password input.\n' >&2; exit 1; }
    IFS= read -r -s -p 'New SSH password: ' password < /dev/tty; printf '\n' > /dev/tty
    IFS= read -r -s -p 'Confirm new SSH password: ' password_confirm < /dev/tty; printf '\n' > /dev/tty
    [[ -n "$password" && "$password" == "$password_confirm" && ${#password} -le 1024 \
      && "$password" != *$'\n'* && "$password" != *$'\r'* ]] || {
      unset password password_confirm; printf 'Passwords are empty, mismatched, or too long.\n' >&2; exit 2;
    }
    root="$(execution_root)/ssh"; stage="$(mktemp -d "$root/.${name}.rotate.XXXXXX")"
    TEMP_SECRET_FILES+=("$stage"); cp -a "$target/." "$stage/"; chmod 700 "$stage"
    umask 077; printf '%s' "$password" > "$stage/password"; unset password password_confirm
    revision="$(random_hex 32)"
    python3 - "$stage/profile.json" "$revision" <<'PY'
import json,sys
path, revision = sys.argv[1:]
value=json.load(open(path, encoding="utf-8")); value["credential_revision"]=revision
open(path, "w", encoding="utf-8").write(json.dumps(value, indent=2)+"\n")
PY
    chown -R 10003:10003 "$stage"; chmod 700 "$stage"; find "$stage" -type f -exec chmod 600 {} +
    backup="$root/.${name}.backup.$(random_hex 8)"
    compose stop execution-ssh-broker >/dev/null 2>&1 || true
    mv "$target" "$backup"; mv "$stage" "$target"; rotate_execution_generation
    if compose up -d --force-recreate execution-ssh-broker && compose exec -T execution-ssh-broker python -m broker.ssh --probe "$name"; then
      rm -rf -- "$backup"; printf 'SSH profile %s password rotated and verified.\n' "$name"
    else
      rm -rf -- "$target"; mv "$backup" "$target"; rotate_execution_generation
      compose up -d --force-recreate execution-ssh-broker || true
      printf 'Password verification failed; the prior profile was restored.\n' >&2; exit 1
    fi
    ;;
  remove-ssh-profile)
    name="${2:-}"; target="$(execution_root)/ssh/$name"
    [[ -z "${3:-}" && "$name" =~ ^[a-z0-9][a-z0-9_-]{0,63}$ && -d "$target" && ! -L "$target" ]] || { printf 'Unknown or unsafe profile.\n' >&2; exit 2; }
    auth="$(python3 - "$target/profile.json" <<'PY'
import json,sys
print(json.load(open(sys.argv[1], encoding="utf-8")).get("auth", "publickey"))
PY
)"
    rm -rf -- "$target"; rotate_execution_generation
    compose up -d --force-recreate execution-ssh-broker hermes
    if [[ "$auth" == password ]]; then
      printf 'Local profile removed. Change or disable the remote account password separately.\n'
    else
      printf 'Local profile removed. Revoke its public key from remote authorized_keys separately.\n'
    fi
    ;;
  purge-execution)
    [[ -z "${2:-}" ]] || { printf 'Usage: ./manage.sh purge-execution\n' >&2; exit 2; }
    read -r -p 'Type PURGE-EXECUTION to delete keys, state, and workspace: ' confirm
    [[ "$confirm" == PURGE-EXECUTION ]] || { printf 'Unchanged.\n' >&2; exit 1; }
    replace_env_value "$ENV_FILE" EXECUTION_FEATURES ""; sync_execution_profiles
    COMPOSE_PROFILES=execution-approval,execution-docker,execution-ssh compose rm -sf execution-approver execution-docker-broker execution-ssh-broker || true
    rm -rf -- "$(execution_root)" "$ROOT_DIR/data/execution-workspace"
    ensure_execution_paths; restart_hermes
    printf 'Execution state purged. Remote authorized_keys and prior Docker effects are not reverted.\n'
    ;;
  set-n8n-api-key)
    require_profiles n8n
    if [[ -n "${2:-}" ]]; then
      printf 'For safety, do not pass the n8n API key in argv. Run without an argument.\n' >&2
      exit 2
    fi
    read -r -s -p 'n8n owner API key: ' key
    printf '\n' >&2
    [[ -n "$key" && "$key" != *[[:space:]]* ]] || {
      printf 'A non-empty API key without whitespace is required.\n' >&2; exit 2;
    }
    n8n_api_check "$key" || { printf 'n8n rejected the API key; nothing was stored.\n' >&2; exit 1; }
    write_n8n_bootstrap_key "$key"
    printf 'n8n bootstrap API key validated and stored with mode 0600.\n'
    ;;
  set-n8n-instance-mcp-token)
    require_router_backend
    require_profiles hermes n8n
    if [[ -n "${2:-}" ]]; then
      printf 'For safety, do not pass the Instance MCP token in argv. Run without an argument.\n' >&2
      exit 2
    fi
    read -r -s -p 'n8n Instance-level MCP token: ' token
    printf '\n' >&2
    [[ -n "$token" && "$token" != *[[:space:]]* ]] || {
      printf 'A non-empty token without whitespace is required.\n' >&2; exit 2;
    }
    case "$token" in
      Bearer\ *|bearer\ *|http://*|https://*|\"*|\'*)
        printf 'Paste only the n8n Instance MCP token, without Bearer, quotes, JSON, or the connection URL.\n' >&2
        exit 2
        ;;
    esac
    n8n_instance_mcp_check "$token" || {
      printf 'n8n rejected the Instance MCP token or required tools are unavailable; nothing was stored.\n' >&2
      exit 1
    }
    [[ -f "$HERMES_ENV" && ! -L "$HERMES_ENV" ]] || {
      printf 'Hermes secret configuration is missing or unsafe.\n' >&2; exit 1;
    }
    migrate_legacy_trigger_env
    replace_env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN "$token"
    chmod 600 "$HERMES_ENV"
    mode="$(n8n_mcp_mode)"
    if [[ "$mode" == instance ]]; then
      if ! run_n8n_reconciler; then
        printf 'The validated replacement token remains stored because regenerating an Instance token revokes the old token immediately; n8n reconciliation failed before Hermes was recreated.\n' >&2
        exit 1
      fi
      if ! set_hermes_n8n_mcp_entry instance; then
        printf 'The validated replacement token remains stored because regenerating an Instance token revokes the old token immediately; Hermes configuration reconciliation failed.\n' >&2
        exit 1
      fi
      finish_legacy_trigger_env_migration
      if ! restart_hermes; then
        printf 'The validated replacement token remains stored because regenerating an Instance token revokes the old token immediately; Hermes recreation failed.\n' >&2
        exit 1
      fi
      if ! "$ROOT_DIR/manage.sh" verify-n8n; then
        printf 'The validated replacement token remains stored because regenerating an Instance token revokes the old token immediately; Instance verification failed.\n' >&2
        exit 1
      fi
      printf 'n8n Instance MCP token validated, stored with mode 0600, and connected to Hermes.\n'
    else
      printf 'n8n Instance MCP token validated and stored with mode 0600. Activate it with ./manage.sh set-n8n-mcp-mode instance.\n'
    fi
    ;;
  remove-n8n-instance-mcp-token)
    require_profiles hermes n8n
    [[ -z "${2:-}" ]] || {
      printf 'Usage: ./manage.sh remove-n8n-instance-mcp-token\n' >&2
      exit 2
    }
    [[ "$(n8n_mcp_mode)" != instance ]] || {
      printf 'Instance MCP mode is active. Switch to trigger or off before removing its token.\n' >&2
      exit 1
    }
    [[ -f "$HERMES_ENV" && ! -L "$HERMES_ENV" ]] || {
      printf 'Hermes secret configuration is missing or unsafe.\n' >&2
      exit 1
    }
    if [[ -n "$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN)" ]]; then
      remove_env_values "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN
      chmod 600 "$HERMES_ENV"
      printf 'Stored n8n Instance MCP token removed. Instance-level MCP in n8n was not disabled.\n'
    else
      printf 'No stored n8n Instance MCP token was present.\n'
    fi
    ;;
  set-n8n-mcp-mode)
    require_router_backend
    require_profiles hermes n8n
    target_mode="${2:-}"
    [[ -z "${3:-}" && ( "$target_mode" == instance || "$target_mode" == trigger || "$target_mode" == off ) ]] || {
      printf 'Usage: ./manage.sh set-n8n-mcp-mode instance|trigger|off\n' >&2
      exit 2
    }
    current_mode="$(n8n_mcp_mode)"
    trigger_token="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)"
    if [[ -z "$trigger_token" ]]; then
      trigger_token="$(env_value "$HERMES_ENV" N8N_MCP_TOKEN)"
    fi
    instance_token="$(env_value "$HERMES_ENV" N8N_INSTANCE_MCP_TOKEN)"
    [[ -n "$(n8n_api_key)" ]] || {
      printf 'A stored owner API key is required to reconcile hosted chat and trigger publication. Run ./manage.sh set-n8n-api-key.\n' >&2
      exit 1
    }
    case "$target_mode" in
      instance)
        [[ -n "$instance_token" ]] || {
          printf 'No Instance MCP token is stored. Enable Instance-level MCP in n8n, generate its token, then run ./manage.sh set-n8n-instance-mcp-token.\n' >&2
          exit 1
        }
        n8n_instance_mcp_check "$instance_token" || {
          printf 'The stored Instance MCP token failed validation; mode was not changed.\n' >&2
          exit 1
        }
        ;;
      trigger)
        [[ -n "$trigger_token" ]] || {
          printf 'No Trigger MCP token is stored. Run ./manage.sh configure and select Trigger mode.\n' >&2
          exit 1
        }
        ;;
    esac
    ensure_stack_secrets_dir
    env_backup="$(mktemp "$STACK_SECRETS_DIR/mode-env.backup.XXXXXX")"
    hermes_backup="$(mktemp "$STACK_SECRETS_DIR/mode-hermes-env.backup.XXXXXX")"
    config_backup="$(mktemp "$STACK_SECRETS_DIR/mode-hermes-config.backup.XXXXXX")"
    TEMP_SECRET_FILES+=("$env_backup" "$hermes_backup" "$config_backup")
    cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$env_backup"
    cp --preserve=mode,ownership,timestamps "$HERMES_ENV" "$hermes_backup"
    cp --preserve=mode,ownership,timestamps "$ROOT_DIR/data/hermes/config.yaml" "$config_backup"
    if ! run_n8n_reconciler_with_token "$trigger_token" "$trigger_token" "$target_mode"; then
      printf 'n8n rejected the mode transition before local configuration changed.\n' >&2
      exit 1
    fi
    migrate_legacy_trigger_env
    replace_env_value "$ENV_FILE" N8N_MCP_MODE "$target_mode"
    if set_hermes_n8n_mcp_entry "$target_mode"; then
      finish_legacy_trigger_env_migration
    else
      cp --preserve=mode,ownership,timestamps "$env_backup" "$ENV_FILE"
      cp --preserve=mode,ownership,timestamps "$hermes_backup" "$HERMES_ENV"
      cp --preserve=mode,ownership,timestamps "$config_backup" "$ROOT_DIR/data/hermes/config.yaml"
      if run_n8n_reconciler_with_token "$trigger_token" "$trigger_token" "$current_mode" >/dev/null; then
        printf 'Hermes configuration update failed; prior files and controllable trigger publication state were restored.\n' >&2
      else
        printf 'Hermes configuration update failed; prior local files were restored, but trigger publication rollback could not be verified. Manual recovery is required.\n' >&2
      fi
      exit 1
    fi
    if restart_hermes && "$ROOT_DIR/manage.sh" verify-n8n; then
      printf 'Hermes n8n MCP mode changed to %s.\n' "$target_mode"
    else
      cp --preserve=mode,ownership,timestamps "$env_backup" "$ENV_FILE"
      cp --preserve=mode,ownership,timestamps "$hermes_backup" "$HERMES_ENV"
      cp --preserve=mode,ownership,timestamps "$config_backup" "$ROOT_DIR/data/hermes/config.yaml"
      if run_n8n_reconciler_with_token "$trigger_token" "$trigger_token" "$current_mode" && restart_hermes && "$ROOT_DIR/manage.sh" verify-n8n; then
        printf 'Mode verification failed; the prior local configuration and controllable trigger publication state were restored and verified.\n' >&2
      else
        printf 'Mode verification failed and rollback could not be fully verified; manual recovery is required.\n' >&2
      fi
      exit 1
    fi
    ;;
  bootstrap-n8n|reconcile-n8n)
    require_router_backend
    require_profiles hermes n8n
    run_n8n_reconciler
    restart_hermes
    "$ROOT_DIR/manage.sh" verify-n8n
    ;;
  verify-n8n)
    require_profiles hermes n8n
    run_n8n_verifier
    ;;
  rotate-n8n-trigger-token|rotate-n8n-token)
    require_router_backend
    require_profiles hermes n8n
    [[ -f "$HERMES_ENV" ]] || { printf 'Hermes is not configured.\n' >&2; exit 1; }
    migrate_legacy_trigger_env
    old_token="$(env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN)"
    [[ -n "$old_token" ]] || { printf 'No Trigger MCP token is configured. Run bootstrap-n8n first.\n' >&2; exit 1; }
    current_mode="$(n8n_mcp_mode)"
    if [[ "$current_mode" == instance ]]; then
      printf 'This rotates only the retained Trigger credential. To replace the Instance token, regenerate it in n8n and run ./manage.sh set-n8n-instance-mcp-token.\n' >&2
    fi
    new_token="$(random_hex 32)"
    if run_n8n_reconciler_with_token "$new_token" "$old_token" "$current_mode"; then
      replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN "$new_token"
      finish_legacy_trigger_env_migration
      if restart_hermes && "$ROOT_DIR/manage.sh" verify-n8n; then
        printf 'n8n Trigger MCP bearer token rotated without printing it.\n'
      else
        if run_n8n_reconciler_with_token "$old_token" "$new_token" "$current_mode"; then
          replace_env_value "$HERMES_ENV" N8N_TRIGGER_MCP_TOKEN "$old_token"
          if restart_hermes && "$ROOT_DIR/manage.sh" verify-n8n; then
            printf 'Rotation verification failed; the prior n8n Trigger credential and Hermes token were restored and verified.\n' >&2
          else
            printf 'Rotation verification failed; the prior values were restored, but their operation could not be verified. Manual recovery is required.\n' >&2
          fi
        else
          printf 'Rotation verification failed, and n8n Trigger credential rollback also failed. Hermes retains the new token; manual recovery is required.\n' >&2
        fi
        exit 1
      fi
    else
      printf 'Rotation failed before Hermes changed; the prior Trigger token remains active.\n' >&2
      exit 1
    fi
    ;;
  remove-n8n-bootstrap-key)
    if [[ -f "$N8N_BOOTSTRAP_ENV" ]]; then
      rm -f "$N8N_BOOTSTRAP_ENV"
      printf 'Stored n8n API key removed. Managed IDs and fingerprints were retained.\n'
    else
      printf 'No stored n8n API key was present.\n'
    fi
    ;;
  set-backend-api-key)
    new_key="${2:-}"
    [[ "$new_key" =~ ^[A-Za-z0-9._:-]+$ ]] || {
      printf 'Provide a non-empty API key containing letters, digits, dot, underscore, colon, or hyphen.
' >&2
      exit 2
    }
    profiles="$(sed -n 's/^COMPOSE_PROFILES=//p' "$ENV_FILE")"
    if [[ ",$profiles," == *,smart-router,* ]]; then
      replace_env_value "$ENV_FILE" SMART_ROUTER_UPSTREAM_API_KEY "$new_key"
      compose up -d --no-deps --force-recreate smart-router
      printf 'Router backend upstream API key updated for Smart Router.
'
    else
      updated=false
      if [[ ",$profiles," == *,hermes,* && -f "$HERMES_ENV" ]]; then
        replace_env_value "$HERMES_ENV" NINEROUTER_API_KEY "$new_key"
        [[ -n "$(env_value "$HERMES_ENV" NINEROUTER_KEY)" ]] \
          && replace_env_value "$HERMES_ENV" NINEROUTER_KEY "$new_key"
        updated=true
      fi
      if [[ ",$profiles," == *,open-webui,* ]]; then
        replace_env_value "$ENV_FILE" OPENWEBUI_OPENAI_API_KEY "$new_key"
        compose up -d --no-deps --force-recreate open-webui
        updated=true
      fi
      if [[ "$updated" != true ]]; then
        printf 'Neither Smart Router nor a direct backend consumer (Hermes/Open WebUI) is selected.
' >&2
        exit 1
      fi
      if [[ ",$profiles," == *,hermes,* && -f "$HERMES_ENV" ]]; then
        restart_hermes
      fi
      printf 'Direct backend API key updated for local consumers.
'
    fi
    ;;
  health) shift; ops health "$@" ;;
  version) ops version ;;
  backup|backup-sections|backup-list|restore|rollback|lock-images|verify-images) cmd="$1"; shift; ops "$cmd" "$@" ;;
  router-status)
    profiles="$(env_value "$ENV_FILE" COMPOSE_PROFILES)"
    if [[ ",$profiles," != *,smart-router,* ]]; then
      printf 'Smart Router is not enabled in COMPOSE_PROFILES. Run ./manage.sh configure to enable it.\n'
      exit 1
    fi
    base="$(router_local_base_url)"
    printf 'Smart Router v0.5.9 stack integration\n'
    printf '  image: %s:%s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_IMAGE_REPOSITORY)" "$(env_value "$ENV_FILE" SMART_ROUTER_IMAGE_TAG)"
    printf '  base URL: %s\n' "$base"
    printf '  OpenAI API: %s/v1\n' "$base"
    printf '  mode: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_MODE)"
    printf '  policy: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_POLICY)"
    printf '  tier override aliases: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_ALLOW_TIER_OVERRIDES)"
    printf '  dashboard: %s (%s/dashboard)\n' "$(env_value "$ENV_FILE" SMART_ROUTER_DASHBOARD_ENABLED)" "$base"
    printf '  operations center: %s (%s/control/)\n' "$(env_value "$ENV_FILE" SMART_ROUTER_CONTROL_PLANE_ENABLED)" "$base"
    printf '  require auth: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_REQUIRE_AUTH)"
    printf '  provider health/circuit breakers: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_PROVIDER_HEALTH_ENABLED)"
    printf '  OIDC: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_OIDC_ENABLED)"
    printf '  HA mode: %s; Redis required: %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_HA_MODE)" "$(env_value "$ENV_FILE" SMART_ROUTER_REDIS_REQUIRED)"
    printf '  route profiles: fast=%s standard=%s strong=%s coding=%s vision=%s\n' \
      "$(env_value "$ENV_FILE" SMART_ROUTER_FAST_MODEL)" "$(env_value "$ENV_FILE" SMART_ROUTER_STANDARD_MODEL)" \
      "$(env_value "$ENV_FILE" SMART_ROUTER_STRONG_MODEL)" "$(env_value "$ENV_FILE" SMART_ROUTER_CODING_MODEL)" \
      "$(env_value "$ENV_FILE" SMART_ROUTER_VISION_MODEL)"
    printf '\nRuntime /router/info:\n'
    router_api_get /router/info | pretty_json || printf 'Runtime info unavailable; is smart-router running?\n' >&2
    ;;
  router-access)
    base="$(router_local_base_url)"
    printf 'Dashboard:     %s/dashboard\n' "$base"
    printf 'Operations Center: %s/control/\n' "$base"
    printf 'OpenAI API:    %s/v1\n' "$base"
    printf 'Admin user:    %s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_BOOTSTRAP_ADMIN_USER)"
    printf 'Dashboard telemetry uses SMART_ROUTER_CLIENT_API_KEY. Operations Center accepts the bootstrap admin user/password or SMART_ROUTER_ADMIN_API_KEY.\n'
    if [[ "${2:-}" == --show-secrets ]]; then
      [[ -r /dev/tty && -w /dev/tty ]] || { printf 'A controlling terminal is required to reveal secrets.\n' >&2; exit 1; }
      read -r -p 'Reveal Smart Router access secrets on this terminal? [y/N]: ' answer </dev/tty
      [[ "$answer" =~ ^[Yy]$ ]] || { printf 'Secrets not shown.\n'; exit 0; }
      printf 'SMART_ROUTER_CLIENT_API_KEY=%s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_CLIENT_API_KEY)"
      printf 'SMART_ROUTER_ADMIN_API_KEY=%s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)"
      printf 'SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD=%s\n' "$(env_value "$ENV_FILE" SMART_ROUTER_BOOTSTRAP_ADMIN_PASSWORD)"
    elif [[ -n "${2:-}" ]]; then
      printf 'Usage: ./manage.sh router-access [--show-secrets]\n' >&2; exit 2
    else
      printf 'Use ./manage.sh router-access --show-secrets only when you need to reveal local credentials.\n'
    fi
    ;;
  router-summary)
    hours="${2:-24}"
    [[ "$hours" =~ ^[0-9]+([.][0-9]+)?$ ]] || { printf 'Hours must be numeric.\n' >&2; exit 2; }
    key="$(env_value "$ENV_FILE" SMART_ROUTER_CLIENT_API_KEY)"
    router_api_get "/dashboard/api/summary?hours=$hours" "$key" | pretty_json
    ;;
  router-routes)
    key="$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)"
    router_api_get /control/api/routes "$key" | pretty_json
    ;;
  router-provider-health)
    key="$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)"
    router_api_get /control/api/provider-health "$key" | pretty_json
    ;;
  router-system)
    key="$(env_value "$ENV_FILE" SMART_ROUTER_ADMIN_API_KEY)"
    router_api_get /control/api/system "$key" | pretty_json
    ;;
  router-policy)
    policy="${2:-}"; [[ "$policy" == heuristic || "$policy" == calibrated || "$policy" == learned ]] || { printf 'heuristic|calibrated|learned required\n' >&2; exit 2; }
    [[ "$policy" != calibrated || -s "$ROOT_DIR/smart-router/policy/calibrated.json" ]] || { printf 'calibrated policy missing\n' >&2; exit 1; }
    replace_env_value "$ENV_FILE" SMART_ROUTER_POLICY "$policy"
    compose up -d --no-deps --force-recreate smart-router
    if ! router_runtime_set "{\"router_policy\":\"$policy\"}" >/dev/null; then
      printf 'WARNING: policy was written to .env, but the Operations Center runtime override could not be synchronized.\n' >&2
      printf 'If a previous UI override exists, open System and choose Reset to environment, then retry.\n' >&2
    fi
    printf 'router policy: %s\n' "$policy"
    ;;
  router-info)
    compose exec -T smart-router python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8080/router/info',timeout=5).read().decode())"
    ;;
  router-calibrate)
    dataset="${2:-}"; [[ -f "$dataset" ]] || { printf 'Labeled JSONL file required\n' >&2; exit 2; }
    dataset="$(cd "$(dirname "$dataset")" && pwd)/$(basename "$dataset")"
    compose run --rm --no-deps -v "$dataset:/work/input.jsonl:ro" -v "$ROOT_DIR/smart-router/policy:/out" smart-router python -m smart_router.eval.calibrate /work/input.jsonl -o /out/calibrated.json
    ;;
  router-report)
    dataset="${2:-}"; [[ -f "$dataset" ]] || { printf 'Labeled JSONL file required\n' >&2; exit 2; }
    dataset="$(cd "$(dirname "$dataset")" && pwd)/$(basename "$dataset")"
    compose run --rm --no-deps -v "$dataset:/work/input.jsonl:ro" smart-router python -m smart_router.eval.report /work/input.jsonl
    ;;
  router-replay)
    dataset="${2:-}"; [[ -f "$dataset" ]] || { printf 'Requests JSONL file required\n' >&2; exit 2; }
    output="${3:-$ROOT_DIR/data/smart-router/replay-v0.5.9.jsonl}"
    dataset="$(cd "$(dirname "$dataset")" && pwd)/$(basename "$dataset")"; mkdir -p "$(dirname "$output")"; touch "$output"; output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
    compose run --rm --no-deps -v "$dataset:/work/input.jsonl:ro" -v "$output:/work/output.jsonl" smart-router python -m smart_router.eval.replay /work/input.jsonl -o /work/output.jsonl
    ;;
  *) printf 'Unknown command: %s\n' "$command" >&2; usage >&2; exit 2 ;;
esac