Files
hermes-agent/hermes_cli/setup.py

1712 lines
74 KiB
Python
Raw Normal View History

"""
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
)
2026-02-20 23:23:32 -08:00
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
# Add visual indicators
menu_choices = [f" {choice}" 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):
# 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 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 config (any provider or config file)
is_existing = (
get_env_value("OPENROUTER_API_KEY") is not None
or get_env_value("OPENAI_BASE_URL") is not None
or get_config_path().exists()
)
# 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:
2026-02-22 02:16:11 -08:00
# 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",
2026-02-22 02:16:11 -08:00
f"Keep current ({current_model})",
]
2026-02-22 02:16:11 -08:00
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)
2026-02-22 02:16:11 -08:00
# 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)",
"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, 'ssh': 4}
idx_to_backend = {0: 'local', 1: 'docker', 2: 'singularity', 3: 'modal', 4: 'ssh'}
keep_current_idx = 5
else:
backend_to_idx = {'local': 0, 'docker': 1, 'modal': 2, 'ssh': 3}
idx_to_backend = {0: 'local', 1: 'docker', 2: 'modal', 3: 'ssh'}
keep_current_idx = 4
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)
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)
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
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
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)
print_success("Terminal set to Modal")
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_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)
# =========================================================================
# 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):
2026-02-25 21:04:36 -08:00
print_info("WhatsApp connects via a built-in bridge (Baileys).")
print_info("Requires Node.js (already installed if you have browser tools).")
print_info("On first gateway start, you'll scan a QR code with your phone.")
print()
2026-02-25 21:04:36 -08:00
if prompt_yes_no("Enable WhatsApp?", True):
save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled")
2026-02-25 21:04:36 -08:00
allowed_users = prompt(" Your phone number (e.g. 15551234567, comma-separated for multiple)")
if allowed_users:
save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("WhatsApp allowlist configured")
else:
print_info("⚠️ No allowlist set — anyone who messages your WhatsApp will get a response!")
print_info("Start the gateway with 'hermes gateway' and scan the QR code.")
# Gateway reminder
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!")
print_info("Start the gateway after setup to bring your bots online:")
print_info(" hermes gateway # Run in foreground")
print_info(" hermes gateway install # Install as background service (Linux)")
# 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_info(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 <channel_id>")
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)