/ hermes_cli / config.py
config.py
   1  """
   2  Configuration management for Hermes Agent.
   3  
   4  Config files are stored in ~/.hermes/ for easy access:
   5  - ~/.hermes/config.yaml  - All settings (model, toolsets, terminal, etc.)
   6  - ~/.hermes/.env         - API keys and secrets
   7  
   8  This module provides:
   9  - hermes config          - Show current configuration
  10  - hermes config edit     - Open config in editor
  11  - hermes config set      - Set a specific value
  12  - hermes config wizard   - Re-run setup wizard
  13  """
  14  
  15  import copy
  16  import logging
  17  import os
  18  import platform
  19  import re
  20  import stat
  21  import subprocess
  22  import sys
  23  import tempfile
  24  from dataclasses import dataclass
  25  from pathlib import Path
  26  from typing import Dict, Any, Optional, List, Tuple
  27  
  28  logger = logging.getLogger(__name__)
  29  
  30  _IS_WINDOWS = platform.system() == "Windows"
  31  _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
  32  _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}
  33  # (path, mtime_ns, size) -> cached expanded config dict.
  34  # load_config() returns a deepcopy of the cached value when the file
  35  # hasn't changed since the last load, skipping yaml.safe_load +
  36  # _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).
  37  # save_config() + migrate_config() write via atomic_yaml_write which
  38  # produces a fresh inode, so stat() sees a new mtime_ns and the next
  39  # load repopulates automatically — no explicit invalidation hook.
  40  _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
  41  # (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as
  42  # _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
  43  # the user's on-disk values without defaults merged in.
  44  _RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
  45  # Env var names written to .env that aren't in OPTIONAL_ENV_VARS
  46  # (managed by setup/provider flows directly).
  47  _EXTRA_ENV_KEYS = frozenset({
  48      "OPENAI_API_KEY", "OPENAI_BASE_URL",
  49      "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
  50      "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME",
  51      "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME",
  52      "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME",
  53      "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL",
  54      "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS",
  55      "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME",
  56      "SMS_HOME_CHANNEL", "SMS_HOME_CHANNEL_NAME",
  57      "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET",
  58      "DINGTALK_HOME_CHANNEL", "DINGTALK_HOME_CHANNEL_NAME",
  59      "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN",
  60      "FEISHU_HOME_CHANNEL", "FEISHU_HOME_CHANNEL_NAME",
  61      "YUANBAO_HOME_CHANNEL", "YUANBAO_HOME_CHANNEL_NAME",
  62      "WECOM_BOT_ID", "WECOM_SECRET",
  63      "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID",
  64      "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY",
  65      "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT",
  66      "WECOM_HOME_CHANNEL", "WECOM_HOME_CHANNEL_NAME",
  67      "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL",
  68      "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY",
  69      "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS",
  70      "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD",
  71      "BLUEBUBBLES_HOME_CHANNEL", "BLUEBUBBLES_HOME_CHANNEL_NAME",
  72      "QQ_APP_ID", "QQ_CLIENT_SECRET", "QQBOT_HOME_CHANNEL", "QQBOT_HOME_CHANNEL_NAME",
  73      "QQ_HOME_CHANNEL", "QQ_HOME_CHANNEL_NAME",  # legacy aliases (pre-rename, still read for back-compat)
  74      "QQ_ALLOWED_USERS", "QQ_GROUP_ALLOWED_USERS", "QQ_ALLOW_ALL_USERS", "QQ_MARKDOWN_SUPPORT",
  75      "QQ_STT_API_KEY", "QQ_STT_BASE_URL", "QQ_STT_MODEL",
  76      "IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
  77      "IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
  78      "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
  79      "WHATSAPP_MODE", "WHATSAPP_ENABLED",
  80      "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
  81      "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
  82      "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", "MATRIX_DM_AUTO_THREAD",
  83      "MATRIX_RECOVERY_KEY",
  84      # Langfuse observability plugin — optional tuning keys + standard SDK vars.
  85      # Activation is via plugins.enabled (opt-in through `hermes plugins enable
  86      # observability/langfuse` or `hermes tools → Langfuse`); credentials gate
  87      # the plugin at runtime.
  88      "HERMES_LANGFUSE_ENV",
  89      "HERMES_LANGFUSE_RELEASE",
  90      "HERMES_LANGFUSE_SAMPLE_RATE",
  91      "HERMES_LANGFUSE_MAX_CHARS",
  92      "HERMES_LANGFUSE_DEBUG",
  93      "LANGFUSE_PUBLIC_KEY",
  94      "LANGFUSE_SECRET_KEY",
  95      "LANGFUSE_BASE_URL",
  96  })
  97  import yaml
  98  
  99  from hermes_cli.colors import Colors, color
 100  from hermes_cli.default_soul import DEFAULT_SOUL_MD
 101  
 102  
 103  # =============================================================================
 104  # Managed mode (NixOS declarative config)
 105  # =============================================================================
 106  
 107  _MANAGED_TRUE_VALUES = ("true", "1", "yes")
 108  _MANAGED_SYSTEM_NAMES = {
 109      "brew": "Homebrew",
 110      "homebrew": "Homebrew",
 111      "nix": "NixOS",
 112      "nixos": "NixOS",
 113  }
 114  
 115  
 116  def get_managed_system() -> Optional[str]:
 117      """Return the package manager owning this install, if any."""
 118      raw = os.getenv("HERMES_MANAGED", "").strip()
 119      if raw:
 120          normalized = raw.lower()
 121          if normalized in _MANAGED_TRUE_VALUES:
 122              return "NixOS"
 123          return _MANAGED_SYSTEM_NAMES.get(normalized, raw)
 124  
 125      managed_marker = get_hermes_home() / ".managed"
 126      if managed_marker.exists():
 127          return "NixOS"
 128      return None
 129  
 130  
 131  def is_managed() -> bool:
 132      """Check if Hermes is running in package-manager-managed mode.
 133  
 134      Two signals: the HERMES_MANAGED env var (set by the systemd service),
 135      or a .managed marker file in HERMES_HOME (set by the NixOS activation
 136      script, so interactive shells also see it).
 137      """
 138      return get_managed_system() is not None
 139  
 140  
 141  def get_managed_update_command() -> Optional[str]:
 142      """Return the preferred upgrade command for a managed install."""
 143      managed_system = get_managed_system()
 144      if managed_system == "Homebrew":
 145          return "brew upgrade hermes-agent"
 146      if managed_system == "NixOS":
 147          return "sudo nixos-rebuild switch"
 148      return None
 149  
 150  
 151  def recommended_update_command() -> str:
 152      """Return the best update command for the current installation."""
 153      return get_managed_update_command() or "hermes update"
 154  
 155  
 156  def format_managed_message(action: str = "modify this Hermes installation") -> str:
 157      """Build a user-facing error for managed installs."""
 158      managed_system = get_managed_system() or "a package manager"
 159      raw = os.getenv("HERMES_MANAGED", "").strip().lower()
 160  
 161      if managed_system == "NixOS":
 162          env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true"
 163          return (
 164              f"Cannot {action}: this Hermes installation is managed by NixOS "
 165              f"(HERMES_MANAGED={env_hint}).\n"
 166              "Edit services.hermes-agent.settings in your configuration.nix and run:\n"
 167              "  sudo nixos-rebuild switch"
 168          )
 169  
 170      if managed_system == "Homebrew":
 171          env_hint = raw or "homebrew"
 172          return (
 173              f"Cannot {action}: this Hermes installation is managed by Homebrew "
 174              f"(HERMES_MANAGED={env_hint}).\n"
 175              "Use:\n"
 176              "  brew upgrade hermes-agent"
 177          )
 178  
 179      return (
 180          f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n"
 181          "Use your package manager to upgrade or reinstall Hermes."
 182      )
 183  
 184  def managed_error(action: str = "modify configuration"):
 185      """Print user-friendly error for managed mode."""
 186      print(format_managed_message(action), file=sys.stderr)
 187  
 188  
 189  # =============================================================================
 190  # Container-aware CLI (NixOS container mode)
 191  # =============================================================================
 192  
 193  def get_container_exec_info() -> Optional[dict]:
 194      """Read container mode metadata from HERMES_HOME/.container-mode.
 195  
 196      Returns a dict with keys: backend, container_name, exec_user, hermes_bin
 197      or None if container mode is not active, we're already inside the
 198      container, or HERMES_DEV=1 is set.
 199  
 200      The .container-mode file is written by the NixOS activation script when
 201      container.enable = true. It tells the host CLI to exec into the container
 202      instead of running locally.
 203      """
 204      if os.environ.get("HERMES_DEV") == "1":
 205          return None
 206  
 207      from hermes_constants import is_container
 208      if is_container():
 209          return None
 210  
 211      container_mode_file = get_hermes_home() / ".container-mode"
 212  
 213      try:
 214          info = {}
 215          with open(container_mode_file, "r") as f:
 216              for line in f:
 217                  line = line.strip()
 218                  if "=" in line and not line.startswith("#"):
 219                      key, _, value = line.partition("=")
 220                      info[key.strip()] = value.strip()
 221      except FileNotFoundError:
 222          return None
 223      # All other exceptions (PermissionError, malformed data, etc.) propagate
 224  
 225      backend = info.get("backend", "docker")
 226      container_name = info.get("container_name", "hermes-agent")
 227      exec_user = info.get("exec_user", "hermes")
 228      hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes")
 229  
 230      return {
 231          "backend": backend,
 232          "container_name": container_name,
 233          "exec_user": exec_user,
 234          "hermes_bin": hermes_bin,
 235      }
 236  
 237  
 238  # =============================================================================
 239  # Config paths
 240  # =============================================================================
 241  
 242  # Re-export from hermes_constants — canonical definition lives there.
 243  from hermes_constants import get_hermes_home  # noqa: F811,E402
 244  from utils import atomic_replace
 245  
 246  def get_config_path() -> Path:
 247      """Get the main config file path."""
 248      return get_hermes_home() / "config.yaml"
 249  
 250  def get_env_path() -> Path:
 251      """Get the .env file path (for API keys)."""
 252      return get_hermes_home() / ".env"
 253  
 254  def get_project_root() -> Path:
 255      """Get the project installation directory."""
 256      return Path(__file__).parent.parent.resolve()
 257  
 258  def _secure_dir(path):
 259      """Set directory to owner-only access (0700 by default). No-op on Windows.
 260  
 261      Skipped in managed mode — the NixOS module sets group-readable
 262      permissions (0750) so interactive users in the hermes group can
 263      share state with the gateway service.
 264  
 265      The mode can be overridden via the HERMES_HOME_MODE environment variable
 266      (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,
 267      caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.
 268      The execute-only bit on a directory permits cd-through without exposing
 269      directory listings.
 270      """
 271      if is_managed():
 272          return
 273      try:
 274          mode_str = os.environ.get("HERMES_HOME_MODE", "").strip()
 275          mode = int(mode_str, 8) if mode_str else 0o700
 276      except ValueError:
 277          mode = 0o700
 278      try:
 279          os.chmod(path, mode)
 280      except (OSError, NotImplementedError):
 281          pass
 282  
 283  
 284  def _is_container() -> bool:
 285      """Detect if we're running inside a Docker/Podman/LXC container.
 286  
 287      When Hermes runs in a container with volume-mounted config files, forcing
 288      0o600 permissions breaks multi-process setups where the gateway and
 289      dashboard run as different UIDs or the volume mount requires broader
 290      permissions.
 291      """
 292      # Explicit opt-out
 293      if os.environ.get("HERMES_CONTAINER") or os.environ.get("HERMES_SKIP_CHMOD"):
 294          return True
 295      # Docker / Podman marker file
 296      if os.path.exists("/.dockerenv"):
 297          return True
 298      # LXC / cgroup-based detection
 299      try:
 300          with open("/proc/1/cgroup", "r") as f:
 301              cgroup_content = f.read()
 302          if "docker" in cgroup_content or "lxc" in cgroup_content or "kubepods" in cgroup_content:
 303              return True
 304      except (OSError, IOError):
 305          pass
 306      return False
 307  
 308  
 309  def _secure_file(path):
 310      """Set file to owner-only read/write (0600). No-op on Windows.
 311  
 312      Skipped in managed mode — the NixOS activation script sets
 313      group-readable permissions (0640) on config files.
 314  
 315      Skipped in containers — Docker/Podman volume mounts often need broader
 316      permissions.  Set HERMES_SKIP_CHMOD=1 to force-skip on other systems.
 317      """
 318      if is_managed() or _is_container():
 319          return
 320      try:
 321          if os.path.exists(str(path)):
 322              os.chmod(path, 0o600)
 323      except (OSError, NotImplementedError):
 324          pass
 325  
 326  
 327  def _ensure_default_soul_md(home: Path) -> None:
 328      """Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet."""
 329      soul_path = home / "SOUL.md"
 330      if soul_path.exists():
 331          return
 332      soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8")
 333      _secure_file(soul_path)
 334  
 335  
 336  def ensure_hermes_home():
 337      """Ensure ~/.hermes directory structure exists with secure permissions.
 338  
 339      In managed mode (NixOS), dirs are created by the activation script with
 340      setgid + group-writable (2770). We skip mkdir and set umask(0o007) so
 341      any files created (e.g. SOUL.md) are group-writable (0660).
 342      """
 343      home = get_hermes_home()
 344      if is_managed():
 345          old_umask = os.umask(0o007)
 346          try:
 347              _ensure_hermes_home_managed(home)
 348          finally:
 349              os.umask(old_umask)
 350      else:
 351          home.mkdir(parents=True, exist_ok=True)
 352          _secure_dir(home)
 353          for subdir in ("cron", "sessions", "logs", "logs/curator", "memories"):
 354              d = home / subdir
 355              d.mkdir(parents=True, exist_ok=True)
 356              _secure_dir(d)
 357          _ensure_default_soul_md(home)
 358  
 359  
 360  def _ensure_hermes_home_managed(home: Path):
 361      """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md."""
 362      if not home.is_dir():
 363          raise RuntimeError(
 364              f"HERMES_HOME {home} does not exist. "
 365              "Run 'sudo nixos-rebuild switch' first."
 366          )
 367      for subdir in ("cron", "sessions", "logs", "memories"):
 368          d = home / subdir
 369          if not d.is_dir():
 370              raise RuntimeError(
 371                  f"{d} does not exist. "
 372                  "Run 'sudo nixos-rebuild switch' first."
 373              )
 374      # Curator reports dir is a sub-path of logs/; create it if missing.
 375      # In managed mode the activation script may not know about this subdir,
 376      # so we mkdir it ourselves (it's inside an already-secured logs/ dir).
 377      (home / "logs" / "curator").mkdir(parents=True, exist_ok=True)
 378      # Inside umask(0o007) scope — SOUL.md will be created as 0660
 379      _ensure_default_soul_md(home)
 380  
 381  
 382  # =============================================================================
 383  # Config loading/saving
 384  # =============================================================================
 385  
 386  DEFAULT_CONFIG = {
 387      "model": "",
 388      "providers": {},
 389      "fallback_providers": [],
 390      "credential_pool_strategies": {},
 391      "toolsets": ["hermes-cli"],
 392      "agent": {
 393          "max_turns": 90,
 394          # Inactivity timeout for gateway agent execution (seconds).
 395          # The agent can run indefinitely as long as it's actively calling
 396          # tools or receiving API responses.  Only fires when the agent has
 397          # been completely idle for this duration.  0 = unlimited.
 398          "gateway_timeout": 1800,
 399          # Graceful drain timeout for gateway stop/restart (seconds).
 400          # The gateway stops accepting new work, waits for running agents
 401          # to finish, then interrupts any remaining runs after the timeout.
 402          # 0 = no drain, interrupt immediately.
 403          #
 404          # 180s is calibrated for realistic in-flight agent turns: a typical
 405          # coding conversation mid-reasoning runs 60–150s per call, so a 60s
 406          # budget routinely interrupted legitimate work on /restart. Raise
 407          # further in config.yaml if you run very-long-reasoning models.
 408          "restart_drain_timeout": 180,
 409          # Max app-level retry attempts for API errors (connection drops,
 410          # provider timeouts, 5xx, etc.) before the agent surfaces the
 411          # failure.  The OpenAI SDK already does its own low-level retries
 412          # (max_retries=2 default) for transient network errors; this is
 413          # the Hermes-level retry loop that wraps the whole call.  Lower
 414          # this to 1 if you use fallback providers and want fast failover
 415          # on flaky primaries; raise it if you prefer to tolerate longer
 416          # provider hiccups on a single provider.
 417          "api_max_retries": 3,
 418          "service_tier": "",
 419          # Tool-use enforcement: injects system prompt guidance that tells the
 420          # model to actually call tools instead of describing intended actions.
 421          # Values: "auto" (default — applies to gpt/codex models), true/false
 422          # (force on/off for all models), or a list of model-name substrings
 423          # to match (e.g. ["gpt", "codex", "gemini", "qwen"]).
 424          "tool_use_enforcement": "auto",
 425          # Staged inactivity warning: send a warning to the user at this
 426          # threshold before escalating to a full timeout.  The warning fires
 427          # once per run and does not interrupt the agent.  0 = disable warning.
 428          "gateway_timeout_warning": 900,
 429          # Periodic "still working" notification interval (seconds).
 430          # Sends a status message every N seconds so the user knows the
 431          # agent hasn't died during long tasks.  0 = disable notifications.
 432          # Lower values mean faster feedback on slow tasks but more chat
 433          # noise; 180s is a compromise that catches spinning weak-model runs
 434          # (60+ tool iterations with tiny output) before users assume the
 435          # bot is dead and /restart.
 436          "gateway_notify_interval": 180,
 437          # Freshness window for the gateway auto-continue note (seconds).
 438          # After a gateway crash/restart/SIGTERM mid-run, the next user
 439          # message gets a "[System note: your previous turn was
 440          # interrupted — process the unfinished tool result(s) first]"
 441          # prepended so the model picks up where it left off.  That's the
 442          # right behaviour while the interruption is fresh, but stale
 443          # markers (transcript last touched hours or days ago) can revive
 444          # an unrelated old task when the user's next message starts new
 445          # work.  This window is the max age of the last persisted
 446          # transcript row for which we still inject the continue note.
 447          # Default 3600s comfortably covers a long turn (gateway_timeout
 448          # default is 1800s) plus runtime slack.  Set to 0 to disable the
 449          # gate and restore pre-fix behaviour (always inject).
 450          "gateway_auto_continue_freshness": 3600,
 451          # How user-attached images are presented to the main model on each turn.
 452          #   "auto"   — attach natively when the active model reports
 453          #              supports_vision=True AND the user hasn't explicitly
 454          #              configured auxiliary.vision.provider.  Otherwise fall
 455          #              back to text (vision_analyze pre-analysis).
 456          #   "native" — always attach natively; non-vision models will either
 457          #              error at the provider or get a last-chance text fallback
 458          #              (see run_agent._prepare_messages_for_api).
 459          #   "text"   — always pre-analyze with vision_analyze and prepend the
 460          #              description as text; the main model never sees pixels.
 461          # Affects gateway platforms, the TUI, and CLI /attach.  vision_analyze
 462          # remains available as a tool regardless of this setting — the routing
 463          # only controls how inbound user images are presented.
 464          "image_input_mode": "auto",
 465          "disabled_toolsets": [],
 466      },
 467      
 468      "terminal": {
 469          "backend": "local",
 470          "modal_mode": "auto",
 471          "cwd": ".",  # Use current directory
 472          "timeout": 180,
 473          # Environment variables to pass through to sandboxed execution
 474          # (terminal and execute_code).  Skill-declared required_environment_variables
 475          # are passed through automatically; this list is for non-skill use cases.
 476          "env_passthrough": [],
 477          # Extra files to source in the login shell when building the
 478          # per-session environment snapshot.  Use this when tools like nvm,
 479          # pyenv, asdf, or custom PATH entries are registered by files that
 480          # a bash login shell would skip — most commonly ``~/.bashrc``
 481          # (bash doesn't source bashrc in non-interactive login mode) or
 482          # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.
 483          # Paths support ``~`` / ``${VAR}``. Missing files are silently
 484          # skipped. When empty, Hermes auto-sources ``~/.profile``,
 485          # ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the
 486          # snapshot shell is bash (this is the ``auto_source_bashrc``
 487          # behaviour — disable with that key if you want strict login-only
 488          # semantics).
 489          "shell_init_files": [],
 490          # When true (default), Hermes sources the user's shell rc files
 491          # (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the
 492          # login shell used to build the environment snapshot. This
 493          # captures PATH additions, shell functions, and aliases — which a
 494          # plain ``bash -l -c`` would otherwise miss because bash skips
 495          # bashrc in non-interactive login mode, and because a default
 496          # Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive
 497          # sources. ``~/.profile`` and ``~/.bash_profile`` are tried first
 498          # because ``n`` / ``nvm`` / ``asdf`` installers typically write
 499          # their PATH exports there without an interactivity guard. Turn
 500          # this off if your rc files misbehave when sourced
 501          # non-interactively (e.g. one that hard-exits on TTY checks).
 502          "auto_source_bashrc": True,
 503          "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
 504          "docker_forward_env": [],
 505          # Explicit environment variables to set inside Docker containers.
 506          # Unlike docker_forward_env (which reads values from the host process),
 507          # docker_env lets you specify exact key-value pairs — useful when Hermes
 508          # runs as a systemd service without access to the user's shell environment.
 509          # Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"}
 510          "docker_env": {},
 511          "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
 512          "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
 513          "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
 514          "vercel_runtime": "node24",
 515          # Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)
 516          "container_cpu": 1,
 517          "container_memory": 5120,       # MB (default 5GB)
 518          "container_disk": 51200,        # MB (default 50GB)
 519          "container_persistent": True,   # Persist filesystem across sessions
 520          # Docker volume mounts — share host directories with the container.
 521          # Each entry is "host_path:container_path" (standard Docker -v syntax).
 522          # Example:
 523          # ["/home/user/projects:/workspace/projects",
 524          #  "/home/user/.hermes/cache/documents:/output"]
 525          # For gateway MEDIA delivery, write inside Docker to /output/... and emit
 526          # the host-visible path in MEDIA:, not the container path.
 527          "docker_volumes": [],
 528          # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.
 529          # Default off because passing host directories into a sandbox weakens isolation.
 530          "docker_mount_cwd_to_workspace": False,
 531          # Explicit opt-in: run the Docker container as the host user's uid:gid
 532          # (via `--user`).  When enabled, files written into bind-mounted dirs
 533          # (docker_volumes, the persistent workspace, or the auto-mounted cwd)
 534          # are owned by your host user instead of root, which avoids needing
 535          # `sudo chown` after container runs. Default off to preserve behavior
 536          # for images whose entrypoints expect to start as root (e.g. the
 537          # bundled Hermes image, which drops to the `hermes` user via gosu).
 538          # When on, SETUID/SETGID caps are omitted from the container since
 539          # no privilege drop is needed.
 540          "docker_run_as_host_user": False,
 541          # Persistent shell — keep a long-lived bash shell across execute() calls
 542          # so cwd/env vars/shell variables survive between commands.
 543          # Enabled by default for non-local backends (SSH); local is always opt-in
 544          # via TERMINAL_LOCAL_PERSISTENT env var.
 545          "persistent_shell": True,
 546      },
 547      
 548      "browser": {
 549          "inactivity_timeout": 120,
 550          "command_timeout": 30,  # Timeout for browser commands in seconds (screenshot, navigate, etc.)
 551          "record_sessions": False,  # Auto-record browser sessions as WebM videos
 552          "allow_private_urls": False,  # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
 553          "auto_local_for_private_urls": True,  # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
 554          "cdp_url": "",  # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
 555          # CDP supervisor — dialog + frame detection via a persistent WebSocket.
 556          # Active only when a CDP-capable backend is attached (Browserbase or
 557          # local Chrome via /browser connect). See
 558          # website/docs/developer-guide/browser-supervisor.md.
 559          "dialog_policy": "must_respond",  # must_respond | auto_dismiss | auto_accept
 560          "dialog_timeout_s": 300,  # Safety auto-dismiss after N seconds under must_respond
 561          "camofox": {
 562              # When true, Hermes sends a stable profile-scoped userId to Camofox
 563              # so the server maps it to a persistent Firefox profile automatically.
 564              # When false (default), each session gets a random userId (ephemeral).
 565              "managed_persistence": False,
 566          },
 567      },
 568  
 569      # Filesystem checkpoints — automatic snapshots before destructive file ops.
 570      # When enabled, the agent takes a snapshot of the working directory once per
 571      # conversation turn (on first write_file/patch call).  Use /rollback to restore.
 572      "checkpoints": {
 573          "enabled": True,
 574          "max_snapshots": 50,  # Max checkpoints to keep per directory
 575          # Auto-maintenance: shadow repos accumulate forever under
 576          # ~/.hermes/checkpoints/ (one per cd'd working directory). Field
 577          # reports put the typical offender at 1000+ repos / ~12 GB. When
 578          # auto_prune is on, hermes sweeps at startup (at most once per
 579          # min_interval_hours) and deletes:
 580          #   * orphan repos: HERMES_WORKDIR no longer exists on disk
 581          #   * stale repos:  newest mtime older than retention_days
 582          # Opt-in so users who rely on /rollback against long-ago sessions
 583          # never lose data silently.
 584          "auto_prune": False,
 585          "retention_days": 7,
 586          "delete_orphans": True,
 587          "min_interval_hours": 24,
 588      },
 589  
 590      # Maximum characters returned by a single read_file call.  Reads that
 591      # exceed this are rejected with guidance to use offset+limit.
 592      # 100K chars ≈ 25–35K tokens across typical tokenisers.
 593      "file_read_max_chars": 100_000,
 594  
 595      # Tool-output truncation thresholds. When terminal output or a
 596      # single read_file page exceeds these limits, Hermes truncates the
 597      # payload sent to the model (keeping head + tail for terminal,
 598      # enforcing pagination for read_file). Tuning these trades context
 599      # footprint against how much raw output the model can see in one
 600      # shot. Ported from anomalyco/opencode PR #23770.
 601      #
 602      # - max_bytes:       terminal_tool output cap, in chars
 603      #                    (default 50_000 ≈ 12-15K tokens).
 604      # - max_lines:       read_file pagination cap — the maximum `limit`
 605      #                    a single read_file call can request before
 606      #                    being clamped (default 2000).
 607      # - max_line_length: per-line cap applied when read_file emits a
 608      #                    line-numbered view (default 2000 chars).
 609      "tool_output": {
 610          "max_bytes": 50_000,
 611          "max_lines": 2000,
 612          "max_line_length": 2000,
 613      },
 614  
 615      # Tool loop guardrails nudge models when they repeat failed or
 616      # non-progressing tool calls. Soft warnings are always-on by default;
 617      # hard stops are opt-in so interactive CLI/TUI sessions keep flowing.
 618      "tool_loop_guardrails": {
 619          "warnings_enabled": True,
 620          "hard_stop_enabled": False,
 621          "warn_after": {
 622              "exact_failure": 2,
 623              "same_tool_failure": 3,
 624              "idempotent_no_progress": 2,
 625          },
 626          "hard_stop_after": {
 627              "exact_failure": 5,
 628              "same_tool_failure": 8,
 629              "idempotent_no_progress": 5,
 630          },
 631      },
 632  
 633      "compression": {
 634          "enabled": True,
 635          "threshold": 0.50,            # compress when context usage exceeds this ratio
 636          "target_ratio": 0.20,         # fraction of threshold to preserve as recent tail
 637          "protect_last_n": 20,         # minimum recent messages to keep uncompressed
 638          "hygiene_hard_message_limit": 400,  # gateway session-hygiene force-compress threshold by message count
 639      },
 640  
 641      # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
 642      # cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
 643      "prompt_caching": {
 644          "cache_ttl": "5m",
 645      },
 646  
 647      # OpenRouter-specific settings.
 648      # response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).
 649      #   When enabled, identical requests return cached responses for free (zero billing).
 650      #   This is separate from Anthropic prompt caching and works alongside it.
 651      #   See: https://openrouter.ai/docs/guides/features/response-caching
 652      # response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).
 653      #   Default 300 (5 minutes). Only used when response_cache is enabled.
 654      "openrouter": {
 655          "response_cache": True,
 656          "response_cache_ttl": 300,
 657      },
 658  
 659      # AWS Bedrock provider configuration.
 660      # Only used when model.provider is "bedrock".
 661      "bedrock": {
 662          "region": "",  # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)
 663          "discovery": {
 664              "enabled": True,           # Auto-discover models via ListFoundationModels
 665              "provider_filter": [],     # Only show models from these providers (e.g. ["anthropic", "amazon"])
 666              "refresh_interval": 3600,  # Cache discovery results for this many seconds
 667          },
 668          "guardrail": {
 669              # Amazon Bedrock Guardrails — content filtering and safety policies.
 670              # Create a guardrail in the Bedrock console, then set the ID and version here.
 671              # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
 672              "guardrail_identifier": "",  # e.g. "abc123def456"
 673              "guardrail_version": "",     # e.g. "1" or "DRAFT"
 674              "stream_processing_mode": "async",  # "sync" or "async"
 675              "trace": "disabled",         # "enabled", "disabled", or "enabled_full"
 676          },
 677      },
 678  
 679      # Auxiliary model config — provider:model for each side task.
 680      # Format: provider is the provider name, model is the model slug.
 681      # "auto" for provider = auto-detect best available provider.
 682      # Empty model = use provider's default auxiliary model.
 683      # All tasks fall back to openrouter:google/gemini-3-flash-preview if
 684      # the configured provider is unavailable.
 685      "auxiliary": {
 686          "vision": {
 687              "provider": "auto",    # auto | openrouter | nous | codex | custom
 688              "model": "",           # e.g. "google/gemini-2.5-flash", "gpt-4o"
 689              "base_url": "",        # direct OpenAI-compatible endpoint (takes precedence over provider)
 690              "api_key": "",         # API key for base_url (falls back to OPENAI_API_KEY)
 691              "timeout": 120,        # seconds — LLM API call timeout; vision payloads need generous timeout
 692              "extra_body": {},      # OpenAI-compatible provider-specific request fields
 693              "download_timeout": 30,  # seconds — image HTTP download timeout; increase for slow connections
 694          },
 695          "web_extract": {
 696              "provider": "auto",
 697              "model": "",
 698              "base_url": "",
 699              "api_key": "",
 700              "timeout": 360,        # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models
 701              "extra_body": {},
 702          },
 703          "compression": {
 704              "provider": "auto",
 705              "model": "",
 706              "base_url": "",
 707              "api_key": "",
 708              "timeout": 120,        # seconds — compression summarises large contexts; increase for local models
 709              "extra_body": {},
 710          },
 711          "session_search": {
 712              "provider": "auto",
 713              "model": "",
 714              "base_url": "",
 715              "api_key": "",
 716              "timeout": 30,
 717              "extra_body": {},
 718              "max_concurrency": 3,  # Clamp parallel summaries to avoid request-burst 429s on small providers
 719          },
 720          "skills_hub": {
 721              "provider": "auto",
 722              "model": "",
 723              "base_url": "",
 724              "api_key": "",
 725              "timeout": 30,
 726              "extra_body": {},
 727          },
 728          "approval": {
 729              "provider": "auto",
 730              "model": "",           # fast/cheap model recommended (e.g. gemini-flash, haiku)
 731              "base_url": "",
 732              "api_key": "",
 733              "timeout": 30,
 734              "extra_body": {},
 735          },
 736          "mcp": {
 737              "provider": "auto",
 738              "model": "",
 739              "base_url": "",
 740              "api_key": "",
 741              "timeout": 30,
 742              "extra_body": {},
 743          },
 744          "title_generation": {
 745              "provider": "auto",
 746              "model": "",
 747              "base_url": "",
 748              "api_key": "",
 749              "timeout": 30,
 750              "extra_body": {},
 751          },
 752          # Curator — skill-usage review fork. Timeout is generous because the
 753          # review pass can take several minutes on reasoning models (umbrella
 754          # building over hundreds of candidate skills). "auto" = use main chat
 755          # model; override via `hermes model` → auxiliary → Curator to route
 756          # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).
 757          "curator": {
 758              "provider": "auto",
 759              "model": "",
 760              "base_url": "",
 761              "api_key": "",
 762              "timeout": 600,
 763              "extra_body": {},
 764          },
 765      },
 766      
 767      "display": {
 768          "compact": False,
 769          "personality": "kawaii",
 770          "resume_display": "full",
 771          "busy_input_mode": "interrupt",  # interrupt | queue | steer
 772          # When true, `hermes --tui` auto-resumes the most recent human-
 773          # facing session on launch instead of forging a fresh one.
 774          # Mirrors `hermes -c` muscle memory.  Default off so existing
 775          # users aren't surprised.  HERMES_TUI_RESUME=<id> always wins.
 776          "tui_auto_resume_recent": False,
 777          "bell_on_complete": False,
 778          "show_reasoning": False,
 779          "streaming": False,
 780          "final_response_markdown": "strip",  # render | strip | raw
 781          "inline_diffs": True,     # Show inline diff previews for write actions (write_file, patch, skill_manage)
 782          "show_cost": False,       # Show $ cost in the status bar (off by default)
 783          "skin": "default",
 784          # TUI busy indicator style: kaomoji (default), emoji, unicode (braille
 785          # spinner), or ascii.  Live-swappable via `/indicator <style>`.
 786          "tui_status_indicator": "kaomoji",
 787          "user_message_preview": {  # CLI: how many submitted user-message lines to echo back in scrollback
 788              "first_lines": 2,
 789              "last_lines": 2,
 790          },
 791          "interim_assistant_messages": True,  # Gateway: show natural mid-turn assistant status messages
 792          "tool_progress_command": False,  # Enable /verbose command in messaging gateway
 793          "tool_progress_overrides": {},  # DEPRECATED — use display.platforms instead
 794          "tool_preview_length": 0,  # Max chars for tool call previews (0 = no limit, show full paths/commands)
 795          # Auto-delete system-notice replies (e.g. "✨ New session started!",
 796          # "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
 797          # that support message deletion (currently Telegram; other platforms
 798          # ignore and leave the message in place).  Only affects slash-command
 799          # replies wrapped with gateway.platforms.base.EphemeralReply — agent
 800          # responses and content messages are never touched.  Default 0
 801          # (disabled) preserves prior behavior.
 802          "ephemeral_system_ttl": 0,
 803          "platforms": {},  # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
 804          # Gateway runtime-metadata footer appended to the FINAL message of a turn
 805          # (disabled by default to keep replies minimal). When enabled, renders
 806          # e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
 807          # display.platforms.<platform>.runtime_footer.
 808          "runtime_footer": {
 809              "enabled": False,
 810              "fields": ["model", "context_pct", "cwd"],  # Order shown; drop any to hide
 811          },
 812      },
 813  
 814      # Web dashboard settings
 815      "dashboard": {
 816          "theme": "default",  # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose"
 817      },
 818  
 819      # Privacy settings
 820      "privacy": {
 821          "redact_pii": False,  # When True, hash user IDs and strip phone numbers from LLM context
 822      },
 823      
 824      # Text-to-speech configuration
 825      # Each provider supports an optional `max_text_length:` override for the
 826      # per-request input-character cap. Omit it to use the provider's documented
 827      # limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
 828      # Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
 829      "tts": {
 830          "provider": "edge",  # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
 831          "edge": {
 832              "voice": "en-US-AriaNeural",
 833              # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
 834          },
 835          "elevenlabs": {
 836              "voice_id": "pNInz6obpgDQGcFmaJgB",  # Adam
 837              "model_id": "eleven_multilingual_v2",
 838          },
 839          "openai": {
 840              "model": "gpt-4o-mini-tts",
 841              "voice": "alloy",
 842              # Voices: alloy, echo, fable, onyx, nova, shimmer
 843          },
 844          "xai": {
 845              "voice_id": "eve",  # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
 846              "language": "en",
 847              "sample_rate": 24000,
 848              "bit_rate": 128000,
 849          },
 850          "mistral": {
 851              "model": "voxtral-mini-tts-2603",
 852              "voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8",  # Paul - Neutral
 853          },
 854          "neutts": {
 855              "ref_audio": "",  # Path to reference voice audio (empty = bundled default)
 856              "ref_text": "",   # Path to reference voice transcript (empty = bundled default)
 857              "model": "neuphonic/neutts-air-q4-gguf",  # HuggingFace model repo
 858              "device": "cpu",  # cpu, cuda, or mps
 859          },
 860          "piper": {
 861              # Voice name (e.g. "en_US-lessac-medium") downloaded on first
 862              # use, OR an absolute path to a pre-downloaded .onnx file.
 863              # Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md
 864              "voice": "en_US-lessac-medium",
 865              # "voices_dir": "",        # Override voice cache dir; default = ~/.hermes/cache/piper-voices/
 866              # "use_cuda": False,       # Requires onnxruntime-gpu
 867              # "length_scale": 1.0,     # 2.0 = twice as slow
 868              # "noise_scale": 0.667,
 869              # "noise_w_scale": 0.8,
 870              # "volume": 1.0,
 871              # "normalize_audio": True,
 872          },
 873      },
 874      
 875      "stt": {
 876          "enabled": True,
 877          "provider": "local",  # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe)
 878          "local": {
 879              "model": "base",  # tiny, base, small, medium, large-v3
 880              "language": "",  # auto-detect by default; set to "en", "es", "fr", etc. to force
 881          },
 882          "openai": {
 883              "model": "whisper-1",  # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe
 884          },
 885          "mistral": {
 886              "model": "voxtral-mini-latest",  # voxtral-mini-latest, voxtral-mini-2602
 887          },
 888      },
 889  
 890      "voice": {
 891          "record_key": "ctrl+b",
 892          "max_recording_seconds": 120,
 893          "auto_tts": False,
 894          "beep_enabled": True,         # Play record start/stop beeps in CLI voice mode
 895          "silence_threshold": 200,     # RMS below this = silence (0-32767)
 896          "silence_duration": 3.0,      # Seconds of silence before auto-stop
 897      },
 898      
 899      "human_delay": {
 900          "mode": "off",
 901          "min_ms": 800,
 902          "max_ms": 2500,
 903      },
 904      
 905      # Context engine -- controls how the context window is managed when
 906      # approaching the model's token limit.
 907      # "compressor" = built-in lossy summarization (default).
 908      # Set to a plugin name to activate an alternative engine (e.g. "lcm"
 909      # for Lossless Context Management).  The engine must be installed as
 910      # a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/.
 911      "context": {
 912          "engine": "compressor",
 913      },
 914  
 915      # Persistent memory -- bounded curated memory injected into system prompt
 916      "memory": {
 917          "memory_enabled": True,
 918          "user_profile_enabled": True,
 919          "memory_char_limit": 2200,   # ~800 tokens at 2.75 chars/token
 920          "user_char_limit": 1375,     # ~500 tokens at 2.75 chars/token
 921          # External memory provider plugin (empty = built-in only).
 922          # Set to a provider name to activate: "openviking", "mem0",
 923          # "hindsight", "holographic", "retaindb", "byterover".
 924          # Only ONE external provider is allowed at a time.
 925          "provider": "",
 926      },
 927  
 928      # Subagent delegation — override the provider:model used by delegate_task
 929      # so child agents can run on a different (cheaper/faster) provider and model.
 930      # Uses the same runtime provider resolution as CLI/gateway startup, so all
 931      # configured providers (OpenRouter, Nous, Z.ai, Kimi, etc.) are supported.
 932      "delegation": {
 933          "model": "",       # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model)
 934          "provider": "",    # e.g. "openrouter" (empty = inherit parent provider + credentials)
 935          "base_url": "",    # direct OpenAI-compatible endpoint for subagents
 936          "api_key": "",     # API key for delegation.base_url (falls back to OPENAI_API_KEY)
 937          # When delegate_task narrows child toolsets explicitly, preserve any
 938          # MCP toolsets the parent already has enabled. On by default so
 939          # narrowing (e.g. toolsets=["web","browser"]) expresses "I want these
 940          # extras" without silently stripping MCP tools the parent already has.
 941          # Set to false for strict intersection.
 942          "inherit_mcp_toolsets": True,
 943          "max_iterations": 50,  # per-subagent iteration cap (each subagent gets its own budget,
 944                                 # independent of the parent's max_iterations)
 945          "child_timeout_seconds": 600,  # wall-clock timeout for each child agent (floor 30s,
 946                                         # no ceiling). High-reasoning models on large tasks
 947                                         # (e.g. gpt-5.5 xhigh, opus-4.6) need generous budgets;
 948                                         # raise if children time out before producing output.
 949          "reasoning_effort": "",  # reasoning effort for subagents: "xhigh", "high", "medium",
 950                                   # "low", "minimal", "none" (empty = inherit parent's level)
 951          "max_concurrent_children": 3,  # max parallel children per batch; floor of 1 enforced, no ceiling
 952          # Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
 953          # and _get_orchestrator_enabled).  Values are clamped to [1, 3] with a
 954          # warning log if out of range.
 955          "max_spawn_depth": 1,        # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
 956          "orchestrator_enabled": True,  # kill switch for role="orchestrator"
 957          # When a subagent hits a dangerous-command approval prompt, the parent's
 958          # prompt_toolkit TUI owns stdin — a thread-local input() call from the
 959          # subagent worker would deadlock the parent UI. To avoid the deadlock,
 960          # subagent threads ALWAYS resolve approvals non-interactively:
 961          #   false (default) → auto-deny with a logger.warning audit line (safe)
 962          #   true             → auto-approve "once" with a logger.warning audit line
 963          # Flip to true only if you trust delegated work to run dangerous cmds
 964          # without human review (cron pipelines, batch automation, etc.).
 965          "subagent_auto_approve": False,
 966      },
 967  
 968      # Ephemeral prefill messages file — JSON list of {role, content} dicts
 969      # injected at the start of every API call for few-shot priming.
 970      # Never saved to sessions, logs, or trajectories.
 971      "prefill_messages_file": "",
 972  
 973      # Goals — persistent cross-turn goals (Ralph-style loop).
 974      # After every turn, a lightweight judge call asks the auxiliary model
 975      # whether the active /goal is satisfied by the assistant's last
 976      # response. If not, Hermes feeds a continuation prompt back into the
 977      # same session and keeps working until the goal is done, the turn
 978      # budget is exhausted, or the user pauses/clears it. Judge failures
 979      # fail OPEN (continue) so a flaky judge never wedges progress — the
 980      # turn budget is the real backstop.
 981      "goals": {
 982          # Max continuation turns before Hermes auto-pauses the goal and
 983          # asks the user to /goal resume. Protects against judge false
 984          # negatives (goal actually done but judge says continue) and
 985          # unbounded model spend on fuzzy / unachievable goals.
 986          "max_turns": 20,
 987      },
 988  
 989      # Skills — external skill directories for sharing skills across tools/agents.
 990      # Each path is expanded (~, ${VAR}) and resolved.  Read-only — skill creation
 991      # always goes to ~/.hermes/skills/.
 992      "skills": {
 993          "external_dirs": [],   # e.g. ["~/.agents/skills", "/shared/team-skills"]
 994          # Substitute ${HERMES_SKILL_DIR} and ${HERMES_SESSION_ID} in SKILL.md
 995          # content with the absolute skill directory and the active session id
 996          # before the agent sees it.  Lets skill authors reference bundled
 997          # scripts without the agent having to join paths.
 998          "template_vars": True,
 999          # Pre-execute inline shell snippets written as !`cmd` in SKILL.md
1000          # body.  Their stdout is inlined into the skill message before the
1001          # agent reads it, so skills can inject dynamic context (dates, git
1002          # state, detected tool versions, …).  Off by default because any
1003          # content from the skill author runs on the host without approval;
1004          # only enable for skill sources you trust.
1005          "inline_shell": False,
1006          # Timeout (seconds) for each !`cmd` snippet when inline_shell is on.
1007          "inline_shell_timeout": 10,
1008          # Run the keyword/pattern security scanner on skills the agent
1009          # writes via skill_manage (create/edit/patch).  Off by default
1010          # because the agent can already execute the same code paths via
1011          # terminal() with no gate, so the scan adds friction (blocks
1012          # skills that mention risky keywords in prose) without meaningful
1013          # security.  Turn on if you want the belt-and-suspenders — a
1014          # dangerous verdict will then surface as a tool error to the
1015          # agent, which can retry with the flagged content removed.
1016          # External hub installs (trusted/community sources) are always
1017          # scanned regardless of this setting.
1018          "guard_agent_created": False,
1019      },
1020  
1021      # Curator — background skill maintenance.
1022      #
1023      # Periodically reviews AGENT-CREATED skills (never bundled or
1024      # hub-installed) and keeps the collection tidy: marks long-unused skills
1025      # as stale, archives genuinely obsolete ones (archive only, never
1026      # deletes), and spawns a forked aux-model agent to consolidate overlaps
1027      # and patch drift. Runs inactivity-triggered from session start — no
1028      # cron daemon.
1029      #
1030      # See `hermes curator status` for the last run summary.
1031      "curator": {
1032          "enabled": True,
1033          # How long to wait between curator runs (hours).  Default: 7 days.
1034          "interval_hours": 24 * 7,
1035          # Only run when the agent has been idle at least this long (hours).
1036          "min_idle_hours": 2,
1037          # Mark a skill as "stale" after this many days without use.
1038          "stale_after_days": 30,
1039          # Archive a skill (move to skills/.archive/) after this many days
1040          # without use. Archived skills are recoverable — no auto-deletion.
1041          "archive_after_days": 90,
1042          # Pre-run backup: before every real curator pass (dry-run is
1043          # skipped), snapshot ~/.hermes/skills/ into
1044          # ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
1045          # user can roll back with `hermes curator rollback`.
1046          "backup": {
1047              "enabled": True,
1048              "keep": 5,  # retain last N regular snapshots
1049          },
1050      },
1051  
1052      # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth.
1053      # This section is only needed for hermes-specific overrides; everything else
1054      # (apiKey, workspace, peerName, sessions, enabled) comes from the global config.
1055      "honcho": {},
1056  
1057      # IANA timezone (e.g. "Asia/Kolkata", "America/New_York").
1058      # Empty string means use server-local time.
1059      "timezone": "",
1060  
1061      # Discord platform settings (gateway mode)
1062      "discord": {
1063          "require_mention": True,       # Require @mention to respond in server channels
1064          "free_response_channels": "",  # Comma-separated channel IDs where bot responds without mention
1065          "allowed_channels": "",        # If set, bot ONLY responds in these channel IDs (whitelist)
1066          "auto_thread": True,           # Auto-create threads on @mention in channels (like Slack)
1067          "reactions": True,             # Add 👀/✅/❌ reactions to messages during processing
1068          "channel_prompts": {},         # Per-channel ephemeral system prompts (forum parents apply to child threads)
1069          # discord / discord_admin tools: restrict which actions the agent may call.
1070          # Default (empty) = all actions allowed (subject to bot privileged intents).
1071          # Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
1072          # or YAML list. Unknown names are dropped with a warning at load time.
1073          # Actions: list_guilds, server_info, list_channels, channel_info,
1074          # list_roles, member_info, search_members, fetch_messages, list_pins,
1075          # pin_message, unpin_message, create_thread, add_role, remove_role.
1076          "server_actions": "",
1077      },
1078  
1079      # WhatsApp platform settings (gateway mode)
1080      "whatsapp": {
1081          # Reply prefix prepended to every outgoing WhatsApp message.
1082          # Default (None) uses the built-in "⚕ *Hermes Agent*" header.
1083          # Set to "" (empty string) to disable the header entirely.
1084          # Supports \n for newlines, e.g. "🤖 *My Bot*\n──────\n"
1085      },
1086  
1087      # Telegram platform settings (gateway mode)
1088      "telegram": {
1089          "reactions": False,            # Add 👀/✅/❌ reactions to messages during processing
1090          "channel_prompts": {},         # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
1091      },
1092  
1093      # Slack platform settings (gateway mode)
1094      "slack": {
1095          "channel_prompts": {},         # Per-channel ephemeral system prompts
1096      },
1097  
1098      # Mattermost platform settings (gateway mode)
1099      "mattermost": {
1100          "channel_prompts": {},         # Per-channel ephemeral system prompts
1101      },
1102  
1103      # Approval mode for dangerous commands:
1104      #   manual — always prompt the user (default)
1105      #   smart  — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
1106      #   off    — skip all approval prompts (equivalent to --yolo)
1107      #
1108      # cron_mode — what to do when a cron job hits a dangerous command:
1109      #   deny    — block the command and let the agent find another way (default, safe)
1110      #   approve — auto-approve all dangerous commands in cron jobs
1111      "approvals": {
1112          "mode": "manual",
1113          "timeout": 60,
1114          "cron_mode": "deny",
1115          # When true, /reload-mcp asks the user to confirm before rebuilding
1116          # the MCP tool set for the active session.  Reloading invalidates
1117          # the provider prompt cache (tool schemas are baked into the system
1118          # prompt), so the next message re-sends full input tokens — this can
1119          # be expensive on long-context or high-reasoning models.  Users click
1120          # "Always Approve" to silence the prompt permanently; that flips
1121          # this key to false.
1122          "mcp_reload_confirm": True,
1123      },
1124  
1125      # Permanently allowed dangerous command patterns (added via "always" approval)
1126      "command_allowlist": [],
1127      # User-defined quick commands that bypass the agent loop (type: exec only)
1128      "quick_commands": {},
1129  
1130      # Shell-script hooks — declarative bridge that invokes shell scripts
1131      # on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
1132      # subagent_stop, etc.).  Each entry maps an event name to a list of
1133      # {matcher, command, timeout} dicts.  First registration of a new
1134      # command prompts the user for consent; subsequent runs reuse the
1135      # stored approval from ~/.hermes/shell-hooks-allowlist.json.
1136      # See `website/docs/user-guide/features/hooks.md` for schema + examples.
1137      "hooks": {},
1138  
1139      # Auto-accept shell-hook registrations without a TTY prompt.  Also
1140      # toggleable per-invocation via --accept-hooks or HERMES_ACCEPT_HOOKS=1.
1141      # Gateway / cron / non-interactive runs need this (or one of the other
1142      # channels) to pick up newly-added hooks.
1143      "hooks_auto_accept": False,
1144      # Custom personalities — add your own entries here
1145      # Supports string format: {"name": "system prompt"}
1146      # Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}}
1147      "personalities": {},
1148  
1149      # Pre-exec security scanning via tirith
1150      "security": {
1151          "allow_private_urls": False,  # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
1152          "redact_secrets": False,
1153          "tirith_enabled": True,
1154          "tirith_path": "tirith",
1155          "tirith_timeout": 5,
1156          "tirith_fail_open": True,
1157          "website_blocklist": {
1158              "enabled": False,
1159              "domains": [],
1160              "shared_files": [],
1161          },
1162      },
1163  
1164      "cron": {
1165          # Wrap delivered cron responses with a header (task name) and footer
1166          # ("The agent cannot see this message").  Set to false for clean output.
1167          "wrap_response": True,
1168          # Maximum number of due jobs to run in parallel per tick.
1169          # null/0 = unbounded (limited only by thread count).
1170          # 1 = serial (pre-v0.9 behaviour).
1171          # Also overridable via HERMES_CRON_MAX_PARALLEL env var.
1172          "max_parallel_jobs": None,
1173      },
1174  
1175      # Kanban multi-agent coordination — controls the dispatcher loop that
1176      # spawns workers for ready tasks. The dispatcher ticks every N seconds
1177      # (default 60), reclaims stale claims, promotes dependency-satisfied
1178      # todos to ready, and fires `hermes -p <assignee> chat -q ...` for
1179      # each claimable ready task. One dispatcher per profile is sufficient;
1180      # running more than one on the same kanban.db will race for claims.
1181      "kanban": {
1182          # Run the dispatcher inside the gateway process. On by default —
1183          # the cost is ~300µs every `dispatch_interval_seconds` when idle,
1184          # and gateway is the supervisor users already have. Set to false
1185          # only if you run the dispatcher as a separate systemd unit or
1186          # don't want the gateway to spawn workers.
1187          "dispatch_in_gateway": True,
1188          # Seconds between dispatcher ticks (idle or not). Lower = snappier
1189          # pickup of newly-ready tasks; higher = less SQL pressure.
1190          "dispatch_interval_seconds": 60,
1191      },
1192  
1193      # execute_code settings — controls the tool used for programmatic tool calls.
1194      "code_execution": {
1195          # Execution mode:
1196          #   project (default) — scripts run in the session's working directory
1197          #     with the active virtualenv/conda env's python, so project deps
1198          #     (pandas, torch, project packages) and relative paths resolve.
1199          #   strict            — scripts run in an isolated temp directory with
1200          #     hermes-agent's own python (sys.executable). Maximum isolation
1201          #     and reproducibility; project deps and relative paths won't work.
1202          # Env scrubbing (strips *_API_KEY, *_TOKEN, *_SECRET, ...) and the
1203          # tool whitelist apply identically in both modes.
1204          "mode": "project",
1205      },
1206  
1207      # Logging — controls file logging to ~/.hermes/logs/.
1208      # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+.
1209      "logging": {
1210          "level": "INFO",       # Minimum level for agent.log: DEBUG, INFO, WARNING
1211          "max_size_mb": 5,      # Max size per log file before rotation
1212          "backup_count": 3,     # Number of rotated backup files to keep
1213      },
1214  
1215      # Remotely-hosted model catalog manifest.  When enabled, the CLI fetches
1216      # curated model lists for OpenRouter and Nous Portal from this URL,
1217      # falling back to the in-repo snapshot on network failure.  Lets us
1218      # update model picker lists without shipping a hermes-agent release.
1219      # The default URL is served by the docs site GitHub Pages deploy.
1220      "model_catalog": {
1221          "enabled": True,
1222          "url": "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json",
1223          # Disk cache TTL in hours.  Beyond this, the CLI refetches on the
1224          # next /model or `hermes model` invocation; network failures
1225          # silently fall back to the stale cache.
1226          "ttl_hours": 24,
1227          # Optional per-provider override URLs for third parties that want
1228          # to self-host their own curation list using the same schema.
1229          # Example:
1230          #   providers:
1231          #     openrouter:
1232          #       url: https://example.com/my-curation.json
1233          "providers": {},
1234      },
1235  
1236      # Network settings — workarounds for connectivity issues.
1237      "network": {
1238          # Force IPv4 connections.  On servers with broken or unreachable IPv6,
1239          # Python tries AAAA records first and hangs for the full TCP timeout
1240          # before falling back to IPv4.  Set to true to skip IPv6 entirely.
1241          "force_ipv4": False,
1242      },
1243  
1244      # Session storage — controls automatic cleanup of ~/.hermes/state.db.
1245      # state.db accumulates every session, message, tool call, and FTS5 index
1246      # entry forever.  Without auto-pruning, a heavy user (gateway + cron)
1247      # reports 384MB+ databases with 68K+ messages, which slows down FTS5
1248      # inserts, /resume listing, and insights queries.
1249      "sessions": {
1250          # When true, prune ended sessions older than retention_days once
1251          # per (roughly) min_interval_hours at CLI/gateway/cron startup.
1252          # Only touches ended sessions — active sessions are always preserved.
1253          # Default false: session history is valuable for search recall, and
1254          # silently deleting it could surprise users.  Opt in explicitly.
1255          "auto_prune": False,
1256          # How many days of ended-session history to keep.  Matches the
1257          # default of ``hermes sessions prune``.
1258          "retention_days": 90,
1259          # VACUUM after a prune that actually deleted rows.  SQLite does not
1260          # reclaim disk space on DELETE — freed pages are just reused on
1261          # subsequent INSERTs — so without VACUUM the file stays bloated
1262          # even after pruning.  VACUUM blocks writes for a few seconds per
1263          # 100MB, so it only runs at startup, and only when prune deleted
1264          # ≥1 session.
1265          "vacuum_after_prune": True,
1266          # Minimum hours between auto-maintenance runs (avoids repeating
1267          # the sweep on every CLI invocation).  Tracked via state_meta in
1268          # state.db itself, so it's shared across all processes.
1269          "min_interval_hours": 24,
1270      },
1271  
1272      # Contextual first-touch onboarding hints (see agent/onboarding.py).
1273      # Each hint is shown once per install and then latched here so it
1274      # never fires again.  Users can wipe the section to re-see all hints.
1275      "onboarding": {
1276          "seen": {},
1277      },
1278  
1279      # ``hermes update`` behaviour.
1280      "updates": {
1281          # Run a full ``hermes backup``-style zip of HERMES_HOME before every
1282          # ``hermes update``.  Backups land in ``<HERMES_HOME>/backups/`` and
1283          # can be restored with ``hermes import <path>``.  Off by default —
1284          # on large HERMES_HOME directories the zip can add minutes to every
1285          # update.  Set to true to re-enable, or pass ``--backup`` to opt in
1286          # for a single update run.
1287          "pre_update_backup": False,
1288          # How many pre-update backup zips to retain.  Older ones are pruned
1289          # automatically after each successful backup.  Values below 1 are
1290          # floored to 1 — the backup just created is always preserved.  To
1291          # disable backups entirely, set ``pre_update_backup: false`` above
1292          # rather than ``backup_keep: 0``.
1293          "backup_keep": 5,
1294      },
1295  
1296      # Config schema version - bump this when adding new required fields
1297      "_config_version": 23,
1298  }
1299  
1300  # =============================================================================
1301  # Config Migration System
1302  # =============================================================================
1303  
1304  # Track which env vars were introduced in each config version.
1305  # Migration only mentions vars new since the user's previous version.
1306  ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
1307      3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"],
1308      4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"],
1309      5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
1310          "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
1311      10: ["TAVILY_API_KEY"],
1312      11: ["TERMINAL_MODAL_MODE"],
1313  }
1314  
1315  # Required environment variables with metadata for migration prompts.
1316  # LLM provider is required but handled in the setup wizard's provider
1317  # selection step (Nous Portal / OpenRouter / Custom endpoint), so this
1318  # dict is intentionally empty — no single env var is universally required.
1319  REQUIRED_ENV_VARS = {}
1320  
1321  # Optional environment variables that enhance functionality
1322  OPTIONAL_ENV_VARS = {
1323      # ── Provider (handled in provider selection, not shown in checklists) ──
1324      "NOUS_BASE_URL": {
1325          "description": "Nous Portal base URL override",
1326          "prompt": "Nous Portal base URL (leave empty for default)",
1327          "url": None,
1328          "password": False,
1329          "category": "provider",
1330          "advanced": True,
1331      },
1332      "OPENROUTER_API_KEY": {
1333          "description": "OpenRouter API key (for vision, web scraping helpers, and MoA)",
1334          "prompt": "OpenRouter API key",
1335          "url": "https://openrouter.ai/keys",
1336          "password": True,
1337          "tools": ["vision_analyze", "mixture_of_agents"],
1338          "category": "provider",
1339          "advanced": True,
1340      },
1341      "GOOGLE_API_KEY": {
1342          "description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)",
1343          "prompt": "Google AI Studio API key",
1344          "url": "https://aistudio.google.com/app/apikey",
1345          "password": True,
1346          "category": "provider",
1347          "advanced": True,
1348      },
1349      "GEMINI_API_KEY": {
1350          "description": "Google AI Studio API key (alias for GOOGLE_API_KEY)",
1351          "prompt": "Gemini API key",
1352          "url": "https://aistudio.google.com/app/apikey",
1353          "password": True,
1354          "category": "provider",
1355          "advanced": True,
1356      },
1357      "GEMINI_BASE_URL": {
1358          "description": "Google AI Studio base URL override",
1359          "prompt": "Gemini base URL (leave empty for default)",
1360          "url": None,
1361          "password": False,
1362          "category": "provider",
1363          "advanced": True,
1364      },
1365      "XAI_API_KEY": {
1366          "description": "xAI API key",
1367          "prompt": "xAI API key",
1368          "url": "https://console.x.ai/",
1369          "password": True,
1370          "category": "provider",
1371          "advanced": True,
1372      },
1373      "XAI_BASE_URL": {
1374          "description": "xAI base URL override",
1375          "prompt": "xAI base URL (leave empty for default)",
1376          "url": None,
1377          "password": False,
1378          "category": "provider",
1379          "advanced": True,
1380      },
1381      "NVIDIA_API_KEY": {
1382          "description": "NVIDIA NIM API key (build.nvidia.com or local NIM endpoint)",
1383          "prompt": "NVIDIA NIM API key",
1384          "url": "https://build.nvidia.com/",
1385          "password": True,
1386          "category": "provider",
1387          "advanced": True,
1388      },
1389      "NVIDIA_BASE_URL": {
1390          "description": "NVIDIA NIM base URL override (e.g. http://localhost:8000/v1 for local NIM)",
1391          "prompt": "NVIDIA NIM base URL (leave empty for default)",
1392          "url": None,
1393          "password": False,
1394          "category": "provider",
1395          "advanced": True,
1396      },
1397      "LM_API_KEY": {
1398          "description": "LM Studio bearer token for auth-enabled local servers",
1399          "prompt": "LM Studio API key / bearer token",
1400          "url": None,
1401          "password": True,
1402          "category": "provider",
1403          "advanced": True,
1404      },
1405      "LM_BASE_URL": {
1406          "description": "LM Studio base URL override",
1407          "prompt": "LM Studio base URL (leave empty for default)",
1408          "url": None,
1409          "password": False,
1410          "category": "provider",
1411          "advanced": True,
1412      },
1413      "GLM_API_KEY": {
1414          "description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)",
1415          "prompt": "Z.AI / GLM API key",
1416          "url": "https://z.ai/",
1417          "password": True,
1418          "category": "provider",
1419          "advanced": True,
1420      },
1421      "ZAI_API_KEY": {
1422          "description": "Z.AI API key (alias for GLM_API_KEY)",
1423          "prompt": "Z.AI API key",
1424          "url": "https://z.ai/",
1425          "password": True,
1426          "category": "provider",
1427          "advanced": True,
1428      },
1429      "Z_AI_API_KEY": {
1430          "description": "Z.AI API key (alias for GLM_API_KEY)",
1431          "prompt": "Z.AI API key",
1432          "url": "https://z.ai/",
1433          "password": True,
1434          "category": "provider",
1435          "advanced": True,
1436      },
1437      "GLM_BASE_URL": {
1438          "description": "Z.AI / GLM base URL override",
1439          "prompt": "Z.AI / GLM base URL (leave empty for default)",
1440          "url": None,
1441          "password": False,
1442          "category": "provider",
1443          "advanced": True,
1444      },
1445      "KIMI_API_KEY": {
1446          "description": "Kimi / Moonshot API key",
1447          "prompt": "Kimi API key",
1448          "url": "https://platform.moonshot.cn/",
1449          "password": True,
1450          "category": "provider",
1451          "advanced": True,
1452      },
1453      "KIMI_BASE_URL": {
1454          "description": "Kimi / Moonshot base URL override",
1455          "prompt": "Kimi base URL (leave empty for default)",
1456          "url": None,
1457          "password": False,
1458          "category": "provider",
1459          "advanced": True,
1460      },
1461      "KIMI_CN_API_KEY": {
1462          "description": "Kimi / Moonshot China API key",
1463          "prompt": "Kimi (China) API key",
1464          "url": "https://platform.moonshot.cn/",
1465          "password": True,
1466          "category": "provider",
1467          "advanced": True,
1468      },
1469      "STEPFUN_API_KEY": {
1470          "description": "StepFun Step Plan API key",
1471          "prompt": "StepFun Step Plan API key",
1472          "url": "https://platform.stepfun.com/",
1473          "password": True,
1474          "category": "provider",
1475          "advanced": True,
1476      },
1477      "STEPFUN_BASE_URL": {
1478          "description": "StepFun Step Plan base URL override",
1479          "prompt": "StepFun Step Plan base URL (leave empty for default)",
1480          "url": None,
1481          "password": False,
1482          "category": "provider",
1483          "advanced": True,
1484      },
1485      "ARCEEAI_API_KEY": {
1486          "description": "Arcee AI API key",
1487          "prompt": "Arcee AI API key",
1488          "url": "https://chat.arcee.ai/",
1489          "password": True,
1490          "category": "provider",
1491          "advanced": True,
1492      },
1493      "ARCEE_BASE_URL": {
1494          "description": "Arcee AI base URL override",
1495          "prompt": "Arcee base URL (leave empty for default)",
1496          "url": None,
1497          "password": False,
1498          "category": "provider",
1499          "advanced": True,
1500      },
1501      "GMI_API_KEY": {
1502          "description": "GMI Cloud API key",
1503          "prompt": "GMI Cloud API key",
1504          "url": "https://www.gmicloud.ai/",
1505          "password": True,
1506          "category": "provider",
1507          "advanced": True,
1508      },
1509      "GMI_BASE_URL": {
1510          "description": "GMI Cloud base URL override",
1511          "prompt": "GMI Cloud base URL (leave empty for default)",
1512          "url": None,
1513          "password": False,
1514          "category": "provider",
1515          "advanced": True,
1516      },
1517      "MINIMAX_API_KEY": {
1518          "description": "MiniMax API key (international)",
1519          "prompt": "MiniMax API key",
1520          "url": "https://www.minimax.io/",
1521          "password": True,
1522          "category": "provider",
1523          "advanced": True,
1524      },
1525      "MINIMAX_BASE_URL": {
1526          "description": "MiniMax base URL override",
1527          "prompt": "MiniMax base URL (leave empty for default)",
1528          "url": None,
1529          "password": False,
1530          "category": "provider",
1531          "advanced": True,
1532      },
1533      "MINIMAX_CN_API_KEY": {
1534          "description": "MiniMax API key (China endpoint)",
1535          "prompt": "MiniMax (China) API key",
1536          "url": "https://www.minimaxi.com/",
1537          "password": True,
1538          "category": "provider",
1539          "advanced": True,
1540      },
1541      "MINIMAX_CN_BASE_URL": {
1542          "description": "MiniMax (China) base URL override",
1543          "prompt": "MiniMax (China) base URL (leave empty for default)",
1544          "url": None,
1545          "password": False,
1546          "category": "provider",
1547          "advanced": True,
1548      },
1549      "DEEPSEEK_API_KEY": {
1550          "description": "DeepSeek API key for direct DeepSeek access",
1551          "prompt": "DeepSeek API Key",
1552          "url": "https://platform.deepseek.com/api_keys",
1553          "password": True,
1554          "category": "provider",
1555      },
1556      "DEEPSEEK_BASE_URL": {
1557          "description": "Custom DeepSeek API base URL (advanced)",
1558          "prompt": "DeepSeek Base URL",
1559          "url": "",
1560          "password": False,
1561          "category": "provider",
1562      },
1563      "DASHSCOPE_API_KEY": {
1564          "description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)",
1565          "prompt": "DashScope API Key",
1566          "url": "https://modelstudio.console.alibabacloud.com/",
1567          "password": True,
1568          "category": "provider",
1569      },
1570      "DASHSCOPE_BASE_URL": {
1571          "description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)",
1572          "prompt": "DashScope Base URL",
1573          "url": "",
1574          "password": False,
1575          "category": "provider",
1576          "advanced": True,
1577      },
1578      "HERMES_QWEN_BASE_URL": {
1579          "description": "Qwen Portal base URL override (default: https://portal.qwen.ai/v1)",
1580          "prompt": "Qwen Portal base URL (leave empty for default)",
1581          "url": None,
1582          "password": False,
1583          "category": "provider",
1584          "advanced": True,
1585      },
1586      "HERMES_GEMINI_CLIENT_ID": {
1587          "description": "Google OAuth client ID for google-gemini-cli (optional; defaults to Google's public gemini-cli client)",
1588          "prompt": "Google OAuth client ID (optional — leave empty to use the public default)",
1589          "url": "https://console.cloud.google.com/apis/credentials",
1590          "password": False,
1591          "category": "provider",
1592          "advanced": True,
1593      },
1594      "HERMES_GEMINI_CLIENT_SECRET": {
1595          "description": "Google OAuth client secret for google-gemini-cli (optional)",
1596          "prompt": "Google OAuth client secret (optional)",
1597          "url": "https://console.cloud.google.com/apis/credentials",
1598          "password": True,
1599          "category": "provider",
1600          "advanced": True,
1601      },
1602      "HERMES_GEMINI_PROJECT_ID": {
1603          "description": "GCP project ID for paid Gemini tiers (free tier auto-provisions)",
1604          "prompt": "GCP project ID for Gemini OAuth (leave empty for free tier)",
1605          "url": None,
1606          "password": False,
1607          "category": "provider",
1608          "advanced": True,
1609      },
1610      "OPENCODE_ZEN_API_KEY": {
1611          "description": "OpenCode Zen API key (pay-as-you-go access to curated models)",
1612          "prompt": "OpenCode Zen API key",
1613          "url": "https://opencode.ai/auth",
1614          "password": True,
1615          "category": "provider",
1616          "advanced": True,
1617      },
1618      "OPENCODE_ZEN_BASE_URL": {
1619          "description": "OpenCode Zen base URL override",
1620          "prompt": "OpenCode Zen base URL (leave empty for default)",
1621          "url": None,
1622          "password": False,
1623          "category": "provider",
1624          "advanced": True,
1625      },
1626      "OPENCODE_GO_API_KEY": {
1627          "description": "OpenCode Go API key ($10/month subscription for open models)",
1628          "prompt": "OpenCode Go API key",
1629          "url": "https://opencode.ai/auth",
1630          "password": True,
1631          "category": "provider",
1632          "advanced": True,
1633      },
1634      "OPENCODE_GO_BASE_URL": {
1635          "description": "OpenCode Go base URL override",
1636          "prompt": "OpenCode Go base URL (leave empty for default)",
1637          "url": None,
1638          "password": False,
1639          "category": "provider",
1640          "advanced": True,
1641      },
1642      "HF_TOKEN": {
1643          "description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)",
1644          "prompt": "Hugging Face Token",
1645          "url": "https://huggingface.co/settings/tokens",
1646          "password": True,
1647          "category": "provider",
1648      },
1649      "HF_BASE_URL": {
1650          "description": "Hugging Face Inference Providers base URL override",
1651          "prompt": "HF base URL (leave empty for default)",
1652          "url": None,
1653          "password": False,
1654          "category": "provider",
1655          "advanced": True,
1656      },
1657      "OLLAMA_API_KEY": {
1658          "description": "Ollama Cloud API key (ollama.com — cloud-hosted open models)",
1659          "prompt": "Ollama Cloud API key",
1660          "url": "https://ollama.com/settings",
1661          "password": True,
1662          "category": "provider",
1663          "advanced": True,
1664      },
1665      "OLLAMA_BASE_URL": {
1666          "description": "Ollama Cloud base URL override (default: https://ollama.com/v1)",
1667          "prompt": "Ollama base URL (leave empty for default)",
1668          "url": None,
1669          "password": False,
1670          "category": "provider",
1671          "advanced": True,
1672      },
1673      "XIAOMI_API_KEY": {
1674          "description": "Xiaomi MiMo API key for MiMo models (mimo-v2.5-pro, mimo-v2.5, mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)",
1675          "prompt": "Xiaomi MiMo API Key",
1676          "url": "https://platform.xiaomimimo.com",
1677          "password": True,
1678          "category": "provider",
1679      },
1680      "XIAOMI_BASE_URL": {
1681          "description": "Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)",
1682          "prompt": "Xiaomi base URL (leave empty for default)",
1683          "url": None,
1684          "password": False,
1685          "category": "provider",
1686          "advanced": True,
1687      },
1688      "AWS_REGION": {
1689          "description": "AWS region for Bedrock API calls (e.g. us-east-1, eu-central-1)",
1690          "prompt": "AWS Region",
1691          "url": "https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html",
1692          "password": False,
1693          "category": "provider",
1694          "advanced": True,
1695      },
1696      "AWS_PROFILE": {
1697          "description": "AWS named profile for Bedrock authentication (from ~/.aws/credentials)",
1698          "prompt": "AWS Profile",
1699          "url": None,
1700          "password": False,
1701          "category": "provider",
1702          "advanced": True,
1703      },
1704      "AZURE_FOUNDRY_API_KEY": {
1705          "description": "Azure Foundry API key for custom Azure endpoints",
1706          "prompt": "Azure Foundry API Key",
1707          "url": "https://ai.azure.com/",
1708          "password": True,
1709          "category": "provider",
1710      },
1711      "AZURE_FOUNDRY_BASE_URL": {
1712          "description": "Azure Foundry base URL (set via 'hermes model' for endpoint-specific config)",
1713          "prompt": "Azure Foundry base URL",
1714          "url": None,
1715          "password": False,
1716          "category": "provider",
1717          "advanced": True,
1718      },
1719  
1720      # ── Tool API keys ──
1721      "EXA_API_KEY": {
1722          "description": "Exa API key for AI-native web search and contents",
1723          "prompt": "Exa API key",
1724          "url": "https://exa.ai/",
1725          "tools": ["web_search", "web_extract"],
1726          "password": True,
1727          "category": "tool",
1728      },
1729      "PARALLEL_API_KEY": {
1730          "description": "Parallel API key for AI-native web search and extract",
1731          "prompt": "Parallel API key",
1732          "url": "https://parallel.ai/",
1733          "tools": ["web_search", "web_extract"],
1734          "password": True,
1735          "category": "tool",
1736      },
1737      "FIRECRAWL_API_KEY": {
1738          "description": "Firecrawl API key for web search and scraping",
1739          "prompt": "Firecrawl API key",
1740          "url": "https://firecrawl.dev/",
1741          "tools": ["web_search", "web_extract"],
1742          "password": True,
1743          "category": "tool",
1744      },
1745      "FIRECRAWL_API_URL": {
1746          "description": "Firecrawl API URL for self-hosted instances (optional)",
1747          "prompt": "Firecrawl API URL (leave empty for cloud)",
1748          "url": None,
1749          "password": False,
1750          "category": "tool",
1751          "advanced": True,
1752      },
1753      "FIRECRAWL_GATEWAY_URL": {
1754          "description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
1755          "prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
1756          "url": None,
1757          "password": False,
1758          "category": "tool",
1759          "advanced": True,
1760      },
1761      "TOOL_GATEWAY_DOMAIN": {
1762          "description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
1763          "prompt": "Tool-gateway domain suffix",
1764          "url": None,
1765          "password": False,
1766          "category": "tool",
1767          "advanced": True,
1768      },
1769      "TOOL_GATEWAY_SCHEME": {
1770          "description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
1771          "prompt": "Tool-gateway URL scheme",
1772          "url": None,
1773          "password": False,
1774          "category": "tool",
1775          "advanced": True,
1776      },
1777      "TOOL_GATEWAY_USER_TOKEN": {
1778          "description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
1779          "prompt": "Tool-gateway user token",
1780          "url": None,
1781          "password": True,
1782          "category": "tool",
1783          "advanced": True,
1784      },
1785      "TAVILY_API_KEY": {
1786          "description": "Tavily API key for AI-native web search, extract, and crawl",
1787          "prompt": "Tavily API key",
1788          "url": "https://app.tavily.com/home",
1789          "tools": ["web_search", "web_extract", "web_crawl"],
1790          "password": True,
1791          "category": "tool",
1792      },
1793      "BROWSERBASE_API_KEY": {
1794          "description": "Browserbase API key for cloud browser (optional — local browser works without this)",
1795          "prompt": "Browserbase API key",
1796          "url": "https://browserbase.com/",
1797          "tools": ["browser_navigate", "browser_click"],
1798          "password": True,
1799          "category": "tool",
1800      },
1801      "BROWSERBASE_PROJECT_ID": {
1802          "description": "Browserbase project ID (optional — only needed for cloud browser)",
1803          "prompt": "Browserbase project ID",
1804          "url": "https://browserbase.com/",
1805          "tools": ["browser_navigate", "browser_click"],
1806          "password": False,
1807          "category": "tool",
1808      },
1809      "BROWSER_USE_API_KEY": {
1810          "description": "Browser Use API key for cloud browser (optional — local browser works without this)",
1811          "prompt": "Browser Use API key",
1812          "url": "https://browser-use.com/",
1813          "tools": ["browser_navigate", "browser_click"],
1814          "password": True,
1815          "category": "tool",
1816      },
1817      "FIRECRAWL_BROWSER_TTL": {
1818          "description": "Firecrawl browser session TTL in seconds (optional, default 300)",
1819          "prompt": "Browser session TTL (seconds)",
1820          "tools": ["browser_navigate", "browser_click"],
1821          "password": False,
1822          "category": "tool",
1823      },
1824      "CAMOFOX_URL": {
1825          "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)",
1826          "prompt": "Camofox server URL",
1827          "url": "https://github.com/jo-inc/camofox-browser",
1828          "tools": ["browser_navigate", "browser_click"],
1829          "password": False,
1830          "category": "tool",
1831      },
1832      "FAL_KEY": {
1833          "description": "FAL API key for image generation",
1834          "prompt": "FAL API key",
1835          "url": "https://fal.ai/",
1836          "tools": ["image_generate"],
1837          "password": True,
1838          "category": "tool",
1839      },
1840      "TINKER_API_KEY": {
1841          "description": "Tinker API key for RL training",
1842          "prompt": "Tinker API key",
1843          "url": "https://tinker-console.thinkingmachines.ai/keys",
1844          "tools": ["rl_start_training", "rl_check_status", "rl_stop_training"],
1845          "password": True,
1846          "category": "tool",
1847      },
1848      "WANDB_API_KEY": {
1849          "description": "Weights & Biases API key for experiment tracking",
1850          "prompt": "WandB API key",
1851          "url": "https://wandb.ai/authorize",
1852          "tools": ["rl_get_results", "rl_check_status"],
1853          "password": True,
1854          "category": "tool",
1855      },
1856      "VOICE_TOOLS_OPENAI_KEY": {
1857          "description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS",
1858          "prompt": "OpenAI API Key (for Whisper STT + TTS)",
1859          "url": "https://platform.openai.com/api-keys",
1860          "tools": ["voice_transcription", "openai_tts"],
1861          "password": True,
1862          "category": "tool",
1863      },
1864      "ELEVENLABS_API_KEY": {
1865          "description": "ElevenLabs API key for premium text-to-speech voices",
1866          "prompt": "ElevenLabs API key",
1867          "url": "https://elevenlabs.io/",
1868          "password": True,
1869          "category": "tool",
1870      },
1871      "MISTRAL_API_KEY": {
1872          "description": "Mistral API key for Voxtral TTS and transcription (STT)",
1873          "prompt": "Mistral API key",
1874          "url": "https://console.mistral.ai/",
1875          "password": True,
1876          "category": "tool",
1877      },
1878      "GITHUB_TOKEN": {
1879          "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)",
1880          "prompt": "GitHub Token",
1881          "url": "https://github.com/settings/tokens",
1882          "password": True,
1883          "category": "tool",
1884      },
1885  
1886      # ── Bundled skills (opt-in: only needed if the user uses that skill) ──
1887      # These use category="skill" (distinct from "tool") so the sandbox
1888      # env blocklist in tools/environments/local.py does NOT rewrite them —
1889      # skills legitimately need these passed through to curl via
1890      # tools/env_passthrough.py when the user's skill calls out.
1891      "NOTION_API_KEY": {
1892          "description": "Notion integration token (used by the `notion` skill)",
1893          "prompt": "Notion API key",
1894          "url": "https://www.notion.so/my-integrations",
1895          "password": True,
1896          "category": "skill",
1897          "advanced": True,
1898      },
1899      "LINEAR_API_KEY": {
1900          "description": "Linear personal API key (used by the `linear` skill)",
1901          "prompt": "Linear API key",
1902          "url": "https://linear.app/settings/api",
1903          "password": True,
1904          "category": "skill",
1905          "advanced": True,
1906      },
1907      "AIRTABLE_API_KEY": {
1908          "description": "Airtable personal access token (used by the `airtable` skill)",
1909          "prompt": "Airtable API key",
1910          "url": "https://airtable.com/create/tokens",
1911          "password": True,
1912          "category": "skill",
1913          "advanced": True,
1914      },
1915      "TENOR_API_KEY": {
1916          "description": "Tenor API key for GIF search (used by the `gif-search` skill)",
1917          "prompt": "Tenor API key",
1918          "url": "https://developers.google.com/tenor/guides/quickstart",
1919          "password": True,
1920          "category": "skill",
1921          "advanced": True,
1922      },
1923  
1924      # ── Honcho ──
1925      "HONCHO_API_KEY": {
1926          "description": "Honcho API key for AI-native persistent memory",
1927          "prompt": "Honcho API key",
1928          "url": "https://app.honcho.dev",
1929          "tools": ["honcho_context"],
1930          "password": True,
1931          "category": "tool",
1932      },
1933      "HONCHO_BASE_URL": {
1934          "description": "Base URL for self-hosted Honcho instances (no API key needed)",
1935          "prompt": "Honcho base URL (e.g. http://localhost:8000)",
1936          "category": "tool",
1937      },
1938  
1939      # ── Langfuse observability ──
1940      "HERMES_LANGFUSE_PUBLIC_KEY": {
1941          "description": "Langfuse project public key (pk-lf-...)",
1942          "prompt": "Langfuse public key",
1943          "url": "https://cloud.langfuse.com",
1944          "password": False,
1945          "category": "tool",
1946      },
1947      "HERMES_LANGFUSE_SECRET_KEY": {
1948          "description": "Langfuse project secret key (sk-lf-...)",
1949          "prompt": "Langfuse secret key",
1950          "url": "https://cloud.langfuse.com",
1951          "password": True,
1952          "category": "tool",
1953      },
1954      "HERMES_LANGFUSE_BASE_URL": {
1955          "description": "Langfuse server URL (default: https://cloud.langfuse.com)",
1956          "prompt": "Langfuse server URL (leave empty for cloud.langfuse.com)",
1957          "url": None,
1958          "password": False,
1959          "category": "tool",
1960          "advanced": True,
1961      },
1962  
1963      # ── Messaging platforms ──
1964      "TELEGRAM_BOT_TOKEN": {
1965          "description": "Telegram bot token from @BotFather",
1966          "prompt": "Telegram bot token",
1967          "url": "https://t.me/BotFather",
1968          "password": True,
1969          "category": "messaging",
1970      },
1971      "TELEGRAM_ALLOWED_USERS": {
1972          "description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)",
1973          "prompt": "Allowed Telegram user IDs (comma-separated)",
1974          "url": "https://t.me/userinfobot",
1975          "password": False,
1976          "category": "messaging",
1977      },
1978      "TELEGRAM_PROXY": {
1979          "description": "Proxy URL for Telegram connections (overrides HTTPS_PROXY). Supports http://, https://, socks5://",
1980          "prompt": "Telegram proxy URL (optional)",
1981          "password": False,
1982          "category": "messaging",
1983      },
1984      "DISCORD_BOT_TOKEN": {
1985          "description": "Discord bot token from Developer Portal",
1986          "prompt": "Discord bot token",
1987          "url": "https://discord.com/developers/applications",
1988          "password": True,
1989          "category": "messaging",
1990      },
1991      "DISCORD_ALLOWED_USERS": {
1992          "description": "Comma-separated Discord user IDs allowed to use the bot",
1993          "prompt": "Allowed Discord user IDs (comma-separated)",
1994          "url": None,
1995          "password": False,
1996          "category": "messaging",
1997      },
1998      "DISCORD_REPLY_TO_MODE": {
1999          "description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)",
2000          "prompt": "Discord reply mode (off/first/all)",
2001          "url": None,
2002          "password": False,
2003          "category": "messaging",
2004      },
2005      "SLACK_BOT_TOKEN": {
2006          "description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. "
2007                         "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
2008                         "im:history, im:read, im:write, users:read, files:read, files:write",
2009          "prompt": "Slack Bot Token (xoxb-...)",
2010          "url": "https://api.slack.com/apps",
2011          "password": True,
2012          "category": "messaging",
2013      },
2014      "SLACK_APP_TOKEN": {
2015          "description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information → "
2016                         "App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
2017                         "message.channels, message.groups, app_mention",
2018          "prompt": "Slack App Token (xapp-...)",
2019          "url": "https://api.slack.com/apps",
2020          "password": True,
2021          "category": "messaging",
2022      },
2023      "MATTERMOST_URL": {
2024          "description": "Mattermost server URL (e.g. https://mm.example.com)",
2025          "prompt": "Mattermost server URL",
2026          "url": "https://mattermost.com/deploy/",
2027          "password": False,
2028          "category": "messaging",
2029      },
2030      "MATTERMOST_TOKEN": {
2031          "description": "Mattermost bot token or personal access token",
2032          "prompt": "Mattermost bot token",
2033          "url": None,
2034          "password": True,
2035          "category": "messaging",
2036      },
2037      "MATTERMOST_ALLOWED_USERS": {
2038          "description": "Comma-separated Mattermost user IDs allowed to use the bot",
2039          "prompt": "Allowed Mattermost user IDs (comma-separated)",
2040          "url": None,
2041          "password": False,
2042          "category": "messaging",
2043      },
2044      "MATTERMOST_REQUIRE_MENTION": {
2045          "description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.",
2046          "prompt": "Require @mention in channels",
2047          "url": None,
2048          "password": False,
2049          "category": "messaging",
2050      },
2051      "MATTERMOST_FREE_RESPONSE_CHANNELS": {
2052          "description": "Comma-separated Mattermost channel IDs where bot responds without @mention",
2053          "prompt": "Free-response channel IDs (comma-separated)",
2054          "url": None,
2055          "password": False,
2056          "category": "messaging",
2057      },
2058      "MATRIX_HOMESERVER": {
2059          "description": "Matrix homeserver URL (e.g. https://matrix.example.org)",
2060          "prompt": "Matrix homeserver URL",
2061          "url": "https://matrix.org/ecosystem/servers/",
2062          "password": False,
2063          "category": "messaging",
2064      },
2065      "MATRIX_ACCESS_TOKEN": {
2066          "description": "Matrix access token (preferred over password login)",
2067          "prompt": "Matrix access token",
2068          "url": None,
2069          "password": True,
2070          "category": "messaging",
2071      },
2072      "MATRIX_USER_ID": {
2073          "description": "Matrix user ID (e.g. @hermes:example.org)",
2074          "prompt": "Matrix user ID (@user:server)",
2075          "url": None,
2076          "password": False,
2077          "category": "messaging",
2078      },
2079      "MATRIX_ALLOWED_USERS": {
2080          "description": "Comma-separated Matrix user IDs allowed to use the bot (@user:server format)",
2081          "prompt": "Allowed Matrix user IDs (comma-separated)",
2082          "url": None,
2083          "password": False,
2084          "category": "messaging",
2085      },
2086      "MATRIX_REQUIRE_MENTION": {
2087          "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.",
2088          "prompt": "Require @mention in rooms (true/false)",
2089          "url": None,
2090          "password": False,
2091          "category": "messaging",
2092          "advanced": True,
2093      },
2094      "MATRIX_FREE_RESPONSE_ROOMS": {
2095          "description": "Comma-separated Matrix room IDs where bot responds without @mention",
2096          "prompt": "Free-response room IDs (comma-separated)",
2097          "url": None,
2098          "password": False,
2099          "category": "messaging",
2100          "advanced": True,
2101      },
2102      "MATRIX_AUTO_THREAD": {
2103          "description": "Auto-create threads for messages in Matrix rooms (default: true)",
2104          "prompt": "Auto-create threads in rooms (true/false)",
2105          "url": None,
2106          "password": False,
2107          "category": "messaging",
2108          "advanced": True,
2109      },
2110      "MATRIX_DM_AUTO_THREAD": {
2111          "description": "Auto-create threads for DM messages in Matrix (default: false)",
2112          "prompt": "Auto-create threads in DMs (true/false)",
2113          "url": None,
2114          "password": False,
2115          "category": "messaging",
2116          "advanced": True,
2117      },
2118      "MATRIX_DEVICE_ID": {
2119          "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)",
2120          "prompt": "Matrix device ID (stable across restarts)",
2121          "url": None,
2122          "password": False,
2123          "category": "messaging",
2124          "advanced": True,
2125      },
2126      "MATRIX_RECOVERY_KEY": {
2127          "description": "Matrix recovery key for cross-signing verification after device key rotation (from Element: Settings → Security → Recovery Key)",
2128          "prompt": "Matrix recovery key",
2129          "url": None,
2130          "password": True,
2131          "category": "messaging",
2132          "advanced": True,
2133      },
2134      "BLUEBUBBLES_SERVER_URL": {
2135          "description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)",
2136          "prompt": "BlueBubbles server URL",
2137          "url": "https://bluebubbles.app/",
2138          "password": False,
2139          "category": "messaging",
2140      },
2141      "BLUEBUBBLES_PASSWORD": {
2142          "description": "BlueBubbles server password (from BlueBubbles Server → Settings → API)",
2143          "prompt": "BlueBubbles server password",
2144          "url": None,
2145          "password": True,
2146          "category": "messaging",
2147      },
2148      "BLUEBUBBLES_ALLOWED_USERS": {
2149          "description": "Comma-separated iMessage addresses (email or phone) allowed to use the bot",
2150          "prompt": "Allowed iMessage addresses (comma-separated)",
2151          "url": None,
2152          "password": False,
2153          "category": "messaging",
2154      },
2155      "BLUEBUBBLES_ALLOW_ALL_USERS": {
2156          "description": "Allow all BlueBubbles users without allowlist",
2157          "prompt": "Allow All BlueBubbles Users",
2158          "category": "messaging",
2159      },
2160      "QQ_APP_ID": {
2161          "description": "QQ Bot App ID from QQ Open Platform (q.qq.com)",
2162          "prompt": "QQ App ID",
2163          "url": "https://q.qq.com",
2164          "category": "messaging",
2165      },
2166      "QQ_CLIENT_SECRET": {
2167          "description": "QQ Bot Client Secret from QQ Open Platform",
2168          "prompt": "QQ Client Secret",
2169          "password": True,
2170          "category": "messaging",
2171      },
2172      "QQ_ALLOWED_USERS": {
2173          "description": "Comma-separated QQ user IDs allowed to use the bot",
2174          "prompt": "QQ Allowed Users",
2175          "category": "messaging",
2176      },
2177      "QQ_GROUP_ALLOWED_USERS": {
2178          "description": "Comma-separated QQ group IDs allowed to interact with the bot",
2179          "prompt": "QQ Group Allowed Users",
2180          "category": "messaging",
2181      },
2182      "QQ_ALLOW_ALL_USERS": {
2183          "description": "Allow all QQ users without an allowlist (true/false)",
2184          "prompt": "Allow All QQ Users",
2185          "category": "messaging",
2186      },
2187      "QQBOT_HOME_CHANNEL": {
2188          "description": "Default QQ channel/group for cron delivery and notifications",
2189          "prompt": "QQ Home Channel",
2190          "category": "messaging",
2191      },
2192      "QQBOT_HOME_CHANNEL_NAME": {
2193          "description": "Display name for the QQ home channel",
2194          "prompt": "QQ Home Channel Name",
2195          "category": "messaging",
2196      },
2197      "QQ_SANDBOX": {
2198          "description": "Enable QQ sandbox mode for development testing (true/false)",
2199          "prompt": "QQ Sandbox Mode",
2200          "category": "messaging",
2201      },
2202      "IRC_SERVER": {
2203          "description": "IRC server hostname (e.g. irc.libera.chat)",
2204          "prompt": "IRC server",
2205          "url": None,
2206          "password": False,
2207          "category": "messaging",
2208      },
2209      "IRC_CHANNEL": {
2210          "description": "IRC channel to join (e.g. #hermes)",
2211          "prompt": "IRC channel",
2212          "url": None,
2213          "password": False,
2214          "category": "messaging",
2215      },
2216      "IRC_NICKNAME": {
2217          "description": "Bot nickname on IRC (default: hermes-bot)",
2218          "prompt": "IRC nickname",
2219          "url": None,
2220          "password": False,
2221          "category": "messaging",
2222      },
2223      "IRC_SERVER_PASSWORD": {
2224          "description": "IRC server password (if required)",
2225          "prompt": "IRC server password",
2226          "url": None,
2227          "password": True,
2228          "category": "messaging",
2229          "advanced": True,
2230      },
2231      "IRC_NICKSERV_PASSWORD": {
2232          "description": "NickServ password for nick identification",
2233          "prompt": "NickServ password",
2234          "url": None,
2235          "password": True,
2236          "category": "messaging",
2237          "advanced": True,
2238      },
2239      "GATEWAY_ALLOW_ALL_USERS": {
2240          "description": "Allow all users to interact with messaging bots (true/false). Default: false.",
2241          "prompt": "Allow all users (true/false)",
2242          "url": None,
2243          "password": False,
2244          "category": "messaging",
2245          "advanced": True,
2246      },
2247      "API_SERVER_ENABLED": {
2248          "description": "Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.",
2249          "prompt": "Enable API server (true/false)",
2250          "url": None,
2251          "password": False,
2252          "category": "messaging",
2253          "advanced": True,
2254      },
2255      "API_SERVER_KEY": {
2256          "description": "Bearer token for API server authentication. Required for non-loopback binding; server refuses to start without it. On loopback (127.0.0.1), all requests are allowed if empty.",
2257          "prompt": "API server auth key (required for network access)",
2258          "url": None,
2259          "password": True,
2260          "category": "messaging",
2261          "advanced": True,
2262      },
2263      "API_SERVER_PORT": {
2264          "description": "Port for the API server (default: 8642).",
2265          "prompt": "API server port",
2266          "url": None,
2267          "password": False,
2268          "category": "messaging",
2269          "advanced": True,
2270      },
2271      "API_SERVER_HOST": {
2272          "description": "Host/bind address for the API server (default: 127.0.0.1). Use 0.0.0.0 for network access — server refuses to start without API_SERVER_KEY.",
2273          "prompt": "API server host",
2274          "url": None,
2275          "password": False,
2276          "category": "messaging",
2277          "advanced": True,
2278      },
2279      "API_SERVER_MODEL_NAME": {
2280          "description": "Model name advertised on /v1/models. Defaults to the profile name (or 'hermes-agent' for the default profile). Useful for multi-user setups with OpenWebUI.",
2281          "prompt": "API server model name",
2282          "url": None,
2283          "password": False,
2284          "category": "messaging",
2285          "advanced": True,
2286      },
2287      "GATEWAY_PROXY_URL": {
2288          "description": "URL of a remote Hermes API server to forward messages to (proxy mode). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Use for Docker E2EE containers that relay to a host agent. Also configurable via gateway.proxy_url in config.yaml.",
2289          "prompt": "Remote Hermes API server URL (e.g. http://192.168.1.100:8642)",
2290          "url": None,
2291          "password": False,
2292          "category": "messaging",
2293          "advanced": True,
2294      },
2295      "GATEWAY_PROXY_KEY": {
2296          "description": "Bearer token for authenticating with the remote Hermes API server (proxy mode). Must match the API_SERVER_KEY on the remote host.",
2297          "prompt": "Remote API server auth key",
2298          "url": None,
2299          "password": True,
2300          "category": "messaging",
2301          "advanced": True,
2302      },
2303      "WEBHOOK_ENABLED": {
2304          "description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.",
2305          "prompt": "Enable webhooks (true/false)",
2306          "url": None,
2307          "password": False,
2308          "category": "messaging",
2309      },
2310      "WEBHOOK_PORT": {
2311          "description": "Port for the webhook HTTP server (default: 8644).",
2312          "prompt": "Webhook port",
2313          "url": None,
2314          "password": False,
2315          "category": "messaging",
2316      },
2317      "WEBHOOK_SECRET": {
2318          "description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).",
2319          "prompt": "Webhook secret",
2320          "url": None,
2321          "password": True,
2322          "category": "messaging",
2323      },
2324  
2325      # ── Agent settings ──
2326      # NOTE: MESSAGING_CWD was removed here — use terminal.cwd in config.yaml
2327      # instead.  The gateway reads TERMINAL_CWD (bridged from terminal.cwd).
2328      "SUDO_PASSWORD": {
2329          "description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting",
2330          "prompt": "Sudo password",
2331          "url": None,
2332          "password": True,
2333          "category": "setting",
2334      },
2335      "HERMES_MAX_ITERATIONS": {
2336          "description": "Maximum tool-calling iterations per conversation (default: 90)",
2337          "prompt": "Max iterations",
2338          "url": None,
2339          "password": False,
2340          "category": "setting",
2341      },
2342      # HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
2343      # now configured via display.tool_progress in config.yaml (off|new|all|verbose).
2344      # Gateway falls back to these env vars for backward compatibility.
2345      "HERMES_TOOL_PROGRESS": {
2346          "description": "(deprecated) Use display.tool_progress in config.yaml instead",
2347          "prompt": "Tool progress (deprecated — use config.yaml)",
2348          "url": None,
2349          "password": False,
2350          "category": "setting",
2351      },
2352      "HERMES_TOOL_PROGRESS_MODE": {
2353          "description": "(deprecated) Use display.tool_progress in config.yaml instead",
2354          "prompt": "Progress mode (deprecated — use config.yaml)",
2355          "url": None,
2356          "password": False,
2357          "category": "setting",
2358      },
2359      "HERMES_PREFILL_MESSAGES_FILE": {
2360          "description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
2361          "prompt": "Prefill messages file path",
2362          "url": None,
2363          "password": False,
2364          "category": "setting",
2365      },
2366      "HERMES_EPHEMERAL_SYSTEM_PROMPT": {
2367          "description": "Ephemeral system prompt injected at API-call time (never persisted to sessions)",
2368          "prompt": "Ephemeral system prompt",
2369          "url": None,
2370          "password": False,
2371          "category": "setting",
2372      },
2373  }
2374  
2375  # Tool Gateway env vars are always visible — they're useful for
2376  # self-hosted / custom gateway setups regardless of subscription state.
2377  
2378  
2379  def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
2380      """
2381      Check which environment variables are missing.
2382      
2383      Returns list of dicts with var info for missing variables.
2384      """
2385      missing = []
2386      
2387      # Check required vars
2388      for var_name, info in REQUIRED_ENV_VARS.items():
2389          if not get_env_value(var_name):
2390              missing.append({"name": var_name, **info, "is_required": True})
2391      
2392      # Check optional vars (if not required_only)
2393      if not required_only:
2394          for var_name, info in OPTIONAL_ENV_VARS.items():
2395              if not get_env_value(var_name):
2396                  missing.append({"name": var_name, **info, "is_required": False})
2397      
2398      return missing
2399  
2400  
2401  def _set_nested(config, dotted_key: str, value):
2402      """Set a value at an arbitrarily nested dotted key path.
2403  
2404      Supports both dict and list navigation:
2405        _set_nested(c, "a.b.c", 1)     → c["a"]["b"]["c"] = 1
2406        _set_nested(c, "a.0.b", 1)     → c["a"][0]["b"] = 1
2407        _set_nested(c, "providers.1", "x") → c["providers"][1] = "x"
2408  
2409      Intermediate dicts are created on demand.  List indices are parsed
2410      from numeric path segments; the referenced index must already exist
2411      (we do not grow lists — the user is navigating into structure they
2412      wrote themselves).  If a segment targets a non-container leaf
2413      (scalar), the leaf is replaced with a fresh dict so the write can
2414      proceed — this preserves the pre-existing behavior for bare scalar
2415      overrides (e.g. setting ``a.b.c`` where ``a.b`` was previously a
2416      string).
2417  
2418      Guards against #17876: before this fix the code unconditionally
2419      replaced any non-dict value (including lists) with ``{}``, silently
2420      destroying list-typed config like ``custom_providers`` whenever a
2421      caller used an indexed path.
2422      """
2423      parts = dotted_key.split(".")
2424      current = config
2425      for part in parts[:-1]:
2426          if isinstance(current, list):
2427              try:
2428                  idx = int(part)
2429              except (TypeError, ValueError):
2430                  raise TypeError(
2431                      f"Cannot navigate into list at key {dotted_key!r}: "
2432                      f"segment {part!r} is not a numeric index"
2433                  )
2434              current = current[idx]
2435          elif isinstance(current, dict):
2436              existing = current.get(part)
2437              # Preserve dicts and lists; replace missing/scalar with a fresh dict.
2438              if part not in current or not isinstance(existing, (dict, list)):
2439                  current[part] = {}
2440              current = current[part]
2441          else:
2442              raise TypeError(
2443                  f"Cannot navigate into {type(current).__name__} at key {dotted_key!r}"
2444              )
2445      last = parts[-1]
2446      if isinstance(current, list):
2447          current[int(last)] = value
2448      else:
2449          current[last] = value
2450  
2451  
2452  def get_missing_config_fields() -> List[Dict[str, Any]]:
2453      """
2454      Check which config fields are missing or outdated (recursive).
2455      
2456      Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys
2457      present in defaults but absent from the user's loaded config.
2458      """
2459      config = load_config()
2460      missing = []
2461  
2462      def _check(defaults: dict, current: dict, prefix: str = ""):
2463          for key, default_value in defaults.items():
2464              if key.startswith('_'):
2465                  continue
2466              full_key = key if not prefix else f"{prefix}.{key}"
2467              if key not in current:
2468                  missing.append({
2469                      "key": full_key,
2470                      "default": default_value,
2471                      "description": f"New config option: {full_key}",
2472                  })
2473              elif isinstance(default_value, dict) and isinstance(current.get(key), dict):
2474                  _check(default_value, current[key], full_key)
2475  
2476      _check(DEFAULT_CONFIG, config)
2477      return missing
2478  
2479  
2480  def get_missing_skill_config_vars() -> List[Dict[str, Any]]:
2481      """Return skill-declared config vars that are missing or empty in config.yaml.
2482  
2483      Scans all enabled skills for ``metadata.hermes.config`` entries, then checks
2484      which ones are absent or empty under ``skills.config.<key>`` in the user's
2485      config.yaml.  Returns a list of dicts suitable for prompting.
2486      """
2487      try:
2488          from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX
2489      except Exception:
2490          return []
2491  
2492      try:
2493          all_vars = discover_all_skill_config_vars()
2494      except Exception as e:
2495          # A malformed SKILL.md, unreadable external skill dir, or similar
2496          # should never break `hermes update`.  Skill-config prompting is a
2497          # post-migration nicety, not a blocker.
2498          import logging
2499          logging.getLogger(__name__).debug(
2500              "discover_all_skill_config_vars failed: %s", e
2501          )
2502          return []
2503      if not all_vars:
2504          return []
2505  
2506      config = load_config()
2507      missing: List[Dict[str, Any]] = []
2508      for var in all_vars:
2509          # Skill config is stored under skills.config.<logical_key>
2510          storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
2511          parts = storage_key.split(".")
2512          current = config
2513          value = None
2514          for part in parts:
2515              if isinstance(current, dict) and part in current:
2516                  current = current[part]
2517                  value = current
2518              else:
2519                  value = None
2520                  break
2521          # Missing = key doesn't exist or is empty string
2522          if value is None or (isinstance(value, str) and not value.strip()):
2523              missing.append(var)
2524      return missing
2525  
2526  
2527  def _normalize_custom_provider_entry(
2528      entry: Any,
2529      *,
2530      provider_key: str = "",
2531  ) -> Optional[Dict[str, Any]]:
2532      """Return a runtime-compatible custom provider entry or ``None``."""
2533      if not isinstance(entry, dict):
2534          return None
2535  
2536      # Accept camelCase aliases commonly used in hand-written configs.
2537      _CAMEL_ALIASES: Dict[str, str] = {
2538          "apiKey": "api_key",
2539          "baseUrl": "base_url",
2540          "apiMode": "api_mode",
2541          "keyEnv": "key_env",
2542          "apiKeyEnv": "key_env",  # alias — OpenClaw-compatible + docs variant
2543          "defaultModel": "default_model",
2544          "contextLength": "context_length",
2545          "rateLimitDelay": "rate_limit_delay",
2546      }
2547      # api_key_env is a documented snake_case alias for key_env (see
2548      # website/docs/guides/azure-foundry.md).  Normalize it up front so the
2549      # rest of the normalizer treats it as the canonical field.
2550      if "api_key_env" in entry and "key_env" not in entry:
2551          entry["key_env"] = entry["api_key_env"]
2552      _KNOWN_KEYS = {
2553          "name", "api", "url", "base_url", "api_key", "key_env", "api_key_env",
2554          "api_mode", "transport", "model", "default_model", "models",
2555          "context_length", "rate_limit_delay",
2556          "request_timeout_seconds", "stale_timeout_seconds",
2557      }
2558      for camel, snake in _CAMEL_ALIASES.items():
2559          if camel in entry and snake not in entry:
2560              logger.warning(
2561                  "providers.%s: camelCase key '%s' auto-mapped to '%s' "
2562                  "(use snake_case to avoid this warning)",
2563                  provider_key or "?", camel, snake,
2564              )
2565              entry[snake] = entry[camel]
2566      unknown = set(entry.keys()) - _KNOWN_KEYS - set(_CAMEL_ALIASES.keys())
2567      if unknown:
2568          logger.warning(
2569              "providers.%s: unknown config keys ignored: %s",
2570              provider_key or "?", ", ".join(sorted(unknown)),
2571          )
2572  
2573      from urllib.parse import urlparse
2574  
2575      base_url = ""
2576      for url_key in ("base_url", "url", "api"):
2577          raw_url = entry.get(url_key)
2578          if isinstance(raw_url, str) and raw_url.strip():
2579              candidate = raw_url.strip()
2580              parsed = urlparse(candidate)
2581              if parsed.scheme and parsed.netloc:
2582                  base_url = candidate
2583                  break
2584              else:
2585                  logger.warning(
2586                      "providers.%s: '%s' value '%s' is not a valid URL "
2587                      "(no scheme or host) — skipped",
2588                      provider_key or "?", url_key, candidate,
2589                  )
2590      if not base_url:
2591          return None
2592  
2593      name = ""
2594      raw_name = entry.get("name")
2595      if isinstance(raw_name, str) and raw_name.strip():
2596          name = raw_name.strip()
2597      elif provider_key.strip():
2598          name = provider_key.strip()
2599      if not name:
2600          return None
2601  
2602      normalized: Dict[str, Any] = {
2603          "name": name,
2604          "base_url": base_url,
2605      }
2606  
2607      provider_key = provider_key.strip()
2608      if provider_key:
2609          normalized["provider_key"] = provider_key
2610  
2611      api_key = entry.get("api_key")
2612      if isinstance(api_key, str) and api_key.strip():
2613          normalized["api_key"] = api_key.strip()
2614  
2615      key_env = entry.get("key_env")
2616      if isinstance(key_env, str) and key_env.strip():
2617          normalized["key_env"] = key_env.strip()
2618  
2619      api_mode = entry.get("api_mode") or entry.get("transport")
2620      if isinstance(api_mode, str) and api_mode.strip():
2621          normalized["api_mode"] = api_mode.strip()
2622  
2623      model_name = entry.get("model") or entry.get("default_model")
2624      if isinstance(model_name, str) and model_name.strip():
2625          normalized["model"] = model_name.strip()
2626  
2627      models = entry.get("models")
2628      if isinstance(models, dict) and models:
2629          normalized["models"] = models
2630      elif isinstance(models, list) and models:
2631          # Hand-edited configs (and older Hermes versions) write ``models`` as
2632          # a plain list of model ids. Preserve them by converting to the dict
2633          # shape downstream code expects; otherwise normalize silently drops
2634          # the list and /model shows the provider with (0) models.
2635          normalized["models"] = {
2636              str(m): {} for m in models if isinstance(m, str) and m.strip()
2637          }
2638  
2639      context_length = entry.get("context_length")
2640      if isinstance(context_length, int) and context_length > 0:
2641          normalized["context_length"] = context_length
2642  
2643      rate_limit_delay = entry.get("rate_limit_delay")
2644      if isinstance(rate_limit_delay, (int, float)) and rate_limit_delay >= 0:
2645          normalized["rate_limit_delay"] = rate_limit_delay
2646  
2647      return normalized
2648  
2649  
2650  def providers_dict_to_custom_providers(providers_dict: Any) -> List[Dict[str, Any]]:
2651      """Normalize ``providers`` config entries into the legacy custom-provider shape."""
2652      if not isinstance(providers_dict, dict):
2653          return []
2654  
2655      custom_providers: List[Dict[str, Any]] = []
2656      for key, entry in providers_dict.items():
2657          normalized = _normalize_custom_provider_entry(entry, provider_key=str(key))
2658          if normalized is not None:
2659              custom_providers.append(normalized)
2660  
2661      return custom_providers
2662  
2663  
2664  def get_compatible_custom_providers(
2665      config: Optional[Dict[str, Any]] = None,
2666  ) -> List[Dict[str, Any]]:
2667      """Return a deduplicated custom-provider view across legacy and v12+ config.
2668  
2669      ``custom_providers`` remains the on-disk legacy format, while ``providers``
2670      is the newer keyed schema.  Runtime and picker flows still need a single
2671      list-shaped view, but we should not materialise that compatibility layer
2672      back into config.yaml because it duplicates entries in UIs.
2673      """
2674      if config is None:
2675          config = load_config()
2676  
2677      compatible: List[Dict[str, Any]] = []
2678      seen_provider_keys: set = set()
2679      seen_name_url_pairs: set = set()
2680  
2681      def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
2682          if entry is None:
2683              return
2684          provider_key = str(entry.get("provider_key", "") or "").strip().lower()
2685          name = str(entry.get("name", "") or "").strip().lower()
2686          base_url = str(entry.get("base_url", "") or "").strip().rstrip("/").lower()
2687          model = str(entry.get("model", "") or "").strip().lower()
2688          pair = (name, base_url, model)
2689  
2690          if provider_key and provider_key in seen_provider_keys:
2691              return
2692          if name and base_url and pair in seen_name_url_pairs:
2693              return
2694  
2695          compatible.append(entry)
2696          if provider_key:
2697              seen_provider_keys.add(provider_key)
2698          if name and base_url:
2699              seen_name_url_pairs.add(pair)
2700  
2701      custom_providers = config.get("custom_providers")
2702      if custom_providers is not None:
2703          if not isinstance(custom_providers, list):
2704              return []
2705          for entry in custom_providers:
2706              _append_if_new(_normalize_custom_provider_entry(entry))
2707  
2708      for entry in providers_dict_to_custom_providers(config.get("providers")):
2709          _append_if_new(entry)
2710  
2711      return compatible
2712  
2713  
2714  def get_custom_provider_context_length(
2715      model: str,
2716      base_url: str,
2717      custom_providers: Optional[List[Dict[str, Any]]] = None,
2718      config: Optional[Dict[str, Any]] = None,
2719  ) -> Optional[int]:
2720      """Look up a per-model ``context_length`` override from ``custom_providers``.
2721  
2722      Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash
2723      insensitive) and returns ``custom_providers[i].models.<model>.context_length``
2724      if present and valid.  Returns ``None`` when no override applies.
2725  
2726      This is the single source of truth for custom-provider context overrides,
2727      used by:
2728        * ``AIAgent.__init__`` (startup resolution)
2729        * ``AIAgent.switch_model`` (mid-session ``/model`` switch)
2730        * ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
2731        * ``gateway.run._format_session_info`` (``/info`` display)
2732        * ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)
2733  
2734      Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
2735      startup path only; every other path (notably ``/model`` switch) fell back
2736      to the 128K default.  See #15779.
2737      """
2738      if not model or not base_url:
2739          return None
2740      if custom_providers is None:
2741          try:
2742              custom_providers = get_compatible_custom_providers(config)
2743          except Exception:
2744              if config is None:
2745                  return None
2746              raw = config.get("custom_providers")
2747              custom_providers = raw if isinstance(raw, list) else []
2748      if not isinstance(custom_providers, list):
2749          return None
2750  
2751      target_url = (base_url or "").rstrip("/")
2752      if not target_url:
2753          return None
2754  
2755      for entry in custom_providers:
2756          if not isinstance(entry, dict):
2757              continue
2758          entry_url = (entry.get("base_url") or "").rstrip("/")
2759          if not entry_url or entry_url != target_url:
2760              continue
2761          models = entry.get("models")
2762          if not isinstance(models, dict):
2763              continue
2764          model_cfg = models.get(model)
2765          if not isinstance(model_cfg, dict):
2766              continue
2767          raw_ctx = model_cfg.get("context_length")
2768          if raw_ctx is None:
2769              continue
2770          try:
2771              ctx = int(raw_ctx)
2772          except (TypeError, ValueError):
2773              continue
2774          if ctx > 0:
2775              return ctx
2776      return None
2777  
2778  
2779  def check_config_version() -> Tuple[int, int]:
2780      """
2781      Check config version.
2782      
2783      Returns (current_version, latest_version).
2784      """
2785      config = load_config()
2786      current = config.get("_config_version", 0)
2787      latest = DEFAULT_CONFIG.get("_config_version", 1)
2788      return current, latest
2789  
2790  
2791  # =============================================================================
2792  # Config structure validation
2793  # =============================================================================
2794  
2795  # Fields that are valid at root level of config.yaml
2796  _KNOWN_ROOT_KEYS = {
2797      "_config_version", "model", "providers", "fallback_model",
2798      "fallback_providers", "credential_pool_strategies", "toolsets",
2799      "agent", "terminal", "display", "compression", "delegation",
2800      "auxiliary", "custom_providers", "context", "memory", "gateway",
2801      "sessions",
2802  }
2803  
2804  # Valid fields inside a custom_providers list entry
2805  _VALID_CUSTOM_PROVIDER_FIELDS = {
2806      "name", "base_url", "api_key", "api_mode", "model", "models",
2807      "context_length", "rate_limit_delay",
2808      # key_env is read at runtime by runtime_provider.py and auxiliary_client.py
2809      # — include it here so the set accurately describes the supported schema.
2810      "key_env",
2811  }
2812  
2813  # Fields that look like they should be inside custom_providers, not at root
2814  _CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"}
2815  
2816  
2817  @dataclass
2818  class ConfigIssue:
2819      """A detected config structure problem."""
2820  
2821      severity: str  # "error", "warning"
2822      message: str
2823      hint: str
2824  
2825  
2826  def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]:
2827      """Validate config.yaml structure and return a list of detected issues.
2828  
2829      Catches common YAML formatting mistakes that produce confusing runtime
2830      errors (like "Unknown provider") instead of clear diagnostics.
2831  
2832      Can be called with a pre-loaded config dict, or will load from disk.
2833      """
2834      if config is None:
2835          try:
2836              config = load_config()
2837          except Exception:
2838              return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")]
2839  
2840      issues: List[ConfigIssue] = []
2841  
2842      # ── custom_providers must be a list, not a dict ──────────────────────
2843      cp = config.get("custom_providers")
2844      if cp is not None:
2845          if isinstance(cp, dict):
2846              issues.append(ConfigIssue(
2847                  "error",
2848                  "custom_providers is a dict — it must be a YAML list (items prefixed with '-')",
2849                  "Change to:\n"
2850                  "  custom_providers:\n"
2851                  "    - name: my-provider\n"
2852                  "      base_url: https://...\n"
2853                  "      api_key: ...",
2854              ))
2855              # Check if dict keys look like they should be list-entry fields
2856              cp_keys = set(cp.keys()) if isinstance(cp, dict) else set()
2857              suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS
2858              if suspicious:
2859                  issues.append(ConfigIssue(
2860                      "warning",
2861                      f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields",
2862                      "These should be indented under a '- name: ...' list entry, not at root level",
2863                  ))
2864          elif isinstance(cp, list):
2865              # Validate each entry in the list
2866              for i, entry in enumerate(cp):
2867                  if not isinstance(entry, dict):
2868                      issues.append(ConfigIssue(
2869                          "warning",
2870                          f"custom_providers[{i}] is not a dict (got {type(entry).__name__})",
2871                          "Each entry should have at minimum: name, base_url",
2872                      ))
2873                      continue
2874                  if not entry.get("name"):
2875                      issues.append(ConfigIssue(
2876                          "warning",
2877                          f"custom_providers[{i}] is missing 'name' field",
2878                          "Add a name, e.g.: name: my-provider",
2879                      ))
2880                  if not entry.get("base_url"):
2881                      issues.append(ConfigIssue(
2882                          "warning",
2883                          f"custom_providers[{i}] is missing 'base_url' field",
2884                          "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1",
2885                      ))
2886  
2887      # ── fallback_model: single dict OR list of dicts (chain) ─────────────
2888      fb = config.get("fallback_model")
2889      if fb is not None:
2890          if isinstance(fb, list):
2891              # Chain fallback — validate each entry
2892              for i, entry in enumerate(fb):
2893                  if not isinstance(entry, dict):
2894                      issues.append(ConfigIssue(
2895                          "error",
2896                          f"fallback_model[{i}] should be a dict, got {type(entry).__name__}",
2897                          "Each entry needs provider + model",
2898                      ))
2899                  else:
2900                      if not entry.get("provider"):
2901                          issues.append(ConfigIssue(
2902                              "warning",
2903                              f"fallback_model[{i}] is missing 'provider' field",
2904                              "Add: provider: openrouter (or another provider)",
2905                          ))
2906                      if not entry.get("model"):
2907                          issues.append(ConfigIssue(
2908                              "warning",
2909                              f"fallback_model[{i}] is missing 'model' field",
2910                              "Add: model: <model-name>",
2911                          ))
2912          elif not isinstance(fb, dict):
2913              issues.append(ConfigIssue(
2914                  "error",
2915                  f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}",
2916                  "Change to:\n"
2917                  "  fallback_model:\n"
2918                  "    provider: openrouter\n"
2919                  "    model: anthropic/claude-sonnet-4",
2920              ))
2921          elif fb:
2922              if not fb.get("provider"):
2923                  issues.append(ConfigIssue(
2924                      "warning",
2925                      "fallback_model is missing 'provider' field — fallback will be disabled",
2926                      "Add: provider: openrouter (or another provider)",
2927                  ))
2928              if not fb.get("model"):
2929                  issues.append(ConfigIssue(
2930                      "warning",
2931                      "fallback_model is missing 'model' field — fallback will be disabled",
2932                      "Add: model: anthropic/claude-sonnet-4 (or another model)",
2933                  ))
2934  
2935      # ── Check for fallback_model accidentally nested inside custom_providers ──
2936      if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}):
2937          issues.append(ConfigIssue(
2938              "error",
2939              "fallback_model appears inside custom_providers instead of at root level",
2940              "Move fallback_model to the top level of config.yaml (no indentation)",
2941          ))
2942  
2943      # ── model section: should exist when custom_providers is configured ──
2944      model_cfg = config.get("model")
2945      if cp and not model_cfg:
2946          issues.append(ConfigIssue(
2947              "warning",
2948              "custom_providers defined but no 'model' section — Hermes won't know which provider to use",
2949              "Add a model section:\n"
2950              "  model:\n"
2951              "    provider: custom\n"
2952              "    default: your-model-name\n"
2953              "    base_url: https://...",
2954          ))
2955  
2956      # ── Root-level keys that look misplaced ──────────────────────────────
2957      for key in config:
2958          if key.startswith("_"):
2959              continue
2960          if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS:
2961              issues.append(ConfigIssue(
2962                  "warning",
2963                  f"Root-level key '{key}' looks misplaced — should it be under 'model:' or inside a 'custom_providers' entry?",
2964                  f"Move '{key}' under the appropriate section",
2965              ))
2966  
2967      return issues
2968  
2969  
2970  def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None:
2971      """Print config structure warnings to stderr at startup.
2972  
2973      Called early in CLI and gateway init so users see problems before
2974      they hit cryptic "Unknown provider" errors.  Prints nothing if
2975      config is healthy.
2976      """
2977      try:
2978          issues = validate_config_structure(config)
2979      except Exception:
2980          return
2981      if not issues:
2982          return
2983  
2984      lines = ["\033[33m⚠ Config issues detected in config.yaml:\033[0m"]
2985      for ci in issues:
2986          marker = "\033[31m✗\033[0m" if ci.severity == "error" else "\033[33m⚠\033[0m"
2987          lines.append(f"  {marker} {ci.message}")
2988      lines.append("  \033[2mRun 'hermes doctor' for fix suggestions.\033[0m")
2989      sys.stderr.write("\n".join(lines) + "\n\n")
2990  
2991  
2992  def warn_deprecated_cwd_env_vars(config: Optional[Dict[str, Any]] = None) -> None:
2993      """Warn if MESSAGING_CWD or TERMINAL_CWD is set in .env instead of config.yaml.
2994  
2995      These env vars are deprecated — the canonical setting is terminal.cwd
2996      in config.yaml.  Prints a migration hint to stderr.
2997      """
2998      messaging_cwd = os.environ.get("MESSAGING_CWD")
2999      terminal_cwd_env = os.environ.get("TERMINAL_CWD")
3000  
3001      if config is None:
3002          try:
3003              config = load_config()
3004          except Exception:
3005              return
3006  
3007      terminal_cfg = config.get("terminal", {})
3008      config_cwd = terminal_cfg.get("cwd", ".") if isinstance(terminal_cfg, dict) else "."
3009      # Only warn if config.yaml doesn't have an explicit path
3010      config_has_explicit_cwd = config_cwd not in (".", "auto", "cwd", "")
3011  
3012      lines: list[str] = []
3013      if messaging_cwd:
3014          lines.append(
3015              f"  \033[33m⚠\033[0m MESSAGING_CWD={messaging_cwd} found in .env — "
3016              f"this is deprecated."
3017          )
3018      if terminal_cwd_env and not config_has_explicit_cwd:
3019          # TERMINAL_CWD in env but not from config bridge — likely from .env
3020          lines.append(
3021              f"  \033[33m⚠\033[0m TERMINAL_CWD={terminal_cwd_env} found in .env — "
3022              f"this is deprecated."
3023          )
3024      if lines:
3025          hint_path = os.environ.get("HERMES_HOME", "~/.hermes")
3026          lines.insert(0, "\033[33m⚠ Deprecated .env settings detected:\033[0m")
3027          lines.append(
3028              f"  \033[2mMove to config.yaml instead:  "
3029              f"terminal:\\n    cwd: /your/project/path\033[0m"
3030          )
3031          lines.append(
3032              f"  \033[2mThen remove the old entries from {hint_path}/.env\033[0m"
3033          )
3034          sys.stderr.write("\n".join(lines) + "\n\n")
3035  
3036  
3037  def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]:
3038      """
3039      Migrate config to latest version, prompting for new required fields.
3040      
3041      Args:
3042          interactive: If True, prompt user for missing values
3043          quiet: If True, suppress output
3044          
3045      Returns:
3046          Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]}
3047      """
3048      results = {"env_added": [], "config_added": [], "warnings": []}
3049  
3050      # ── Always: sanitize .env (split concatenated keys) ──
3051      try:
3052          fixes = sanitize_env_file()
3053          if fixes and not quiet:
3054              print(f"  ✓ Repaired .env file ({fixes} corrupted entries fixed)")
3055      except Exception:
3056          pass  # best-effort; don't block migration on sanitize failure
3057  
3058      # Check config version
3059      current_ver, latest_ver = check_config_version()
3060      
3061      # ── Version 3 → 4: migrate tool progress from .env to config.yaml ──
3062      if current_ver < 4:
3063          config = load_config()
3064          display = config.get("display", {})
3065          if not isinstance(display, dict):
3066              display = {}
3067          if "tool_progress" not in display:
3068              old_enabled = get_env_value("HERMES_TOOL_PROGRESS")
3069              old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE")
3070              if old_enabled and old_enabled.lower() in ("false", "0", "no"):
3071                  display["tool_progress"] = "off"
3072                  results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)")
3073              elif old_mode and old_mode.lower() in ("new", "all"):
3074                  display["tool_progress"] = old_mode.lower()
3075                  results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)")
3076              else:
3077                  display["tool_progress"] = "all"
3078                  results["config_added"].append("display.tool_progress=all (default)")
3079              config["display"] = display
3080              save_config(config)
3081              if not quiet:
3082                  print(f"  ✓ Migrated tool progress to config.yaml: {display['tool_progress']}")
3083      
3084      # ── Version 4 → 5: add timezone field ──
3085      if current_ver < 5:
3086          config = load_config()
3087          if "timezone" not in config:
3088              old_tz = os.getenv("HERMES_TIMEZONE", "")
3089              if old_tz and old_tz.strip():
3090                  config["timezone"] = old_tz.strip()
3091                  results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)")
3092              else:
3093                  config["timezone"] = ""
3094                  results["config_added"].append("timezone= (empty, uses server-local)")
3095              save_config(config)
3096              if not quiet:
3097                  tz_display = config["timezone"] or "(server-local)"
3098                  print(f"  ✓ Added timezone to config.yaml: {tz_display}")
3099  
3100      # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ──
3101      # The new Anthropic auth flow no longer uses this env var.
3102      if current_ver < 9:
3103          try:
3104              old_token = get_env_value("ANTHROPIC_TOKEN")
3105              if old_token:
3106                  save_env_value("ANTHROPIC_TOKEN", "")
3107                  if not quiet:
3108                      print("  ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)")
3109          except Exception:
3110              pass
3111  
3112      # ── Version 11 → 12: migrate custom_providers list → providers dict ──
3113      if current_ver < 12:
3114          config = load_config()
3115          custom_list = config.get("custom_providers")
3116          if isinstance(custom_list, list) and custom_list:
3117              providers_dict = config.get("providers", {})
3118              if not isinstance(providers_dict, dict):
3119                  providers_dict = {}
3120              migrated_count = 0
3121              for entry in custom_list:
3122                  if not isinstance(entry, dict):
3123                      continue
3124                  old_name = entry.get("name", "")
3125                  old_url = entry.get("base_url", "") or entry.get("url", "") or ""
3126                  old_key = entry.get("api_key", "")
3127                  if not old_url:
3128                      continue  # skip entries with no URL
3129  
3130                  # Generate a kebab-case key from the display name
3131                  key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
3132                  # Remove consecutive hyphens and trailing hyphens
3133                  while "--" in key:
3134                      key = key.replace("--", "-")
3135                  key = key.strip("-")
3136                  if not key:
3137                      # Fallback: derive from URL hostname
3138                      try:
3139                          from urllib.parse import urlparse
3140                          parsed = urlparse(old_url)
3141                          key = (parsed.hostname or "endpoint").replace(".", "-")
3142                      except Exception:
3143                          key = f"endpoint-{migrated_count}"
3144  
3145                  # Don't overwrite existing entries
3146                  if key in providers_dict:
3147                      key = f"{key}-{migrated_count}"
3148  
3149                  new_entry = {"api": old_url}
3150                  if old_name:
3151                      new_entry["name"] = old_name
3152                  if old_key and old_key not in ("no-key", "no-key-required", ""):
3153                      new_entry["api_key"] = old_key
3154  
3155                  # Carry over model and api_mode if present
3156                  if entry.get("model"):
3157                      new_entry["default_model"] = entry["model"]
3158                  if entry.get("api_mode"):
3159                      new_entry["transport"] = entry["api_mode"]
3160  
3161                  providers_dict[key] = new_entry
3162                  migrated_count += 1
3163  
3164              if migrated_count > 0:
3165                  config["providers"] = providers_dict
3166                  # Remove the old list — runtime reads via get_compatible_custom_providers()
3167                  config.pop("custom_providers", None)
3168                  save_config(config)
3169                  if not quiet:
3170                      print(f"  ✓ Migrated {migrated_count} custom provider(s) to providers: section")
3171                      for key in list(providers_dict.keys())[-migrated_count:]:
3172                          ep = providers_dict[key]
3173                          print(f"    → {key}: {ep.get('api', '')}")
3174  
3175      # ── Version 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ──
3176      # These env vars were written by the old setup wizard but nothing reads
3177      # them anymore (config.yaml is the sole source of truth since March 2026).
3178      # Stale entries cause user confusion — see issue report.
3179      if current_ver < 13:
3180          for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
3181              try:
3182                  old_val = get_env_value(dead_var)
3183                  if old_val:
3184                      save_env_value(dead_var, "")
3185                      if not quiet:
3186                          print(f"  ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
3187              except Exception:
3188                  pass
3189  
3190      # ── Version 13 → 14: migrate legacy flat stt.model to provider section ──
3191      # Old configs (and cli-config.yaml.example) had a flat `stt.model` key
3192      # that was provider-agnostic.  When the provider was "local" this caused
3193      # OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper,
3194      # crashing with "Invalid model size".  Move the value into the correct
3195      # provider-specific section and remove the flat key.
3196      if current_ver < 14:
3197          # Read raw config (no defaults merged) to check what the user actually
3198          # wrote, then apply changes to the merged config for saving.
3199          raw = read_raw_config()
3200          raw_stt = raw.get("stt", {})
3201          if isinstance(raw_stt, dict) and "model" in raw_stt:
3202              legacy_model = raw_stt["model"]
3203              provider = raw_stt.get("provider", "local")
3204              config = load_config()
3205              stt = config.get("stt", {})
3206              # Remove the legacy flat key
3207              stt.pop("model", None)
3208              # Place it in the appropriate provider section only if the
3209              # user didn't already set a model there
3210              if provider in ("local", "local_command"):
3211                  # Don't migrate an OpenAI model name into the local section
3212                  _local_models = {
3213                      "tiny.en", "tiny", "base.en", "base", "small.en", "small",
3214                      "medium.en", "medium", "large-v1", "large-v2", "large-v3",
3215                      "large", "distil-large-v2", "distil-medium.en",
3216                      "distil-small.en", "distil-large-v3", "distil-large-v3.5",
3217                      "large-v3-turbo", "turbo",
3218                  }
3219                  if legacy_model in _local_models:
3220                      # Check raw config — only set if user didn't already
3221                      # have a nested local.model
3222                      raw_local = raw_stt.get("local", {})
3223                      if not isinstance(raw_local, dict) or "model" not in raw_local:
3224                          local_cfg = stt.setdefault("local", {})
3225                          local_cfg["model"] = legacy_model
3226                  # else: drop it — it was an OpenAI model name, local section
3227                  # already defaults to "base" via DEFAULT_CONFIG
3228              else:
3229                  # Cloud provider — put it in that provider's section only
3230                  # if user didn't already set a nested model
3231                  raw_provider = raw_stt.get(provider, {})
3232                  if not isinstance(raw_provider, dict) or "model" not in raw_provider:
3233                      provider_cfg = stt.setdefault(provider, {})
3234                      provider_cfg["model"] = legacy_model
3235              config["stt"] = stt
3236              save_config(config)
3237              if not quiet:
3238                  print(f"  ✓ Migrated legacy stt.model to provider-specific config")
3239  
3240      # ── Version 14 → 15: add explicit gateway interim-message gate ──
3241      if current_ver < 15:
3242          config = read_raw_config()
3243          display = config.get("display", {})
3244          if not isinstance(display, dict):
3245              display = {}
3246          if "interim_assistant_messages" not in display:
3247              display["interim_assistant_messages"] = True
3248              config["display"] = display
3249              results["config_added"].append("display.interim_assistant_messages=true (default)")
3250              save_config(config)
3251              if not quiet:
3252                  print("  ✓ Added display.interim_assistant_messages=true")
3253  
3254      # ── Version 15 → 16: migrate tool_progress_overrides into display.platforms ──
3255      if current_ver < 16:
3256          config = read_raw_config()
3257          display = config.get("display", {})
3258          if not isinstance(display, dict):
3259              display = {}
3260          old_overrides = display.get("tool_progress_overrides")
3261          if isinstance(old_overrides, dict) and old_overrides:
3262              platforms = display.get("platforms", {})
3263              if not isinstance(platforms, dict):
3264                  platforms = {}
3265              for plat, mode in old_overrides.items():
3266                  if plat not in platforms:
3267                      platforms[plat] = {}
3268                  if "tool_progress" not in platforms[plat]:
3269                      platforms[plat]["tool_progress"] = mode
3270              display["platforms"] = platforms
3271              config["display"] = display
3272              save_config(config)
3273              if not quiet:
3274                  migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
3275                  print(f"  ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
3276              results["config_added"].append("display.platforms (migrated from tool_progress_overrides)")
3277  
3278      # ── Version 16 → 17: remove legacy compression.summary_* keys ──
3279      if current_ver < 17:
3280          config = read_raw_config()
3281          comp = config.get("compression", {})
3282          if isinstance(comp, dict):
3283              s_model = comp.pop("summary_model", None)
3284              s_provider = comp.pop("summary_provider", None)
3285              s_base_url = comp.pop("summary_base_url", None)
3286              migrated_keys = []
3287              # Migrate non-empty, non-default values to auxiliary.compression
3288              if s_model and str(s_model).strip():
3289                  aux = config.setdefault("auxiliary", {})
3290                  aux_comp = aux.setdefault("compression", {})
3291                  if not aux_comp.get("model"):
3292                      aux_comp["model"] = str(s_model).strip()
3293                      migrated_keys.append(f"model={s_model}")
3294              if s_provider and str(s_provider).strip() not in ("", "auto"):
3295                  aux = config.setdefault("auxiliary", {})
3296                  aux_comp = aux.setdefault("compression", {})
3297                  if not aux_comp.get("provider") or aux_comp.get("provider") == "auto":
3298                      aux_comp["provider"] = str(s_provider).strip()
3299                      migrated_keys.append(f"provider={s_provider}")
3300              if s_base_url and str(s_base_url).strip():
3301                  aux = config.setdefault("auxiliary", {})
3302                  aux_comp = aux.setdefault("compression", {})
3303                  if not aux_comp.get("base_url"):
3304                      aux_comp["base_url"] = str(s_base_url).strip()
3305                      migrated_keys.append(f"base_url={s_base_url}")
3306              if migrated_keys or s_model is not None or s_provider is not None or s_base_url is not None:
3307                  config["compression"] = comp
3308                  save_config(config)
3309                  if not quiet:
3310                      if migrated_keys:
3311                          print(f"  ✓ Migrated compression.summary_* → auxiliary.compression: {', '.join(migrated_keys)}")
3312                      else:
3313                          print("  ✓ Removed unused compression.summary_* keys")
3314  
3315      # ── Version 20 → 21: plugins are now opt-in; grandfather existing user plugins ──
3316      # The loader now requires plugins to appear in ``plugins.enabled`` before
3317      # loading. Existing installs had all discovered plugins loading by default
3318      # (minus anything in ``plugins.disabled``). To avoid silently breaking
3319      # those setups on upgrade, populate ``plugins.enabled`` with the set of
3320      # currently-installed user plugins that aren't already disabled.
3321      #
3322      # Bundled plugins (shipped in the repo itself) are NOT grandfathered —
3323      # they ship off for everyone, including existing users, so any user who
3324      # wants one has to opt in explicitly.
3325      if current_ver < 21:
3326          config = read_raw_config()
3327          plugins_cfg = config.get("plugins")
3328          if not isinstance(plugins_cfg, dict):
3329              plugins_cfg = {}
3330          # Only migrate if the enabled allow-list hasn't been set yet.
3331          if "enabled" not in plugins_cfg:
3332              disabled = plugins_cfg.get("disabled", []) or []
3333              if not isinstance(disabled, list):
3334                  disabled = []
3335              disabled_set = set(disabled)
3336  
3337              # Scan ``$HERMES_HOME/plugins/`` for currently installed user plugins.
3338              grandfathered: List[str] = []
3339              try:
3340                  user_plugins_dir = get_hermes_home() / "plugins"
3341                  if user_plugins_dir.is_dir():
3342                      for child in sorted(user_plugins_dir.iterdir()):
3343                          if not child.is_dir():
3344                              continue
3345                          manifest_file = child / "plugin.yaml"
3346                          if not manifest_file.exists():
3347                              manifest_file = child / "plugin.yml"
3348                          if not manifest_file.exists():
3349                              continue
3350                          try:
3351                              with open(manifest_file) as _mf:
3352                                  manifest = yaml.safe_load(_mf) or {}
3353                          except Exception:
3354                              manifest = {}
3355                          name = manifest.get("name") or child.name
3356                          if name in disabled_set:
3357                              continue
3358                          grandfathered.append(name)
3359              except Exception:
3360                  grandfathered = []
3361  
3362              plugins_cfg["enabled"] = grandfathered
3363              config["plugins"] = plugins_cfg
3364              save_config(config)
3365              results["config_added"].append(
3366                  f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)"
3367              )
3368              if not quiet:
3369                  if grandfathered:
3370                      print(
3371                          f"  ✓ Plugins now opt-in: grandfathered "
3372                          f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
3373                      )
3374                  else:
3375                      print(
3376                          "  ✓ Plugins now opt-in: no existing plugins to grandfather. "
3377                          "Use `hermes plugins enable <name>` to activate."
3378                      )
3379  
3380      # ── Version 22 → 23: seed curator defaults + create logs/curator/ ──
3381      # The curator (background skill maintenance) was added in PR #16049, but
3382      # existing configs from before that PR (or before the April 2026
3383      # unification under `auxiliary.curator`) never wrote the curator section
3384      # to disk. The runtime deep-merge in `load_config()` fills defaults at
3385      # read time, so the curator *functions*; but users can't see/edit the
3386      # settings in their `config.yaml`, and `hermes curator status` has no
3387      # stable logs dir to point at until the first run mkdir's it.
3388      #
3389      # This migration:
3390      #   1. Writes the `curator` top-level section to config.yaml (enabled,
3391      #      interval_hours, min_idle_hours, stale_after_days, archive_after_days)
3392      #      — only keys the user hasn't already overridden.
3393      #   2. Writes the `auxiliary.curator` aux-task slot (provider, model,
3394      #      base_url, api_key, timeout, extra_body) — canonical slot for
3395      #      routing the curator fork to a cheaper aux model.
3396      #   3. Creates `~/.hermes/logs/curator/` if missing (belt-and-suspenders
3397      #      on top of ensure_hermes_home() — old profiles that predate this
3398      #      migration still benefit).
3399      if current_ver < 23:
3400          try:
3401              curator_dir = get_hermes_home() / "logs" / "curator"
3402              curator_dir.mkdir(parents=True, exist_ok=True)
3403          except Exception as e:
3404              results["warnings"].append(f"Could not create {curator_dir}: {e}")
3405  
3406          config = read_raw_config()
3407          touched = False
3408  
3409          # (1) Top-level curator section — only add missing keys
3410          _curator_defaults = DEFAULT_CONFIG.get("curator", {})
3411          raw_curator = config.get("curator")
3412          if not isinstance(raw_curator, dict):
3413              raw_curator = {}
3414          added_curator: List[str] = []
3415          for k, v in _curator_defaults.items():
3416              if k not in raw_curator:
3417                  raw_curator[k] = copy.deepcopy(v)
3418                  added_curator.append(k)
3419          if added_curator:
3420              config["curator"] = raw_curator
3421              touched = True
3422  
3423          # (2) auxiliary.curator task slot
3424          _aux_curator_defaults = (
3425              DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {})
3426          )
3427          raw_aux = config.get("auxiliary")
3428          if not isinstance(raw_aux, dict):
3429              raw_aux = {}
3430          raw_aux_curator = raw_aux.get("curator")
3431          if not isinstance(raw_aux_curator, dict):
3432              raw_aux_curator = {}
3433          added_aux: List[str] = []
3434          for k, v in _aux_curator_defaults.items():
3435              if k not in raw_aux_curator:
3436                  raw_aux_curator[k] = copy.deepcopy(v)
3437                  added_aux.append(k)
3438          if added_aux:
3439              raw_aux["curator"] = raw_aux_curator
3440              config["auxiliary"] = raw_aux
3441              touched = True
3442  
3443          if touched:
3444              save_config(config)
3445              if added_curator:
3446                  results["config_added"].append(
3447                      f"curator ({len(added_curator)} default key(s))"
3448                  )
3449                  if not quiet:
3450                      print(
3451                          "  ✓ Seeded curator defaults in config.yaml: "
3452                          f"{', '.join(added_curator)}"
3453                      )
3454              if added_aux:
3455                  results["config_added"].append(
3456                      f"auxiliary.curator ({len(added_aux)} default key(s))"
3457                  )
3458                  if not quiet:
3459                      print(
3460                          "  ✓ Seeded auxiliary.curator defaults in config.yaml: "
3461                          f"{', '.join(added_aux)}"
3462                      )
3463  
3464      if current_ver < latest_ver and not quiet:
3465          print(f"Config version: {current_ver} → {latest_ver}")
3466      
3467      # Check for missing required env vars
3468      missing_env = get_missing_env_vars(required_only=True)
3469      
3470      if missing_env and not quiet:
3471          print("\n⚠️  Missing required environment variables:")
3472          for var in missing_env:
3473              print(f"   • {var['name']}: {var['description']}")
3474      
3475      if interactive and missing_env:
3476          print("\nLet's configure them now:\n")
3477          for var in missing_env:
3478              if var.get("url"):
3479                  print(f"  Get your key at: {var['url']}")
3480              
3481              if var.get("password"):
3482                  import getpass
3483                  value = getpass.getpass(f"  {var['prompt']}: ")
3484              else:
3485                  value = input(f"  {var['prompt']}: ").strip()
3486              
3487              if value:
3488                  save_env_value(var["name"], value)
3489                  results["env_added"].append(var["name"])
3490                  print(f"  ✓ Saved {var['name']}")
3491              else:
3492                  results["warnings"].append(f"Skipped {var['name']} - some features may not work")
3493              print()
3494      
3495      # Check for missing optional env vars and offer to configure interactively
3496      # Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users
3497      missing_optional = get_missing_env_vars(required_only=False)
3498      required_names = {v["name"] for v in missing_env} if missing_env else set()
3499      missing_optional = [
3500          v for v in missing_optional
3501          if v["name"] not in required_names and not v.get("advanced")
3502      ]
3503      
3504      # Only offer to configure env vars that are NEW since the user's previous version
3505      new_var_names = set()
3506      for ver in range(current_ver + 1, latest_ver + 1):
3507          new_var_names.update(ENV_VARS_BY_VERSION.get(ver, []))
3508  
3509      if new_var_names and interactive and not quiet:
3510          new_and_unset = [
3511              (name, OPTIONAL_ENV_VARS[name])
3512              for name in sorted(new_var_names)
3513              if not get_env_value(name) and name in OPTIONAL_ENV_VARS
3514          ]
3515          if new_and_unset:
3516              print(f"\n  {len(new_and_unset)} new optional key(s) in this update:")
3517              for name, info in new_and_unset:
3518                  print(f"    • {name} — {info.get('description', '')}")
3519              print()
3520              try:
3521                  answer = input("  Configure new keys? [y/N]: ").strip().lower()
3522              except (EOFError, KeyboardInterrupt):
3523                  answer = "n"
3524  
3525              if answer in ("y", "yes"):
3526                  print()
3527                  for name, info in new_and_unset:
3528                      if info.get("url"):
3529                          print(f"  {info.get('description', name)}")
3530                          print(f"  Get your key at: {info['url']}")
3531                      else:
3532                          print(f"  {info.get('description', name)}")
3533                      if info.get("password"):
3534                          import getpass
3535                          value = getpass.getpass(f"  {info.get('prompt', name)} (Enter to skip): ")
3536                      else:
3537                          value = input(f"  {info.get('prompt', name)} (Enter to skip): ").strip()
3538                      if value:
3539                          save_env_value(name, value)
3540                          results["env_added"].append(name)
3541                          print(f"  ✓ Saved {name}")
3542                      print()
3543              else:
3544                  print("  Set later with: hermes config set <key> <value>")
3545      
3546      # Check for missing config fields
3547      missing_config = get_missing_config_fields()
3548      
3549      if missing_config:
3550          config = load_config()
3551          
3552          for field in missing_config:
3553              key = field["key"]
3554              default = field["default"]
3555              
3556              _set_nested(config, key, default)
3557              results["config_added"].append(key)
3558              if not quiet:
3559                  print(f"  ✓ Added {key} = {default}")
3560          
3561          # Update version and save
3562          config["_config_version"] = latest_ver
3563          save_config(config)
3564      elif current_ver < latest_ver:
3565          # Just update version
3566          config = load_config()
3567          config["_config_version"] = latest_ver
3568          save_config(config)
3569  
3570      # ── Skill-declared config vars ──────────────────────────────────────
3571      # Skills can declare config.yaml settings they need via
3572      # metadata.hermes.config in their SKILL.md frontmatter.
3573      # Prompt for any that are missing/empty.
3574      missing_skill_config = get_missing_skill_config_vars()
3575      if missing_skill_config and interactive and not quiet:
3576          print(f"\n  {len(missing_skill_config)} skill setting(s) not configured:")
3577          for var in missing_skill_config:
3578              skill_name = var.get("skill", "unknown")
3579              print(f"    • {var['key']} — {var['description']} (from skill: {skill_name})")
3580          print()
3581          try:
3582              answer = input("  Configure skill settings? [y/N]: ").strip().lower()
3583          except (EOFError, KeyboardInterrupt):
3584              answer = "n"
3585  
3586          if answer in ("y", "yes"):
3587              print()
3588              config = load_config()
3589              try:
3590                  from agent.skill_utils import SKILL_CONFIG_PREFIX
3591              except Exception:
3592                  SKILL_CONFIG_PREFIX = "skills.config"
3593              for var in missing_skill_config:
3594                  default = var.get("default", "")
3595                  default_hint = f" (default: {default})" if default else ""
3596                  value = input(f"  {var['prompt']}{default_hint}: ").strip()
3597                  if not value and default:
3598                      value = str(default)
3599                  if value:
3600                      storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}"
3601                      _set_nested(config, storage_key, value)
3602                      results["config_added"].append(var["key"])
3603                      print(f"  ✓ Saved {var['key']} = {value}")
3604                  else:
3605                      results["warnings"].append(
3606                          f"Skipped {var['key']} — skill '{var.get('skill', '?')}' may ask for it later"
3607                      )
3608                  print()
3609              save_config(config)
3610          else:
3611              print("  Set later with: hermes config set <key> <value>")
3612  
3613      return results
3614  
3615  
3616  def _deep_merge(base: dict, override: dict) -> dict:
3617      """Recursively merge *override* into *base*, preserving nested defaults.
3618  
3619      Keys in *override* take precedence. If both values are dicts the merge
3620      recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will
3621      keep the default ``tts.elevenlabs.model_id`` intact.
3622      """
3623      result = base.copy()
3624      for key, value in override.items():
3625          if (
3626              key in result
3627              and isinstance(result[key], dict)
3628              and isinstance(value, dict)
3629          ):
3630              result[key] = _deep_merge(result[key], value)
3631          else:
3632              result[key] = value
3633      return result
3634  
3635  
3636  def _expand_env_vars(obj):
3637      """Recursively expand ``${VAR}`` references in config values.
3638  
3639      Only string values are processed; dict keys, numbers, booleans, and
3640      None are left untouched.  Unresolved references (variable not in
3641      ``os.environ``) are kept verbatim so callers can detect them.
3642      """
3643      if isinstance(obj, str):
3644          return re.sub(
3645              r"\${([^}]+)}",
3646              lambda m: os.environ.get(m.group(1), m.group(0)),
3647              obj,
3648          )
3649      if isinstance(obj, dict):
3650          return {k: _expand_env_vars(v) for k, v in obj.items()}
3651      if isinstance(obj, list):
3652          return [_expand_env_vars(item) for item in obj]
3653      return obj
3654  
3655  
3656  def _items_by_unique_name(items):
3657      """Return a name-indexed dict only when all items have unique string names."""
3658      if not isinstance(items, list):
3659          return None
3660      indexed = {}
3661      for item in items:
3662          if not isinstance(item, dict) or not isinstance(item.get("name"), str):
3663              return None
3664          name = item["name"]
3665          if name in indexed:
3666              return None
3667          indexed[name] = item
3668      return indexed
3669  
3670  
3671  def _preserve_env_ref_templates(current, raw, loaded_expanded=None):
3672      """Restore raw ``${VAR}`` templates when a value is otherwise unchanged.
3673  
3674      ``load_config()`` expands env refs for runtime use. When a caller later
3675      persists that config after modifying some unrelated setting, keep the
3676      original on-disk template instead of writing the expanded plaintext
3677      secret back to ``config.yaml``.
3678  
3679      Prefer preserving the raw template when ``current`` still matches either
3680      the value previously returned by ``load_config()`` for this config path or
3681      the current environment expansion of ``raw``. This handles env-var
3682      rotation between load and save while still treating mixed literal/template
3683      string edits as caller-owned once their rendered value diverges.
3684      """
3685      if isinstance(current, str) and isinstance(raw, str) and re.search(r"\${[^}]+}", raw):
3686          if current == raw:
3687              return raw
3688          if isinstance(loaded_expanded, str) and current == loaded_expanded:
3689              return raw
3690          if _expand_env_vars(raw) == current:
3691              return raw
3692          return current
3693  
3694      if isinstance(current, dict) and isinstance(raw, dict):
3695          return {
3696              key: _preserve_env_ref_templates(
3697                  value,
3698                  raw.get(key),
3699                  loaded_expanded.get(key) if isinstance(loaded_expanded, dict) else None,
3700              )
3701              for key, value in current.items()
3702          }
3703  
3704      if isinstance(current, list) and isinstance(raw, list):
3705          # Prefer matching named config objects (e.g. custom_providers) by name
3706          # so harmless reordering doesn't drop the original template. If names
3707          # are duplicated, fall back to positional matching instead of silently
3708          # shadowing one entry.
3709          current_by_name = _items_by_unique_name(current)
3710          raw_by_name = _items_by_unique_name(raw)
3711          loaded_by_name = _items_by_unique_name(loaded_expanded)
3712          if current_by_name is not None and raw_by_name is not None:
3713              return [
3714                  _preserve_env_ref_templates(
3715                      item,
3716                      raw_by_name.get(item.get("name")),
3717                      loaded_by_name.get(item.get("name")) if loaded_by_name is not None else None,
3718                  )
3719                  for item in current
3720              ]
3721          return [
3722              _preserve_env_ref_templates(
3723                  item,
3724                  raw[index] if index < len(raw) else None,
3725                  loaded_expanded[index]
3726                  if isinstance(loaded_expanded, list) and index < len(loaded_expanded)
3727                  else None,
3728              )
3729              for index, item in enumerate(current)
3730          ]
3731  
3732      return current
3733  
3734  
3735  def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]:
3736      """Move stale root-level provider/base_url/context_length into model section.
3737  
3738      Some users (or older code) placed ``provider:``, ``base_url:``, or
3739      ``context_length:`` at the config root instead of inside ``model:``.
3740      These root-level keys are only used as a fallback when the corresponding
3741      ``model.*`` key is empty — they never override an existing value.
3742      After migration the root-level keys are removed so they can't cause
3743      confusion on subsequent loads.
3744      """
3745      # Only act if there are root-level keys to migrate
3746      has_root = any(config.get(k) for k in ("provider", "base_url", "context_length"))
3747      if not has_root:
3748          return config
3749  
3750      config = dict(config)
3751      model = config.get("model")
3752      if not isinstance(model, dict):
3753          model = {"default": model} if model else {}
3754          config["model"] = model
3755  
3756      for key in ("provider", "base_url", "context_length"):
3757          root_val = config.get(key)
3758          if root_val and not model.get(key):
3759              model[key] = root_val
3760          config.pop(key, None)
3761  
3762      return config
3763  
3764  
3765  def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]:
3766      """Normalize legacy root-level max_turns into agent.max_turns."""
3767      config = dict(config)
3768      agent_config = dict(config.get("agent") or {})
3769  
3770      if "max_turns" in config and "max_turns" not in agent_config:
3771          agent_config["max_turns"] = config["max_turns"]
3772  
3773      if "max_turns" not in agent_config:
3774          agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"]
3775  
3776      config["agent"] = agent_config
3777      config.pop("max_turns", None)
3778      return config
3779  
3780  
3781  def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any:
3782      """Traverse nested dict keys safely, returning ``default`` on any miss.
3783  
3784      Canonical helper for the ``cfg.get("X", {}).get("Y", default)`` pattern
3785      that appears 50+ times across the codebase. Handles three common gotchas
3786      in one place:
3787  
3788        1. Missing intermediate keys (returns ``default``, no KeyError).
3789        2. An intermediate value that's not a dict (e.g. a user wrote a string
3790           where a section was expected). Returns ``default`` instead of
3791           AttributeError on ``.get()``.
3792        3. ``cfg is None`` (callers sometimes pass ``load_config() or None``).
3793  
3794      Named ``cfg_get`` rather than ``cfg_path`` to avoid shadowing the
3795      ubiquitous ``cfg_path = _hermes_home / "config.yaml"`` local variable
3796      that appears in gateway/run.py, cron/scheduler.py, main.py, etc.
3797  
3798      Explicit ``None`` values are returned as-is (matches ``dict.get(key,
3799      default)`` semantics — ``default`` is only returned when the key is
3800      *absent*, not when it's present but set to ``None``).
3801  
3802      Examples:
3803          >>> cfg_get({"agent": {"reasoning_effort": "high"}}, "agent", "reasoning_effort")
3804          'high'
3805          >>> cfg_get({}, "agent", "reasoning_effort", default="medium")
3806          'medium'
3807          >>> cfg_get({"agent": "oops_a_string"}, "agent", "reasoning_effort", default="low")
3808          'low'
3809          >>> cfg_get(None, "anything", default=42)
3810          42
3811          >>> cfg_get({"a": {"b": None}}, "a", "b", default="def")  # explicit None preserved
3812          >>> cfg_get({"a": {"b": False}}, "a", "b", default=True)  # falsy values preserved
3813          False
3814      """
3815      if not isinstance(cfg, dict):
3816          return default
3817      node: Any = cfg
3818      for key in keys:
3819          if not isinstance(node, dict):
3820              return default
3821          if key not in node:
3822              return default
3823          node = node[key]
3824      return node
3825  
3826  
3827  
3828  def read_raw_config() -> Dict[str, Any]:
3829      """Read ~/.hermes/config.yaml as-is, without merging defaults or migrating.
3830  
3831      Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't
3832      be parsed.  Use this for lightweight config reads where you just need a
3833      single value and don't want the overhead of ``load_config()``'s deep-merge
3834      + migration pipeline.
3835  
3836      Cached on the config file's (mtime_ns, size) — same strategy as
3837      ``load_config()``. Returns a deepcopy on every call since some callers
3838      mutate the result before passing to ``save_config()``.
3839      """
3840      try:
3841          config_path = get_config_path()
3842          st = config_path.stat()
3843          cache_key = (st.st_mtime_ns, st.st_size)
3844      except (FileNotFoundError, OSError):
3845          return {}
3846  
3847      path_key = str(config_path)
3848      cached = _RAW_CONFIG_CACHE.get(path_key)
3849      if cached is not None and cached[:2] == cache_key:
3850          return copy.deepcopy(cached[2])
3851  
3852      try:
3853          with open(config_path, encoding="utf-8") as f:
3854              data = yaml.safe_load(f) or {}
3855      except Exception:
3856          return {}
3857  
3858      if not isinstance(data, dict):
3859          data = {}
3860      _RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
3861      return data
3862  
3863  
3864  def load_config() -> Dict[str, Any]:
3865      """Load configuration from ~/.hermes/config.yaml.
3866  
3867      Cached on the config file's (mtime_ns, size). Returns a deepcopy of
3868      the cached value when unchanged, since most call sites mutate the
3869      result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``).
3870      The cache is keyed on ``str(config_path)`` so profile switches
3871      (which change ``HERMES_HOME`` and therefore ``get_config_path()``)
3872      don't collide.
3873      """
3874      ensure_hermes_home()
3875      config_path = get_config_path()
3876      path_key = str(config_path)
3877  
3878      try:
3879          st = config_path.stat()
3880          cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
3881      except FileNotFoundError:
3882          cache_key = None
3883  
3884      cached = _LOAD_CONFIG_CACHE.get(path_key)
3885      if cached is not None and cache_key is not None and cached[:2] == cache_key:
3886          return copy.deepcopy(cached[2])
3887  
3888      config = copy.deepcopy(DEFAULT_CONFIG)
3889  
3890      if cache_key is not None:
3891          try:
3892              with open(config_path, encoding="utf-8") as f:
3893                  user_config = yaml.safe_load(f) or {}
3894  
3895              if "max_turns" in user_config:
3896                  agent_user_config = dict(user_config.get("agent") or {})
3897                  if agent_user_config.get("max_turns") is None:
3898                      agent_user_config["max_turns"] = user_config["max_turns"]
3899                  user_config["agent"] = agent_user_config
3900                  user_config.pop("max_turns", None)
3901  
3902              config = _deep_merge(config, user_config)
3903          except Exception as e:
3904              print(f"Warning: Failed to load config: {e}")
3905  
3906      normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
3907      expanded = _expand_env_vars(normalized)
3908      _LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
3909      if cache_key is not None:
3910          _LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
3911      else:
3912          _LOAD_CONFIG_CACHE.pop(path_key, None)
3913      return expanded
3914  
3915  
3916  _SECURITY_COMMENT = """
3917  # ── Security ──────────────────────────────────────────────────────────
3918  # Secret redaction is OFF by default — tool output (terminal stdout,
3919  # read_file results, web content) passes through unmodified. Set
3920  # redact_secrets to true to mask strings that look like API keys, tokens,
3921  # and passwords before they enter the model context and logs.
3922  # tirith pre-exec scanning is enabled by default when the tirith binary
3923  # is available. Configure via security.tirith_* keys or env vars
3924  # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
3925  #
3926  # security:
3927  #   redact_secrets: true
3928  #   tirith_enabled: true
3929  #   tirith_path: "tirith"
3930  #   tirith_timeout: 5
3931  #   tirith_fail_open: true
3932  """
3933  
3934  _FALLBACK_COMMENT = """
3935  # ── Fallback Model ────────────────────────────────────────────────────
3936  # Automatic provider failover when primary is unavailable.
3937  # Uncomment and configure to enable. Triggers on rate limits (429),
3938  # overload (529), service errors (503), or connection failures.
3939  #
3940  # Supported providers:
3941  #   openrouter   (OPENROUTER_API_KEY)  — routes to any model
3942  #   openai-codex (OAuth — hermes auth) — OpenAI Codex
3943  #   nous         (OAuth — hermes auth) — Nous Portal
3944  #   zai          (ZAI_API_KEY)         — Z.AI / GLM
3945  #   kimi-coding  (KIMI_API_KEY)        — Kimi / Moonshot
3946  #   kimi-coding-cn (KIMI_CN_API_KEY)   — Kimi / Moonshot (China)
3947  #   minimax      (MINIMAX_API_KEY)     — MiniMax
3948  #   minimax-cn   (MINIMAX_CN_API_KEY)  — MiniMax (China)
3949  #
3950  # For custom OpenAI-compatible endpoints, add base_url and key_env.
3951  #
3952  # fallback_model:
3953  #   provider: openrouter
3954  #   model: anthropic/claude-sonnet-4
3955  """
3956  
3957  
3958  _COMMENTED_SECTIONS = """
3959  # ── Security ──────────────────────────────────────────────────────────
3960  # Secret redaction is OFF by default. Set to true to mask strings that
3961  # look like API keys, tokens, and passwords in tool output and logs.
3962  #
3963  # security:
3964  #   redact_secrets: true
3965  
3966  # ── Fallback Model ────────────────────────────────────────────────────
3967  # Automatic provider failover when primary is unavailable.
3968  # Uncomment and configure to enable. Triggers on rate limits (429),
3969  # overload (529), service errors (503), or connection failures.
3970  #
3971  # Supported providers:
3972  #   openrouter   (OPENROUTER_API_KEY)  — routes to any model
3973  #   openai-codex (OAuth — hermes auth) — OpenAI Codex
3974  #   nous         (OAuth — hermes auth) — Nous Portal
3975  #   zai          (ZAI_API_KEY)         — Z.AI / GLM
3976  #   kimi-coding  (KIMI_API_KEY)        — Kimi / Moonshot
3977  #   kimi-coding-cn (KIMI_CN_API_KEY)   — Kimi / Moonshot (China)
3978  #   minimax      (MINIMAX_API_KEY)     — MiniMax
3979  #   minimax-cn   (MINIMAX_CN_API_KEY)  — MiniMax (China)
3980  #
3981  # For custom OpenAI-compatible endpoints, add base_url and key_env.
3982  #
3983  # fallback_model:
3984  #   provider: openrouter
3985  #   model: anthropic/claude-sonnet-4
3986  """
3987  
3988  
3989  def save_config(config: Dict[str, Any]):
3990      """Save configuration to ~/.hermes/config.yaml."""
3991      if is_managed():
3992          managed_error("save configuration")
3993          return
3994      from utils import atomic_yaml_write
3995  
3996      ensure_hermes_home()
3997      config_path = get_config_path()
3998      current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
3999      normalized = current_normalized
4000      raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
4001      if raw_existing:
4002          normalized = _preserve_env_ref_templates(
4003              normalized,
4004              raw_existing,
4005              _LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
4006          )
4007  
4008      # Build optional commented-out sections for features that are off by
4009      # default or only relevant when explicitly configured.
4010      parts = []
4011      sec = normalized.get("security", {})
4012      if not sec or sec.get("redact_secrets") is None:
4013          parts.append(_SECURITY_COMMENT)
4014      fb = normalized.get("fallback_model", {})
4015      fb_is_valid = False
4016      if isinstance(fb, list):
4017          fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
4018      elif isinstance(fb, dict):
4019          fb_is_valid = bool(fb.get("provider") and fb.get("model"))
4020      if not fb_is_valid:
4021          parts.append(_FALLBACK_COMMENT)
4022  
4023      atomic_yaml_write(
4024          config_path,
4025          normalized,
4026          extra_content="".join(parts) if parts else None,
4027      )
4028      _secure_file(config_path)
4029      _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
4030  
4031  
4032  def load_env() -> Dict[str, str]:
4033      """Load environment variables from ~/.hermes/.env.
4034  
4035      Sanitizes lines before parsing so that corrupted files (e.g.
4036      concatenated KEY=VALUE pairs on a single line) are handled
4037      gracefully instead of producing mangled values such as duplicated
4038      bot tokens.  See #8908.
4039      """
4040      env_path = get_env_path()
4041      env_vars = {}
4042      
4043      if env_path.exists():
4044          # On Windows, open() defaults to the system locale (cp1252) which can
4045          # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows.
4046          open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
4047          with open(env_path, **open_kw) as f:
4048              raw_lines = f.readlines()
4049          # Sanitize before parsing: split concatenated lines & drop stale
4050          # placeholders so corrupted .env files don't produce invalid tokens.
4051          lines = _sanitize_env_lines(raw_lines)
4052          for line in lines:
4053              line = line.strip()
4054              if line and not line.startswith('#') and '=' in line:
4055                  key, _, value = line.partition('=')
4056                  env_vars[key.strip()] = value.strip().strip('"\'')
4057      
4058      return env_vars
4059  
4060  
4061  def _sanitize_env_lines(lines: list) -> list:
4062      """Fix corrupted .env lines before reading or writing.
4063  
4064      Handles two known corruption patterns:
4065      1. Concatenated KEY=VALUE pairs on a single line (missing newline between
4066         entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``).
4067      2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs.
4068  
4069      Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only
4070      split on real Hermes env var names, avoiding false positives from values
4071      that happen to contain uppercase text with ``=``.
4072      """
4073      # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras.
4074      # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined.
4075      known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
4076  
4077      sanitized: list[str] = []
4078      for line in lines:
4079          raw = line.rstrip("\r\n")
4080          stripped = raw.strip()
4081  
4082          # Preserve blank lines and comments
4083          if not stripped or stripped.startswith("#"):
4084              sanitized.append(raw + "\n")
4085              continue
4086  
4087          # Detect concatenated KEY=VALUE pairs on one line.
4088          # Search for known KEY= patterns at any position in the line.
4089          # We collect full needle ranges so we can drop matches that are
4090          # fully contained within a longer overlapping needle. Without this,
4091          # suffix collisions corrupt the file: e.g. LM_API_KEY= inside
4092          # GLM_API_KEY= would otherwise split the line into "G\nLM_API_KEY=...".
4093          match_ranges: list[tuple[int, int]] = []
4094          for key_name in known_keys:
4095              needle = key_name + "="
4096              idx = stripped.find(needle)
4097              while idx >= 0:
4098                  match_ranges.append((idx, idx + len(needle)))
4099                  idx = stripped.find(needle, idx + len(needle))
4100  
4101          split_positions = sorted({
4102              s for s, e in match_ranges
4103              if not any(
4104                  s2 <= s and e2 >= e and (s2, e2) != (s, e)
4105                  for s2, e2 in match_ranges
4106              )
4107          })
4108  
4109          if len(split_positions) > 1:
4110              for i, pos in enumerate(split_positions):
4111                  end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped)
4112                  part = stripped[pos:end].strip()
4113                  if part:
4114                      sanitized.append(part + "\n")
4115          else:
4116              sanitized.append(stripped + "\n")
4117  
4118      return sanitized
4119  
4120  
4121  def sanitize_env_file() -> int:
4122      """Read, sanitize, and rewrite ~/.hermes/.env in place.
4123  
4124      Returns the number of lines that were fixed (concatenation splits +
4125      placeholder removals).  Returns 0 when no changes are needed.
4126      """
4127      env_path = get_env_path()
4128      if not env_path.exists():
4129          return 0
4130  
4131      read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
4132      write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
4133  
4134      with open(env_path, **read_kw) as f:
4135          original_lines = f.readlines()
4136  
4137      sanitized = _sanitize_env_lines(original_lines)
4138  
4139      if sanitized == original_lines:
4140          return 0
4141  
4142      # Count fixes: difference in line count (from splits) + removed lines
4143      fixes = abs(len(sanitized) - len(original_lines))
4144      if fixes == 0:
4145          # Lines changed content (e.g. *** removal) even if count is same
4146          fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b)
4147          fixes += abs(len(sanitized) - len(original_lines))
4148  
4149      fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_")
4150      try:
4151          with os.fdopen(fd, "w", **write_kw) as f:
4152              f.writelines(sanitized)
4153              f.flush()
4154              os.fsync(f.fileno())
4155          atomic_replace(tmp_path, env_path)
4156      except BaseException:
4157          try:
4158              os.unlink(tmp_path)
4159          except OSError:
4160              pass
4161          raise
4162      _secure_file(env_path)
4163      return fixes
4164  
4165  
4166  def _check_non_ascii_credential(key: str, value: str) -> str:
4167      """Warn and strip non-ASCII characters from credential values.
4168  
4169      API keys and tokens must be pure ASCII — they are sent as HTTP header
4170      values which httpx/httpcore encode as ASCII.  Non-ASCII characters
4171      (commonly introduced by copy-pasting from rich-text editors or PDFs
4172      that substitute lookalike Unicode glyphs for ASCII letters) cause
4173      ``UnicodeEncodeError: 'ascii' codec can't encode character`` at
4174      request time.
4175  
4176      Returns the sanitized (ASCII-only) value.  Prints a warning if any
4177      non-ASCII characters were found and removed.
4178      """
4179      try:
4180          value.encode("ascii")
4181          return value  # all ASCII — nothing to do
4182      except UnicodeEncodeError:
4183          pass
4184  
4185      # Build a readable list of the offending characters
4186      bad_chars: list[str] = []
4187      for i, ch in enumerate(value):
4188          if ord(ch) > 127:
4189              bad_chars.append(f"  position {i}: {ch!r} (U+{ord(ch):04X})")
4190      sanitized = value.encode("ascii", errors="ignore").decode("ascii")
4191  
4192      print(
4193          f"\n  Warning: {key} contains non-ASCII characters that will break API requests.\n"
4194          f"  This usually happens when copy-pasting from a PDF, rich-text editor,\n"
4195          f"  or web page that substitutes lookalike Unicode glyphs for ASCII letters.\n"
4196          f"\n"
4197          + "\n".join(f"  {line}" for line in bad_chars[:5])
4198          + ("\n  ... and more" if len(bad_chars) > 5 else "")
4199          + f"\n\n  The non-ASCII characters have been stripped automatically.\n"
4200          f"  If authentication fails, re-copy the key from the provider's dashboard.\n",
4201          file=sys.stderr,
4202      )
4203      return sanitized
4204  
4205  
4206  def save_env_value(key: str, value: str):
4207      """Save or update a value in ~/.hermes/.env."""
4208      if is_managed():
4209          managed_error(f"set {key}")
4210          return
4211      if not _ENV_VAR_NAME_RE.match(key):
4212          raise ValueError(f"Invalid environment variable name: {key!r}")
4213      value = value.replace("\n", "").replace("\r", "")
4214      # API keys / tokens must be ASCII — strip non-ASCII with a warning.
4215      value = _check_non_ascii_credential(key, value)
4216      ensure_hermes_home()
4217      env_path = get_env_path()
4218  
4219      # On Windows, open() defaults to the system locale (cp1252) which can
4220      # cause OSError errno 22 on UTF-8 .env files.
4221      read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
4222      write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
4223  
4224      lines = []
4225      if env_path.exists():
4226          with open(env_path, **read_kw) as f:
4227              lines = f.readlines()
4228          # Sanitize on every read: split concatenated keys, drop stale placeholders
4229          lines = _sanitize_env_lines(lines)
4230  
4231      # Find and update or append
4232      found = False
4233      for i, line in enumerate(lines):
4234          if line.strip().startswith(f"{key}="):
4235              lines[i] = f"{key}={value}\n"
4236              found = True
4237              break
4238  
4239      if not found:
4240          # Ensure there's a newline at the end of the file before appending
4241          if lines and not lines[-1].endswith("\n"):
4242              lines[-1] += "\n"
4243          lines.append(f"{key}={value}\n")
4244      
4245      fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
4246      # Preserve original permissions so Docker volume mounts aren't clobbered.
4247      original_mode = None
4248      if env_path.exists():
4249          try:
4250              original_mode = stat.S_IMODE(env_path.stat().st_mode)
4251          except OSError:
4252              pass
4253      try:
4254          with os.fdopen(fd, 'w', **write_kw) as f:
4255              f.writelines(lines)
4256              f.flush()
4257              os.fsync(f.fileno())
4258          atomic_replace(tmp_path, env_path)
4259          # Restore original permissions before _secure_file may tighten them.
4260          if original_mode is not None:
4261              try:
4262                  os.chmod(env_path, original_mode)
4263              except OSError:
4264                  pass
4265      except BaseException:
4266          try:
4267              os.unlink(tmp_path)
4268          except OSError:
4269              pass
4270          raise
4271      _secure_file(env_path)
4272  
4273      os.environ[key] = value
4274  
4275  
4276  def remove_env_value(key: str) -> bool:
4277      """Remove a key from ~/.hermes/.env and os.environ.
4278  
4279      Returns True if the key was found and removed, False otherwise.
4280      """
4281      if is_managed():
4282          managed_error(f"remove {key}")
4283          return False
4284      if not _ENV_VAR_NAME_RE.match(key):
4285          raise ValueError(f"Invalid environment variable name: {key!r}")
4286      env_path = get_env_path()
4287      if not env_path.exists():
4288          os.environ.pop(key, None)
4289          return False
4290  
4291      read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
4292      write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
4293  
4294      with open(env_path, **read_kw) as f:
4295          lines = f.readlines()
4296      lines = _sanitize_env_lines(lines)
4297  
4298      new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
4299      found = len(new_lines) < len(lines)
4300  
4301      if found:
4302          fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
4303          # Preserve original permissions so Docker volume mounts aren't clobbered.
4304          original_mode = None
4305          try:
4306              original_mode = stat.S_IMODE(env_path.stat().st_mode)
4307          except OSError:
4308              pass
4309          try:
4310              with os.fdopen(fd, 'w', **write_kw) as f:
4311                  f.writelines(new_lines)
4312                  f.flush()
4313                  os.fsync(f.fileno())
4314              atomic_replace(tmp_path, env_path)
4315              if original_mode is not None:
4316                  try:
4317                      os.chmod(env_path, original_mode)
4318                  except OSError:
4319                      pass
4320          except BaseException:
4321              try:
4322                  os.unlink(tmp_path)
4323              except OSError:
4324                  pass
4325              raise
4326          _secure_file(env_path)
4327  
4328      os.environ.pop(key, None)
4329      return found
4330  
4331  
4332  def save_anthropic_oauth_token(value: str, save_fn=None):
4333      """Persist an Anthropic OAuth/setup token and clear the API-key slot."""
4334      writer = save_fn or save_env_value
4335      writer("ANTHROPIC_TOKEN", value)
4336      writer("ANTHROPIC_API_KEY", "")
4337  
4338  
4339  def use_anthropic_claude_code_credentials(save_fn=None):
4340      """Use Claude Code's own credential files instead of persisting env tokens."""
4341      writer = save_fn or save_env_value
4342      writer("ANTHROPIC_TOKEN", "")
4343      writer("ANTHROPIC_API_KEY", "")
4344  
4345  
4346  def save_anthropic_api_key(value: str, save_fn=None):
4347      """Persist an Anthropic API key and clear the OAuth/setup-token slot."""
4348      writer = save_fn or save_env_value
4349      writer("ANTHROPIC_API_KEY", value)
4350      writer("ANTHROPIC_TOKEN", "")
4351  
4352  
4353  def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
4354      save_env_value(key, value)
4355      return {
4356          "success": True,
4357          "stored_as": key,
4358          "validated": False,
4359      }
4360  
4361  
4362  
4363  def reload_env() -> int:
4364      """Re-read ~/.hermes/.env into os.environ. Returns count of vars updated.
4365  
4366      Adds/updates vars that changed and removes vars that were deleted from
4367      the .env file (but only vars known to Hermes — OPTIONAL_ENV_VARS and
4368      _EXTRA_ENV_KEYS — to avoid clobbering unrelated environment).
4369      """
4370      env_vars = load_env()
4371      known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS
4372      count = 0
4373      for key, value in env_vars.items():
4374          if os.environ.get(key) != value:
4375              os.environ[key] = value
4376              count += 1
4377      # Remove known Hermes vars that are no longer in .env
4378      for key in known_keys:
4379          if key not in env_vars and key in os.environ:
4380              del os.environ[key]
4381              count += 1
4382      return count
4383  
4384  
4385  def get_env_value(key: str) -> Optional[str]:
4386      """Get a value from ~/.hermes/.env or environment."""
4387      # Check environment first
4388      if key in os.environ:
4389          return os.environ[key]
4390      
4391      # Then check .env file
4392      env_vars = load_env()
4393      return env_vars.get(key)
4394  
4395  
4396  # =============================================================================
4397  # Config display
4398  # =============================================================================
4399  
4400  def redact_key(key: str) -> str:
4401      """Redact an API key for display.
4402  
4403      Thin wrapper over :func:`agent.redact.mask_secret` — preserves the
4404      "(not set)" placeholder in dim color for the empty case.
4405      """
4406      from agent.redact import mask_secret
4407      return mask_secret(key, empty=color("(not set)", Colors.DIM))
4408  
4409  
4410  def show_config():
4411      """Display current configuration."""
4412      config = load_config()
4413      
4414      print()
4415      print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
4416      print(color("│              ⚕ Hermes Configuration                    │", Colors.CYAN))
4417      print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
4418      
4419      # Paths
4420      print()
4421      print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
4422      print(f"  Config:       {get_config_path()}")
4423      print(f"  Secrets:      {get_env_path()}")
4424      print(f"  Install:      {get_project_root()}")
4425      
4426      # API Keys
4427      print()
4428      print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
4429      
4430      keys = [
4431          ("OPENROUTER_API_KEY", "OpenRouter"),
4432          ("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"),
4433          ("EXA_API_KEY", "Exa"),
4434          ("PARALLEL_API_KEY", "Parallel"),
4435          ("FIRECRAWL_API_KEY", "Firecrawl"),
4436          ("TAVILY_API_KEY", "Tavily"),
4437          ("BROWSERBASE_API_KEY", "Browserbase"),
4438          ("BROWSER_USE_API_KEY", "Browser Use"),
4439          ("FAL_KEY", "FAL"),
4440      ]
4441      
4442      for env_key, name in keys:
4443          value = get_env_value(env_key)
4444          print(f"  {name:<14} {redact_key(value)}")
4445      from hermes_cli.auth import get_anthropic_key
4446      anthropic_value = get_anthropic_key()
4447      print(f"  {'Anthropic':<14} {redact_key(anthropic_value)}")
4448      
4449      # Model settings
4450      print()
4451      print(color("◆ Model", Colors.CYAN, Colors.BOLD))
4452      print(f"  Model:        {config.get('model', 'not set')}")
4453      print(f"  Max turns:    {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}")
4454      
4455      # Display
4456      print()
4457      print(color("◆ Display", Colors.CYAN, Colors.BOLD))
4458      display = config.get('display', {})
4459      print(f"  Personality:  {display.get('personality', 'kawaii')}")
4460      print(f"  Reasoning:    {'on' if display.get('show_reasoning', False) else 'off'}")
4461      print(f"  Bell:         {'on' if display.get('bell_on_complete', False) else 'off'}")
4462      ump = display.get('user_message_preview', {}) if isinstance(display.get('user_message_preview', {}), dict) else {}
4463      ump_first = ump.get('first_lines', 2)
4464      ump_last = ump.get('last_lines', 2)
4465      print(f"  User preview: first {ump_first} line(s), last {ump_last} line(s)")
4466  
4467      # Terminal
4468      print()
4469      print(color("◆ Terminal", Colors.CYAN, Colors.BOLD))
4470      terminal = config.get('terminal', {})
4471      print(f"  Backend:      {terminal.get('backend', 'local')}")
4472      print(f"  Working dir:  {terminal.get('cwd', '.')}")
4473      print(f"  Timeout:      {terminal.get('timeout', 60)}s")
4474      
4475      if terminal.get('backend') == 'docker':
4476          print(f"  Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
4477      elif terminal.get('backend') == 'singularity':
4478          print(f"  Image:        {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}")
4479      elif terminal.get('backend') == 'modal':
4480          print(f"  Modal image:  {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
4481          modal_token = get_env_value('MODAL_TOKEN_ID')
4482          print(f"  Modal token:  {'configured' if modal_token else '(not set)'}")
4483      elif terminal.get('backend') == 'daytona':
4484          print(f"  Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
4485          daytona_key = get_env_value('DAYTONA_API_KEY')
4486          print(f"  API key:      {'configured' if daytona_key else '(not set)'}")
4487      elif terminal.get('backend') == 'vercel_sandbox':
4488          print(f"  Vercel runtime: {terminal.get('vercel_runtime', 'node24')}")
4489          print(f"  Vercel auth:    {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}")
4490      elif terminal.get('backend') == 'ssh':
4491          ssh_host = get_env_value('TERMINAL_SSH_HOST')
4492          ssh_user = get_env_value('TERMINAL_SSH_USER')
4493          print(f"  SSH host:     {ssh_host or '(not set)'}")
4494          print(f"  SSH user:     {ssh_user or '(not set)'}")
4495      
4496      # Timezone
4497      print()
4498      print(color("◆ Timezone", Colors.CYAN, Colors.BOLD))
4499      tz = config.get('timezone', '')
4500      if tz:
4501          print(f"  Timezone:     {tz}")
4502      else:
4503          print(f"  Timezone:     {color('(server-local)', Colors.DIM)}")
4504  
4505      # Compression
4506      print()
4507      print(color("◆ Context Compression", Colors.CYAN, Colors.BOLD))
4508      compression = config.get('compression', {})
4509      enabled = compression.get('enabled', True)
4510      print(f"  Enabled:      {'yes' if enabled else 'no'}")
4511      if enabled:
4512          print(f"  Threshold:    {compression.get('threshold', 0.50) * 100:.0f}%")
4513          print(f"  Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved")
4514          print(f"  Protect last: {compression.get('protect_last_n', 20)} messages")
4515          _aux_comp = config.get('auxiliary', {}).get('compression', {})
4516          _sm = _aux_comp.get('model', '') or '(auto)'
4517          print(f"  Model:        {_sm}")
4518          comp_provider = _aux_comp.get('provider', 'auto')
4519          if comp_provider and comp_provider != 'auto':
4520              print(f"  Provider:     {comp_provider}")
4521      
4522      # Auxiliary models
4523      auxiliary = config.get('auxiliary', {})
4524      aux_tasks = {
4525          "Vision":      auxiliary.get('vision', {}),
4526          "Web extract": auxiliary.get('web_extract', {}),
4527      }
4528      has_overrides = any(
4529          t.get('provider', 'auto') != 'auto' or t.get('model', '')
4530          for t in aux_tasks.values()
4531      )
4532      if has_overrides:
4533          print()
4534          print(color("◆ Auxiliary Models (overrides)", Colors.CYAN, Colors.BOLD))
4535          for label, task_cfg in aux_tasks.items():
4536              prov = task_cfg.get('provider', 'auto')
4537              mdl = task_cfg.get('model', '')
4538              if prov != 'auto' or mdl:
4539                  parts = [f"provider={prov}"]
4540                  if mdl:
4541                      parts.append(f"model={mdl}")
4542                  print(f"  {label:12s}  {', '.join(parts)}")
4543      
4544      # Messaging
4545      print()
4546      print(color("◆ Messaging Platforms", Colors.CYAN, Colors.BOLD))
4547      
4548      telegram_token = get_env_value('TELEGRAM_BOT_TOKEN')
4549      discord_token = get_env_value('DISCORD_BOT_TOKEN')
4550      
4551      print(f"  Telegram:     {'configured' if telegram_token else color('not configured', Colors.DIM)}")
4552      print(f"  Discord:      {'configured' if discord_token else color('not configured', Colors.DIM)}")
4553      
4554      # Skill config
4555      try:
4556          from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
4557          skill_vars = discover_all_skill_config_vars()
4558          if skill_vars:
4559              resolved = resolve_skill_config_values(skill_vars)
4560              print()
4561              print(color("◆ Skill Settings", Colors.CYAN, Colors.BOLD))
4562              for var in skill_vars:
4563                  key = var["key"]
4564                  value = resolved.get(key, "")
4565                  skill_name = var.get("skill", "")
4566                  display_val = str(value) if value else color("(not set)", Colors.DIM)
4567                  print(f"  {key:<20s} {display_val}  {color(f'[{skill_name}]', Colors.DIM)}")
4568      except Exception:
4569          pass
4570  
4571      print()
4572      print(color("─" * 60, Colors.DIM))
4573      print(color("  hermes config edit     # Edit config file", Colors.DIM))
4574      print(color("  hermes config set <key> <value>", Colors.DIM))
4575      print(color("  hermes setup           # Run setup wizard", Colors.DIM))
4576      print()
4577  
4578  
4579  def edit_config():
4580      """Open config file in user's editor."""
4581      if is_managed():
4582          managed_error("edit configuration")
4583          return
4584      config_path = get_config_path()
4585      
4586      # Ensure config exists
4587      if not config_path.exists():
4588          save_config(DEFAULT_CONFIG)
4589          print(f"Created {config_path}")
4590      
4591      # Find editor
4592      editor = os.getenv('EDITOR') or os.getenv('VISUAL')
4593      
4594      if not editor:
4595          # Try common editors
4596          for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']:
4597              import shutil
4598              if shutil.which(cmd):
4599                  editor = cmd
4600                  break
4601      
4602      if not editor:
4603          print("No editor found. Config file is at:")
4604          print(f"  {config_path}")
4605          return
4606      
4607      print(f"Opening {config_path} in {editor}...")
4608      subprocess.run([editor, str(config_path)])
4609  
4610  
4611  def set_config_value(key: str, value: str):
4612      """Set a configuration value."""
4613      if is_managed():
4614          managed_error("set configuration values")
4615          return
4616      # Check if it's an API key (goes to .env)
4617      api_keys = [
4618          'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
4619          'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
4620          'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
4621          'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
4622          'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
4623          'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
4624          'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
4625          'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN',
4626          'GITHUB_TOKEN', 'HONCHO_API_KEY', 'WANDB_API_KEY',
4627          'TINKER_API_KEY',
4628      ]
4629      
4630      if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'):
4631          save_env_value(key.upper(), value)
4632          print(f"✓ Set {key} in {get_env_path()}")
4633          return
4634      
4635      # Otherwise it goes to config.yaml
4636      # Read the raw user config (not merged with defaults) to avoid
4637      # dumping all default values back to the file
4638      config_path = get_config_path()
4639      user_config = {}
4640      if config_path.exists():
4641          try:
4642              with open(config_path, encoding="utf-8") as f:
4643                  user_config = yaml.safe_load(f) or {}
4644          except Exception:
4645              user_config = {}
4646      
4647      # Handle nested keys (e.g., "tts.provider") including numeric list
4648      # indices (e.g., "custom_providers.0.api_key").  Delegates to
4649      # _set_nested which preserves list-typed nodes; before #17876 the
4650      # inline navigation here silently overwrote lists with dicts.
4651  
4652      # Convert value to appropriate type
4653      if value.lower() in ('true', 'yes', 'on'):
4654          value = True
4655      elif value.lower() in ('false', 'no', 'off'):
4656          value = False
4657      elif value.isdigit():
4658          value = int(value)
4659      elif value.replace('.', '', 1).isdigit():
4660          value = float(value)
4661  
4662      _set_nested(user_config, key, value)
4663      
4664      # Write only user config back (not the full merged defaults)
4665      ensure_hermes_home()
4666      from utils import atomic_yaml_write
4667      atomic_yaml_write(config_path, user_config, sort_keys=False)
4668      
4669      # Keep .env in sync for keys that terminal_tool reads directly from env vars.
4670      # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
4671      _config_to_env_sync = {
4672          "terminal.backend": "TERMINAL_ENV",
4673          "terminal.modal_mode": "TERMINAL_MODAL_MODE",
4674          "terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
4675          "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
4676          "terminal.modal_image": "TERMINAL_MODAL_IMAGE",
4677          "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
4678          "terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
4679          "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
4680          "terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
4681          # terminal.cwd intentionally excluded — CLI resolves at runtime,
4682          # gateway bridges it in gateway/run.py. Persisting to .env causes
4683          # stale values to poison child processes.
4684          "terminal.timeout": "TERMINAL_TIMEOUT",
4685          "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
4686          "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
4687          "terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
4688          "terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
4689          "terminal.container_disk": "TERMINAL_CONTAINER_DISK",
4690          "terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
4691      }
4692      if key in _config_to_env_sync:
4693          save_env_value(_config_to_env_sync[key], str(value))
4694  
4695      print(f"✓ Set {key} = {value} in {config_path}")
4696  
4697  
4698  # =============================================================================
4699  # Command handler
4700  # =============================================================================
4701  
4702  def config_command(args):
4703      """Handle config subcommands."""
4704      subcmd = getattr(args, 'config_command', None)
4705      
4706      if subcmd is None or subcmd == "show":
4707          show_config()
4708      
4709      elif subcmd == "edit":
4710          edit_config()
4711      
4712      elif subcmd == "set":
4713          key = getattr(args, 'key', None)
4714          value = getattr(args, 'value', None)
4715          if not key or value is None:
4716              print("Usage: hermes config set <key> <value>")
4717              print()
4718              print("Examples:")
4719              print("  hermes config set model anthropic/claude-sonnet-4")
4720              print("  hermes config set terminal.backend docker")
4721              print("  hermes config set OPENROUTER_API_KEY sk-or-...")
4722              sys.exit(1)
4723          set_config_value(key, value)
4724      
4725      elif subcmd == "path":
4726          print(get_config_path())
4727      
4728      elif subcmd == "env-path":
4729          print(get_env_path())
4730      
4731      elif subcmd == "migrate":
4732          print()
4733          print(color("🔄 Checking configuration for updates...", Colors.CYAN, Colors.BOLD))
4734          print()
4735          
4736          # Check what's missing
4737          missing_env = get_missing_env_vars(required_only=False)
4738          missing_config = get_missing_config_fields()
4739          current_ver, latest_ver = check_config_version()
4740          
4741          if not missing_env and not missing_config and current_ver >= latest_ver:
4742              print(color("✓ Configuration is up to date!", Colors.GREEN))
4743              print()
4744              return
4745          
4746          # Show what needs to be updated
4747          if current_ver < latest_ver:
4748              print(f"  Config version: {current_ver} → {latest_ver}")
4749          
4750          if missing_config:
4751              print(f"\n  {len(missing_config)} new config option(s) will be added with defaults")
4752          
4753          required_missing = [v for v in missing_env if v.get("is_required")]
4754          optional_missing = [
4755              v for v in missing_env
4756              if not v.get("is_required") and not v.get("advanced")
4757          ]
4758          
4759          if required_missing:
4760              print(f"\n  ⚠️  {len(required_missing)} required API key(s) missing:")
4761              for var in required_missing:
4762                  print(f"     • {var['name']}")
4763          
4764          if optional_missing:
4765              print(f"\n  ℹ️  {len(optional_missing)} optional API key(s) not configured:")
4766              for var in optional_missing:
4767                  tools = var.get("tools", [])
4768                  tools_str = f" (enables: {', '.join(tools[:2])})" if tools else ""
4769                  print(f"     • {var['name']}{tools_str}")
4770          
4771          print()
4772          
4773          # Run migration
4774          results = migrate_config(interactive=True, quiet=False)
4775          
4776          print()
4777          if results["env_added"] or results["config_added"]:
4778              print(color("✓ Configuration updated!", Colors.GREEN))
4779          
4780          if results["warnings"]:
4781              print()
4782              for warning in results["warnings"]:
4783                  print(color(f"  ⚠️  {warning}", Colors.YELLOW))
4784          
4785          print()
4786      
4787      elif subcmd == "check":
4788          # Non-interactive check for what's missing
4789          print()
4790          print(color("📋 Configuration Status", Colors.CYAN, Colors.BOLD))
4791          print()
4792          
4793          current_ver, latest_ver = check_config_version()
4794          if current_ver >= latest_ver:
4795              print(f"  Config version: {current_ver} ✓")
4796          else:
4797              print(color(f"  Config version: {current_ver} → {latest_ver} (update available)", Colors.YELLOW))
4798          
4799          print()
4800          print(color("  Required:", Colors.BOLD))
4801          for var_name in REQUIRED_ENV_VARS:
4802              if get_env_value(var_name):
4803                  print(f"    ✓ {var_name}")
4804              else:
4805                  print(color(f"    ✗ {var_name} (missing)", Colors.RED))
4806          
4807          print()
4808          print(color("  Optional:", Colors.BOLD))
4809          for var_name, info in OPTIONAL_ENV_VARS.items():
4810              if get_env_value(var_name):
4811                  print(f"    ✓ {var_name}")
4812              else:
4813                  tools = info.get("tools", [])
4814                  tools_str = f" → {', '.join(tools[:2])}" if tools else ""
4815                  print(color(f"    ○ {var_name}{tools_str}", Colors.DIM))
4816          
4817          missing_config = get_missing_config_fields()
4818          if missing_config:
4819              print()
4820              print(color(f"  {len(missing_config)} new config option(s) available", Colors.YELLOW))
4821              print("    Run 'hermes config migrate' to add them")
4822          
4823          print()
4824      
4825      else:
4826          print(f"Unknown config command: {subcmd}")
4827          print()
4828          print("Available commands:")
4829          print("  hermes config           Show current configuration")
4830          print("  hermes config edit      Open config in editor")
4831          print("  hermes config set <key> <value>   Set a config value")
4832          print("  hermes config check     Check for missing/outdated config")
4833          print("  hermes config migrate   Update config with new options")
4834          print("  hermes config path      Show config file path")
4835          print("  hermes config env-path  Show .env file path")
4836          sys.exit(1)