Spaces:
Paused
Paused
| """ | |
| Configuration management for Hermes Agent. | |
| Config files are stored in ~/.hermes/ for easy access: | |
| - ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.) | |
| - ~/.hermes/.env - API keys and secrets | |
| This module provides: | |
| - hermes config - Show current configuration | |
| - hermes config edit - Open config in editor | |
| - hermes config set - Set a specific value | |
| - hermes config wizard - Re-run setup wizard | |
| """ | |
| import os | |
| import platform | |
| import re | |
| import stat | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Dict, Any, Optional, List, Tuple | |
| from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled | |
| _IS_WINDOWS = platform.system() == "Windows" | |
| _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") | |
| # Env var names written to .env that aren't in OPTIONAL_ENV_VARS | |
| # (managed by setup/provider flows directly). | |
| _EXTRA_ENV_KEYS = frozenset({ | |
| "OPENAI_API_KEY", "OPENAI_BASE_URL", | |
| "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", | |
| "DISCORD_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL", | |
| "SIGNAL_ACCOUNT", "SIGNAL_HTTP_URL", | |
| "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", | |
| "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", | |
| "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", | |
| "WECOM_BOT_ID", "WECOM_SECRET", | |
| "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", | |
| "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", | |
| "WECOM_CALLBACK_HOST", "WECOM_CALLBACK_PORT", | |
| "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", | |
| "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", | |
| "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", | |
| "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", | |
| "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", | |
| "WHATSAPP_MODE", "WHATSAPP_ENABLED", | |
| "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", | |
| "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", | |
| "MATRIX_REQUIRE_MENTION", "MATRIX_FREE_RESPONSE_ROOMS", "MATRIX_AUTO_THREAD", | |
| }) | |
| import yaml | |
| from hermes_cli.colors import Colors, color | |
| from hermes_cli.default_soul import DEFAULT_SOUL_MD | |
| # ============================================================================= | |
| # Managed mode (NixOS declarative config) | |
| # ============================================================================= | |
| _MANAGED_TRUE_VALUES = ("true", "1", "yes") | |
| _MANAGED_SYSTEM_NAMES = { | |
| "brew": "Homebrew", | |
| "homebrew": "Homebrew", | |
| "nix": "NixOS", | |
| "nixos": "NixOS", | |
| } | |
| def get_managed_system() -> Optional[str]: | |
| """Return the package manager owning this install, if any.""" | |
| raw = os.getenv("HERMES_MANAGED", "").strip() | |
| if raw: | |
| normalized = raw.lower() | |
| if normalized in _MANAGED_TRUE_VALUES: | |
| return "NixOS" | |
| return _MANAGED_SYSTEM_NAMES.get(normalized, raw) | |
| managed_marker = get_hermes_home() / ".managed" | |
| if managed_marker.exists(): | |
| return "NixOS" | |
| return None | |
| def is_managed() -> bool: | |
| """Check if Hermes is running in package-manager-managed mode. | |
| Two signals: the HERMES_MANAGED env var (set by the systemd service), | |
| or a .managed marker file in HERMES_HOME (set by the NixOS activation | |
| script, so interactive shells also see it). | |
| """ | |
| return get_managed_system() is not None | |
| def get_managed_update_command() -> Optional[str]: | |
| """Return the preferred upgrade command for a managed install.""" | |
| managed_system = get_managed_system() | |
| if managed_system == "Homebrew": | |
| return "brew upgrade hermes-agent" | |
| if managed_system == "NixOS": | |
| return "sudo nixos-rebuild switch" | |
| return None | |
| def recommended_update_command() -> str: | |
| """Return the best update command for the current installation.""" | |
| return get_managed_update_command() or "hermes update" | |
| def format_managed_message(action: str = "modify this Hermes installation") -> str: | |
| """Build a user-facing error for managed installs.""" | |
| managed_system = get_managed_system() or "a package manager" | |
| raw = os.getenv("HERMES_MANAGED", "").strip().lower() | |
| if managed_system == "NixOS": | |
| env_hint = "true" if raw in _MANAGED_TRUE_VALUES else raw or "true" | |
| return ( | |
| f"Cannot {action}: this Hermes installation is managed by NixOS " | |
| f"(HERMES_MANAGED={env_hint}).\n" | |
| "Edit services.hermes-agent.settings in your configuration.nix and run:\n" | |
| " sudo nixos-rebuild switch" | |
| ) | |
| if managed_system == "Homebrew": | |
| env_hint = raw or "homebrew" | |
| return ( | |
| f"Cannot {action}: this Hermes installation is managed by Homebrew " | |
| f"(HERMES_MANAGED={env_hint}).\n" | |
| "Use:\n" | |
| " brew upgrade hermes-agent" | |
| ) | |
| return ( | |
| f"Cannot {action}: this Hermes installation is managed by {managed_system}.\n" | |
| "Use your package manager to upgrade or reinstall Hermes." | |
| ) | |
| def managed_error(action: str = "modify configuration"): | |
| """Print user-friendly error for managed mode.""" | |
| print(format_managed_message(action), file=sys.stderr) | |
| # ============================================================================= | |
| # Container-aware CLI (NixOS container mode) | |
| # ============================================================================= | |
| def _is_inside_container() -> bool: | |
| """Detect if we're already running inside a Docker/Podman container.""" | |
| # Standard Docker/Podman indicators | |
| if os.path.exists("/.dockerenv"): | |
| return True | |
| # Podman uses /run/.containerenv | |
| if os.path.exists("/run/.containerenv"): | |
| return True | |
| # Check cgroup for container runtime evidence (works for both Docker & Podman) | |
| try: | |
| with open("/proc/1/cgroup", "r") as f: | |
| cgroup = f.read() | |
| if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup: | |
| return True | |
| except OSError: | |
| pass | |
| return False | |
| def get_container_exec_info() -> Optional[dict]: | |
| """Read container mode metadata from HERMES_HOME/.container-mode. | |
| Returns a dict with keys: backend, container_name, exec_user, hermes_bin | |
| or None if container mode is not active, we're already inside the | |
| container, or HERMES_DEV=1 is set. | |
| The .container-mode file is written by the NixOS activation script when | |
| container.enable = true. It tells the host CLI to exec into the container | |
| instead of running locally. | |
| """ | |
| if os.environ.get("HERMES_DEV") == "1": | |
| return None | |
| if _is_inside_container(): | |
| return None | |
| container_mode_file = get_hermes_home() / ".container-mode" | |
| try: | |
| info = {} | |
| with open(container_mode_file, "r") as f: | |
| for line in f: | |
| line = line.strip() | |
| if "=" in line and not line.startswith("#"): | |
| key, _, value = line.partition("=") | |
| info[key.strip()] = value.strip() | |
| except FileNotFoundError: | |
| return None | |
| # All other exceptions (PermissionError, malformed data, etc.) propagate | |
| backend = info.get("backend", "docker") | |
| container_name = info.get("container_name", "hermes-agent") | |
| exec_user = info.get("exec_user", "hermes") | |
| hermes_bin = info.get("hermes_bin", "/data/current-package/bin/hermes") | |
| return { | |
| "backend": backend, | |
| "container_name": container_name, | |
| "exec_user": exec_user, | |
| "hermes_bin": hermes_bin, | |
| } | |
| # ============================================================================= | |
| # Config paths | |
| # ============================================================================= | |
| # Re-export from hermes_constants β canonical definition lives there. | |
| from hermes_constants import get_hermes_home # noqa: F811,E402 | |
| def get_config_path() -> Path: | |
| """Get the main config file path.""" | |
| return get_hermes_home() / "config.yaml" | |
| def get_env_path() -> Path: | |
| """Get the .env file path (for API keys).""" | |
| return get_hermes_home() / ".env" | |
| def get_project_root() -> Path: | |
| """Get the project installation directory.""" | |
| return Path(__file__).parent.parent.resolve() | |
| def _secure_dir(path): | |
| """Set directory to owner-only access (0700 by default). No-op on Windows. | |
| Skipped in managed mode β the NixOS module sets group-readable | |
| permissions (0750) so interactive users in the hermes group can | |
| share state with the gateway service. | |
| The mode can be overridden via the HERMES_HOME_MODE environment variable | |
| (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx, | |
| caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory. | |
| The execute-only bit on a directory permits cd-through without exposing | |
| directory listings. | |
| """ | |
| if is_managed(): | |
| return | |
| try: | |
| mode_str = os.environ.get("HERMES_HOME_MODE", "").strip() | |
| mode = int(mode_str, 8) if mode_str else 0o700 | |
| except ValueError: | |
| mode = 0o700 | |
| try: | |
| os.chmod(path, mode) | |
| except (OSError, NotImplementedError): | |
| pass | |
| def _secure_file(path): | |
| """Set file to owner-only read/write (0600). No-op on Windows. | |
| Skipped in managed mode β the NixOS activation script sets | |
| group-readable permissions (0640) on config files. | |
| """ | |
| if is_managed(): | |
| return | |
| try: | |
| if os.path.exists(str(path)): | |
| os.chmod(path, 0o600) | |
| except (OSError, NotImplementedError): | |
| pass | |
| def _ensure_default_soul_md(home: Path) -> None: | |
| """Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet.""" | |
| soul_path = home / "SOUL.md" | |
| if soul_path.exists(): | |
| return | |
| soul_path.write_text(DEFAULT_SOUL_MD, encoding="utf-8") | |
| _secure_file(soul_path) | |
| def ensure_hermes_home(): | |
| """Ensure ~/.hermes directory structure exists with secure permissions. | |
| In managed mode (NixOS), dirs are created by the activation script with | |
| setgid + group-writable (2770). We skip mkdir and set umask(0o007) so | |
| any files created (e.g. SOUL.md) are group-writable (0660). | |
| """ | |
| home = get_hermes_home() | |
| if is_managed(): | |
| old_umask = os.umask(0o007) | |
| try: | |
| _ensure_hermes_home_managed(home) | |
| finally: | |
| os.umask(old_umask) | |
| else: | |
| home.mkdir(parents=True, exist_ok=True) | |
| _secure_dir(home) | |
| for subdir in ("cron", "sessions", "logs", "memories"): | |
| d = home / subdir | |
| d.mkdir(parents=True, exist_ok=True) | |
| _secure_dir(d) | |
| _ensure_default_soul_md(home) | |
| def _ensure_hermes_home_managed(home: Path): | |
| """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" | |
| if not home.is_dir(): | |
| raise RuntimeError( | |
| f"HERMES_HOME {home} does not exist. " | |
| "Run 'sudo nixos-rebuild switch' first." | |
| ) | |
| for subdir in ("cron", "sessions", "logs", "memories"): | |
| d = home / subdir | |
| if not d.is_dir(): | |
| raise RuntimeError( | |
| f"{d} does not exist. " | |
| "Run 'sudo nixos-rebuild switch' first." | |
| ) | |
| # Inside umask(0o007) scope β SOUL.md will be created as 0660 | |
| _ensure_default_soul_md(home) | |
| # ============================================================================= | |
| # Config loading/saving | |
| # ============================================================================= | |
| DEFAULT_CONFIG = { | |
| "model": "", | |
| "providers": {}, | |
| "fallback_providers": [], | |
| "credential_pool_strategies": {}, | |
| "toolsets": ["hermes-cli"], | |
| "agent": { | |
| "max_turns": 90, | |
| # Inactivity timeout for gateway agent execution (seconds). | |
| # The agent can run indefinitely as long as it's actively calling | |
| # tools or receiving API responses. Only fires when the agent has | |
| # been completely idle for this duration. 0 = unlimited. | |
| "gateway_timeout": 1800, | |
| # Graceful drain timeout for gateway stop/restart (seconds). | |
| # The gateway stops accepting new work, waits for running agents | |
| # to finish, then interrupts any remaining runs after the timeout. | |
| # 0 = no drain, interrupt immediately. | |
| "restart_drain_timeout": 60, | |
| "service_tier": "", | |
| # Tool-use enforcement: injects system prompt guidance that tells the | |
| # model to actually call tools instead of describing intended actions. | |
| # Values: "auto" (default β applies to gpt/codex models), true/false | |
| # (force on/off for all models), or a list of model-name substrings | |
| # to match (e.g. ["gpt", "codex", "gemini", "qwen"]). | |
| "tool_use_enforcement": "auto", | |
| # Staged inactivity warning: send a warning to the user at this | |
| # threshold before escalating to a full timeout. The warning fires | |
| # once per run and does not interrupt the agent. 0 = disable warning. | |
| "gateway_timeout_warning": 900, | |
| }, | |
| "terminal": { | |
| "backend": "local", | |
| "modal_mode": "auto", | |
| "cwd": ".", # Use current directory | |
| "timeout": 180, | |
| # Environment variables to pass through to sandboxed execution | |
| # (terminal and execute_code). Skill-declared required_environment_variables | |
| # are passed through automatically; this list is for non-skill use cases. | |
| "env_passthrough": [], | |
| "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", | |
| "docker_forward_env": [], | |
| # Explicit environment variables to set inside Docker containers. | |
| # Unlike docker_forward_env (which reads values from the host process), | |
| # docker_env lets you specify exact key-value pairs β useful when Hermes | |
| # runs as a systemd service without access to the user's shell environment. | |
| # Example: {"SSH_AUTH_SOCK": "/run/user/1000/ssh-agent.sock"} | |
| "docker_env": {}, | |
| "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", | |
| "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", | |
| "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", | |
| # Container resource limits (docker, singularity, modal, daytona β ignored for local/ssh) | |
| "container_cpu": 1, | |
| "container_memory": 5120, # MB (default 5GB) | |
| "container_disk": 51200, # MB (default 50GB) | |
| "container_persistent": True, # Persist filesystem across sessions | |
| # Docker volume mounts β share host directories with the container. | |
| # Each entry is "host_path:container_path" (standard Docker -v syntax). | |
| # Example: ["/home/user/projects:/workspace/projects", "/data:/data"] | |
| "docker_volumes": [], | |
| # Explicit opt-in: mount the host cwd into /workspace for Docker sessions. | |
| # Default off because passing host directories into a sandbox weakens isolation. | |
| "docker_mount_cwd_to_workspace": False, | |
| # Persistent shell β keep a long-lived bash shell across execute() calls | |
| # so cwd/env vars/shell variables survive between commands. | |
| # Enabled by default for non-local backends (SSH); local is always opt-in | |
| # via TERMINAL_LOCAL_PERSISTENT env var. | |
| "persistent_shell": True, | |
| }, | |
| "browser": { | |
| "inactivity_timeout": 120, | |
| "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) | |
| "record_sessions": False, # Auto-record browser sessions as WebM videos | |
| "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) | |
| "camofox": { | |
| # When true, Hermes sends a stable profile-scoped userId to Camofox | |
| # so the server can map it to a persistent browser profile directory. | |
| # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR. | |
| # When false (default), each session gets a random userId (ephemeral). | |
| "managed_persistence": False, | |
| }, | |
| }, | |
| # Filesystem checkpoints β automatic snapshots before destructive file ops. | |
| # When enabled, the agent takes a snapshot of the working directory once per | |
| # conversation turn (on first write_file/patch call). Use /rollback to restore. | |
| "checkpoints": { | |
| "enabled": True, | |
| "max_snapshots": 50, # Max checkpoints to keep per directory | |
| }, | |
| # Maximum characters returned by a single read_file call. Reads that | |
| # exceed this are rejected with guidance to use offset+limit. | |
| # 100K chars β 25β35K tokens across typical tokenisers. | |
| "file_read_max_chars": 100_000, | |
| "compression": { | |
| "enabled": True, | |
| "threshold": 0.50, # compress when context usage exceeds this ratio | |
| "target_ratio": 0.20, # fraction of threshold to preserve as recent tail | |
| "protect_last_n": 20, # minimum recent messages to keep uncompressed | |
| "summary_model": "", # empty = use main configured model | |
| "summary_provider": "auto", | |
| "summary_base_url": None, | |
| }, | |
| "smart_model_routing": { | |
| "enabled": False, | |
| "max_simple_chars": 160, | |
| "max_simple_words": 28, | |
| "cheap_model": {}, | |
| }, | |
| # Auxiliary model config β provider:model for each side task. | |
| # Format: provider is the provider name, model is the model slug. | |
| # "auto" for provider = auto-detect best available provider. | |
| # Empty model = use provider's default auxiliary model. | |
| # All tasks fall back to openrouter:google/gemini-3-flash-preview if | |
| # the configured provider is unavailable. | |
| "auxiliary": { | |
| "vision": { | |
| "provider": "auto", # auto | openrouter | nous | codex | custom | |
| "model": "", # e.g. "google/gemini-2.5-flash", "gpt-4o" | |
| "base_url": "", # direct OpenAI-compatible endpoint (takes precedence over provider) | |
| "api_key": "", # API key for base_url (falls back to OPENAI_API_KEY) | |
| "timeout": 120, # seconds β LLM API call timeout; vision payloads need generous timeout | |
| "download_timeout": 30, # seconds β image HTTP download timeout; increase for slow connections | |
| }, | |
| "web_extract": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 360, # seconds (6min) β per-attempt LLM summarization timeout; increase for slow local models | |
| }, | |
| "compression": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 120, # seconds β compression summarises large contexts; increase for local models | |
| }, | |
| "session_search": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 30, | |
| }, | |
| "skills_hub": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 30, | |
| }, | |
| "approval": { | |
| "provider": "auto", | |
| "model": "", # fast/cheap model recommended (e.g. gemini-flash, haiku) | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 30, | |
| }, | |
| "mcp": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 30, | |
| }, | |
| "flush_memories": { | |
| "provider": "auto", | |
| "model": "", | |
| "base_url": "", | |
| "api_key": "", | |
| "timeout": 30, | |
| }, | |
| }, | |
| "display": { | |
| "compact": False, | |
| "personality": "kawaii", | |
| "resume_display": "full", | |
| "busy_input_mode": "interrupt", | |
| "bell_on_complete": False, | |
| "show_reasoning": False, | |
| "streaming": False, | |
| "inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage) | |
| "show_cost": False, # Show $ cost in the status bar (off by default) | |
| "skin": "default", | |
| "interim_assistant_messages": True, # Gateway: show natural mid-turn assistant status messages | |
| "tool_progress_command": False, # Enable /verbose command in messaging gateway | |
| "tool_progress_overrides": {}, # DEPRECATED β use display.platforms instead | |
| "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) | |
| "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} | |
| }, | |
| # Privacy settings | |
| "privacy": { | |
| "redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context | |
| }, | |
| # Text-to-speech configuration | |
| "tts": { | |
| "provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "minimax" | "mistral" | "neutts" (local) | |
| "edge": { | |
| "voice": "en-US-AriaNeural", | |
| # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural | |
| }, | |
| "elevenlabs": { | |
| "voice_id": "pNInz6obpgDQGcFmaJgB", # Adam | |
| "model_id": "eleven_multilingual_v2", | |
| }, | |
| "openai": { | |
| "model": "gpt-4o-mini-tts", | |
| "voice": "alloy", | |
| # Voices: alloy, echo, fable, onyx, nova, shimmer | |
| }, | |
| "mistral": { | |
| "model": "voxtral-mini-tts-2603", | |
| "voice_id": "c69964a6-ab8b-4f8a-9465-ec0925096ec8", # Paul - Neutral | |
| }, | |
| "neutts": { | |
| "ref_audio": "", # Path to reference voice audio (empty = bundled default) | |
| "ref_text": "", # Path to reference voice transcript (empty = bundled default) | |
| "model": "neuphonic/neutts-air-q4-gguf", # HuggingFace model repo | |
| "device": "cpu", # cpu, cuda, or mps | |
| }, | |
| }, | |
| "stt": { | |
| "enabled": True, | |
| "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | |
| "local": { | |
| "model": "base", # tiny, base, small, medium, large-v3 | |
| "language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force | |
| }, | |
| "openai": { | |
| "model": "whisper-1", # whisper-1, gpt-4o-mini-transcribe, gpt-4o-transcribe | |
| }, | |
| "mistral": { | |
| "model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602 | |
| }, | |
| }, | |
| "voice": { | |
| "record_key": "ctrl+b", | |
| "max_recording_seconds": 120, | |
| "auto_tts": False, | |
| "silence_threshold": 200, # RMS below this = silence (0-32767) | |
| "silence_duration": 3.0, # Seconds of silence before auto-stop | |
| }, | |
| "human_delay": { | |
| "mode": "off", | |
| "min_ms": 800, | |
| "max_ms": 2500, | |
| }, | |
| # Context engine -- controls how the context window is managed when | |
| # approaching the model's token limit. | |
| # "compressor" = built-in lossy summarization (default). | |
| # Set to a plugin name to activate an alternative engine (e.g. "lcm" | |
| # for Lossless Context Management). The engine must be installed as | |
| # a plugin in plugins/context_engine/<name>/ or ~/.hermes/plugins/. | |
| "context": { | |
| "engine": "compressor", | |
| }, | |
| # Persistent memory -- bounded curated memory injected into system prompt | |
| "memory": { | |
| "memory_enabled": True, | |
| "user_profile_enabled": True, | |
| "memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token | |
| "user_char_limit": 1375, # ~500 tokens at 2.75 chars/token | |
| # External memory provider plugin (empty = built-in only). | |
| # Set to a provider name to activate: "openviking", "mem0", | |
| # "hindsight", "holographic", "retaindb", "byterover". | |
| # Only ONE external provider is allowed at a time. | |
| "provider": "", | |
| }, | |
| # Subagent delegation β override the provider:model used by delegate_task | |
| # so child agents can run on a different (cheaper/faster) provider and model. | |
| # Uses the same runtime provider resolution as CLI/gateway startup, so all | |
| # configured providers (OpenRouter, Nous, Z.ai, Kimi, etc.) are supported. | |
| "delegation": { | |
| "model": "", # e.g. "google/gemini-3-flash-preview" (empty = inherit parent model) | |
| "provider": "", # e.g. "openrouter" (empty = inherit parent provider + credentials) | |
| "base_url": "", # direct OpenAI-compatible endpoint for subagents | |
| "api_key": "", # API key for delegation.base_url (falls back to OPENAI_API_KEY) | |
| "max_iterations": 50, # per-subagent iteration cap (each subagent gets its own budget, | |
| # independent of the parent's max_iterations) | |
| "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", | |
| # "low", "minimal", "none" (empty = inherit parent's level) | |
| }, | |
| # Ephemeral prefill messages file β JSON list of {role, content} dicts | |
| # injected at the start of every API call for few-shot priming. | |
| # Never saved to sessions, logs, or trajectories. | |
| "prefill_messages_file": "", | |
| # Skills β external skill directories for sharing skills across tools/agents. | |
| # Each path is expanded (~, ${VAR}) and resolved. Read-only β skill creation | |
| # always goes to ~/.hermes/skills/. | |
| "skills": { | |
| "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] | |
| }, | |
| # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth. | |
| # This section is only needed for hermes-specific overrides; everything else | |
| # (apiKey, workspace, peerName, sessions, enabled) comes from the global config. | |
| "honcho": {}, | |
| # IANA timezone (e.g. "Asia/Kolkata", "America/New_York"). | |
| # Empty string means use server-local time. | |
| "timezone": "", | |
| # Discord platform settings (gateway mode) | |
| "discord": { | |
| "require_mention": True, # Require @mention to respond in server channels | |
| "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention | |
| "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) | |
| "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) | |
| "reactions": True, # Add π/β /β reactions to messages during processing | |
| }, | |
| # WhatsApp platform settings (gateway mode) | |
| "whatsapp": { | |
| # Reply prefix prepended to every outgoing WhatsApp message. | |
| # Default (None) uses the built-in "β *Hermes Agent*" header. | |
| # Set to "" (empty string) to disable the header entirely. | |
| # Supports \n for newlines, e.g. "π€ *My Bot*\nββββββ\n" | |
| }, | |
| # Approval mode for dangerous commands: | |
| # manual β always prompt the user (default) | |
| # smart β use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk | |
| # off β skip all approval prompts (equivalent to --yolo) | |
| "approvals": { | |
| "mode": "manual", | |
| "timeout": 60, | |
| }, | |
| # Permanently allowed dangerous command patterns (added via "always" approval) | |
| "command_allowlist": [], | |
| # User-defined quick commands that bypass the agent loop (type: exec only) | |
| "quick_commands": {}, | |
| # Custom personalities β add your own entries here | |
| # Supports string format: {"name": "system prompt"} | |
| # Or dict format: {"name": {"description": "...", "system_prompt": "...", "tone": "...", "style": "..."}} | |
| "personalities": {}, | |
| # Pre-exec security scanning via tirith | |
| "security": { | |
| "redact_secrets": True, | |
| "tirith_enabled": True, | |
| "tirith_path": "tirith", | |
| "tirith_timeout": 5, | |
| "tirith_fail_open": True, | |
| "website_blocklist": { | |
| "enabled": False, | |
| "domains": [], | |
| "shared_files": [], | |
| }, | |
| }, | |
| "cron": { | |
| # Wrap delivered cron responses with a header (task name) and footer | |
| # ("The agent cannot see this message"). Set to false for clean output. | |
| "wrap_response": True, | |
| }, | |
| # Logging β controls file logging to ~/.hermes/logs/. | |
| # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+. | |
| "logging": { | |
| "level": "INFO", # Minimum level for agent.log: DEBUG, INFO, WARNING | |
| "max_size_mb": 5, # Max size per log file before rotation | |
| "backup_count": 3, # Number of rotated backup files to keep | |
| }, | |
| # Config schema version - bump this when adding new required fields | |
| "_config_version": 16, | |
| } | |
| # ============================================================================= | |
| # Config Migration System | |
| # ============================================================================= | |
| # Track which env vars were introduced in each config version. | |
| # Migration only mentions vars new since the user's previous version. | |
| ENV_VARS_BY_VERSION: Dict[int, List[str]] = { | |
| 3: ["FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "FAL_KEY"], | |
| 4: ["VOICE_TOOLS_OPENAI_KEY", "ELEVENLABS_API_KEY"], | |
| 5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", | |
| "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"], | |
| 10: ["TAVILY_API_KEY"], | |
| 11: ["TERMINAL_MODAL_MODE"], | |
| } | |
| # Required environment variables with metadata for migration prompts. | |
| # LLM provider is required but handled in the setup wizard's provider | |
| # selection step (Nous Portal / OpenRouter / Custom endpoint), so this | |
| # dict is intentionally empty β no single env var is universally required. | |
| REQUIRED_ENV_VARS = {} | |
| # Optional environment variables that enhance functionality | |
| OPTIONAL_ENV_VARS = { | |
| # ββ Provider (handled in provider selection, not shown in checklists) ββ | |
| "NOUS_BASE_URL": { | |
| "description": "Nous Portal base URL override", | |
| "prompt": "Nous Portal base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "OPENROUTER_API_KEY": { | |
| "description": "OpenRouter API key (for vision, web scraping helpers, and MoA)", | |
| "prompt": "OpenRouter API key", | |
| "url": "https://openrouter.ai/keys", | |
| "password": True, | |
| "tools": ["vision_analyze", "mixture_of_agents"], | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "GOOGLE_API_KEY": { | |
| "description": "Google AI Studio API key (also recognized as GEMINI_API_KEY)", | |
| "prompt": "Google AI Studio API key", | |
| "url": "https://aistudio.google.com/app/apikey", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "GEMINI_API_KEY": { | |
| "description": "Google AI Studio API key (alias for GOOGLE_API_KEY)", | |
| "prompt": "Gemini API key", | |
| "url": "https://aistudio.google.com/app/apikey", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "GEMINI_BASE_URL": { | |
| "description": "Google AI Studio base URL override", | |
| "prompt": "Gemini base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "GLM_API_KEY": { | |
| "description": "Z.AI / GLM API key (also recognized as ZAI_API_KEY / Z_AI_API_KEY)", | |
| "prompt": "Z.AI / GLM API key", | |
| "url": "https://z.ai/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "ZAI_API_KEY": { | |
| "description": "Z.AI API key (alias for GLM_API_KEY)", | |
| "prompt": "Z.AI API key", | |
| "url": "https://z.ai/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "Z_AI_API_KEY": { | |
| "description": "Z.AI API key (alias for GLM_API_KEY)", | |
| "prompt": "Z.AI API key", | |
| "url": "https://z.ai/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "GLM_BASE_URL": { | |
| "description": "Z.AI / GLM base URL override", | |
| "prompt": "Z.AI / GLM base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "KIMI_API_KEY": { | |
| "description": "Kimi / Moonshot API key", | |
| "prompt": "Kimi API key", | |
| "url": "https://platform.moonshot.cn/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "KIMI_BASE_URL": { | |
| "description": "Kimi / Moonshot base URL override", | |
| "prompt": "Kimi base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "MINIMAX_API_KEY": { | |
| "description": "MiniMax API key (international)", | |
| "prompt": "MiniMax API key", | |
| "url": "https://www.minimax.io/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "MINIMAX_BASE_URL": { | |
| "description": "MiniMax base URL override", | |
| "prompt": "MiniMax base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "MINIMAX_CN_API_KEY": { | |
| "description": "MiniMax API key (China endpoint)", | |
| "prompt": "MiniMax (China) API key", | |
| "url": "https://www.minimaxi.com/", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "MINIMAX_CN_BASE_URL": { | |
| "description": "MiniMax (China) base URL override", | |
| "prompt": "MiniMax (China) base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "DEEPSEEK_API_KEY": { | |
| "description": "DeepSeek API key for direct DeepSeek access", | |
| "prompt": "DeepSeek API Key", | |
| "url": "https://platform.deepseek.com/api_keys", | |
| "password": True, | |
| "category": "provider", | |
| }, | |
| "DEEPSEEK_BASE_URL": { | |
| "description": "Custom DeepSeek API base URL (advanced)", | |
| "prompt": "DeepSeek Base URL", | |
| "url": "", | |
| "password": False, | |
| "category": "provider", | |
| }, | |
| "DASHSCOPE_API_KEY": { | |
| "description": "Alibaba Cloud DashScope API key (Qwen + multi-provider models)", | |
| "prompt": "DashScope API Key", | |
| "url": "https://modelstudio.console.alibabacloud.com/", | |
| "password": True, | |
| "category": "provider", | |
| }, | |
| "DASHSCOPE_BASE_URL": { | |
| "description": "Custom DashScope base URL (default: coding-intl OpenAI-compat endpoint)", | |
| "prompt": "DashScope Base URL", | |
| "url": "", | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "HERMES_QWEN_BASE_URL": { | |
| "description": "Qwen Portal base URL override (default: https://portal.qwen.ai/v1)", | |
| "prompt": "Qwen Portal base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "OPENCODE_ZEN_API_KEY": { | |
| "description": "OpenCode Zen API key (pay-as-you-go access to curated models)", | |
| "prompt": "OpenCode Zen API key", | |
| "url": "https://opencode.ai/auth", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "OPENCODE_ZEN_BASE_URL": { | |
| "description": "OpenCode Zen base URL override", | |
| "prompt": "OpenCode Zen base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "OPENCODE_GO_API_KEY": { | |
| "description": "OpenCode Go API key ($10/month subscription for open models)", | |
| "prompt": "OpenCode Go API key", | |
| "url": "https://opencode.ai/auth", | |
| "password": True, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "OPENCODE_GO_BASE_URL": { | |
| "description": "OpenCode Go base URL override", | |
| "prompt": "OpenCode Go base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "HF_TOKEN": { | |
| "description": "Hugging Face token for Inference Providers (20+ open models via router.huggingface.co)", | |
| "prompt": "Hugging Face Token", | |
| "url": "https://huggingface.co/settings/tokens", | |
| "password": True, | |
| "category": "provider", | |
| }, | |
| "HF_BASE_URL": { | |
| "description": "Hugging Face Inference Providers base URL override", | |
| "prompt": "HF base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| "XIAOMI_API_KEY": { | |
| "description": "Xiaomi MiMo API key for MiMo models (mimo-v2-pro, mimo-v2-omni, mimo-v2-flash)", | |
| "prompt": "Xiaomi MiMo API Key", | |
| "url": "https://platform.xiaomimimo.com", | |
| "password": True, | |
| "category": "provider", | |
| }, | |
| "XIAOMI_BASE_URL": { | |
| "description": "Xiaomi MiMo base URL override (default: https://api.xiaomimimo.com/v1)", | |
| "prompt": "Xiaomi base URL (leave empty for default)", | |
| "url": None, | |
| "password": False, | |
| "category": "provider", | |
| "advanced": True, | |
| }, | |
| # ββ Tool API keys ββ | |
| "EXA_API_KEY": { | |
| "description": "Exa API key for AI-native web search and contents", | |
| "prompt": "Exa API key", | |
| "url": "https://exa.ai/", | |
| "tools": ["web_search", "web_extract"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "PARALLEL_API_KEY": { | |
| "description": "Parallel API key for AI-native web search and extract", | |
| "prompt": "Parallel API key", | |
| "url": "https://parallel.ai/", | |
| "tools": ["web_search", "web_extract"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "FIRECRAWL_API_KEY": { | |
| "description": "Firecrawl API key for web search and scraping", | |
| "prompt": "Firecrawl API key", | |
| "url": "https://firecrawl.dev/", | |
| "tools": ["web_search", "web_extract"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "FIRECRAWL_API_URL": { | |
| "description": "Firecrawl API URL for self-hosted instances (optional)", | |
| "prompt": "Firecrawl API URL (leave empty for cloud)", | |
| "url": None, | |
| "password": False, | |
| "category": "tool", | |
| "advanced": True, | |
| }, | |
| "FIRECRAWL_GATEWAY_URL": { | |
| "description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)", | |
| "prompt": "Firecrawl gateway URL (leave empty to derive from domain)", | |
| "url": None, | |
| "password": False, | |
| "category": "tool", | |
| "advanced": True, | |
| }, | |
| "TOOL_GATEWAY_DOMAIN": { | |
| "description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com", | |
| "prompt": "Tool-gateway domain suffix", | |
| "url": None, | |
| "password": False, | |
| "category": "tool", | |
| "advanced": True, | |
| }, | |
| "TOOL_GATEWAY_SCHEME": { | |
| "description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)", | |
| "prompt": "Tool-gateway URL scheme", | |
| "url": None, | |
| "password": False, | |
| "category": "tool", | |
| "advanced": True, | |
| }, | |
| "TOOL_GATEWAY_USER_TOKEN": { | |
| "description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)", | |
| "prompt": "Tool-gateway user token", | |
| "url": None, | |
| "password": True, | |
| "category": "tool", | |
| "advanced": True, | |
| }, | |
| "TAVILY_API_KEY": { | |
| "description": "Tavily API key for AI-native web search, extract, and crawl", | |
| "prompt": "Tavily API key", | |
| "url": "https://app.tavily.com/home", | |
| "tools": ["web_search", "web_extract", "web_crawl"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "BROWSERBASE_API_KEY": { | |
| "description": "Browserbase API key for cloud browser (optional β local browser works without this)", | |
| "prompt": "Browserbase API key", | |
| "url": "https://browserbase.com/", | |
| "tools": ["browser_navigate", "browser_click"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "BROWSERBASE_PROJECT_ID": { | |
| "description": "Browserbase project ID (optional β only needed for cloud browser)", | |
| "prompt": "Browserbase project ID", | |
| "url": "https://browserbase.com/", | |
| "tools": ["browser_navigate", "browser_click"], | |
| "password": False, | |
| "category": "tool", | |
| }, | |
| "BROWSER_USE_API_KEY": { | |
| "description": "Browser Use API key for cloud browser (optional β local browser works without this)", | |
| "prompt": "Browser Use API key", | |
| "url": "https://browser-use.com/", | |
| "tools": ["browser_navigate", "browser_click"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "FIRECRAWL_BROWSER_TTL": { | |
| "description": "Firecrawl browser session TTL in seconds (optional, default 300)", | |
| "prompt": "Browser session TTL (seconds)", | |
| "tools": ["browser_navigate", "browser_click"], | |
| "password": False, | |
| "category": "tool", | |
| }, | |
| "CAMOFOX_URL": { | |
| "description": "Camofox browser server URL for local anti-detection browsing (e.g. http://localhost:9377)", | |
| "prompt": "Camofox server URL", | |
| "url": "https://github.com/jo-inc/camofox-browser", | |
| "tools": ["browser_navigate", "browser_click"], | |
| "password": False, | |
| "category": "tool", | |
| }, | |
| "FAL_KEY": { | |
| "description": "FAL API key for image generation", | |
| "prompt": "FAL API key", | |
| "url": "https://fal.ai/", | |
| "tools": ["image_generate"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "TINKER_API_KEY": { | |
| "description": "Tinker API key for RL training", | |
| "prompt": "Tinker API key", | |
| "url": "https://tinker-console.thinkingmachines.ai/keys", | |
| "tools": ["rl_start_training", "rl_check_status", "rl_stop_training"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "WANDB_API_KEY": { | |
| "description": "Weights & Biases API key for experiment tracking", | |
| "prompt": "WandB API key", | |
| "url": "https://wandb.ai/authorize", | |
| "tools": ["rl_get_results", "rl_check_status"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "VOICE_TOOLS_OPENAI_KEY": { | |
| "description": "OpenAI API key for voice transcription (Whisper) and OpenAI TTS", | |
| "prompt": "OpenAI API Key (for Whisper STT + TTS)", | |
| "url": "https://platform.openai.com/api-keys", | |
| "tools": ["voice_transcription", "openai_tts"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "ELEVENLABS_API_KEY": { | |
| "description": "ElevenLabs API key for premium text-to-speech voices", | |
| "prompt": "ElevenLabs API key", | |
| "url": "https://elevenlabs.io/", | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "MISTRAL_API_KEY": { | |
| "description": "Mistral API key for Voxtral TTS and transcription (STT)", | |
| "prompt": "Mistral API key", | |
| "url": "https://console.mistral.ai/", | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "GITHUB_TOKEN": { | |
| "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)", | |
| "prompt": "GitHub Token", | |
| "url": "https://github.com/settings/tokens", | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| # ββ Honcho ββ | |
| "HONCHO_API_KEY": { | |
| "description": "Honcho API key for AI-native persistent memory", | |
| "prompt": "Honcho API key", | |
| "url": "https://app.honcho.dev", | |
| "tools": ["honcho_context"], | |
| "password": True, | |
| "category": "tool", | |
| }, | |
| "HONCHO_BASE_URL": { | |
| "description": "Base URL for self-hosted Honcho instances (no API key needed)", | |
| "prompt": "Honcho base URL (e.g. http://localhost:8000)", | |
| "category": "tool", | |
| }, | |
| # ββ Messaging platforms ββ | |
| "TELEGRAM_BOT_TOKEN": { | |
| "description": "Telegram bot token from @BotFather", | |
| "prompt": "Telegram bot token", | |
| "url": "https://t.me/BotFather", | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "TELEGRAM_ALLOWED_USERS": { | |
| "description": "Comma-separated Telegram user IDs allowed to use the bot (get ID from @userinfobot)", | |
| "prompt": "Allowed Telegram user IDs (comma-separated)", | |
| "url": "https://t.me/userinfobot", | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "DISCORD_BOT_TOKEN": { | |
| "description": "Discord bot token from Developer Portal", | |
| "prompt": "Discord bot token", | |
| "url": "https://discord.com/developers/applications", | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "DISCORD_ALLOWED_USERS": { | |
| "description": "Comma-separated Discord user IDs allowed to use the bot", | |
| "prompt": "Allowed Discord user IDs (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "DISCORD_REPLY_TO_MODE": { | |
| "description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)", | |
| "prompt": "Discord reply mode (off/first/all)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "SLACK_BOT_TOKEN": { | |
| "description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. " | |
| "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, " | |
| "im:history, im:read, im:write, users:read, files:write", | |
| "prompt": "Slack Bot Token (xoxb-...)", | |
| "url": "https://api.slack.com/apps", | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "SLACK_APP_TOKEN": { | |
| "description": "Slack app-level token (xapp-) for Socket Mode. Get from Basic Information β " | |
| "App-Level Tokens. Also ensure Event Subscriptions include: message.im, " | |
| "message.channels, message.groups, app_mention", | |
| "prompt": "Slack App Token (xapp-...)", | |
| "url": "https://api.slack.com/apps", | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "MATTERMOST_URL": { | |
| "description": "Mattermost server URL (e.g. https://mm.example.com)", | |
| "prompt": "Mattermost server URL", | |
| "url": "https://mattermost.com/deploy/", | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATTERMOST_TOKEN": { | |
| "description": "Mattermost bot token or personal access token", | |
| "prompt": "Mattermost bot token", | |
| "url": None, | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "MATTERMOST_ALLOWED_USERS": { | |
| "description": "Comma-separated Mattermost user IDs allowed to use the bot", | |
| "prompt": "Allowed Mattermost user IDs (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATTERMOST_REQUIRE_MENTION": { | |
| "description": "Require @mention in Mattermost channels (default: true). Set to false to respond to all messages.", | |
| "prompt": "Require @mention in channels", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATTERMOST_FREE_RESPONSE_CHANNELS": { | |
| "description": "Comma-separated Mattermost channel IDs where bot responds without @mention", | |
| "prompt": "Free-response channel IDs (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATRIX_HOMESERVER": { | |
| "description": "Matrix homeserver URL (e.g. https://matrix.example.org)", | |
| "prompt": "Matrix homeserver URL", | |
| "url": "https://matrix.org/ecosystem/servers/", | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATRIX_ACCESS_TOKEN": { | |
| "description": "Matrix access token (preferred over password login)", | |
| "prompt": "Matrix access token", | |
| "url": None, | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "MATRIX_USER_ID": { | |
| "description": "Matrix user ID (e.g. @hermes:example.org)", | |
| "prompt": "Matrix user ID (@user:server)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATRIX_ALLOWED_USERS": { | |
| "description": "Comma-separated Matrix user IDs allowed to use the bot (@user:server format)", | |
| "prompt": "Allowed Matrix user IDs (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "MATRIX_REQUIRE_MENTION": { | |
| "description": "Require @mention in Matrix rooms (default: true). Set to false to respond to all messages.", | |
| "prompt": "Require @mention in rooms (true/false)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "MATRIX_FREE_RESPONSE_ROOMS": { | |
| "description": "Comma-separated Matrix room IDs where bot responds without @mention", | |
| "prompt": "Free-response room IDs (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "MATRIX_AUTO_THREAD": { | |
| "description": "Auto-create threads for messages in Matrix rooms (default: true)", | |
| "prompt": "Auto-create threads in rooms (true/false)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "MATRIX_DEVICE_ID": { | |
| "description": "Stable Matrix device ID for E2EE persistence across restarts (e.g. HERMES_BOT)", | |
| "prompt": "Matrix device ID (stable across restarts)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "BLUEBUBBLES_SERVER_URL": { | |
| "description": "BlueBubbles server URL for iMessage integration (e.g. http://192.168.1.10:1234)", | |
| "prompt": "BlueBubbles server URL", | |
| "url": "https://bluebubbles.app/", | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "BLUEBUBBLES_PASSWORD": { | |
| "description": "BlueBubbles server password (from BlueBubbles Server β Settings β API)", | |
| "prompt": "BlueBubbles server password", | |
| "url": None, | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| "BLUEBUBBLES_ALLOWED_USERS": { | |
| "description": "Comma-separated iMessage addresses (email or phone) allowed to use the bot", | |
| "prompt": "Allowed iMessage addresses (comma-separated)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "GATEWAY_ALLOW_ALL_USERS": { | |
| "description": "Allow all users to interact with messaging bots (true/false). Default: false.", | |
| "prompt": "Allow all users (true/false)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "API_SERVER_ENABLED": { | |
| "description": "Enable the OpenAI-compatible API server (true/false). Allows frontends like Open WebUI, LobeChat, etc. to connect.", | |
| "prompt": "Enable API server (true/false)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "API_SERVER_KEY": { | |
| "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.", | |
| "prompt": "API server auth key (required for network access)", | |
| "url": None, | |
| "password": True, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "API_SERVER_PORT": { | |
| "description": "Port for the API server (default: 8642).", | |
| "prompt": "API server port", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "API_SERVER_HOST": { | |
| "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.", | |
| "prompt": "API server host", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "API_SERVER_MODEL_NAME": { | |
| "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.", | |
| "prompt": "API server model name", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| "advanced": True, | |
| }, | |
| "WEBHOOK_ENABLED": { | |
| "description": "Enable the webhook platform adapter for receiving events from GitHub, GitLab, etc.", | |
| "prompt": "Enable webhooks (true/false)", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "WEBHOOK_PORT": { | |
| "description": "Port for the webhook HTTP server (default: 8644).", | |
| "prompt": "Webhook port", | |
| "url": None, | |
| "password": False, | |
| "category": "messaging", | |
| }, | |
| "WEBHOOK_SECRET": { | |
| "description": "Global HMAC secret for webhook signature validation (overridable per route in config.yaml).", | |
| "prompt": "Webhook secret", | |
| "url": None, | |
| "password": True, | |
| "category": "messaging", | |
| }, | |
| # ββ Agent settings ββ | |
| "MESSAGING_CWD": { | |
| "description": "Working directory for terminal commands via messaging", | |
| "prompt": "Messaging working directory (default: home)", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| "SUDO_PASSWORD": { | |
| "description": "Sudo password for terminal commands requiring root access; set to an explicit empty string to try empty without prompting", | |
| "prompt": "Sudo password", | |
| "url": None, | |
| "password": True, | |
| "category": "setting", | |
| }, | |
| "HERMES_MAX_ITERATIONS": { | |
| "description": "Maximum tool-calling iterations per conversation (default: 90)", | |
| "prompt": "Max iterations", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| # HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated β | |
| # now configured via display.tool_progress in config.yaml (off|new|all|verbose). | |
| # Gateway falls back to these env vars for backward compatibility. | |
| "HERMES_TOOL_PROGRESS": { | |
| "description": "(deprecated) Use display.tool_progress in config.yaml instead", | |
| "prompt": "Tool progress (deprecated β use config.yaml)", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| "HERMES_TOOL_PROGRESS_MODE": { | |
| "description": "(deprecated) Use display.tool_progress in config.yaml instead", | |
| "prompt": "Progress mode (deprecated β use config.yaml)", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| "HERMES_PREFILL_MESSAGES_FILE": { | |
| "description": "Path to JSON file with ephemeral prefill messages for few-shot priming", | |
| "prompt": "Prefill messages file path", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| "HERMES_EPHEMERAL_SYSTEM_PROMPT": { | |
| "description": "Ephemeral system prompt injected at API-call time (never persisted to sessions)", | |
| "prompt": "Ephemeral system prompt", | |
| "url": None, | |
| "password": False, | |
| "category": "setting", | |
| }, | |
| } | |
| if not _managed_nous_tools_enabled(): | |
| for _hidden_var in ( | |
| "FIRECRAWL_GATEWAY_URL", | |
| "TOOL_GATEWAY_DOMAIN", | |
| "TOOL_GATEWAY_SCHEME", | |
| "TOOL_GATEWAY_USER_TOKEN", | |
| ): | |
| OPTIONAL_ENV_VARS.pop(_hidden_var, None) | |
| def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: | |
| """ | |
| Check which environment variables are missing. | |
| Returns list of dicts with var info for missing variables. | |
| """ | |
| missing = [] | |
| # Check required vars | |
| for var_name, info in REQUIRED_ENV_VARS.items(): | |
| if not get_env_value(var_name): | |
| missing.append({"name": var_name, **info, "is_required": True}) | |
| # Check optional vars (if not required_only) | |
| if not required_only: | |
| for var_name, info in OPTIONAL_ENV_VARS.items(): | |
| if not get_env_value(var_name): | |
| missing.append({"name": var_name, **info, "is_required": False}) | |
| return missing | |
| def _set_nested(config: dict, dotted_key: str, value): | |
| """Set a value at an arbitrarily nested dotted key path. | |
| Creates intermediate dicts as needed, e.g. ``_set_nested(c, "a.b.c", 1)`` | |
| ensures ``c["a"]["b"]["c"] == 1``. | |
| """ | |
| parts = dotted_key.split(".") | |
| current = config | |
| for part in parts[:-1]: | |
| if part not in current or not isinstance(current.get(part), dict): | |
| current[part] = {} | |
| current = current[part] | |
| current[parts[-1]] = value | |
| def get_missing_config_fields() -> List[Dict[str, Any]]: | |
| """ | |
| Check which config fields are missing or outdated (recursive). | |
| Walks the DEFAULT_CONFIG tree at arbitrary depth and reports any keys | |
| present in defaults but absent from the user's loaded config. | |
| """ | |
| config = load_config() | |
| missing = [] | |
| def _check(defaults: dict, current: dict, prefix: str = ""): | |
| for key, default_value in defaults.items(): | |
| if key.startswith('_'): | |
| continue | |
| full_key = key if not prefix else f"{prefix}.{key}" | |
| if key not in current: | |
| missing.append({ | |
| "key": full_key, | |
| "default": default_value, | |
| "description": f"New config option: {full_key}", | |
| }) | |
| elif isinstance(default_value, dict) and isinstance(current.get(key), dict): | |
| _check(default_value, current[key], full_key) | |
| _check(DEFAULT_CONFIG, config) | |
| return missing | |
| def get_missing_skill_config_vars() -> List[Dict[str, Any]]: | |
| """Return skill-declared config vars that are missing or empty in config.yaml. | |
| Scans all enabled skills for ``metadata.hermes.config`` entries, then checks | |
| which ones are absent or empty under ``skills.config.<key>`` in the user's | |
| config.yaml. Returns a list of dicts suitable for prompting. | |
| """ | |
| try: | |
| from agent.skill_utils import discover_all_skill_config_vars, SKILL_CONFIG_PREFIX | |
| except Exception: | |
| return [] | |
| all_vars = discover_all_skill_config_vars() | |
| if not all_vars: | |
| return [] | |
| config = load_config() | |
| missing: List[Dict[str, Any]] = [] | |
| for var in all_vars: | |
| # Skill config is stored under skills.config.<logical_key> | |
| storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" | |
| parts = storage_key.split(".") | |
| current = config | |
| value = None | |
| for part in parts: | |
| if isinstance(current, dict) and part in current: | |
| current = current[part] | |
| value = current | |
| else: | |
| value = None | |
| break | |
| # Missing = key doesn't exist or is empty string | |
| if value is None or (isinstance(value, str) and not value.strip()): | |
| missing.append(var) | |
| return missing | |
| def check_config_version() -> Tuple[int, int]: | |
| """ | |
| Check config version. | |
| Returns (current_version, latest_version). | |
| """ | |
| config = load_config() | |
| current = config.get("_config_version", 0) | |
| latest = DEFAULT_CONFIG.get("_config_version", 1) | |
| return current, latest | |
| # ============================================================================= | |
| # Config structure validation | |
| # ============================================================================= | |
| # Fields that are valid at root level of config.yaml | |
| _KNOWN_ROOT_KEYS = { | |
| "_config_version", "model", "providers", "fallback_model", | |
| "fallback_providers", "credential_pool_strategies", "toolsets", | |
| "agent", "terminal", "display", "compression", "delegation", | |
| "auxiliary", "custom_providers", "context", "memory", "gateway", | |
| } | |
| # Valid fields inside a custom_providers list entry | |
| _VALID_CUSTOM_PROVIDER_FIELDS = { | |
| "name", "base_url", "api_key", "api_mode", "model", "models", | |
| "context_length", "rate_limit_delay", | |
| } | |
| # Fields that look like they should be inside custom_providers, not at root | |
| _CUSTOM_PROVIDER_LIKE_FIELDS = {"base_url", "api_key", "rate_limit_delay", "api_mode"} | |
| class ConfigIssue: | |
| """A detected config structure problem.""" | |
| severity: str # "error", "warning" | |
| message: str | |
| hint: str | |
| def validate_config_structure(config: Optional[Dict[str, Any]] = None) -> List["ConfigIssue"]: | |
| """Validate config.yaml structure and return a list of detected issues. | |
| Catches common YAML formatting mistakes that produce confusing runtime | |
| errors (like "Unknown provider") instead of clear diagnostics. | |
| Can be called with a pre-loaded config dict, or will load from disk. | |
| """ | |
| if config is None: | |
| try: | |
| config = load_config() | |
| except Exception: | |
| return [ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")] | |
| issues: List[ConfigIssue] = [] | |
| # ββ custom_providers must be a list, not a dict ββββββββββββββββββββββ | |
| cp = config.get("custom_providers") | |
| if cp is not None: | |
| if isinstance(cp, dict): | |
| issues.append(ConfigIssue( | |
| "error", | |
| "custom_providers is a dict β it must be a YAML list (items prefixed with '-')", | |
| "Change to:\n" | |
| " custom_providers:\n" | |
| " - name: my-provider\n" | |
| " base_url: https://...\n" | |
| " api_key: ...", | |
| )) | |
| # Check if dict keys look like they should be list-entry fields | |
| cp_keys = set(cp.keys()) if isinstance(cp, dict) else set() | |
| suspicious = cp_keys & _CUSTOM_PROVIDER_LIKE_FIELDS | |
| if suspicious: | |
| issues.append(ConfigIssue( | |
| "warning", | |
| f"Root-level keys {sorted(suspicious)} look like custom_providers entry fields", | |
| "These should be indented under a '- name: ...' list entry, not at root level", | |
| )) | |
| elif isinstance(cp, list): | |
| # Validate each entry in the list | |
| for i, entry in enumerate(cp): | |
| if not isinstance(entry, dict): | |
| issues.append(ConfigIssue( | |
| "warning", | |
| f"custom_providers[{i}] is not a dict (got {type(entry).__name__})", | |
| "Each entry should have at minimum: name, base_url", | |
| )) | |
| continue | |
| if not entry.get("name"): | |
| issues.append(ConfigIssue( | |
| "warning", | |
| f"custom_providers[{i}] is missing 'name' field", | |
| "Add a name, e.g.: name: my-provider", | |
| )) | |
| if not entry.get("base_url"): | |
| issues.append(ConfigIssue( | |
| "warning", | |
| f"custom_providers[{i}] is missing 'base_url' field", | |
| "Add the API endpoint URL, e.g.: base_url: https://api.example.com/v1", | |
| )) | |
| # ββ fallback_model must be a top-level dict with provider + model ββββ | |
| fb = config.get("fallback_model") | |
| if fb is not None: | |
| if not isinstance(fb, dict): | |
| issues.append(ConfigIssue( | |
| "error", | |
| f"fallback_model should be a dict with 'provider' and 'model', got {type(fb).__name__}", | |
| "Change to:\n" | |
| " fallback_model:\n" | |
| " provider: openrouter\n" | |
| " model: anthropic/claude-sonnet-4", | |
| )) | |
| elif fb: | |
| if not fb.get("provider"): | |
| issues.append(ConfigIssue( | |
| "warning", | |
| "fallback_model is missing 'provider' field β fallback will be disabled", | |
| "Add: provider: openrouter (or another provider)", | |
| )) | |
| if not fb.get("model"): | |
| issues.append(ConfigIssue( | |
| "warning", | |
| "fallback_model is missing 'model' field β fallback will be disabled", | |
| "Add: model: anthropic/claude-sonnet-4 (or another model)", | |
| )) | |
| # ββ Check for fallback_model accidentally nested inside custom_providers ββ | |
| if isinstance(cp, dict) and "fallback_model" not in config and "fallback_model" in (cp or {}): | |
| issues.append(ConfigIssue( | |
| "error", | |
| "fallback_model appears inside custom_providers instead of at root level", | |
| "Move fallback_model to the top level of config.yaml (no indentation)", | |
| )) | |
| # ββ model section: should exist when custom_providers is configured ββ | |
| model_cfg = config.get("model") | |
| if cp and not model_cfg: | |
| issues.append(ConfigIssue( | |
| "warning", | |
| "custom_providers defined but no 'model' section β Hermes won't know which provider to use", | |
| "Add a model section:\n" | |
| " model:\n" | |
| " provider: custom\n" | |
| " default: your-model-name\n" | |
| " base_url: https://...", | |
| )) | |
| # ββ Root-level keys that look misplaced ββββββββββββββββββββββββββββββ | |
| for key in config: | |
| if key.startswith("_"): | |
| continue | |
| if key not in _KNOWN_ROOT_KEYS and key in _CUSTOM_PROVIDER_LIKE_FIELDS: | |
| issues.append(ConfigIssue( | |
| "warning", | |
| f"Root-level key '{key}' looks misplaced β should it be under 'model:' or inside a 'custom_providers' entry?", | |
| f"Move '{key}' under the appropriate section", | |
| )) | |
| return issues | |
| def print_config_warnings(config: Optional[Dict[str, Any]] = None) -> None: | |
| """Print config structure warnings to stderr at startup. | |
| Called early in CLI and gateway init so users see problems before | |
| they hit cryptic "Unknown provider" errors. Prints nothing if | |
| config is healthy. | |
| """ | |
| try: | |
| issues = validate_config_structure(config) | |
| except Exception: | |
| return | |
| if not issues: | |
| return | |
| import sys | |
| lines = ["\033[33mβ Config issues detected in config.yaml:\033[0m"] | |
| for ci in issues: | |
| marker = "\033[31mβ\033[0m" if ci.severity == "error" else "\033[33mβ \033[0m" | |
| lines.append(f" {marker} {ci.message}") | |
| lines.append(" \033[2mRun 'hermes doctor' for fix suggestions.\033[0m") | |
| sys.stderr.write("\n".join(lines) + "\n\n") | |
| def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, Any]: | |
| """ | |
| Migrate config to latest version, prompting for new required fields. | |
| Args: | |
| interactive: If True, prompt user for missing values | |
| quiet: If True, suppress output | |
| Returns: | |
| Dict with migration results: {"env_added": [...], "config_added": [...], "warnings": [...]} | |
| """ | |
| results = {"env_added": [], "config_added": [], "warnings": []} | |
| # ββ Always: sanitize .env (split concatenated keys) ββ | |
| try: | |
| fixes = sanitize_env_file() | |
| if fixes and not quiet: | |
| print(f" β Repaired .env file ({fixes} corrupted entries fixed)") | |
| except Exception: | |
| pass # best-effort; don't block migration on sanitize failure | |
| # Check config version | |
| current_ver, latest_ver = check_config_version() | |
| # ββ Version 3 β 4: migrate tool progress from .env to config.yaml ββ | |
| if current_ver < 4: | |
| config = load_config() | |
| display = config.get("display", {}) | |
| if not isinstance(display, dict): | |
| display = {} | |
| if "tool_progress" not in display: | |
| old_enabled = get_env_value("HERMES_TOOL_PROGRESS") | |
| old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE") | |
| if old_enabled and old_enabled.lower() in ("false", "0", "no"): | |
| display["tool_progress"] = "off" | |
| results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)") | |
| elif old_mode and old_mode.lower() in ("new", "all"): | |
| display["tool_progress"] = old_mode.lower() | |
| results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)") | |
| else: | |
| display["tool_progress"] = "all" | |
| results["config_added"].append("display.tool_progress=all (default)") | |
| config["display"] = display | |
| save_config(config) | |
| if not quiet: | |
| print(f" β Migrated tool progress to config.yaml: {display['tool_progress']}") | |
| # ββ Version 4 β 5: add timezone field ββ | |
| if current_ver < 5: | |
| config = load_config() | |
| if "timezone" not in config: | |
| old_tz = os.getenv("HERMES_TIMEZONE", "") | |
| if old_tz and old_tz.strip(): | |
| config["timezone"] = old_tz.strip() | |
| results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)") | |
| else: | |
| config["timezone"] = "" | |
| results["config_added"].append("timezone= (empty, uses server-local)") | |
| save_config(config) | |
| if not quiet: | |
| tz_display = config["timezone"] or "(server-local)" | |
| print(f" β Added timezone to config.yaml: {tz_display}") | |
| # ββ Version 8 β 9: clear ANTHROPIC_TOKEN from .env ββ | |
| # The new Anthropic auth flow no longer uses this env var. | |
| if current_ver < 9: | |
| try: | |
| old_token = get_env_value("ANTHROPIC_TOKEN") | |
| if old_token: | |
| save_env_value("ANTHROPIC_TOKEN", "") | |
| if not quiet: | |
| print(" β Cleared ANTHROPIC_TOKEN from .env (no longer used)") | |
| except Exception: | |
| pass | |
| # ββ Version 11 β 12: migrate custom_providers list β providers dict ββ | |
| if current_ver < 12: | |
| config = load_config() | |
| custom_list = config.get("custom_providers") | |
| if isinstance(custom_list, list) and custom_list: | |
| providers_dict = config.get("providers", {}) | |
| if not isinstance(providers_dict, dict): | |
| providers_dict = {} | |
| migrated_count = 0 | |
| for entry in custom_list: | |
| if not isinstance(entry, dict): | |
| continue | |
| old_name = entry.get("name", "") | |
| old_url = entry.get("base_url", "") or entry.get("url", "") or "" | |
| old_key = entry.get("api_key", "") | |
| if not old_url: | |
| continue # skip entries with no URL | |
| # Generate a kebab-case key from the display name | |
| key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "") | |
| # Remove consecutive hyphens and trailing hyphens | |
| while "--" in key: | |
| key = key.replace("--", "-") | |
| key = key.strip("-") | |
| if not key: | |
| # Fallback: derive from URL hostname | |
| try: | |
| from urllib.parse import urlparse | |
| parsed = urlparse(old_url) | |
| key = (parsed.hostname or "endpoint").replace(".", "-") | |
| except Exception: | |
| key = f"endpoint-{migrated_count}" | |
| # Don't overwrite existing entries | |
| if key in providers_dict: | |
| key = f"{key}-{migrated_count}" | |
| new_entry = {"api": old_url} | |
| if old_name: | |
| new_entry["name"] = old_name | |
| if old_key and old_key not in ("no-key", "no-key-required", ""): | |
| new_entry["api_key"] = old_key | |
| # Carry over model and api_mode if present | |
| if entry.get("model"): | |
| new_entry["default_model"] = entry["model"] | |
| if entry.get("api_mode"): | |
| new_entry["transport"] = entry["api_mode"] | |
| providers_dict[key] = new_entry | |
| migrated_count += 1 | |
| if migrated_count > 0: | |
| config["providers"] = providers_dict | |
| # Remove the old list | |
| del config["custom_providers"] | |
| save_config(config) | |
| if not quiet: | |
| print(f" β Migrated {migrated_count} custom provider(s) to providers: section") | |
| for key in list(providers_dict.keys())[-migrated_count:]: | |
| ep = providers_dict[key] | |
| print(f" β {key}: {ep.get('api', '')}") | |
| # ββ Version 12 β 13: clear dead LLM_MODEL / OPENAI_MODEL from .env ββ | |
| # These env vars were written by the old setup wizard but nothing reads | |
| # them anymore (config.yaml is the sole source of truth since March 2026). | |
| # Stale entries cause user confusion β see issue report. | |
| if current_ver < 13: | |
| for dead_var in ("LLM_MODEL", "OPENAI_MODEL"): | |
| try: | |
| old_val = get_env_value(dead_var) | |
| if old_val: | |
| save_env_value(dead_var, "") | |
| if not quiet: | |
| print(f" β Cleared {dead_var} from .env (no longer used β config.yaml is source of truth)") | |
| except Exception: | |
| pass | |
| # ββ Version 13 β 14: migrate legacy flat stt.model to provider section ββ | |
| # Old configs (and cli-config.yaml.example) had a flat `stt.model` key | |
| # that was provider-agnostic. When the provider was "local" this caused | |
| # OpenAI model names (e.g. "whisper-1") to be fed to faster-whisper, | |
| # crashing with "Invalid model size". Move the value into the correct | |
| # provider-specific section and remove the flat key. | |
| if current_ver < 14: | |
| # Read raw config (no defaults merged) to check what the user actually | |
| # wrote, then apply changes to the merged config for saving. | |
| raw = read_raw_config() | |
| raw_stt = raw.get("stt", {}) | |
| if isinstance(raw_stt, dict) and "model" in raw_stt: | |
| legacy_model = raw_stt["model"] | |
| provider = raw_stt.get("provider", "local") | |
| config = load_config() | |
| stt = config.get("stt", {}) | |
| # Remove the legacy flat key | |
| stt.pop("model", None) | |
| # Place it in the appropriate provider section only if the | |
| # user didn't already set a model there | |
| if provider in ("local", "local_command"): | |
| # Don't migrate an OpenAI model name into the local section | |
| _local_models = { | |
| "tiny.en", "tiny", "base.en", "base", "small.en", "small", | |
| "medium.en", "medium", "large-v1", "large-v2", "large-v3", | |
| "large", "distil-large-v2", "distil-medium.en", | |
| "distil-small.en", "distil-large-v3", "distil-large-v3.5", | |
| "large-v3-turbo", "turbo", | |
| } | |
| if legacy_model in _local_models: | |
| # Check raw config β only set if user didn't already | |
| # have a nested local.model | |
| raw_local = raw_stt.get("local", {}) | |
| if not isinstance(raw_local, dict) or "model" not in raw_local: | |
| local_cfg = stt.setdefault("local", {}) | |
| local_cfg["model"] = legacy_model | |
| # else: drop it β it was an OpenAI model name, local section | |
| # already defaults to "base" via DEFAULT_CONFIG | |
| else: | |
| # Cloud provider β put it in that provider's section only | |
| # if user didn't already set a nested model | |
| raw_provider = raw_stt.get(provider, {}) | |
| if not isinstance(raw_provider, dict) or "model" not in raw_provider: | |
| provider_cfg = stt.setdefault(provider, {}) | |
| provider_cfg["model"] = legacy_model | |
| config["stt"] = stt | |
| save_config(config) | |
| if not quiet: | |
| print(f" β Migrated legacy stt.model to provider-specific config") | |
| # ββ Version 14 β 15: add explicit gateway interim-message gate ββ | |
| if current_ver < 15: | |
| config = read_raw_config() | |
| display = config.get("display", {}) | |
| if not isinstance(display, dict): | |
| display = {} | |
| if "interim_assistant_messages" not in display: | |
| display["interim_assistant_messages"] = True | |
| config["display"] = display | |
| results["config_added"].append("display.interim_assistant_messages=true (default)") | |
| save_config(config) | |
| if not quiet: | |
| print(" β Added display.interim_assistant_messages=true") | |
| # ββ Version 15 β 16: migrate tool_progress_overrides into display.platforms ββ | |
| if current_ver < 16: | |
| config = read_raw_config() | |
| display = config.get("display", {}) | |
| if not isinstance(display, dict): | |
| display = {} | |
| old_overrides = display.get("tool_progress_overrides") | |
| if isinstance(old_overrides, dict) and old_overrides: | |
| platforms = display.get("platforms", {}) | |
| if not isinstance(platforms, dict): | |
| platforms = {} | |
| for plat, mode in old_overrides.items(): | |
| if plat not in platforms: | |
| platforms[plat] = {} | |
| if "tool_progress" not in platforms[plat]: | |
| platforms[plat]["tool_progress"] = mode | |
| display["platforms"] = platforms | |
| config["display"] = display | |
| save_config(config) | |
| if not quiet: | |
| migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items()) | |
| print(f" β Migrated tool_progress_overrides β display.platforms: {migrated}") | |
| results["config_added"].append("display.platforms (migrated from tool_progress_overrides)") | |
| if current_ver < latest_ver and not quiet: | |
| print(f"Config version: {current_ver} β {latest_ver}") | |
| # Check for missing required env vars | |
| missing_env = get_missing_env_vars(required_only=True) | |
| if missing_env and not quiet: | |
| print("\nβ οΈ Missing required environment variables:") | |
| for var in missing_env: | |
| print(f" β’ {var['name']}: {var['description']}") | |
| if interactive and missing_env: | |
| print("\nLet's configure them now:\n") | |
| for var in missing_env: | |
| if var.get("url"): | |
| print(f" Get your key at: {var['url']}") | |
| if var.get("password"): | |
| import getpass | |
| value = getpass.getpass(f" {var['prompt']}: ") | |
| else: | |
| value = input(f" {var['prompt']}: ").strip() | |
| if value: | |
| save_env_value(var["name"], value) | |
| results["env_added"].append(var["name"]) | |
| print(f" β Saved {var['name']}") | |
| else: | |
| results["warnings"].append(f"Skipped {var['name']} - some features may not work") | |
| print() | |
| # Check for missing optional env vars and offer to configure interactively | |
| # Skip "advanced" vars (like OPENAI_BASE_URL) -- those are for power users | |
| missing_optional = get_missing_env_vars(required_only=False) | |
| required_names = {v["name"] for v in missing_env} if missing_env else set() | |
| missing_optional = [ | |
| v for v in missing_optional | |
| if v["name"] not in required_names and not v.get("advanced") | |
| ] | |
| # Only offer to configure env vars that are NEW since the user's previous version | |
| new_var_names = set() | |
| for ver in range(current_ver + 1, latest_ver + 1): | |
| new_var_names.update(ENV_VARS_BY_VERSION.get(ver, [])) | |
| if new_var_names and interactive and not quiet: | |
| new_and_unset = [ | |
| (name, OPTIONAL_ENV_VARS[name]) | |
| for name in sorted(new_var_names) | |
| if not get_env_value(name) and name in OPTIONAL_ENV_VARS | |
| ] | |
| if new_and_unset: | |
| print(f"\n {len(new_and_unset)} new optional key(s) in this update:") | |
| for name, info in new_and_unset: | |
| print(f" β’ {name} β {info.get('description', '')}") | |
| print() | |
| try: | |
| answer = input(" Configure new keys? [y/N]: ").strip().lower() | |
| except (EOFError, KeyboardInterrupt): | |
| answer = "n" | |
| if answer in ("y", "yes"): | |
| print() | |
| for name, info in new_and_unset: | |
| if info.get("url"): | |
| print(f" {info.get('description', name)}") | |
| print(f" Get your key at: {info['url']}") | |
| else: | |
| print(f" {info.get('description', name)}") | |
| if info.get("password"): | |
| import getpass | |
| value = getpass.getpass(f" {info.get('prompt', name)} (Enter to skip): ") | |
| else: | |
| value = input(f" {info.get('prompt', name)} (Enter to skip): ").strip() | |
| if value: | |
| save_env_value(name, value) | |
| results["env_added"].append(name) | |
| print(f" β Saved {name}") | |
| print() | |
| else: | |
| print(" Set later with: hermes config set <key> <value>") | |
| # Check for missing config fields | |
| missing_config = get_missing_config_fields() | |
| if missing_config: | |
| config = load_config() | |
| for field in missing_config: | |
| key = field["key"] | |
| default = field["default"] | |
| _set_nested(config, key, default) | |
| results["config_added"].append(key) | |
| if not quiet: | |
| print(f" β Added {key} = {default}") | |
| # Update version and save | |
| config["_config_version"] = latest_ver | |
| save_config(config) | |
| elif current_ver < latest_ver: | |
| # Just update version | |
| config = load_config() | |
| config["_config_version"] = latest_ver | |
| save_config(config) | |
| # ββ Skill-declared config vars ββββββββββββββββββββββββββββββββββββββ | |
| # Skills can declare config.yaml settings they need via | |
| # metadata.hermes.config in their SKILL.md frontmatter. | |
| # Prompt for any that are missing/empty. | |
| missing_skill_config = get_missing_skill_config_vars() | |
| if missing_skill_config and interactive and not quiet: | |
| print(f"\n {len(missing_skill_config)} skill setting(s) not configured:") | |
| for var in missing_skill_config: | |
| skill_name = var.get("skill", "unknown") | |
| print(f" β’ {var['key']} β {var['description']} (from skill: {skill_name})") | |
| print() | |
| try: | |
| answer = input(" Configure skill settings? [y/N]: ").strip().lower() | |
| except (EOFError, KeyboardInterrupt): | |
| answer = "n" | |
| if answer in ("y", "yes"): | |
| print() | |
| config = load_config() | |
| try: | |
| from agent.skill_utils import SKILL_CONFIG_PREFIX | |
| except Exception: | |
| SKILL_CONFIG_PREFIX = "skills.config" | |
| for var in missing_skill_config: | |
| default = var.get("default", "") | |
| default_hint = f" (default: {default})" if default else "" | |
| value = input(f" {var['prompt']}{default_hint}: ").strip() | |
| if not value and default: | |
| value = str(default) | |
| if value: | |
| storage_key = f"{SKILL_CONFIG_PREFIX}.{var['key']}" | |
| _set_nested(config, storage_key, value) | |
| results["config_added"].append(var["key"]) | |
| print(f" β Saved {var['key']} = {value}") | |
| else: | |
| results["warnings"].append( | |
| f"Skipped {var['key']} β skill '{var.get('skill', '?')}' may ask for it later" | |
| ) | |
| print() | |
| save_config(config) | |
| else: | |
| print(" Set later with: hermes config set <key> <value>") | |
| return results | |
| def _deep_merge(base: dict, override: dict) -> dict: | |
| """Recursively merge *override* into *base*, preserving nested defaults. | |
| Keys in *override* take precedence. If both values are dicts the merge | |
| recurses, so a user who overrides only ``tts.elevenlabs.voice_id`` will | |
| keep the default ``tts.elevenlabs.model_id`` intact. | |
| """ | |
| result = base.copy() | |
| for key, value in override.items(): | |
| if ( | |
| key in result | |
| and isinstance(result[key], dict) | |
| and isinstance(value, dict) | |
| ): | |
| result[key] = _deep_merge(result[key], value) | |
| else: | |
| result[key] = value | |
| return result | |
| def _expand_env_vars(obj): | |
| """Recursively expand ``${VAR}`` references in config values. | |
| Only string values are processed; dict keys, numbers, booleans, and | |
| None are left untouched. Unresolved references (variable not in | |
| ``os.environ``) are kept verbatim so callers can detect them. | |
| """ | |
| if isinstance(obj, str): | |
| return re.sub( | |
| r"\${([^}]+)}", | |
| lambda m: os.environ.get(m.group(1), m.group(0)), | |
| obj, | |
| ) | |
| if isinstance(obj, dict): | |
| return {k: _expand_env_vars(v) for k, v in obj.items()} | |
| if isinstance(obj, list): | |
| return [_expand_env_vars(item) for item in obj] | |
| return obj | |
| def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]: | |
| """Move stale root-level provider/base_url into model section. | |
| Some users (or older code) placed ``provider:`` and ``base_url:`` at the | |
| config root instead of inside ``model:``. These root-level keys are only | |
| used as a fallback when the corresponding ``model.*`` key is empty β they | |
| never override an existing ``model.provider`` or ``model.base_url``. | |
| After migration the root-level keys are removed so they can't cause | |
| confusion on subsequent loads. | |
| """ | |
| # Only act if there are root-level keys to migrate | |
| has_root = any(config.get(k) for k in ("provider", "base_url")) | |
| if not has_root: | |
| return config | |
| config = dict(config) | |
| model = config.get("model") | |
| if not isinstance(model, dict): | |
| model = {"default": model} if model else {} | |
| config["model"] = model | |
| for key in ("provider", "base_url"): | |
| root_val = config.get(key) | |
| if root_val and not model.get(key): | |
| model[key] = root_val | |
| config.pop(key, None) | |
| return config | |
| def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]: | |
| """Normalize legacy root-level max_turns into agent.max_turns.""" | |
| config = dict(config) | |
| agent_config = dict(config.get("agent") or {}) | |
| if "max_turns" in config and "max_turns" not in agent_config: | |
| agent_config["max_turns"] = config["max_turns"] | |
| if "max_turns" not in agent_config: | |
| agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"] | |
| config["agent"] = agent_config | |
| config.pop("max_turns", None) | |
| return config | |
| def read_raw_config() -> Dict[str, Any]: | |
| """Read ~/.hermes/config.yaml as-is, without merging defaults or migrating. | |
| Returns the raw YAML dict, or ``{}`` if the file doesn't exist or can't | |
| be parsed. Use this for lightweight config reads where you just need a | |
| single value and don't want the overhead of ``load_config()``'s deep-merge | |
| + migration pipeline. | |
| """ | |
| try: | |
| config_path = get_config_path() | |
| if config_path.exists(): | |
| with open(config_path, encoding="utf-8") as f: | |
| return yaml.safe_load(f) or {} | |
| except Exception: | |
| pass | |
| return {} | |
| def load_config() -> Dict[str, Any]: | |
| """Load configuration from ~/.hermes/config.yaml.""" | |
| import copy | |
| ensure_hermes_home() | |
| config_path = get_config_path() | |
| config = copy.deepcopy(DEFAULT_CONFIG) | |
| if config_path.exists(): | |
| try: | |
| with open(config_path, encoding="utf-8") as f: | |
| user_config = yaml.safe_load(f) or {} | |
| if "max_turns" in user_config: | |
| agent_user_config = dict(user_config.get("agent") or {}) | |
| if agent_user_config.get("max_turns") is None: | |
| agent_user_config["max_turns"] = user_config["max_turns"] | |
| user_config["agent"] = agent_user_config | |
| user_config.pop("max_turns", None) | |
| config = _deep_merge(config, user_config) | |
| except Exception as e: | |
| print(f"Warning: Failed to load config: {e}") | |
| return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config))) | |
| _SECURITY_COMMENT = """ | |
| # ββ Security ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # API keys, tokens, and passwords are redacted from tool output by default. | |
| # Set to false to see full values (useful for debugging auth issues). | |
| # tirith pre-exec scanning is enabled by default when the tirith binary | |
| # is available. Configure via security.tirith_* keys or env vars | |
| # (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN). | |
| # | |
| # security: | |
| # redact_secrets: false | |
| # tirith_enabled: true | |
| # tirith_path: "tirith" | |
| # tirith_timeout: 5 | |
| # tirith_fail_open: true | |
| """ | |
| _FALLBACK_COMMENT = """ | |
| # ββ Fallback Model ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Automatic provider failover when primary is unavailable. | |
| # Uncomment and configure to enable. Triggers on rate limits (429), | |
| # overload (529), service errors (503), or connection failures. | |
| # | |
| # Supported providers: | |
| # openrouter (OPENROUTER_API_KEY) β routes to any model | |
| # openai-codex (OAuth β hermes auth) β OpenAI Codex | |
| # nous (OAuth β hermes auth) β Nous Portal | |
| # zai (ZAI_API_KEY) β Z.AI / GLM | |
| # kimi-coding (KIMI_API_KEY) β Kimi / Moonshot | |
| # minimax (MINIMAX_API_KEY) β MiniMax | |
| # minimax-cn (MINIMAX_CN_API_KEY) β MiniMax (China) | |
| # | |
| # For custom OpenAI-compatible endpoints, add base_url and api_key_env. | |
| # | |
| # fallback_model: | |
| # provider: openrouter | |
| # model: anthropic/claude-sonnet-4 | |
| # | |
| # ββ Smart Model Routing ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Optional cheap-vs-strong routing for simple turns. | |
| # Keeps the primary model for complex work, but can route short/simple | |
| # messages to a cheaper model across providers. | |
| # | |
| # smart_model_routing: | |
| # enabled: true | |
| # max_simple_chars: 160 | |
| # max_simple_words: 28 | |
| # cheap_model: | |
| # provider: openrouter | |
| # model: google/gemini-2.5-flash | |
| """ | |
| _COMMENTED_SECTIONS = """ | |
| # ββ Security ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # API keys, tokens, and passwords are redacted from tool output by default. | |
| # Set to false to see full values (useful for debugging auth issues). | |
| # | |
| # security: | |
| # redact_secrets: false | |
| # ββ Fallback Model ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Automatic provider failover when primary is unavailable. | |
| # Uncomment and configure to enable. Triggers on rate limits (429), | |
| # overload (529), service errors (503), or connection failures. | |
| # | |
| # Supported providers: | |
| # openrouter (OPENROUTER_API_KEY) β routes to any model | |
| # openai-codex (OAuth β hermes auth) β OpenAI Codex | |
| # nous (OAuth β hermes auth) β Nous Portal | |
| # zai (ZAI_API_KEY) β Z.AI / GLM | |
| # kimi-coding (KIMI_API_KEY) β Kimi / Moonshot | |
| # minimax (MINIMAX_API_KEY) β MiniMax | |
| # minimax-cn (MINIMAX_CN_API_KEY) β MiniMax (China) | |
| # | |
| # For custom OpenAI-compatible endpoints, add base_url and api_key_env. | |
| # | |
| # fallback_model: | |
| # provider: openrouter | |
| # model: anthropic/claude-sonnet-4 | |
| # | |
| # ββ Smart Model Routing ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Optional cheap-vs-strong routing for simple turns. | |
| # Keeps the primary model for complex work, but can route short/simple | |
| # messages to a cheaper model across providers. | |
| # | |
| # smart_model_routing: | |
| # enabled: true | |
| # max_simple_chars: 160 | |
| # max_simple_words: 28 | |
| # cheap_model: | |
| # provider: openrouter | |
| # model: google/gemini-2.5-flash | |
| """ | |
| def save_config(config: Dict[str, Any]): | |
| """Save configuration to ~/.hermes/config.yaml.""" | |
| if is_managed(): | |
| managed_error("save configuration") | |
| return | |
| from utils import atomic_yaml_write | |
| ensure_hermes_home() | |
| config_path = get_config_path() | |
| normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) | |
| # Build optional commented-out sections for features that are off by | |
| # default or only relevant when explicitly configured. | |
| parts = [] | |
| sec = normalized.get("security", {}) | |
| if not sec or sec.get("redact_secrets") is None: | |
| parts.append(_SECURITY_COMMENT) | |
| fb = normalized.get("fallback_model", {}) | |
| if not fb or not (fb.get("provider") and fb.get("model")): | |
| parts.append(_FALLBACK_COMMENT) | |
| atomic_yaml_write( | |
| config_path, | |
| normalized, | |
| extra_content="".join(parts) if parts else None, | |
| ) | |
| _secure_file(config_path) | |
| def load_env() -> Dict[str, str]: | |
| """Load environment variables from ~/.hermes/.env.""" | |
| env_path = get_env_path() | |
| env_vars = {} | |
| if env_path.exists(): | |
| # On Windows, open() defaults to the system locale (cp1252) which can | |
| # fail on UTF-8 .env files. Use explicit UTF-8 only on Windows. | |
| open_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} | |
| with open(env_path, **open_kw) as f: | |
| for line in f: | |
| line = line.strip() | |
| if line and not line.startswith('#') and '=' in line: | |
| key, _, value = line.partition('=') | |
| env_vars[key.strip()] = value.strip().strip('"\'') | |
| return env_vars | |
| def _sanitize_env_lines(lines: list) -> list: | |
| """Fix corrupted .env lines before writing. | |
| Handles two known corruption patterns: | |
| 1. Concatenated KEY=VALUE pairs on a single line (missing newline between | |
| entries, e.g. ``ANTHROPIC_API_KEY=sk-...OPENAI_BASE_URL=https://...``). | |
| 2. Stale ``KEY=***`` placeholder entries left by incomplete setup runs. | |
| Uses a known-keys set (OPTIONAL_ENV_VARS + _EXTRA_ENV_KEYS) so we only | |
| split on real Hermes env var names, avoiding false positives from values | |
| that happen to contain uppercase text with ``=``. | |
| """ | |
| # Build the known keys set lazily from OPTIONAL_ENV_VARS + extras. | |
| # Done inside the function so OPTIONAL_ENV_VARS is guaranteed to be defined. | |
| known_keys = set(OPTIONAL_ENV_VARS.keys()) | _EXTRA_ENV_KEYS | |
| sanitized: list[str] = [] | |
| for line in lines: | |
| raw = line.rstrip("\r\n") | |
| stripped = raw.strip() | |
| # Preserve blank lines and comments | |
| if not stripped or stripped.startswith("#"): | |
| sanitized.append(raw + "\n") | |
| continue | |
| # Detect concatenated KEY=VALUE pairs on one line. | |
| # Search for known KEY= patterns at any position in the line. | |
| split_positions = [] | |
| for key_name in known_keys: | |
| needle = key_name + "=" | |
| idx = stripped.find(needle) | |
| while idx >= 0: | |
| split_positions.append(idx) | |
| idx = stripped.find(needle, idx + len(needle)) | |
| if len(split_positions) > 1: | |
| split_positions.sort() | |
| # Deduplicate (shouldn't happen, but be safe) | |
| split_positions = sorted(set(split_positions)) | |
| for i, pos in enumerate(split_positions): | |
| end = split_positions[i + 1] if i + 1 < len(split_positions) else len(stripped) | |
| part = stripped[pos:end].strip() | |
| if part: | |
| sanitized.append(part + "\n") | |
| else: | |
| sanitized.append(stripped + "\n") | |
| return sanitized | |
| def sanitize_env_file() -> int: | |
| """Read, sanitize, and rewrite ~/.hermes/.env in place. | |
| Returns the number of lines that were fixed (concatenation splits + | |
| placeholder removals). Returns 0 when no changes are needed. | |
| """ | |
| env_path = get_env_path() | |
| if not env_path.exists(): | |
| return 0 | |
| read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} | |
| write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} | |
| with open(env_path, **read_kw) as f: | |
| original_lines = f.readlines() | |
| sanitized = _sanitize_env_lines(original_lines) | |
| if sanitized == original_lines: | |
| return 0 | |
| # Count fixes: difference in line count (from splits) + removed lines | |
| fixes = abs(len(sanitized) - len(original_lines)) | |
| if fixes == 0: | |
| # Lines changed content (e.g. *** removal) even if count is same | |
| fixes = sum(1 for a, b in zip(original_lines, sanitized) if a != b) | |
| fixes += abs(len(sanitized) - len(original_lines)) | |
| fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix=".tmp", prefix=".env_") | |
| try: | |
| with os.fdopen(fd, "w", **write_kw) as f: | |
| f.writelines(sanitized) | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| os.replace(tmp_path, env_path) | |
| except BaseException: | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| raise | |
| _secure_file(env_path) | |
| return fixes | |
| def save_env_value(key: str, value: str): | |
| """Save or update a value in ~/.hermes/.env.""" | |
| if is_managed(): | |
| managed_error(f"set {key}") | |
| return | |
| if not _ENV_VAR_NAME_RE.match(key): | |
| raise ValueError(f"Invalid environment variable name: {key!r}") | |
| value = value.replace("\n", "").replace("\r", "") | |
| ensure_hermes_home() | |
| env_path = get_env_path() | |
| # On Windows, open() defaults to the system locale (cp1252) which can | |
| # cause OSError errno 22 on UTF-8 .env files. | |
| read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} | |
| write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} | |
| lines = [] | |
| if env_path.exists(): | |
| with open(env_path, **read_kw) as f: | |
| lines = f.readlines() | |
| # Sanitize on every read: split concatenated keys, drop stale placeholders | |
| lines = _sanitize_env_lines(lines) | |
| # Find and update or append | |
| found = False | |
| for i, line in enumerate(lines): | |
| if line.strip().startswith(f"{key}="): | |
| lines[i] = f"{key}={value}\n" | |
| found = True | |
| break | |
| if not found: | |
| # Ensure there's a newline at the end of the file before appending | |
| if lines and not lines[-1].endswith("\n"): | |
| lines[-1] += "\n" | |
| lines.append(f"{key}={value}\n") | |
| fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') | |
| try: | |
| with os.fdopen(fd, 'w', **write_kw) as f: | |
| f.writelines(lines) | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| os.replace(tmp_path, env_path) | |
| except BaseException: | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| raise | |
| _secure_file(env_path) | |
| os.environ[key] = value | |
| # Restrict .env permissions to owner-only (contains API keys) | |
| if not _IS_WINDOWS: | |
| try: | |
| os.chmod(env_path, stat.S_IRUSR | stat.S_IWUSR) | |
| except OSError: | |
| pass | |
| def remove_env_value(key: str) -> bool: | |
| """Remove a key from ~/.hermes/.env and os.environ. | |
| Returns True if the key was found and removed, False otherwise. | |
| """ | |
| if is_managed(): | |
| managed_error(f"remove {key}") | |
| return False | |
| if not _ENV_VAR_NAME_RE.match(key): | |
| raise ValueError(f"Invalid environment variable name: {key!r}") | |
| env_path = get_env_path() | |
| if not env_path.exists(): | |
| os.environ.pop(key, None) | |
| return False | |
| read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {} | |
| write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {} | |
| with open(env_path, **read_kw) as f: | |
| lines = f.readlines() | |
| lines = _sanitize_env_lines(lines) | |
| new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")] | |
| found = len(new_lines) < len(lines) | |
| if found: | |
| fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_') | |
| try: | |
| with os.fdopen(fd, 'w', **write_kw) as f: | |
| f.writelines(new_lines) | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| os.replace(tmp_path, env_path) | |
| except BaseException: | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| raise | |
| _secure_file(env_path) | |
| os.environ.pop(key, None) | |
| return found | |
| def save_anthropic_oauth_token(value: str, save_fn=None): | |
| """Persist an Anthropic OAuth/setup token and clear the API-key slot.""" | |
| writer = save_fn or save_env_value | |
| writer("ANTHROPIC_TOKEN", value) | |
| writer("ANTHROPIC_API_KEY", "") | |
| def use_anthropic_claude_code_credentials(save_fn=None): | |
| """Use Claude Code's own credential files instead of persisting env tokens.""" | |
| writer = save_fn or save_env_value | |
| writer("ANTHROPIC_TOKEN", "") | |
| writer("ANTHROPIC_API_KEY", "") | |
| def save_anthropic_api_key(value: str, save_fn=None): | |
| """Persist an Anthropic API key and clear the OAuth/setup-token slot.""" | |
| writer = save_fn or save_env_value | |
| writer("ANTHROPIC_API_KEY", value) | |
| writer("ANTHROPIC_TOKEN", "") | |
| def save_env_value_secure(key: str, value: str) -> Dict[str, Any]: | |
| save_env_value(key, value) | |
| return { | |
| "success": True, | |
| "stored_as": key, | |
| "validated": False, | |
| } | |
| def get_env_value(key: str) -> Optional[str]: | |
| """Get a value from ~/.hermes/.env or environment.""" | |
| # Check environment first | |
| if key in os.environ: | |
| return os.environ[key] | |
| # Then check .env file | |
| env_vars = load_env() | |
| return env_vars.get(key) | |
| # ============================================================================= | |
| # Config display | |
| # ============================================================================= | |
| def redact_key(key: str) -> str: | |
| """Redact an API key for display.""" | |
| if not key: | |
| return color("(not set)", Colors.DIM) | |
| if len(key) < 12: | |
| return "***" | |
| return key[:4] + "..." + key[-4:] | |
| def show_config(): | |
| """Display current configuration.""" | |
| config = load_config() | |
| print() | |
| print(color("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ", Colors.CYAN)) | |
| print(color("β β Hermes Configuration β", Colors.CYAN)) | |
| print(color("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ", Colors.CYAN)) | |
| # Paths | |
| print() | |
| print(color("β Paths", Colors.CYAN, Colors.BOLD)) | |
| print(f" Config: {get_config_path()}") | |
| print(f" Secrets: {get_env_path()}") | |
| print(f" Install: {get_project_root()}") | |
| # API Keys | |
| print() | |
| print(color("β API Keys", Colors.CYAN, Colors.BOLD)) | |
| keys = [ | |
| ("OPENROUTER_API_KEY", "OpenRouter"), | |
| ("VOICE_TOOLS_OPENAI_KEY", "OpenAI (STT/TTS)"), | |
| ("EXA_API_KEY", "Exa"), | |
| ("PARALLEL_API_KEY", "Parallel"), | |
| ("FIRECRAWL_API_KEY", "Firecrawl"), | |
| ("TAVILY_API_KEY", "Tavily"), | |
| ("BROWSERBASE_API_KEY", "Browserbase"), | |
| ("BROWSER_USE_API_KEY", "Browser Use"), | |
| ("FAL_KEY", "FAL"), | |
| ] | |
| for env_key, name in keys: | |
| value = get_env_value(env_key) | |
| print(f" {name:<14} {redact_key(value)}") | |
| from hermes_cli.auth import get_anthropic_key | |
| anthropic_value = get_anthropic_key() | |
| print(f" {'Anthropic':<14} {redact_key(anthropic_value)}") | |
| # Model settings | |
| print() | |
| print(color("β Model", Colors.CYAN, Colors.BOLD)) | |
| print(f" Model: {config.get('model', 'not set')}") | |
| print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}") | |
| # Display | |
| print() | |
| print(color("β Display", Colors.CYAN, Colors.BOLD)) | |
| display = config.get('display', {}) | |
| print(f" Personality: {display.get('personality', 'kawaii')}") | |
| print(f" Reasoning: {'on' if display.get('show_reasoning', False) else 'off'}") | |
| print(f" Bell: {'on' if display.get('bell_on_complete', False) else 'off'}") | |
| # Terminal | |
| print() | |
| print(color("β Terminal", Colors.CYAN, Colors.BOLD)) | |
| terminal = config.get('terminal', {}) | |
| print(f" Backend: {terminal.get('backend', 'local')}") | |
| print(f" Working dir: {terminal.get('cwd', '.')}") | |
| print(f" Timeout: {terminal.get('timeout', 60)}s") | |
| if terminal.get('backend') == 'docker': | |
| print(f" Docker image: {terminal.get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") | |
| elif terminal.get('backend') == 'singularity': | |
| print(f" Image: {terminal.get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20')}") | |
| elif terminal.get('backend') == 'modal': | |
| print(f" Modal image: {terminal.get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") | |
| modal_token = get_env_value('MODAL_TOKEN_ID') | |
| print(f" Modal token: {'configured' if modal_token else '(not set)'}") | |
| elif terminal.get('backend') == 'daytona': | |
| print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") | |
| daytona_key = get_env_value('DAYTONA_API_KEY') | |
| print(f" API key: {'configured' if daytona_key else '(not set)'}") | |
| elif terminal.get('backend') == 'ssh': | |
| ssh_host = get_env_value('TERMINAL_SSH_HOST') | |
| ssh_user = get_env_value('TERMINAL_SSH_USER') | |
| print(f" SSH host: {ssh_host or '(not set)'}") | |
| print(f" SSH user: {ssh_user or '(not set)'}") | |
| # Timezone | |
| print() | |
| print(color("β Timezone", Colors.CYAN, Colors.BOLD)) | |
| tz = config.get('timezone', '') | |
| if tz: | |
| print(f" Timezone: {tz}") | |
| else: | |
| print(f" Timezone: {color('(server-local)', Colors.DIM)}") | |
| # Compression | |
| print() | |
| print(color("β Context Compression", Colors.CYAN, Colors.BOLD)) | |
| compression = config.get('compression', {}) | |
| enabled = compression.get('enabled', True) | |
| print(f" Enabled: {'yes' if enabled else 'no'}") | |
| if enabled: | |
| print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") | |
| print(f" Target ratio: {compression.get('target_ratio', 0.20) * 100:.0f}% of threshold preserved") | |
| print(f" Protect last: {compression.get('protect_last_n', 20)} messages") | |
| _sm = compression.get('summary_model', '') or '(main model)' | |
| print(f" Model: {_sm}") | |
| comp_provider = compression.get('summary_provider', 'auto') | |
| if comp_provider != 'auto': | |
| print(f" Provider: {comp_provider}") | |
| # Auxiliary models | |
| auxiliary = config.get('auxiliary', {}) | |
| aux_tasks = { | |
| "Vision": auxiliary.get('vision', {}), | |
| "Web extract": auxiliary.get('web_extract', {}), | |
| } | |
| has_overrides = any( | |
| t.get('provider', 'auto') != 'auto' or t.get('model', '') | |
| for t in aux_tasks.values() | |
| ) | |
| if has_overrides: | |
| print() | |
| print(color("β Auxiliary Models (overrides)", Colors.CYAN, Colors.BOLD)) | |
| for label, task_cfg in aux_tasks.items(): | |
| prov = task_cfg.get('provider', 'auto') | |
| mdl = task_cfg.get('model', '') | |
| if prov != 'auto' or mdl: | |
| parts = [f"provider={prov}"] | |
| if mdl: | |
| parts.append(f"model={mdl}") | |
| print(f" {label:12s} {', '.join(parts)}") | |
| # Messaging | |
| print() | |
| print(color("β Messaging Platforms", Colors.CYAN, Colors.BOLD)) | |
| telegram_token = get_env_value('TELEGRAM_BOT_TOKEN') | |
| discord_token = get_env_value('DISCORD_BOT_TOKEN') | |
| print(f" Telegram: {'configured' if telegram_token else color('not configured', Colors.DIM)}") | |
| print(f" Discord: {'configured' if discord_token else color('not configured', Colors.DIM)}") | |
| # Skill config | |
| try: | |
| from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values | |
| skill_vars = discover_all_skill_config_vars() | |
| if skill_vars: | |
| resolved = resolve_skill_config_values(skill_vars) | |
| print() | |
| print(color("β Skill Settings", Colors.CYAN, Colors.BOLD)) | |
| for var in skill_vars: | |
| key = var["key"] | |
| value = resolved.get(key, "") | |
| skill_name = var.get("skill", "") | |
| display_val = str(value) if value else color("(not set)", Colors.DIM) | |
| print(f" {key:<20s} {display_val} {color(f'[{skill_name}]', Colors.DIM)}") | |
| except Exception: | |
| pass | |
| print() | |
| print(color("β" * 60, Colors.DIM)) | |
| print(color(" hermes config edit # Edit config file", Colors.DIM)) | |
| print(color(" hermes config set <key> <value>", Colors.DIM)) | |
| print(color(" hermes setup # Run setup wizard", Colors.DIM)) | |
| print() | |
| def edit_config(): | |
| """Open config file in user's editor.""" | |
| if is_managed(): | |
| managed_error("edit configuration") | |
| return | |
| config_path = get_config_path() | |
| # Ensure config exists | |
| if not config_path.exists(): | |
| save_config(DEFAULT_CONFIG) | |
| print(f"Created {config_path}") | |
| # Find editor | |
| editor = os.getenv('EDITOR') or os.getenv('VISUAL') | |
| if not editor: | |
| # Try common editors | |
| for cmd in ['nano', 'vim', 'vi', 'code', 'notepad']: | |
| import shutil | |
| if shutil.which(cmd): | |
| editor = cmd | |
| break | |
| if not editor: | |
| print("No editor found. Config file is at:") | |
| print(f" {config_path}") | |
| return | |
| print(f"Opening {config_path} in {editor}...") | |
| subprocess.run([editor, str(config_path)]) | |
| def set_config_value(key: str, value: str): | |
| """Set a configuration value.""" | |
| if is_managed(): | |
| managed_error("set configuration values") | |
| return | |
| # Check if it's an API key (goes to .env) | |
| api_keys = [ | |
| 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', | |
| 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', | |
| 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', | |
| 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', | |
| 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', | |
| 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', | |
| 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', | |
| 'SUDO_PASSWORD', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', | |
| 'GITHUB_TOKEN', 'HONCHO_API_KEY', 'WANDB_API_KEY', | |
| 'TINKER_API_KEY', | |
| ] | |
| if key.upper() in api_keys or key.upper().endswith(('_API_KEY', '_TOKEN')) or key.upper().startswith('TERMINAL_SSH'): | |
| save_env_value(key.upper(), value) | |
| print(f"β Set {key} in {get_env_path()}") | |
| return | |
| # Otherwise it goes to config.yaml | |
| # Read the raw user config (not merged with defaults) to avoid | |
| # dumping all default values back to the file | |
| config_path = get_config_path() | |
| user_config = {} | |
| if config_path.exists(): | |
| try: | |
| with open(config_path, encoding="utf-8") as f: | |
| user_config = yaml.safe_load(f) or {} | |
| except Exception: | |
| user_config = {} | |
| # Handle nested keys (e.g., "tts.provider") | |
| parts = key.split('.') | |
| current = user_config | |
| for part in parts[:-1]: | |
| if part not in current or not isinstance(current.get(part), dict): | |
| current[part] = {} | |
| current = current[part] | |
| # Convert value to appropriate type | |
| if value.lower() in ('true', 'yes', 'on'): | |
| value = True | |
| elif value.lower() in ('false', 'no', 'off'): | |
| value = False | |
| elif value.isdigit(): | |
| value = int(value) | |
| elif value.replace('.', '', 1).isdigit(): | |
| value = float(value) | |
| current[parts[-1]] = value | |
| # Write only user config back (not the full merged defaults) | |
| ensure_hermes_home() | |
| from utils import atomic_yaml_write | |
| atomic_yaml_write(config_path, user_config, sort_keys=False) | |
| # Keep .env in sync for keys that terminal_tool reads directly from env vars. | |
| # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc. | |
| _config_to_env_sync = { | |
| "terminal.backend": "TERMINAL_ENV", | |
| "terminal.modal_mode": "TERMINAL_MODAL_MODE", | |
| "terminal.docker_image": "TERMINAL_DOCKER_IMAGE", | |
| "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", | |
| "terminal.modal_image": "TERMINAL_MODAL_IMAGE", | |
| "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", | |
| "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", | |
| "terminal.cwd": "TERMINAL_CWD", | |
| "terminal.timeout": "TERMINAL_TIMEOUT", | |
| "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", | |
| "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL", | |
| "terminal.container_cpu": "TERMINAL_CONTAINER_CPU", | |
| "terminal.container_memory": "TERMINAL_CONTAINER_MEMORY", | |
| "terminal.container_disk": "TERMINAL_CONTAINER_DISK", | |
| "terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT", | |
| } | |
| if key in _config_to_env_sync: | |
| save_env_value(_config_to_env_sync[key], str(value)) | |
| print(f"β Set {key} = {value} in {config_path}") | |
| # ============================================================================= | |
| # Command handler | |
| # ============================================================================= | |
| def config_command(args): | |
| """Handle config subcommands.""" | |
| subcmd = getattr(args, 'config_command', None) | |
| if subcmd is None or subcmd == "show": | |
| show_config() | |
| elif subcmd == "edit": | |
| edit_config() | |
| elif subcmd == "set": | |
| key = getattr(args, 'key', None) | |
| value = getattr(args, 'value', None) | |
| if not key or value is None: | |
| print("Usage: hermes config set <key> <value>") | |
| print() | |
| print("Examples:") | |
| print(" hermes config set model anthropic/claude-sonnet-4") | |
| print(" hermes config set terminal.backend docker") | |
| print(" hermes config set OPENROUTER_API_KEY sk-or-...") | |
| sys.exit(1) | |
| set_config_value(key, value) | |
| elif subcmd == "path": | |
| print(get_config_path()) | |
| elif subcmd == "env-path": | |
| print(get_env_path()) | |
| elif subcmd == "migrate": | |
| print() | |
| print(color("π Checking configuration for updates...", Colors.CYAN, Colors.BOLD)) | |
| print() | |
| # Check what's missing | |
| missing_env = get_missing_env_vars(required_only=False) | |
| missing_config = get_missing_config_fields() | |
| current_ver, latest_ver = check_config_version() | |
| if not missing_env and not missing_config and current_ver >= latest_ver: | |
| print(color("β Configuration is up to date!", Colors.GREEN)) | |
| print() | |
| return | |
| # Show what needs to be updated | |
| if current_ver < latest_ver: | |
| print(f" Config version: {current_ver} β {latest_ver}") | |
| if missing_config: | |
| print(f"\n {len(missing_config)} new config option(s) will be added with defaults") | |
| required_missing = [v for v in missing_env if v.get("is_required")] | |
| optional_missing = [ | |
| v for v in missing_env | |
| if not v.get("is_required") and not v.get("advanced") | |
| ] | |
| if required_missing: | |
| print(f"\n β οΈ {len(required_missing)} required API key(s) missing:") | |
| for var in required_missing: | |
| print(f" β’ {var['name']}") | |
| if optional_missing: | |
| print(f"\n βΉοΈ {len(optional_missing)} optional API key(s) not configured:") | |
| for var in optional_missing: | |
| tools = var.get("tools", []) | |
| tools_str = f" (enables: {', '.join(tools[:2])})" if tools else "" | |
| print(f" β’ {var['name']}{tools_str}") | |
| print() | |
| # Run migration | |
| results = migrate_config(interactive=True, quiet=False) | |
| print() | |
| if results["env_added"] or results["config_added"]: | |
| print(color("β Configuration updated!", Colors.GREEN)) | |
| if results["warnings"]: | |
| print() | |
| for warning in results["warnings"]: | |
| print(color(f" β οΈ {warning}", Colors.YELLOW)) | |
| print() | |
| elif subcmd == "check": | |
| # Non-interactive check for what's missing | |
| print() | |
| print(color("π Configuration Status", Colors.CYAN, Colors.BOLD)) | |
| print() | |
| current_ver, latest_ver = check_config_version() | |
| if current_ver >= latest_ver: | |
| print(f" Config version: {current_ver} β") | |
| else: | |
| print(color(f" Config version: {current_ver} β {latest_ver} (update available)", Colors.YELLOW)) | |
| print() | |
| print(color(" Required:", Colors.BOLD)) | |
| for var_name in REQUIRED_ENV_VARS: | |
| if get_env_value(var_name): | |
| print(f" β {var_name}") | |
| else: | |
| print(color(f" β {var_name} (missing)", Colors.RED)) | |
| print() | |
| print(color(" Optional:", Colors.BOLD)) | |
| for var_name, info in OPTIONAL_ENV_VARS.items(): | |
| if get_env_value(var_name): | |
| print(f" β {var_name}") | |
| else: | |
| tools = info.get("tools", []) | |
| tools_str = f" β {', '.join(tools[:2])}" if tools else "" | |
| print(color(f" β {var_name}{tools_str}", Colors.DIM)) | |
| missing_config = get_missing_config_fields() | |
| if missing_config: | |
| print() | |
| print(color(f" {len(missing_config)} new config option(s) available", Colors.YELLOW)) | |
| print(" Run 'hermes config migrate' to add them") | |
| print() | |
| else: | |
| print(f"Unknown config command: {subcmd}") | |
| print() | |
| print("Available commands:") | |
| print(" hermes config Show current configuration") | |
| print(" hermes config edit Open config in editor") | |
| print(" hermes config set <key> <value> Set a config value") | |
| print(" hermes config check Check for missing/outdated config") | |
| print(" hermes config migrate Update config with new options") | |
| print(" hermes config path Show config file path") | |
| print(" hermes config env-path Show .env file path") | |
| sys.exit(1) | |