Files
hermes-agent/hermes_cli/setup.py
teknium1 b4873a5de7 fix(setup): Escape skips instead of exiting, add control hints to all prompts
Previously pressing Escape in any setup wizard menu called sys.exit(1),
killing the entire wizard with no way to recover. Now:

- prompt_choice: Escape keeps the current default and moves on (prints
  'Skipped (keeping current)'). Shows '↑/↓ Navigate  Enter Select
  Esc Skip  Ctrl+C Exit' hint.
- prompt_checklist: Escape returns pre-selected items instead of empty
  list. Shows 'SPACE Toggle  ENTER Confirm  ESC Skip  Ctrl+C Exit'.
- prompt_yes_no: now catches KeyboardInterrupt/EOFError properly.
- Fallback number prompts also show control hints.

Ctrl+C still exits the wizard cleanly.
2026-03-06 05:27:11 -08:00

1929 lines
83 KiB
Python

"""
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.
Escape keeps the current default (skips the question).
Ctrl+C exits the wizard.
"""
print(color(question, Colors.YELLOW))
# Try to use interactive menu if available
try:
from simple_term_menu import TerminalMenu
import re
# Strip emoji characters — simple_term_menu miscalculates visual
# width of emojis, causing duplicated/garbled lines on redraw.
_emoji_re = re.compile(
"[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f"
"\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE
)
menu_choices = [f" {_emoji_re.sub('', choice).strip()}" for choice in choices]
print_info(" ↑/↓ Navigate Enter Select Esc Skip Ctrl+C Exit")
terminal_menu = TerminalMenu(
menu_choices,
cursor_index=default,
menu_cursor="",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
)
idx = terminal_menu.show()
if idx is None: # User pressed Escape — keep current value
print_info(f" Skipped (keeping current)")
print()
return default
print() # Add newline after selection
return idx
except (ImportError, NotImplementedError):
pass
except Exception as e:
print(f" (Interactive menu unavailable: {e})")
# Fallback to number-based selection (simple_term_menu doesn't support Windows)
for i, choice in enumerate(choices):
marker = "" if i == default else ""
if i == default:
print(color(f" {marker} {choice}", Colors.GREEN))
else:
print(f" {marker} {choice}")
print_info(f" Enter for default ({default + 1}) Ctrl+C to exit")
while True:
try:
value = input(color(f" Select [1-{len(choices)}] ({default + 1}): ", Colors.DIM))
if not value:
return default
idx = int(value) - 1
if 0 <= idx < len(choices):
return idx
print_error(f"Please enter a number between 1 and {len(choices)}")
except ValueError:
print_error("Please enter a number")
except (KeyboardInterrupt, EOFError):
print()
sys.exit(1)
def prompt_yes_no(question: str, default: bool = True) -> bool:
"""Prompt for yes/no. Ctrl+C exits, empty input returns default."""
default_str = "Y/n" if default else "y/N"
while True:
try:
value = input(color(f"{question} [{default_str}]: ", Colors.YELLOW)).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
sys.exit(1)
if not value:
return default
if value in ('y', 'yes'):
return True
if value in ('n', 'no'):
return False
print_error("Please enter 'y' or 'n'")
def prompt_checklist(title: str, items: list, pre_selected: list = None) -> list:
"""
Display a multi-select checklist and return the indices of selected items.
Each item in `items` is a display string. `pre_selected` is a list of
indices that should be checked by default. A "Continue →" option is
appended at the end — the user toggles items with Space and confirms
with Enter on "Continue →".
Falls back to a numbered toggle interface when simple_term_menu is
unavailable.
Returns:
List of selected indices (not including the Continue option).
"""
if pre_selected is None:
pre_selected = []
print(color(title, Colors.YELLOW))
print_info(" SPACE Toggle ENTER Confirm ESC Skip Ctrl+C Exit")
print()
try:
from simple_term_menu import TerminalMenu
import re
# Strip emoji characters from menu labels — simple_term_menu miscalculates
# visual width of emojis on macOS, causing duplicated/garbled lines.
_emoji_re = re.compile(
"[\U0001f300-\U0001f9ff\U00002600-\U000027bf\U0000fe00-\U0000fe0f"
"\U0001fa00-\U0001fa6f\U0001fa70-\U0001faff\u200d]+", flags=re.UNICODE
)
menu_items = [f" {_emoji_re.sub('', item).strip()}" for item in items]
# Map pre-selected indices to the actual menu entry strings
preselected = [menu_items[i] for i in pre_selected if i < len(menu_items)]
terminal_menu = TerminalMenu(
menu_items,
multi_select=True,
show_multi_select_hint=False,
multi_select_cursor="[✓] ",
multi_select_select_on_accept=False,
multi_select_empty_ok=True,
preselected_entries=preselected if preselected else None,
menu_cursor="",
menu_cursor_style=("fg_green", "bold"),
menu_highlight_style=("fg_green",),
cycle_cursor=True,
clear_screen=False,
)
terminal_menu.show()
if terminal_menu.chosen_menu_entries is None:
print_info(" Skipped (keeping current)")
return list(pre_selected)
selected = list(terminal_menu.chosen_menu_indices or [])
return selected
except (ImportError, NotImplementedError):
# Fallback: numbered toggle interface (simple_term_menu doesn't support Windows)
selected = set(pre_selected)
while True:
for i, item in enumerate(items):
marker = color("[✓]", Colors.GREEN) if i in selected else "[ ]"
print(f" {marker} {i + 1}. {item}")
print()
try:
value = input(color(" Toggle # (or Enter to confirm): ", Colors.DIM)).strip()
if not value:
break
idx = int(value) - 1
if 0 <= idx < len(items):
if idx in selected:
selected.discard(idx)
else:
selected.add(idx)
else:
print_error(f"Enter a number between 1 and {len(items) + 1}")
except ValueError:
print_error("Enter a number")
except (KeyboardInterrupt, EOFError):
print()
return []
# Clear and redraw (simple approach)
print()
return sorted(selected)
def _prompt_api_key(var: dict):
"""Display a nicely formatted API key input screen for a single env var."""
tools = var.get("tools", [])
tools_str = ", ".join(tools[:3])
if len(tools) > 3:
tools_str += f", +{len(tools) - 3} more"
print()
print(color(f" ─── {var.get('description', var['name'])} ───", Colors.CYAN))
print()
if tools_str:
print_info(f" Enables: {tools_str}")
if var.get("url"):
print_info(f" Get your key at: {var['url']}")
print()
if var.get("password"):
value = prompt(f" {var.get('prompt', var['name'])}", password=True)
else:
value = prompt(f" {var.get('prompt', var['name'])}")
if value:
save_env_value(var["name"], value)
print_success(f" ✓ Saved")
else:
print_warning(f" Skipped (configure later with 'hermes setup')")
def _print_setup_summary(config: dict, hermes_home):
"""Print the setup completion summary."""
# Tool availability summary
print()
print_header("Tool Availability Summary")
tool_status = []
# OpenRouter (required for vision, moa)
if get_env_value('OPENROUTER_API_KEY'):
tool_status.append(("Vision (image analysis)", True, None))
tool_status.append(("Mixture of Agents", True, None))
else:
tool_status.append(("Vision (image analysis)", False, "OPENROUTER_API_KEY"))
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
# Firecrawl (web tools)
if get_env_value('FIRECRAWL_API_KEY'):
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 <channel_id>")
# 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)