""" Interactive setup wizard for Hermes Agent. Modular wizard with independently-runnable sections: 1. Model & Provider — choose your AI provider and model 2. Terminal Backend — where your agent runs commands 3. Messaging Platforms — connect Telegram, Discord, etc. 4. Tools — configure TTS, web search, image generation, etc. 5. Agent Settings — iterations, compression, session reset Config files are stored in ~/.hermes/ for easy access. """ import logging import os import sys from pathlib import Path from typing import Optional, Dict, Any logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() # Import config helpers from hermes_cli.config import ( get_hermes_home, get_config_path, get_env_path, load_config, save_config, save_env_value, get_env_value, ensure_hermes_home, DEFAULT_CONFIG ) from hermes_cli.colors import Colors, color def print_header(title: str): """Print a section header.""" print() print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) def print_info(text: str): """Print info text.""" print(color(f" {text}", Colors.DIM)) def print_success(text: str): """Print success message.""" print(color(f"✓ {text}", Colors.GREEN)) def print_warning(text: str): """Print warning message.""" print(color(f"⚠ {text}", Colors.YELLOW)) def print_error(text: str): """Print error message.""" print(color(f"✗ {text}", Colors.RED)) def prompt(question: str, default: str = None, password: bool = False) -> str: """Prompt for input with optional default.""" if default: display = f"{question} [{default}]: " else: display = f"{question}: " try: if password: import getpass value = getpass.getpass(color(display, Colors.YELLOW)) else: value = input(color(display, Colors.YELLOW)) return value.strip() or default or "" except (KeyboardInterrupt, EOFError): print() sys.exit(1) def prompt_choice(question: str, choices: list, default: int = 0) -> int: """Prompt for a choice from a list with arrow key navigation. Escape keeps the current default (skips the question). Ctrl+C exits the wizard. """ print(color(question, Colors.YELLOW)) # Try to use interactive menu if available try: from simple_term_menu import TerminalMenu import re # Strip emoji characters — simple_term_menu miscalculates visual # width of emojis, causing duplicated/garbled lines on redraw. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE ) menu_choices = [f" {_emoji_re.sub('', choice).strip()}" for choice in choices] print_info(" ↑/↓ Navigate Enter Select Esc Skip Ctrl+C Exit") terminal_menu = TerminalMenu( menu_choices, cursor_index=default, menu_cursor="→ ", menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, ) idx = terminal_menu.show() if idx is None: # User pressed Escape — keep current value print_info(f" Skipped (keeping current)") print() return default print() # Add newline after selection return idx except (ImportError, NotImplementedError): pass except Exception as e: print(f" (Interactive menu unavailable: {e})") # Fallback to number-based selection (simple_term_menu doesn't support Windows) for i, choice in enumerate(choices): marker = "●" if i == default else "○" if i == default: print(color(f" {marker} {choice}", Colors.GREEN)) else: print(f" {marker} {choice}") print_info(f" Enter for default ({default + 1}) Ctrl+C to exit") while True: try: value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM)) if not value: return default idx = int(value) - 1 if 0 <= idx < len(choices): return idx print_error(f"Please enter a number between 1 and {len(choices)}") except ValueError: print_error("Please enter a number") except (KeyboardInterrupt, EOFError): print() sys.exit(1) def prompt_yes_no(question: str, default: bool = True) -> bool: """Prompt for yes/no. Ctrl+C exits, empty input returns default.""" default_str = "Y/n" if default else "y/N" while True: try: value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() except (KeyboardInterrupt, EOFError): print() sys.exit(1) if not value: return default if value in ('y', 'yes'): return True if value in ('n', 'no'): return False print_error("Please enter 'y' or 'n'") def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list: """ Display a multi-select checklist and return the indices of selected items. Each item in `items` is a display string. `pre_selected` is a list of indices that should be checked by default. A "Continue →" option is appended at the end — the user toggles items with Space and confirms with Enter on "Continue →". Falls back to a numbered toggle interface when simple_term_menu is unavailable. Returns: List of selected indices (not including the Continue option). """ if pre_selected is None: pre_selected = [] print(color(title, Colors.YELLOW)) print_info(" SPACE Toggle ENTER Confirm ESC Skip Ctrl+C Exit") print() try: from simple_term_menu import TerminalMenu import re # Strip emoji characters from menu labels — simple_term_menu miscalculates # visual width of emojis on macOS, causing duplicated/garbled lines. _emoji_re = re.compile( "[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f" "\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE ) menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items] # Map pre-selected indices to the actual menu entry strings preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)] terminal_menu = TerminalMenu( menu_items, multi_select=True, show_multi_select_hint=False, multi_select_cursor="[✓] ", multi_select_select_on_accept=False, multi_select_empty_ok=True, preselected_entries=preselected if preselected else None, menu_cursor="→ ", menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), cycle_cursor=True, clear_screen=False, ) terminal_menu.show() if terminal_menu.chosen_menu_entries is None: print_info(" Skipped (keeping current)") return list(pre_selected) selected = list(terminal_menu.chosen_menu_indices or []) return selected except (ImportError, NotImplementedError): # Fallback: numbered toggle interface (simple_term_menu doesn't support Windows) selected = set(pre_selected) while True: for i, item in enumerate(items): marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]" print(f" {marker} {i + 1}. {item}") print() try: value = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip() if not value: break idx = int(value) - 1 if 0 <= idx < len(items): if idx in selected: selected.discard(idx) else: selected.add(idx) else: print_error(f"Enter a number between 1 and {len(items) + 1}") except ValueError: print_error("Enter a number") except (KeyboardInterrupt, EOFError): print() return [] # Clear and redraw (simple approach) print() return sorted(selected) def _prompt_api_key(var: dict): """Display a nicely formatted API key input screen for a single env var.""" tools = var.get("tools", []) tools_str = ", ".join(tools[:3]) if len(tools) > 3: tools_str += f", +{len(tools) - 3} more" print() print(color(f" ─── {var.get('description', var['name'])} ───", Colors.CYAN)) print() if tools_str: print_info(f" Enables: {tools_str}") if var.get("url"): print_info(f" Get your key at: {var['url']}") print() if var.get("password"): value = prompt(f" {var.get('prompt', var['name'])}", password=True) else: value = prompt(f" {var.get('prompt', var['name'])}") if value: save_env_value(var["name"], value) print_success(f" ✓ Saved") else: print_warning(f" Skipped (configure later with 'hermes setup')") def _print_setup_summary(config: dict, hermes_home): """Print the setup completion summary.""" # Tool availability summary print() print_header("Tool Availability Summary") tool_status = [] # OpenRouter (required for vision, moa) if get_env_value('OPENROUTER_API_KEY'): tool_status.append(("Vision (image analysis)", True, None)) tool_status.append(("Mixture of Agents", True, None)) else: tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY")) tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY")) # Firecrawl (web tools) if get_env_value('FIRECRAWL_API_KEY') or get_env_value('FIRECRAWL_API_URL'): tool_status.append(("Web Search & Extract", True, None)) else: tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY")) # Browser tools (local Chromium or Browserbase cloud) import shutil _ab_found = shutil.which("agent-browser") or (Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser").exists() if get_env_value('BROWSERBASE_API_KEY'): tool_status.append(("Browser Automation (Browserbase)", True, None)) elif _ab_found: tool_status.append(("Browser Automation (local)", True, None)) else: tool_status.append(("Browser Automation", False, "npm install -g agent-browser")) # FAL (image generation) if get_env_value('FAL_KEY'): tool_status.append(("Image Generation", True, None)) else: tool_status.append(("Image Generation", False, "FAL_KEY")) # TTS — show configured provider tts_provider = config.get('tts', {}).get('provider', 'edge') if tts_provider == 'elevenlabs' and get_env_value('ELEVENLABS_API_KEY'): tool_status.append(("Text-to-Speech (ElevenLabs)", True, None)) elif tts_provider == 'openai' and get_env_value('VOICE_TOOLS_OPENAI_KEY'): tool_status.append(("Text-to-Speech (OpenAI)", True, None)) else: tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) # Tinker + WandB (RL training) if get_env_value('TINKER_API_KEY') and get_env_value('WANDB_API_KEY'): tool_status.append(("RL Training (Tinker)", True, None)) elif get_env_value('TINKER_API_KEY'): tool_status.append(("RL Training (Tinker)", False, "WANDB_API_KEY")) else: tool_status.append(("RL Training (Tinker)", False, "TINKER_API_KEY")) # Home Assistant if get_env_value('HASS_TOKEN'): tool_status.append(("Smart Home (Home Assistant)", True, None)) # Skills Hub if get_env_value('GITHUB_TOKEN'): tool_status.append(("Skills Hub (GitHub)", True, None)) else: tool_status.append(("Skills Hub (GitHub)", False, "GITHUB_TOKEN")) # Terminal (always available if system deps met) tool_status.append(("Terminal/Commands", True, None)) # Task planning (always available, in-memory) tool_status.append(("Task Planning (todo)", True, None)) # Skills (always available -- bundled skills + user-created skills) tool_status.append(("Skills (view, create, edit)", True, None)) # Print status available_count = sum(1 for _, avail, _ in tool_status if avail) total_count = len(tool_status) print_info(f"{available_count}/{total_count} tool categories available:") print() for name, available, missing_var in tool_status: if available: print(f" {color('✓', Colors.GREEN)} {name}") else: print(f" {color('✗', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}") print() disabled_tools = [(name, var) for name, avail, var in tool_status if not avail] if disabled_tools: print_warning("Some tools are disabled. Run 'hermes setup tools' to configure them,") print_warning("or edit ~/.hermes/.env directly to add the missing API keys.") print() # Done banner print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN)) print(color("│ ✓ Setup Complete! │", Colors.GREEN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN)) print() # Show file locations prominently print(color("📁 All your files are in ~/.hermes/:", Colors.CYAN, Colors.BOLD)) print() print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}") print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}") print(f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/") print() print(color("─" * 60, Colors.DIM)) print() print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD)) print() print(f" {color('hermes setup', Colors.GREEN)} Re-run the full wizard") print(f" {color('hermes setup model', Colors.GREEN)} Change model/provider") print(f" {color('hermes setup terminal', Colors.GREEN)} Change terminal backend") print(f" {color('hermes setup gateway', Colors.GREEN)} Configure messaging") print(f" {color('hermes setup tools', Colors.GREEN)} Configure tool providers") print() print(f" {color('hermes config', Colors.GREEN)} View current settings") print(f" {color('hermes config edit', Colors.GREEN)} Open config in your editor") print(f" {color('hermes config set KEY VALUE', Colors.GREEN)}") print(f" Set a specific value") print() print(f" Or edit the files directly:") print(f" {color(f'nano {get_config_path()}', Colors.DIM)}") print(f" {color(f'nano {get_env_path()}', Colors.DIM)}") print() print(color("─" * 60, Colors.DIM)) print() print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD)) print() print(f" {color('hermes', Colors.GREEN)} Start chatting") print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway") print(f" {color('hermes doctor', Colors.GREEN)} Check for issues") print() def _prompt_container_resources(config: dict): """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" terminal = config.setdefault('terminal', {}) print() print_info("Container Resource Settings:") # Persistence current_persist = terminal.get('container_persistent', True) persist_label = "yes" if current_persist else "no" print_info(" Persistent filesystem keeps files between sessions.") print_info(" Set to 'no' for ephemeral sandboxes that reset each time.") persist_str = prompt(f" Persist filesystem across sessions? (yes/no)", persist_label) terminal['container_persistent'] = persist_str.lower() in ('yes', 'true', 'y', '1') # CPU current_cpu = terminal.get('container_cpu', 1) cpu_str = prompt(f" CPU cores", str(current_cpu)) try: terminal['container_cpu'] = float(cpu_str) except ValueError: pass # Memory current_mem = terminal.get('container_memory', 5120) mem_str = prompt(f" Memory in MB (5120 = 5GB)", str(current_mem)) try: terminal['container_memory'] = int(mem_str) except ValueError: pass # Disk current_disk = terminal.get('container_disk', 51200) disk_str = prompt(f" Disk in MB (51200 = 50GB)", str(current_disk)) try: terminal['container_disk'] = int(disk_str) except ValueError: pass # Tool categories and provider config are now in tools_config.py (shared # between `hermes tools` and `hermes setup tools`). # ============================================================================= # Section 1: Model & Provider Configuration # ============================================================================= def setup_model_provider(config: dict): """Configure the inference provider and default model.""" from hermes_cli.auth import ( get_active_provider, get_provider_auth_state, PROVIDER_REGISTRY, format_auth_error, AuthError, fetch_nous_models, resolve_nous_runtime_credentials, _update_config_for_provider, _login_openai_codex, get_codex_auth_status, DEFAULT_CODEX_BASE_URL, detect_external_credentials, ) print_header("Inference Provider") print_info("Choose how to connect to your main chat model.") print() existing_or = get_env_value("OPENROUTER_API_KEY") active_oauth = get_active_provider() existing_custom = get_env_value("OPENAI_BASE_URL") # Detect credentials from other CLI tools detected_creds = detect_external_credentials() if detected_creds: print_info("Detected existing credentials:") for cred in detected_creds: if cred["provider"] == "openai-codex": print_success(f" * {cred['label']} -- select \"OpenAI Codex\" to use it") else: print_info(f" * {cred['label']}") print() # Detect if any provider is already configured has_any_provider = bool(active_oauth or existing_custom or existing_or) # Build "keep current" label if active_oauth and active_oauth in PROVIDER_REGISTRY: keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" elif existing_custom: keep_label = f"Keep current (Custom: {existing_custom})" elif existing_or: keep_label = "Keep current (OpenRouter)" else: keep_label = None # No provider configured — don't show "Keep current" provider_choices = [ "Nous Portal API key (direct API key access)", "Login with Nous Portal (Nous Research subscription — OAuth)", "Login with OpenAI Codex", "OpenRouter API key (100+ models, pay-per-use)", "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", "Z.AI / GLM (Zhipu AI models)", "Kimi / Moonshot (Kimi coding models)", "MiniMax (global endpoint)", "MiniMax China (mainland China endpoint)", ] if keep_label: provider_choices.append(keep_label) # Default to "Keep current" if a provider exists, otherwise OpenRouter (most common) default_provider = len(provider_choices) - 1 if has_any_provider else 3 if not has_any_provider: print_warning("An inference provider is required for Hermes to work.") print() provider_idx = prompt_choice("Select your inference provider:", provider_choices, default_provider) # Track which provider was selected for model step selected_provider = None # "nous", "openai-codex", "openrouter", "custom", or None (keep) nous_models = [] # populated if Nous login succeeds if provider_idx == 0: # Nous Portal API Key (direct) selected_provider = "nous-api" print() print_header("Nous Portal API Key") print_info("Use a Nous Portal API key for direct access to Nous inference.") print_info("Get your API key at: https://portal.nousresearch.com") print() existing_key = get_env_value("NOUS_API_KEY") if existing_key: print_info(f"Current: {existing_key[:8]}... (configured)") if prompt_yes_no("Update Nous API key?", False): api_key = prompt(" Nous API key", password=True) if api_key: save_env_value("NOUS_API_KEY", api_key) print_success("Nous API key updated") else: api_key = prompt(" Nous API key", password=True) if api_key: save_env_value("NOUS_API_KEY", api_key) print_success("Nous API key saved") else: print_warning("Skipped - agent won't work without an API key") # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("nous-api", "https://inference-api.nousresearch.com/v1") elif provider_idx == 1: # Nous Portal selected_provider = "nous" print() print_header("Nous Portal Login") print_info("This will open your browser to authenticate with Nous Portal.") print_info("You'll need a Nous Research account with an active subscription.") print() try: from hermes_cli.auth import _login_nous, ProviderConfig import argparse mock_args = argparse.Namespace( portal_url=None, inference_url=None, client_id=None, scope=None, no_browser=False, timeout=15.0, ca_bundle=None, insecure=False, ) pconfig = PROVIDER_REGISTRY["nous"] _login_nous(mock_args, pconfig) # Fetch models for the selection step try: creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=5 * 60, timeout_seconds=15.0, ) nous_models = fetch_nous_models( inference_base_url=creds.get("base_url", ""), api_key=creds.get("api_key", ""), ) except Exception as e: logger.debug("Could not fetch Nous models after login: %s", e) except SystemExit: print_warning("Nous Portal login was cancelled or failed.") print_info("You can try again later with: hermes model") selected_provider = None except Exception as e: print_error(f"Login failed: {e}") print_info("You can try again later with: hermes model") selected_provider = None elif provider_idx == 2: # OpenAI Codex selected_provider = "openai-codex" print() print_header("OpenAI Codex Login") print() try: import argparse mock_args = argparse.Namespace() _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) # Clear custom endpoint vars that would override provider routing. if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) except SystemExit: print_warning("OpenAI Codex login was cancelled or failed.") print_info("You can try again later with: hermes model") selected_provider = None except Exception as e: print_error(f"Login failed: {e}") print_info("You can try again later with: hermes model") selected_provider = None elif provider_idx == 3: # OpenRouter selected_provider = "openrouter" print() print_header("OpenRouter API Key") print_info("OpenRouter provides access to 100+ models from multiple providers.") print_info("Get your API key at: https://openrouter.ai/keys") if existing_or: print_info(f"Current: {existing_or[:8]}... (configured)") if prompt_yes_no("Update OpenRouter API key?", False): api_key = prompt(" OpenRouter API key", password=True) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) print_success("OpenRouter API key updated") else: api_key = prompt(" OpenRouter API key", password=True) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) print_success("OpenRouter API key saved") else: print_warning("Skipped - agent won't work without an API key") # Clear any custom endpoint if switching to OpenRouter if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") # Update config.yaml and deactivate any OAuth provider so the # resolver doesn't keep returning the old provider (e.g. Codex). try: from hermes_cli.auth import deactivate_provider deactivate_provider() except Exception: pass import yaml config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" try: disk_cfg = {} if config_path.exists(): disk_cfg = yaml.safe_load(config_path.read_text()) or {} model_section = disk_cfg.get("model", {}) if isinstance(model_section, str): model_section = {"default": model_section} model_section["provider"] = "openrouter" model_section.pop("base_url", None) # OpenRouter uses default URL disk_cfg["model"] = model_section config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False)) except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) elif provider_idx == 4: # Custom endpoint selected_provider = "custom" print() print_header("Custom OpenAI-Compatible Endpoint") print_info("Works with any API that follows OpenAI's chat completions spec") current_url = get_env_value("OPENAI_BASE_URL") or "" current_key = get_env_value("OPENAI_API_KEY") _raw_model = config.get('model', '') current_model = _raw_model.get('default', '') if isinstance(_raw_model, dict) else (_raw_model or '') if current_url: print_info(f" Current URL: {current_url}") if current_key: print_info(f" Current key: {current_key[:8]}... (configured)") base_url = prompt(" API base URL (e.g., https://api.example.com/v1)", current_url) api_key = prompt(" API key", password=True) model_name = prompt(" Model name (e.g., gpt-4, claude-3-opus)", current_model) if base_url: save_env_value("OPENAI_BASE_URL", base_url) if api_key: save_env_value("OPENAI_API_KEY", api_key) if model_name: config['model'] = model_name save_env_value("LLM_MODEL", model_name) # Save provider and base_url to config.yaml so the gateway and CLI # both resolve the correct provider without relying on env-var heuristics. if base_url: import yaml config_path = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) / "config.yaml" try: disk_cfg = {} if config_path.exists(): disk_cfg = yaml.safe_load(config_path.read_text()) or {} model_section = disk_cfg.get("model", {}) if isinstance(model_section, str): model_section = {"default": model_section} model_section["provider"] = "custom" model_section["base_url"] = base_url.rstrip("/") if model_name: model_section["default"] = model_name disk_cfg["model"] = model_section config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False)) except Exception as e: logger.debug("Could not save provider to config.yaml: %s", e) print_success("Custom endpoint configured") elif provider_idx == 5: # Z.AI / GLM selected_provider = "zai" print() print_header("Z.AI / GLM API Key") pconfig = PROVIDER_REGISTRY["zai"] print_info(f"Provider: {pconfig.name}") print_info("Get your API key at: https://open.bigmodel.cn/") print() existing_key = get_env_value("GLM_API_KEY") or get_env_value("ZAI_API_KEY") api_key = existing_key # will be overwritten if user enters a new one if existing_key: print_info(f"Current: {existing_key[:8]}... (configured)") if prompt_yes_no("Update API key?", False): new_key = prompt(" GLM API key", password=True) if new_key: api_key = new_key save_env_value("GLM_API_KEY", api_key) print_success("GLM API key updated") else: api_key = prompt(" GLM API key", password=True) if api_key: save_env_value("GLM_API_KEY", api_key) print_success("GLM API key saved") else: print_warning("Skipped - agent won't work without an API key") # Detect the correct z.ai endpoint for this key. # Z.AI has separate billing for general vs coding plans and # global vs China endpoints — we probe to find the right one. zai_base_url = pconfig.inference_base_url if api_key: print() print_info("Detecting your z.ai endpoint...") from hermes_cli.auth import detect_zai_endpoint detected = detect_zai_endpoint(api_key) if detected: zai_base_url = detected["base_url"] print_success(f"Detected: {detected['label']} endpoint") print_info(f" URL: {detected['base_url']}") if detected["id"].startswith("coding"): print_info(f" Note: Coding Plan detected — GLM-5 is not available, using {detected['model']}") save_env_value("GLM_BASE_URL", zai_base_url) else: print_warning("Could not verify any z.ai endpoint with this key.") print_info(f" Using default: {zai_base_url}") print_info(" If you get billing errors, check your plan at https://open.bigmodel.cn/") # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("zai", zai_base_url) elif provider_idx == 6: # Kimi / Moonshot selected_provider = "kimi-coding" print() print_header("Kimi / Moonshot API Key") pconfig = PROVIDER_REGISTRY["kimi-coding"] print_info(f"Provider: {pconfig.name}") print_info(f"Base URL: {pconfig.inference_base_url}") print_info("Get your API key at: https://platform.moonshot.cn/") print() existing_key = get_env_value("KIMI_API_KEY") if existing_key: print_info(f"Current: {existing_key[:8]}... (configured)") if prompt_yes_no("Update API key?", False): api_key = prompt(" Kimi API key", password=True) if api_key: save_env_value("KIMI_API_KEY", api_key) print_success("Kimi API key updated") else: api_key = prompt(" Kimi API key", password=True) if api_key: save_env_value("KIMI_API_KEY", api_key) print_success("Kimi API key saved") else: print_warning("Skipped - agent won't work without an API key") # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("kimi-coding", pconfig.inference_base_url) elif provider_idx == 7: # MiniMax selected_provider = "minimax" print() print_header("MiniMax API Key") pconfig = PROVIDER_REGISTRY["minimax"] print_info(f"Provider: {pconfig.name}") print_info(f"Base URL: {pconfig.inference_base_url}") print_info("Get your API key at: https://platform.minimaxi.com/") print() existing_key = get_env_value("MINIMAX_API_KEY") if existing_key: print_info(f"Current: {existing_key[:8]}... (configured)") if prompt_yes_no("Update API key?", False): api_key = prompt(" MiniMax API key", password=True) if api_key: save_env_value("MINIMAX_API_KEY", api_key) print_success("MiniMax API key updated") else: api_key = prompt(" MiniMax API key", password=True) if api_key: save_env_value("MINIMAX_API_KEY", api_key) print_success("MiniMax API key saved") else: print_warning("Skipped - agent won't work without an API key") # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax", pconfig.inference_base_url) elif provider_idx == 8: # MiniMax China selected_provider = "minimax-cn" print() print_header("MiniMax China API Key") pconfig = PROVIDER_REGISTRY["minimax-cn"] print_info(f"Provider: {pconfig.name}") print_info(f"Base URL: {pconfig.inference_base_url}") print_info("Get your API key at: https://platform.minimaxi.com/") print() existing_key = get_env_value("MINIMAX_CN_API_KEY") if existing_key: print_info(f"Current: {existing_key[:8]}... (configured)") if prompt_yes_no("Update API key?", False): api_key = prompt(" MiniMax CN API key", password=True) if api_key: save_env_value("MINIMAX_CN_API_KEY", api_key) print_success("MiniMax CN API key updated") else: api_key = prompt(" MiniMax CN API key", password=True) if api_key: save_env_value("MINIMAX_CN_API_KEY", api_key) print_success("MiniMax CN API key saved") else: print_warning("Skipped - agent won't work without an API key") # Clear custom endpoint vars if switching if existing_custom: save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") _update_config_for_provider("minimax-cn", pconfig.inference_base_url) # else: provider_idx == 9 (Keep current) — only shown when a provider already exists # ── OpenRouter API Key for tools (if not already set) ── # Tools (vision, web, MoA) use OpenRouter independently of the main provider. # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. if selected_provider in ("nous", "nous-api", "openai-codex", "custom", "zai", "kimi-coding", "minimax", "minimax-cn") and not get_env_value("OPENROUTER_API_KEY"): print() print_header("OpenRouter API Key (for tools)") print_info("Tools like vision analysis, web search, and MoA use OpenRouter") print_info("independently of your main inference provider.") print_info("Get your API key at: https://openrouter.ai/keys") api_key = prompt(" OpenRouter API key (optional, press Enter to skip)", password=True) if api_key: save_env_value("OPENROUTER_API_KEY", api_key) print_success("OpenRouter API key saved (for tools)") else: print_info("Skipped - some tools (vision, web scraping) won't work without this") # ── Model Selection (adapts based on provider) ── if selected_provider != "custom": # Custom already prompted for model name print_header("Default Model") _raw_model = config.get('model', 'anthropic/claude-opus-4.6') current_model = _raw_model.get('default', 'anthropic/claude-opus-4.6') if isinstance(_raw_model, dict) else (_raw_model or 'anthropic/claude-opus-4.6') print_info(f"Current: {current_model}") if selected_provider == "nous" and nous_models: # Dynamic model list from Nous Portal model_choices = [f"{m}" for m in nous_models] model_choices.append("Custom model") model_choices.append(f"Keep current ({current_model})") # Post-login validation: warn if current model might not be available if current_model and current_model not in nous_models: print_warning(f"Your current model ({current_model}) may not be available via Nous Portal.") print_info("Select a model from the list, or keep current to use it anyway.") print() model_idx = prompt_choice("Select default model:", model_choices, len(model_choices) - 1) if model_idx < len(nous_models): config['model'] = nous_models[model_idx] elif model_idx == len(model_choices) - 2: # Custom model_name = prompt(" Model name") if model_name: config['model'] = model_name # else: keep current elif selected_provider == "nous": # Nous login succeeded but model fetch failed — prompt manually # instead of falling through to the OpenRouter static list. print_warning("Could not fetch available models from Nous Portal.") print_info("Enter a Nous model name manually (e.g., claude-opus-4-6).") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) elif selected_provider == "nous-api": # Nous API key provider — prompt for model manually print_info("Enter a model name available on Nous inference API.") print_info("Examples: anthropic/claude-opus-4.6, deepseek/deepseek-r1") custom = prompt(f" Model name (Enter to keep '{current_model}')") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) elif selected_provider == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids codex_models = get_codex_model_ids() model_choices = codex_models + [f"Keep current ({current_model})"] default_codex = 0 if current_model in codex_models: default_codex = codex_models.index(current_model) elif current_model: default_codex = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, default_codex) if model_idx < len(codex_models): config['model'] = codex_models[model_idx] save_env_value("LLM_MODEL", codex_models[model_idx]) elif model_idx == len(codex_models): custom = prompt("Enter model name") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) elif selected_provider == "zai": # Coding Plan endpoints don't have GLM-5 is_coding_plan = get_env_value("GLM_BASE_URL") and "coding" in (get_env_value("GLM_BASE_URL") or "") if is_coding_plan: zai_models = ["glm-4.7", "glm-4.5", "glm-4.5-flash"] else: zai_models = ["glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"] model_choices = list(zai_models) model_choices.append("Custom model") model_choices.append(f"Keep current ({current_model})") keep_idx = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(zai_models): config['model'] = zai_models[model_idx] save_env_value("LLM_MODEL", zai_models[model_idx]) elif model_idx == len(zai_models): custom = prompt("Enter model name") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider == "kimi-coding": kimi_models = ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"] model_choices = list(kimi_models) model_choices.append("Custom model") model_choices.append(f"Keep current ({current_model})") keep_idx = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(kimi_models): config['model'] = kimi_models[model_idx] save_env_value("LLM_MODEL", kimi_models[model_idx]) elif model_idx == len(kimi_models): custom = prompt("Enter model name") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider in ("minimax", "minimax-cn"): minimax_models = ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"] model_choices = list(minimax_models) model_choices.append("Custom model") model_choices.append(f"Keep current ({current_model})") keep_idx = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(minimax_models): config['model'] = minimax_models[model_idx] save_env_value("LLM_MODEL", minimax_models[model_idx]) elif model_idx == len(minimax_models): custom = prompt("Enter model name") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) # else: keep current else: # Static list for OpenRouter / fallback (from canonical list) from hermes_cli.models import model_ids, menu_labels ids = model_ids() model_choices = menu_labels() + [ "Custom model", f"Keep current ({current_model})", ] keep_idx = len(model_choices) - 1 model_idx = prompt_choice("Select default model:", model_choices, keep_idx) if model_idx < len(ids): config['model'] = ids[model_idx] save_env_value("LLM_MODEL", ids[model_idx]) elif model_idx == len(ids): # Custom custom = prompt("Enter model name (e.g., anthropic/claude-opus-4.6)") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) # else: Keep current _final_model = config.get('model', '') if _final_model: _display = _final_model.get('default', _final_model) if isinstance(_final_model, dict) else _final_model print_success(f"Model set to: {_display}") save_config(config) # ============================================================================= # Section 2: Terminal Backend Configuration # ============================================================================= def setup_terminal_backend(config: dict): """Configure the terminal execution backend.""" import platform as _platform import shutil print_header("Terminal Backend") print_info("Choose where Hermes runs shell commands and code.") print_info("This affects tool execution, file access, and isolation.") print() current_backend = config.get('terminal', {}).get('backend', 'local') is_linux = _platform.system() == "Linux" # Build backend choices with descriptions terminal_choices = [ "Local - run directly on this machine (default)", "Docker - isolated container with configurable resources", "Modal - serverless cloud sandbox", "SSH - run on a remote machine", "Daytona - persistent cloud development environment", ] idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"} backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4} next_idx = 5 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" backend_to_idx["singularity"] = next_idx next_idx += 1 # Add keep current option keep_current_idx = next_idx terminal_choices.append(f"Keep current ({current_backend})") idx_to_backend[keep_current_idx] = current_backend default_terminal = backend_to_idx.get(current_backend, 0) terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, keep_current_idx) selected_backend = idx_to_backend.get(terminal_idx) if terminal_idx == keep_current_idx: print_info(f"Keeping current backend: {current_backend}") return config.setdefault('terminal', {})['backend'] = selected_backend if selected_backend == "local": print_success("Terminal backend: Local") print_info("Commands run directly on this machine.") # CWD for messaging print() print_info("Working directory for messaging sessions:") print_info(" When using Hermes via Telegram/Discord, this is where") print_info(" the agent starts. CLI mode always starts in the current directory.") current_cwd = config.get('terminal', {}).get('cwd', '') cwd = prompt(" Messaging working directory", current_cwd or str(Path.home())) if cwd: config['terminal']['cwd'] = cwd # Sudo support print() existing_sudo = get_env_value("SUDO_PASSWORD") if existing_sudo: print_info("Sudo password: configured") else: if prompt_yes_no("Enable sudo support? (stores password for apt install, etc.)", False): sudo_pass = prompt(" Sudo password", password=True) if sudo_pass: save_env_value("SUDO_PASSWORD", sudo_pass) print_success("Sudo password saved") elif selected_backend == "docker": print_success("Terminal backend: Docker") # Check if Docker is available docker_bin = shutil.which("docker") if not docker_bin: print_warning("Docker not found in PATH!") print_info("Install Docker: https://docs.docker.com/get-docker/") else: print_info(f"Docker found: {docker_bin}") # Docker image current_image = config.get('terminal', {}).get('docker_image', 'python:3.11-slim') image = prompt(" Docker image", current_image) config['terminal']['docker_image'] = image save_env_value("TERMINAL_DOCKER_IMAGE", image) _prompt_container_resources(config) elif selected_backend == "singularity": print_success("Terminal backend: Singularity/Apptainer") # Check if singularity/apptainer is available sing_bin = shutil.which("apptainer") or shutil.which("singularity") if not sing_bin: print_warning("Singularity/Apptainer not found in PATH!") print_info("Install: https://apptainer.org/docs/admin/main/installation.html") else: print_info(f"Found: {sing_bin}") current_image = config.get('terminal', {}).get('singularity_image', 'docker://python:3.11-slim') image = prompt(" Container image", current_image) config['terminal']['singularity_image'] = image save_env_value("TERMINAL_SINGULARITY_IMAGE", image) _prompt_container_resources(config) elif selected_backend == "modal": print_success("Terminal backend: Modal") print_info("Serverless cloud sandboxes. Each session gets its own container.") print_info("Requires a Modal account: https://modal.com") # Check if swe-rex[modal] is installed try: __import__("swe_rex") except ImportError: print_info("Installing swe-rex[modal]...") import subprocess uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "swe-rex[modal]"], capture_output=True, text=True ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "swe-rex[modal]"], capture_output=True, text=True ) if result.returncode == 0: print_success("swe-rex[modal] installed") else: print_warning("Install failed — run manually: pip install 'swe-rex[modal]'") # Modal token print() print_info("Modal authentication:") print_info(" Get your token at: https://modal.com/settings") existing_token = get_env_value("MODAL_TOKEN_ID") if existing_token: print_info(" Modal token: already configured") if prompt_yes_no(" Update Modal credentials?", False): token_id = prompt(" Modal Token ID", password=True) token_secret = prompt(" Modal Token Secret", password=True) if token_id: save_env_value("MODAL_TOKEN_ID", token_id) if token_secret: save_env_value("MODAL_TOKEN_SECRET", token_secret) else: token_id = prompt(" Modal Token ID", password=True) token_secret = prompt(" Modal Token Secret", password=True) if token_id: save_env_value("MODAL_TOKEN_ID", token_id) if token_secret: save_env_value("MODAL_TOKEN_SECRET", token_secret) _prompt_container_resources(config) elif selected_backend == "daytona": print_success("Terminal backend: Daytona") print_info("Persistent cloud development environments.") print_info("Each session gets a dedicated sandbox with filesystem persistence.") print_info("Sign up at: https://daytona.io") # Check if daytona SDK is installed try: __import__("daytona") except ImportError: print_info("Installing daytona SDK...") import subprocess uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "--python", sys.executable, "daytona"], capture_output=True, text=True ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "daytona"], capture_output=True, text=True ) if result.returncode == 0: print_success("daytona SDK installed") else: print_warning("Install failed — run manually: pip install daytona") if result.stderr: print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") # Daytona API key print() existing_key = get_env_value("DAYTONA_API_KEY") if existing_key: print_info(" Daytona API key: already configured") if prompt_yes_no(" Update API key?", False): api_key = prompt(" Daytona API key", password=True) if api_key: save_env_value("DAYTONA_API_KEY", api_key) print_success(" Updated") else: api_key = prompt(" Daytona API key", password=True) if api_key: save_env_value("DAYTONA_API_KEY", api_key) print_success(" Configured") # Daytona image current_image = config.get('terminal', {}).get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20') image = prompt(" Sandbox image", current_image) config['terminal']['daytona_image'] = image save_env_value("TERMINAL_DAYTONA_IMAGE", image) _prompt_container_resources(config) elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") # SSH host current_host = get_env_value("TERMINAL_SSH_HOST") or "" host = prompt(" SSH host (hostname or IP)", current_host) if host: save_env_value("TERMINAL_SSH_HOST", host) # SSH user current_user = get_env_value("TERMINAL_SSH_USER") or "" user = prompt(" SSH user", current_user or os.getenv("USER", "")) if user: save_env_value("TERMINAL_SSH_USER", user) # SSH port current_port = get_env_value("TERMINAL_SSH_PORT") or "22" port = prompt(" SSH port", current_port) if port and port != "22": save_env_value("TERMINAL_SSH_PORT", port) # SSH key current_key = get_env_value("TERMINAL_SSH_KEY") or "" default_key = str(Path.home() / ".ssh" / "id_rsa") ssh_key = prompt(" SSH private key path", current_key or default_key) if ssh_key: save_env_value("TERMINAL_SSH_KEY", ssh_key) # Test connection if host and prompt_yes_no(" Test SSH connection?", True): print_info(" Testing connection...") import subprocess ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"] if ssh_key: ssh_cmd.extend(["-i", ssh_key]) if port and port != "22": ssh_cmd.extend(["-p", port]) ssh_cmd.append(f"{user}@{host}" if user else host) ssh_cmd.append("echo ok") result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=10) if result.returncode == 0: print_success(" SSH connection successful!") else: print_warning(f" SSH connection failed: {result.stderr.strip()}") print_info(" Check your SSH key and host settings.") # Sync terminal backend to .env so terminal_tool picks it up directly. # config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV. save_env_value("TERMINAL_ENV", selected_backend) save_config(config) print() print_success(f"Terminal backend set to: {selected_backend}") # ============================================================================= # Section 3: Agent Settings # ============================================================================= def setup_agent_settings(config: dict): """Configure agent behavior: iterations, progress display, compression, session reset.""" # ── Max Iterations ── print_header("Agent Settings") current_max = get_env_value('HERMES_MAX_ITERATIONS') or str(config.get('agent', {}).get('max_turns', 90)) print_info("Maximum tool-calling iterations per conversation.") print_info("Higher = more complex tasks, but costs more tokens.") print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.") max_iter_str = prompt("Max iterations", current_max) try: max_iter = int(max_iter_str) if max_iter > 0: save_env_value("HERMES_MAX_ITERATIONS", str(max_iter)) config.setdefault('agent', {})['max_turns'] = max_iter config.pop('max_turns', None) print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") # ── Tool Progress Display ── print_info("") print_info("Tool Progress Display") print_info("Controls how much tool activity is shown (CLI and messaging).") print_info(" off — Silent, just the final response") print_info(" new — Show tool name only when it changes (less noise)") print_info(" all — Show every tool call with a short preview") print_info(" verbose — Full args, results, and debug logs") current_mode = config.get("display", {}).get("tool_progress", "all") mode = prompt("Tool progress mode", current_mode) if mode.lower() in ("off", "new", "all", "verbose"): if "display" not in config: config["display"] = {} config["display"]["tool_progress"] = mode.lower() save_config(config) print_success(f"Tool progress set to: {mode.lower()}") else: print_warning(f"Unknown mode '{mode}', keeping '{current_mode}'") # ── Context Compression ── print_header("Context Compression") print_info("Automatically summarizes old messages when context gets too long.") print_info("Higher threshold = compress later (use more context). Lower = compress sooner.") config.setdefault('compression', {})['enabled'] = True current_threshold = config.get('compression', {}).get('threshold', 0.85) threshold_str = prompt("Compression threshold (0.5-0.95)", str(current_threshold)) try: threshold = float(threshold_str) if 0.5 <= threshold <= 0.95: config['compression']['threshold'] = threshold except ValueError: pass print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}") # ── Session Reset Policy ── print_header("Session Reset Policy") print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.") print_info("Each message adds to the conversation history, which means growing API costs.") print_info("") print_info("To manage this, sessions can automatically reset after a period of inactivity") print_info("or at a fixed time each day. When a reset happens, the agent saves important") print_info("things to its persistent memory first — but the conversation context is cleared.") print_info("") print_info("You can also manually reset anytime by typing /reset in chat.") print_info("") reset_choices = [ "Inactivity + daily reset (recommended - reset whichever comes first)", "Inactivity only (reset after N minutes of no messages)", "Daily only (reset at a fixed hour each day)", "Never auto-reset (context lives until /reset or context compression)", "Keep current settings", ] current_policy = config.get('session_reset', {}) current_mode = current_policy.get('mode', 'both') current_idle = current_policy.get('idle_minutes', 1440) current_hour = current_policy.get('at_hour', 4) default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0) reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset) config.setdefault('session_reset', {}) if reset_idx == 0: # Both config['session_reset']['mode'] = 'both' idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: config['session_reset']['idle_minutes'] = idle_val except ValueError: pass hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: config['session_reset']['at_hour'] = hour_val except ValueError: pass print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00") elif reset_idx == 1: # Idle only config['session_reset']['mode'] = 'idle' idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle)) try: idle_val = int(idle_str) if idle_val > 0: config['session_reset']['idle_minutes'] = idle_val except ValueError: pass print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity") elif reset_idx == 2: # Daily only config['session_reset']['mode'] = 'daily' hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour)) try: hour_val = int(hour_str) if 0 <= hour_val <= 23: config['session_reset']['at_hour'] = hour_val except ValueError: pass print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00") elif reset_idx == 3: # None config['session_reset']['mode'] = 'none' print_info("Sessions will never auto-reset. Context is managed only by compression.") print_warning("Long conversations will grow in cost. Use /reset manually when needed.") # else: keep current (idx == 4) save_config(config) # ============================================================================= # Section 4: Messaging Platforms (Gateway) # ============================================================================= def setup_gateway(config: dict): """Configure messaging platform integrations.""" print_header("Messaging Platforms") print_info("Connect to messaging platforms to chat with Hermes from anywhere.") print() # ── Telegram ── existing_telegram = get_env_value('TELEGRAM_BOT_TOKEN') if existing_telegram: print_info("Telegram: already configured") if prompt_yes_no("Reconfigure Telegram?", False): existing_telegram = None if not existing_telegram and prompt_yes_no("Set up Telegram bot?", False): print_info("Create a bot via @BotFather on Telegram") token = prompt("Telegram bot token", password=True) if token: save_env_value("TELEGRAM_BOT_TOKEN", token) print_success("Telegram token saved") # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") print_info(" To find your Telegram user ID:") print_info(" 1. Message @userinfobot on Telegram") print_info(" 2. It will reply with your numeric ID (e.g., 123456789)") print() allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") if allowed_users: save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Telegram allowlist configured - only listed users can use the bot") else: print_info("⚠️ No allowlist set - anyone who finds your bot can use it!") # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") print_info(" For Telegram DMs, this is your user ID (same as above).") first_user_id = allowed_users.split(",")[0].strip() if allowed_users else "" if first_user_id: if prompt_yes_no(f"Use your user ID ({first_user_id}) as the home channel?", True): save_env_value("TELEGRAM_HOME_CHANNEL", first_user_id) print_success(f"Telegram home channel set to {first_user_id}") else: home_channel = prompt("Home channel ID (or leave empty to set later with /set-home in Telegram)") if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) else: print_info(" You can also set this later by typing /set-home in your Telegram chat.") home_channel = prompt("Home channel ID (leave empty to set later)") if home_channel: save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) # Check/update existing Telegram allowlist elif existing_telegram: existing_allowlist = get_env_value('TELEGRAM_ALLOWED_USERS') if not existing_allowlist: print_info("⚠️ Telegram has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): print_info(" To find your Telegram user ID: message @userinfobot") allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: save_env_value("TELEGRAM_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Telegram allowlist configured") # ── Discord ── existing_discord = get_env_value('DISCORD_BOT_TOKEN') if existing_discord: print_info("Discord: already configured") if prompt_yes_no("Reconfigure Discord?", False): existing_discord = None if not existing_discord and prompt_yes_no("Set up Discord bot?", False): print_info("Create a bot at https://discord.com/developers/applications") token = prompt("Discord bot token", password=True) if token: save_env_value("DISCORD_BOT_TOKEN", token) print_success("Discord token saved") # Allowed users (security) print() print_info("🔒 Security: Restrict who can use your bot") print_info(" To find your Discord user ID:") print_info(" 1. Enable Developer Mode in Discord settings") print_info(" 2. Right-click your name → Copy ID") print() print_info(" You can also use Discord usernames (resolved on gateway start).") print() allowed_users = prompt("Allowed user IDs or usernames (comma-separated, leave empty for open access)") if allowed_users: save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Discord allowlist configured") else: print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") # Home channel setup with better guidance print() print_info("📬 Home Channel: where Hermes delivers cron job results,") print_info(" cross-platform messages, and notifications.") print_info(" To get a channel ID: right-click a channel → Copy Channel ID") print_info(" (requires Developer Mode in Discord settings)") print_info(" You can also set this later by typing /set-home in a Discord channel.") home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) # Check/update existing Discord allowlist elif existing_discord: existing_allowlist = get_env_value('DISCORD_ALLOWED_USERS') if not existing_allowlist: print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") if prompt_yes_no("Add allowed users now?", True): print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: save_env_value("DISCORD_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Discord allowlist configured") # ── Slack ── existing_slack = get_env_value('SLACK_BOT_TOKEN') if existing_slack: print_info("Slack: already configured") if prompt_yes_no("Reconfigure Slack?", False): existing_slack = None if not existing_slack and prompt_yes_no("Set up Slack bot?", False): print_info("Steps to create a Slack app:") print_info(" 1. Go to https://api.slack.com/apps → Create New App (from scratch)") print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable") print_info(" • Create an App-Level Token with 'connections:write' scope") print_info(" 3. Add Bot Token Scopes: Features → OAuth & Permissions") print_info(" Required scopes: chat:write, app_mentions:read,") print_info(" channels:history, channels:read, groups:history,") print_info(" im:history, im:read, im:write, users:read, files:write") print_info(" 4. Subscribe to Events: Features → Event Subscriptions → Enable") print_info(" Required events: message.im, message.channels,") print_info(" message.groups, app_mention") print_warning(" ⚠ Without message.channels/message.groups events,") print_warning(" the bot will ONLY work in DMs, not channels!") print_info(" 5. Install to Workspace: Settings → Install App") print_info(" 6. After installing, invite the bot to channels: /invite @YourBot") print() print_info(" Full guide: https://hermes-agent.ai/docs/user-guide/messaging/slack") print() bot_token = prompt("Slack Bot Token (xoxb-...)", password=True) if bot_token: save_env_value("SLACK_BOT_TOKEN", bot_token) app_token = prompt("Slack App Token (xapp-...)", password=True) if app_token: save_env_value("SLACK_APP_TOKEN", app_token) print_success("Slack tokens saved") print() print_info("🔒 Security: Restrict who can use your bot") print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID") print() allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)") if allowed_users: save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", "")) print_success("Slack allowlist configured") else: print_info("⚠️ No allowlist set - anyone in your workspace can use the bot!") # ── WhatsApp ── existing_whatsapp = get_env_value('WHATSAPP_ENABLED') if not existing_whatsapp and prompt_yes_no("Set up WhatsApp?", False): print_info("WhatsApp connects via a built-in bridge (Baileys).") print_info("Requires Node.js. Run 'hermes whatsapp' for guided setup.") print() if prompt_yes_no("Enable WhatsApp now?", True): save_env_value("WHATSAPP_ENABLED", "true") print_success("WhatsApp enabled") print_info("Run 'hermes whatsapp' to choose your mode (separate bot number") print_info("or personal self-chat) and pair via QR code.") # ── Gateway Service Setup ── any_messaging = ( get_env_value('TELEGRAM_BOT_TOKEN') or get_env_value('DISCORD_BOT_TOKEN') or get_env_value('SLACK_BOT_TOKEN') or get_env_value('WHATSAPP_ENABLED') ) if any_messaging: print() print_info("━" * 50) print_success("Messaging platforms configured!") # Check if any home channels are missing missing_home = [] if get_env_value('TELEGRAM_BOT_TOKEN') and not get_env_value('TELEGRAM_HOME_CHANNEL'): missing_home.append("Telegram") if get_env_value('DISCORD_BOT_TOKEN') and not get_env_value('DISCORD_HOME_CHANNEL'): missing_home.append("Discord") if get_env_value('SLACK_BOT_TOKEN') and not get_env_value('SLACK_HOME_CHANNEL'): missing_home.append("Slack") if missing_home: print() print_warning(f"No home channel set for: {', '.join(missing_home)}") print_info(" Without a home channel, cron jobs and cross-platform") print_info(" messages can't be delivered to those platforms.") print_info(" Set one later with /set-home in your chat, or:") for plat in missing_home: print_info(f" hermes config set {plat.upper()}_HOME_CHANNEL ") # Offer to install the gateway as a system service import platform as _platform _is_linux = _platform.system() == "Linux" _is_macos = _platform.system() == "Darwin" from hermes_cli.gateway import ( _is_service_installed, _is_service_running, systemd_install, systemd_start, systemd_restart, launchd_install, launchd_start, launchd_restart, ) service_installed = _is_service_installed() service_running = _is_service_running() print() if service_running: if prompt_yes_no(" Restart the gateway to pick up changes?", True): try: if _is_linux: systemd_restart() elif _is_macos: launchd_restart() except Exception as e: print_error(f" Restart failed: {e}") elif service_installed: if prompt_yes_no(" Start the gateway service?", True): try: if _is_linux: systemd_start() elif _is_macos: launchd_start() except Exception as e: print_error(f" Start failed: {e}") elif _is_linux or _is_macos: svc_name = "systemd" if _is_linux else "launchd" if prompt_yes_no(f" Install the gateway as a {svc_name} service? (runs in background, starts on boot)", True): try: if _is_linux: systemd_install(force=False) else: launchd_install(force=False) print() if prompt_yes_no(" Start the service now?", True): try: if _is_linux: systemd_start() elif _is_macos: launchd_start() except Exception as e: print_error(f" Start failed: {e}") except Exception as e: print_error(f" Install failed: {e}") print_info(" You can try manually: hermes gateway install") else: print_info(" You can install later: hermes gateway install") print_info(" Or run in foreground: hermes gateway") else: print_info("Start the gateway to bring your bots online:") print_info(" hermes gateway # Run in foreground") print_info("━" * 50) # ============================================================================= # Section 5: Tool Configuration (delegates to unified tools_config.py) # ============================================================================= def setup_tools(config: dict, first_install: bool = False): """Configure tools — delegates to the unified tools_command() in tools_config.py. Both `hermes setup tools` and `hermes tools` use the same flow: platform selection → toolset toggles → provider/API key configuration. Args: first_install: When True, uses the simplified first-install flow (no platform menu, prompts for all unconfigured API keys). """ from hermes_cli.tools_config import tools_command tools_command(first_install=first_install, config=config) # ============================================================================= # Main Wizard Orchestrator # ============================================================================= SETUP_SECTIONS = [ ("model", "Model & Provider", setup_model_provider), ("terminal", "Terminal Backend", setup_terminal_backend), ("gateway", "Messaging Platforms (Gateway)", setup_gateway), ("tools", "Tools", setup_tools), ("agent", "Agent Settings", setup_agent_settings), ] def run_setup_wizard(args): """Run the interactive setup wizard. Supports full, quick, and section-specific setup: hermes setup — full or quick (auto-detected) hermes setup model — just model/provider hermes setup terminal — just terminal backend hermes setup gateway — just messaging platforms hermes setup tools — just tool configuration hermes setup agent — just agent settings """ ensure_hermes_home() config = load_config() hermes_home = get_hermes_home() # Check if a specific section was requested section = getattr(args, 'section', None) if section: for key, label, func in SETUP_SECTIONS: if key == section: print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) print(color(f"│ ⚕ Hermes Setup — {label:<34s} │", Colors.MAGENTA)) print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) func(config) save_config(config) print() print_success(f"{label} configuration complete!") return print_error(f"Unknown setup section: {section}") print_info(f"Available sections: {', '.join(k for k, _, _ in SETUP_SECTIONS)}") return # Check if this is an existing installation with a provider configured from hermes_cli.auth import get_active_provider active_provider = get_active_provider() is_existing = ( bool(get_env_value("OPENROUTER_API_KEY")) or bool(get_env_value("OPENAI_BASE_URL")) or active_provider is not None ) print() print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA)) print(color("│ ⚕ Hermes Agent Setup Wizard │", Colors.MAGENTA)) print(color("├─────────────────────────────────────────────────────────┤", Colors.MAGENTA)) print(color("│ Let's configure your Hermes Agent installation. │", Colors.MAGENTA)) print(color("│ Press Ctrl+C at any time to exit. │", Colors.MAGENTA)) print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA)) if is_existing: # ── Returning User Menu ── print() print_header("Welcome Back!") print_success("You already have Hermes configured.") print() menu_choices = [ "Quick Setup - configure missing items only", "Full Setup - reconfigure everything", "---", "Model & Provider", "Terminal Backend", "Messaging Platforms (Gateway)", "Tools", "Agent Settings", "---", "Exit", ] # Separator indices (not selectable, but prompt_choice doesn't filter them, # so we handle them below) choice = prompt_choice("What would you like to do?", menu_choices, 0) if choice == 0: # Quick setup _run_quick_setup(config, hermes_home) return elif choice == 1: # Full setup — fall through to run all sections pass elif choice in (2, 8): # Separator — treat as exit print_info("Exiting. Run 'hermes setup' again when ready.") return elif choice == 9: print_info("Exiting. Run 'hermes setup' again when ready.") return elif 3 <= choice <= 7: # Individual section section_idx = choice - 3 _, label, func = SETUP_SECTIONS[section_idx] func(config) save_config(config) _print_setup_summary(config, hermes_home) return else: # ── First-Time Setup ── print() print_info("We'll walk you through:") print_info(" 1. Model & Provider — choose your AI provider and model") print_info(" 2. Terminal Backend — where your agent runs commands") print_info(" 3. Messaging Platforms — connect Telegram, Discord, etc.") print_info(" 4. Tools — configure TTS, web search, image generation, etc.") print_info(" 5. Agent Settings — iterations, compression, session reset") print() print_info("Press Enter to begin, or Ctrl+C to exit.") try: input(color(" Press Enter to start... ", Colors.YELLOW)) except (KeyboardInterrupt, EOFError): print() return # ── Full Setup — run all sections ── print_header("Configuration Location") print_info(f"Config file: {get_config_path()}") print_info(f"Secrets file: {get_env_path()}") print_info(f"Data folder: {hermes_home}") print_info(f"Install dir: {PROJECT_ROOT}") print() print_info("You can edit these files directly or use 'hermes config edit'") # Section 1: Model & Provider setup_model_provider(config) # Section 2: Terminal Backend setup_terminal_backend(config) # Section 3: Agent Settings setup_agent_settings(config) # Section 4: Messaging Platforms setup_gateway(config) # Section 5: Tools setup_tools(config, first_install=not is_existing) # Save and show summary save_config(config) _print_setup_summary(config, hermes_home) def _run_quick_setup(config: dict, hermes_home): """Quick setup — only configure items that are missing.""" from hermes_cli.config import ( get_missing_env_vars, get_missing_config_fields, check_config_version, migrate_config, ) print() print_header("Quick Setup — Missing Items Only") # Check what's missing missing_required = [v for v in get_missing_env_vars(required_only=False) if v.get("is_required")] missing_optional = [v for v in get_missing_env_vars(required_only=False) if not v.get("is_required")] missing_config = get_missing_config_fields() current_ver, latest_ver = check_config_version() has_anything_missing = missing_required or missing_optional or missing_config or current_ver < latest_ver if not has_anything_missing: print_success("Everything is configured! Nothing to do.") print() print_info("Run 'hermes setup' and choose 'Full Setup' to reconfigure,") print_info("or pick a specific section from the menu.") return # Handle missing required env vars if missing_required: print() print_info(f"{len(missing_required)} required setting(s) missing:") for var in missing_required: print(f" • {var['name']}") print() for var in missing_required: print() print(color(f" {var['name']}", Colors.CYAN)) print_info(f" {var.get('description', '')}") if var.get("url"): print_info(f" Get key at: {var['url']}") if var.get("password"): value = prompt(f" {var.get('prompt', var['name'])}", password=True) else: value = prompt(f" {var.get('prompt', var['name'])}") if value: save_env_value(var["name"], value) print_success(f" Saved {var['name']}") else: print_warning(f" Skipped {var['name']}") # Split missing optional vars by category missing_tools = [v for v in missing_optional if v.get("category") == "tool"] missing_messaging = [v for v in missing_optional if v.get("category") == "messaging" and not v.get("advanced")] # ── Tool API keys (checklist) ── if missing_tools: print() print_header("Tool API Keys") checklist_labels = [] for var in missing_tools: tools = var.get("tools", []) tools_str = f" → {', '.join(tools[:2])}" if tools else "" checklist_labels.append(f"{var.get('description', var['name'])}{tools_str}") selected_indices = prompt_checklist( "Which tools would you like to configure?", checklist_labels, ) for idx in selected_indices: var = missing_tools[idx] _prompt_api_key(var) # ── Messaging platforms (checklist then prompt for selected) ── if missing_messaging: print() print_header("Messaging Platforms") print_info("Connect Hermes to messaging apps to chat from anywhere.") print_info("You can configure these later with 'hermes setup gateway'.") # Group by platform (preserving order) platform_order = [] platforms = {} for var in missing_messaging: name = var["name"] if "TELEGRAM" in name: plat = "Telegram" elif "DISCORD" in name: plat = "Discord" elif "SLACK" in name: plat = "Slack" else: continue if plat not in platforms: platform_order.append(plat) platforms.setdefault(plat, []).append(var) platform_labels = [ {"Telegram": "📱 Telegram", "Discord": "💬 Discord", "Slack": "💼 Slack"}.get(p, p) for p in platform_order ] selected_indices = prompt_checklist( "Which platforms would you like to set up?", platform_labels, ) for idx in selected_indices: plat = platform_order[idx] vars_list = platforms[plat] emoji = {"Telegram": "📱", "Discord": "💬", "Slack": "💼"}.get(plat, "") print() print(color(f" ─── {emoji} {plat} ───", Colors.CYAN)) print() for var in vars_list: print_info(f" {var.get('description', '')}") if var.get("url"): print_info(f" {var['url']}") if var.get("password"): value = prompt(f" {var.get('prompt', var['name'])}", password=True) else: value = prompt(f" {var.get('prompt', var['name'])}") if value: save_env_value(var["name"], value) print_success(f" ✓ Saved") else: print_warning(f" Skipped") print() # Handle missing config fields if missing_config: print() print_info(f"Adding {len(missing_config)} new config option(s) with defaults...") for field in missing_config: print_success(f" Added {field['key']} = {field['default']}") # Update config version config["_config_version"] = latest_ver save_config(config) # Jump to summary _print_setup_summary(config, hermes_home)