""" Interactive setup wizard for Hermes Agent. Guides users through: 1. Installation directory confirmation 2. API key configuration 3. Model selection 4. Terminal backend selection 5. Messaging platform setup 6. Optional features 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.""" 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] 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 or Ctrl+C print() sys.exit(1) 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}") 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.""" default_str = "Y/n" if default else "y/N" while True: value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower() 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 to toggle, ENTER to confirm.") 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: return [] 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'): tool_status.append(("Web Search & Extract", True, None)) else: tool_status.append(("Web Search & Extract", False, "FIRECRAWL_API_KEY")) # Browserbase (browser tools) if get_env_value('BROWSERBASE_API_KEY'): tool_status.append(("Browser Automation", True, None)) else: tool_status.append(("Browser Automation", False, "BROWSERBASE_API_KEY")) # 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 (always available via Edge TTS; ElevenLabs/OpenAI are optional) tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) if get_env_value('ELEVENLABS_API_KEY'): tool_status.append(("Text-to-Speech (ElevenLabs)", 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")) # 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' again 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 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(f" Persistent filesystem keeps files between sessions.") print_info(f" 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 def run_setup_wizard(args): """Run the interactive setup wizard.""" ensure_hermes_home() config = load_config() hermes_home = get_hermes_home() # Check if this is an existing installation with a provider configured. # Just having config.yaml is NOT enough — the installer creates it from # a template, so it always exists after install. We need an actual # inference provider to consider it "existing" (otherwise quick mode # would skip provider selection, leaving hermes non-functional). # NOTE: Use bool() not `is not None` — the .env template has empty # values (e.g. OPENROUTER_API_KEY=) that load_dotenv sets to "", which # passes `is not None` but isn't a real configured provider. 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 ) # Import migration helpers from hermes_cli.config import ( get_missing_env_vars, get_missing_config_fields, check_config_version, migrate_config, REQUIRED_ENV_VARS, OPTIONAL_ENV_VARS ) # 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_missing = missing_required or missing_optional or missing_config or current_ver < latest_ver 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 existing installation, show what's missing and offer quick mode quick_mode = False if is_existing and has_missing: print() print_header("Existing Installation Detected") print_success("You already have Hermes configured!") print() if missing_required: print_warning(f" {len(missing_required)} required setting(s) missing:") for var in missing_required: print(f" • {var['name']}") if missing_optional: print_info(f" {len(missing_optional)} optional tool(s) not configured:") for var in missing_optional[:3]: # Show first 3 tools = var.get("tools", []) tools_str = f" → {', '.join(tools[:2])}" if tools else "" print(f" • {var['name']}{tools_str}") if len(missing_optional) > 3: print(f" • ...and {len(missing_optional) - 3} more") if missing_config: print_info(f" {len(missing_config)} new config option(s) available") print() setup_choices = [ "Quick setup - just configure missing items", "Full setup - reconfigure everything", "Skip - exit setup" ] choice = prompt_choice("What would you like to do?", setup_choices, 0) if choice == 0: quick_mode = True elif choice == 2: print() print_info("Exiting. Run 'hermes setup' again when ready.") return # choice == 1 continues with full setup elif is_existing and not has_missing: print() print_header("Configuration Status") print_success("Your configuration is complete!") print() if not prompt_yes_no("Would you like to reconfigure anyway?", False): print() print_info("Exiting. Your configuration is already set up.") print_info(f"Config: {get_config_path()}") print_info(f"Secrets: {get_env_path()}") return # Quick mode: only configure missing items if quick_mode: print() print_header("Quick Setup - Missing Items Only") # Handle missing required env vars if missing_required: 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")] # Settings are silently applied with defaults in quick mode # ── 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'.") # 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) return # ========================================================================= # Step 0: Show paths (full setup) # ========================================================================= 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'") # ========================================================================= # Step 1: Inference Provider Selection # ========================================================================= print_header("Inference Provider") print_info("Choose how to connect to your main chat model.") print() # Detect current provider state 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, ) existing_custom = get_env_value("OPENAI_BASE_URL") existing_or = get_env_value("OPENROUTER_API_KEY") active_oauth = get_active_provider() # 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 = [ "Login with Nous Portal (Nous Research subscription)", "Login with OpenAI Codex", "OpenRouter API key (100+ models, pay-per-use)", "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", ] 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 2 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 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 == 1: # 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 == 2: # 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", "") elif provider_idx == 3: # 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") current_model = config.get('model', '') 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) print_success("Custom endpoint configured") # else: provider_idx == 4 (Keep current) — only shown when a provider already exists # ========================================================================= # Step 1b: 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", "openai-codex", "custom") 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") # ========================================================================= # Step 2: Model Selection (adapts based on provider) # ========================================================================= if selected_provider != "custom": # Custom already prompted for model name print_header("Default Model") current_model = config.get('model', '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] save_env_value("LLM_MODEL", nous_models[model_idx]) elif model_idx == len(nous_models): # Custom custom = prompt("Enter model name") if custom: config['model'] = custom save_env_value("LLM_MODEL", custom) # else: keep current elif selected_provider == "openai-codex": from hermes_cli.codex_models import get_codex_model_ids # Try to get the access token for live model discovery _codex_token = None try: from hermes_cli.auth import resolve_codex_runtime_credentials _codex_creds = resolve_codex_runtime_credentials() _codex_token = _codex_creds.get("api_key") except Exception: pass codex_models = get_codex_model_ids(access_token=_codex_token) model_choices = [f"{m}" for m in codex_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(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) 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 # ========================================================================= # Step 4: Terminal Backend # ========================================================================= print_header("Terminal Backend") print_info("The terminal tool allows the agent to run commands.") current_backend = config.get('terminal', {}).get('backend', 'local') print_info(f"Current: {current_backend}") # Detect platform for backend availability import platform is_linux = platform.system() == "Linux" is_macos = platform.system() == "Darwin" is_windows = platform.system() == "Windows" # Build choices based on platform terminal_choices = [ "Local (run commands on this machine - no isolation)", "Docker (isolated containers - recommended for security)", ] # Singularity/Apptainer is Linux-only (HPC) if is_linux: terminal_choices.append("Singularity/Apptainer (HPC clusters, shared compute)") terminal_choices.extend([ "Modal (cloud execution, GPU access, serverless)", "Daytona (cloud sandboxes, persistent workspaces)", "SSH (run commands on a remote server)", f"Keep current ({current_backend})" ]) # Build index map based on available choices if is_linux: backend_to_idx = {'local': 0, 'docker': 1, 'singularity': 2, 'modal': 3, 'daytona': 4, 'ssh': 5} idx_to_backend = {0: 'local', 1: 'docker', 2: 'singularity', 3: 'modal', 4: 'daytona', 5: 'ssh'} keep_current_idx = 6 else: backend_to_idx = {'local': 0, 'docker': 1, 'modal': 2, 'daytona': 3, 'ssh': 4} idx_to_backend = {0: 'local', 1: 'docker', 2: 'modal', 3: 'daytona', 4: 'ssh'} keep_current_idx = 5 if current_backend == 'singularity': print_warning("Singularity is only available on Linux - please select a different backend") # Default based on current default_terminal = backend_to_idx.get(current_backend, 0) terminal_idx = prompt_choice("Select terminal backend:", terminal_choices, keep_current_idx) # Map index to backend name (handles platform differences) selected_backend = idx_to_backend.get(terminal_idx) # Validate that required binaries exist for the chosen backend import shutil as _shutil _backend_bins = { 'docker': ('docker', [ "Docker is not installed on this machine.", "Install Docker Desktop: https://www.docker.com/products/docker-desktop/", "On Linux: curl -fsSL https://get.docker.com | sh", ]), 'singularity': (None, []), # check both names 'ssh': ('ssh', [ "SSH client not found.", "On Linux: sudo apt install openssh-client", "On macOS: SSH should be pre-installed.", ]), } if selected_backend == 'docker': if not _shutil.which('docker'): print() print_warning("Docker is not installed on this machine.") print_info(" Install Docker Desktop: https://www.docker.com/products/docker-desktop/") print_info(" On Linux: curl -fsSL https://get.docker.com | sh") print() if not prompt_yes_no(" Proceed with Docker anyway? (you can install it later)", False): print_info(" Falling back to local backend.") selected_backend = 'local' elif selected_backend == 'singularity': if not _shutil.which('apptainer') and not _shutil.which('singularity'): print() print_warning("Neither apptainer nor singularity is installed on this machine.") print_info(" Apptainer: https://apptainer.org/docs/admin/main/installation.html") print_info(" This is typically only available on HPC/Linux systems.") print() if not prompt_yes_no(" Proceed with Singularity anyway? (you can install it later)", False): print_info(" Falling back to local backend.") selected_backend = 'local' if selected_backend == 'local': config.setdefault('terminal', {})['backend'] = 'local' print_info("Local Execution Configuration:") print_info("Commands run directly on this machine (no isolation)") if is_windows: print_info("Note: On Windows, commands run via cmd.exe or PowerShell") # Messaging working directory configuration print_info("") print_info("Working Directory for Messaging (Telegram/Discord/etc):") print_info(" The CLI always uses the directory you run 'hermes' from") print_info(" But messaging bots need a static starting directory") current_cwd = get_env_value('MESSAGING_CWD') or str(Path.home()) print_info(f" Current: {current_cwd}") cwd_input = prompt(" Messaging working directory", current_cwd) # Expand ~ to full path if cwd_input.startswith('~'): cwd_expanded = str(Path.home()) + cwd_input[1:] else: cwd_expanded = cwd_input save_env_value("MESSAGING_CWD", cwd_expanded) print() print_info("Note: Container resource settings (CPU, memory, disk, persistence)") print_info("are in your config but only apply to Docker/Singularity/Modal/Daytona backends.") if prompt_yes_no(" Enable sudo support? (allows agent to run sudo commands)", False): print_warning(" SECURITY WARNING: Sudo password will be stored in plaintext") sudo_pass = prompt(" Sudo password (leave empty to skip)", password=True) if sudo_pass: save_env_value("SUDO_PASSWORD", sudo_pass) print_success(" Sudo password saved") print_success("Terminal set to local") elif selected_backend == 'docker': config.setdefault('terminal', {})['backend'] = 'docker' default_docker = config.get('terminal', {}).get('docker_image', 'nikolaik/python-nodejs:python3.11-nodejs20') print_info("Docker Configuration:") if is_macos: print_info("Requires Docker Desktop for Mac") elif is_windows: print_info("Requires Docker Desktop for Windows") docker_image = prompt(" Docker image", default_docker) config['terminal']['docker_image'] = docker_image _prompt_container_resources(config) print_success("Terminal set to Docker") elif selected_backend == 'singularity': config.setdefault('terminal', {})['backend'] = 'singularity' default_singularity = config.get('terminal', {}).get('singularity_image', 'docker://nikolaik/python-nodejs:python3.11-nodejs20') print_info("Singularity/Apptainer Configuration:") print_info("Requires apptainer or singularity to be installed") singularity_image = prompt(" Image (docker:// prefix for Docker Hub)", default_singularity) config['terminal']['singularity_image'] = singularity_image _prompt_container_resources(config) print_success("Terminal set to Singularity/Apptainer") elif selected_backend == 'modal': config.setdefault('terminal', {})['backend'] = 'modal' default_modal = config.get('terminal', {}).get('modal_image', 'nikolaik/python-nodejs:python3.11-nodejs20') print_info("Modal Cloud Configuration:") print_info("Get credentials at: https://modal.com/settings") # Check if swe-rex[modal] is installed, install if missing try: from swerex.deployment.modal import ModalDeployment print_info("swe-rex[modal] package: installed ✓") except ImportError: print_info("Installing required package: swe-rex[modal]...") import subprocess import shutil # Prefer uv for speed, fall back to pip uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "swe-rex[modal]>=1.4.0"], capture_output=True, text=True ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "swe-rex[modal]>=1.4.0"], capture_output=True, text=True ) if result.returncode == 0: print_success("swe-rex[modal] installed (includes modal + boto3)") else: print_warning("Failed to install swe-rex[modal] — install manually:") print_info(' uv pip install "swe-rex[modal]>=1.4.0"') # Always show current status and allow reconfiguration current_token = get_env_value('MODAL_TOKEN_ID') if current_token: print_info(f" Token ID: {current_token[:8]}... (configured)") modal_image = prompt(" Container image", default_modal) config['terminal']['modal_image'] = modal_image token_id = prompt(" Modal token ID", current_token or "") 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) print_success("Terminal set to Modal") elif selected_backend == 'daytona': config.setdefault('terminal', {})['backend'] = 'daytona' default_daytona = config.get('terminal', {}).get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20') print_info("Daytona Cloud Configuration:") print_info("Get your API key at: https://app.daytona.io/dashboard/keys") # Check if daytona SDK is installed try: from daytona import Daytona print_info("daytona SDK: installed ✓") except ImportError: print_info("Installing required package: daytona...") import subprocess import shutil uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "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("Failed to install daytona SDK — install manually:") print_info(' pip install daytona') daytona_image = prompt(" Container image", default_daytona) config['terminal']['daytona_image'] = daytona_image current_key = get_env_value('DAYTONA_API_KEY') if current_key: print_info(f" API Key: {current_key[:8]}... (configured)") api_key = prompt(" Daytona API key", current_key or "", password=True) if api_key: save_env_value("DAYTONA_API_KEY", api_key) _prompt_container_resources(config) print_success("Terminal set to Daytona") elif selected_backend == 'ssh': config.setdefault('terminal', {})['backend'] = 'ssh' print_info("SSH Remote Execution Configuration:") print_info("Commands will run on a remote server over SSH") current_host = get_env_value('TERMINAL_SSH_HOST') or '' current_user = get_env_value('TERMINAL_SSH_USER') or os.getenv("USER", "") current_port = get_env_value('TERMINAL_SSH_PORT') or '22' current_key = get_env_value('TERMINAL_SSH_KEY') or '~/.ssh/id_rsa' if current_host: print_info(f" Current host: {current_user}@{current_host}:{current_port}") ssh_host = prompt(" SSH host", current_host) ssh_user = prompt(" SSH user", current_user) ssh_port = prompt(" SSH port", current_port) ssh_key = prompt(" SSH key path (or leave empty for ssh-agent)", current_key) if ssh_host: save_env_value("TERMINAL_SSH_HOST", ssh_host) if ssh_user: save_env_value("TERMINAL_SSH_USER", ssh_user) if ssh_port and ssh_port != '22': save_env_value("TERMINAL_SSH_PORT", ssh_port) if ssh_key: save_env_value("TERMINAL_SSH_KEY", ssh_key) print() print_info("Note: Container resource settings (CPU, memory, disk, persistence)") print_info("are in your config but only apply to Docker/Singularity/Modal/Daytona backends.") print_success("Terminal set to SSH") # else: Keep current (selected_backend is None) # 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. if selected_backend: save_env_value("TERMINAL_ENV", selected_backend) docker_image = config.get('terminal', {}).get('docker_image') if docker_image: save_env_value("TERMINAL_DOCKER_IMAGE", docker_image) daytona_image = config.get('terminal', {}).get('daytona_image') if daytona_image: save_env_value("TERMINAL_DAYTONA_IMAGE", daytona_image) # ========================================================================= # Step 5: Agent Settings # ========================================================================= print_header("Agent Settings") # Max iterations current_max = get_env_value('HERMES_MAX_ITERATIONS') or '60' 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['max_turns'] = max_iter print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") # Tool progress notifications 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}'") # ========================================================================= # Step 6: 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)}") # ========================================================================= # Step 6b: Session Reset Policy (Messaging) # ========================================================================= 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) # ========================================================================= # Step 7: Messaging Platforms (Optional) # ========================================================================= print_header("Messaging Platforms (Optional)") print_info("Connect to messaging platforms to chat with Hermes from anywhere.") # 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") print_info(" 2. Enable Socket Mode: App Settings → Socket Mode → Enable") print_info(" 3. Bot Token: OAuth & Permissions → Install to Workspace") print_info(" 4. App Token: Basic Information → App-Level Tokens → Generate") 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(" Find Slack user IDs in your profile or via the Slack API") 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) # ========================================================================= # Step 8: Additional Tools (Checkbox Selection) # ========================================================================= print_header("Additional Tools") print_info("Select which tools you'd like to configure.") print_info("You can always add more later with 'hermes setup'.") print() # Define tool categories for the checklist. # Each entry: (display_label, setup_function_key, check_keys) # check_keys = env vars that indicate this tool is already configured TOOL_CATEGORIES = [ { "label": "🔍 Web Search & Scraping (Firecrawl)", "key": "firecrawl", "check": ["FIRECRAWL_API_KEY"], }, { "label": "🌐 Browser Automation (Browserbase)", "key": "browserbase", "check": ["BROWSERBASE_API_KEY"], }, { "label": "🎨 Image Generation (FAL / FLUX)", "key": "fal", "check": ["FAL_KEY"], }, { "label": "🎤 Voice Transcription & TTS (OpenAI Whisper + TTS)", "key": "openai_voice", "check": ["VOICE_TOOLS_OPENAI_KEY"], }, { "label": "🗣️ Premium Text-to-Speech (ElevenLabs)", "key": "elevenlabs", "check": ["ELEVENLABS_API_KEY"], }, { "label": "🧪 RL Training (Tinker + WandB)", "key": "rl_training", "check": ["TINKER_API_KEY", "WANDB_API_KEY"], }, { "label": "🔧 Skills Hub (GitHub token for higher rate limits)", "key": "github", "check": ["GITHUB_TOKEN"], }, ] # Pre-select tools that are already configured pre_selected = [] for i, cat in enumerate(TOOL_CATEGORIES): if all(get_env_value(k) for k in cat["check"]): pre_selected.append(i) checklist_labels = [cat["label"] for cat in TOOL_CATEGORIES] selected_indices = prompt_checklist( "Which tools would you like to enable?", checklist_labels, pre_selected=pre_selected, ) selected_keys = {TOOL_CATEGORIES[i]["key"] for i in selected_indices} # Now prompt for API keys only for the tools the user selected if "firecrawl" in selected_keys: print() print(color(" ─── Web Search & Scraping (Firecrawl) ───", Colors.CYAN)) print_info(" Get your API key at: https://firecrawl.dev/") existing = get_env_value('FIRECRAWL_API_KEY') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update API key?", False): api_key = prompt(" Firecrawl API key", password=True) if api_key: save_env_value("FIRECRAWL_API_KEY", api_key) print_success(" Updated") else: api_key = prompt(" Firecrawl API key", password=True) if api_key: save_env_value("FIRECRAWL_API_KEY", api_key) print_success(" Configured ✓") if "browserbase" in selected_keys: print() print(color(" ─── Browser Automation (Browserbase) ───", Colors.CYAN)) print_info(" Get credentials at: https://browserbase.com/") existing = get_env_value('BROWSERBASE_API_KEY') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update credentials?", False): api_key = prompt(" API key", password=True) project_id = prompt(" Project ID") if api_key: save_env_value("BROWSERBASE_API_KEY", api_key) if project_id: save_env_value("BROWSERBASE_PROJECT_ID", project_id) print_success(" Updated") else: api_key = prompt(" Browserbase API key", password=True) project_id = prompt(" Browserbase Project ID") if api_key: save_env_value("BROWSERBASE_API_KEY", api_key) if project_id: save_env_value("BROWSERBASE_PROJECT_ID", project_id) # Auto-install Node.js deps if possible import shutil node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" if not node_modules.exists() and shutil.which("npm"): print_info(" Installing Node.js dependencies for browser tools...") import subprocess result = subprocess.run( ["npm", "install", "--silent"], capture_output=True, text=True, cwd=str(PROJECT_ROOT) ) if result.returncode == 0: print_success(" Node.js dependencies installed") else: print_warning(" npm install failed — run manually: cd ~/.hermes/hermes-agent && npm install") elif not node_modules.exists(): print_warning(" Node.js not found — browser tools require: npm install (in the hermes-agent directory)") if api_key: print_success(" Configured ✓") if "fal" in selected_keys: print() print(color(" ─── Image Generation (FAL) ───", Colors.CYAN)) print_info(" Get your API key at: https://fal.ai/") existing = get_env_value('FAL_KEY') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update API key?", False): api_key = prompt(" FAL API key", password=True) if api_key: save_env_value("FAL_KEY", api_key) print_success(" Updated") else: api_key = prompt(" FAL API key", password=True) if api_key: save_env_value("FAL_KEY", api_key) print_success(" Configured ✓") if "openai_voice" in selected_keys: print() print(color(" ─── Voice Transcription & TTS (OpenAI) ───", Colors.CYAN)) print_info(" Used for Whisper speech-to-text and OpenAI TTS voices.") print_info(" Get your API key at: https://platform.openai.com/api-keys") existing = get_env_value('VOICE_TOOLS_OPENAI_KEY') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update API key?", False): api_key = prompt(" OpenAI API key", password=True) if api_key: save_env_value("VOICE_TOOLS_OPENAI_KEY", api_key) print_success(" Updated") else: api_key = prompt(" OpenAI API key", password=True) if api_key: save_env_value("VOICE_TOOLS_OPENAI_KEY", api_key) print_success(" Configured ✓") if "elevenlabs" in selected_keys: print() print(color(" ─── Premium TTS (ElevenLabs) ───", Colors.CYAN)) print_info(" High-quality voice synthesis. Free Edge TTS works without a key.") print_info(" Get your API key at: https://elevenlabs.io/") existing = get_env_value('ELEVENLABS_API_KEY') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update API key?", False): api_key = prompt(" ElevenLabs API key", password=True) if api_key: save_env_value("ELEVENLABS_API_KEY", api_key) print_success(" Updated") else: api_key = prompt(" ElevenLabs API key", password=True) if api_key: save_env_value("ELEVENLABS_API_KEY", api_key) print_success(" Configured ✓") if "rl_training" in selected_keys: print() print(color(" ─── RL Training (Tinker + WandB) ───", Colors.CYAN)) rl_python_ok = sys.version_info >= (3, 11) if not rl_python_ok: print_error(f" Requires Python 3.11+ (current: {sys.version_info.major}.{sys.version_info.minor})") print_info(" Upgrade Python and reinstall to enable RL training tools") else: print_info(" Get Tinker key at: https://tinker-console.thinkingmachines.ai/keys") print_info(" Get WandB key at: https://wandb.ai/authorize") tinker_existing = get_env_value('TINKER_API_KEY') wandb_existing = get_env_value('WANDB_API_KEY') if tinker_existing and wandb_existing: print_success(" Already configured ✓") if prompt_yes_no(" Update credentials?", False): api_key = prompt(" Tinker API key", password=True) if api_key: save_env_value("TINKER_API_KEY", api_key) wandb_key = prompt(" WandB API key", password=True) if wandb_key: save_env_value("WANDB_API_KEY", wandb_key) print_success(" Updated") else: api_key = prompt(" Tinker API key", password=True) if api_key: save_env_value("TINKER_API_KEY", api_key) wandb_key = prompt(" WandB API key", password=True) if wandb_key: save_env_value("WANDB_API_KEY", wandb_key) # Auto-install tinker-atropos submodule if missing try: __import__("tinker_atropos") except ImportError: tinker_dir = PROJECT_ROOT / "tinker-atropos" if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists(): print_info(" Installing tinker-atropos submodule...") import subprocess import shutil uv_bin = shutil.which("uv") if uv_bin: result = subprocess.run( [uv_bin, "pip", "install", "-e", str(tinker_dir)], capture_output=True, text=True ) else: result = subprocess.run( [sys.executable, "-m", "pip", "install", "-e", str(tinker_dir)], capture_output=True, text=True ) if result.returncode == 0: print_success(" tinker-atropos installed") else: print_warning(" tinker-atropos install failed — run manually:") print_info(' uv pip install -e "./tinker-atropos"') else: print_warning(" tinker-atropos submodule not found — run:") print_info(" git submodule update --init --recursive") print_info(' uv pip install -e "./tinker-atropos"') if api_key and wandb_key: print_success(" Configured ✓") else: print_warning(" Partially configured (both keys required)") if "github" in selected_keys: print() print(color(" ─── Skills Hub (GitHub) ───", Colors.CYAN)) print_info(" Enables higher API rate limits for skill search/install") print_info(" and publishing skills via GitHub PRs.") print_info(" Get a token at: https://github.com/settings/tokens") existing = get_env_value('GITHUB_TOKEN') if existing: print_success(" Already configured ✓") if prompt_yes_no(" Update token?", False): token = prompt(" GitHub Token (ghp_...)", password=True) if token: save_env_value("GITHUB_TOKEN", token) print_success(" Updated") else: token = prompt(" GitHub Token", password=True) if token: save_env_value("GITHUB_TOKEN", token) print_success(" Configured ✓") # ========================================================================= # Save config and show summary # ========================================================================= save_config(config) _print_setup_summary(config, hermes_home)