/ 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)