Files
hermes-agent/hermes_cli/setup.py
T

1114 lines
45 KiB
Python

"""
Interactive setup wizard for Hermes Agent.
Modular wizard with independently-runnable sections:
1. Model & Provider — choose your AI provider and model
2. Terminal Backend — where your agent runs commands
3. Agent Settings — iterations, compression, session reset
4. Messaging Platforms — connect Telegram, Discord, etc.
5. Tools — configure TTS, web search, image generation, etc.
Config files are stored in ~/.hermes/ for easy access.
Section bodies live in sibling modules (setup_tts, setup_terminal, setup_platforms,
setup_summary, setup_migration, setup_quick) and are re-exported here; they resolve shared
prompt/config helpers lazily through this module so test patches on ``hermes_cli.setup.<name>``
keep working.
"""
import importlib.util
import logging
import os
import re
import sys
import copy
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Callable
from hermes_cli.curses_ui import MenuNavigationEvent, MenuNavigationStart
from hermes_cli.nous_subscription import get_nous_subscription_features # noqa: F401 (re-export; patched by tests)
from tools.tool_backend_helpers import managed_nous_tools_enabled # noqa: F401 (re-export; patched by tests)
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
# Default model lists per provider — used as fallback when the live
# /models endpoint can't be reached.
_DEFAULT_PROVIDER_MODELS = {
"copilot-acp": ["copilot-acp"],
"copilot": [
"gpt-5.4", "gpt-5.4-mini", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-4.1", "gpt-4o",
"gpt-4o-mini", "claude-opus-4.6", "claude-sonnet-5", "claude-sonnet-4.6", "claude-sonnet-4.5",
"claude-haiku-4.5", "gemini-2.5-pro",
],
"gemini": [
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
"gemini-3.6-flash", "gemini-3.1-flash-lite-preview",
],
"vertex": [
"google/gemini-3.1-pro-preview", "google/gemini-3-pro-preview",
"google/gemini-3-flash-preview", "google/gemini-3.1-flash-lite-preview",
"google/gemini-2.5-pro", "google/gemini-2.5-flash",
],
"zai": ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
"kimi-coding": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"kimi-coding-cn": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
"kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
"opencode-zen": ["x-preview-f-free", "gpt-5.6-sol", "gpt-5.4", "gpt-5.3-codex", "claude-opus-5", "claude-sonnet-5", "gemini-3.7-flash", "glm-5.2", "kimi-k3", "minimax-m3"],
"opencode-free": ["deepseek-v4-flash-free", "hy3-free", "mimo-v2.5-free", "laguna-s-2.1-free", "nemotron-3-ultra-free", "nemotron-3.5-lightning-free", "muse-spark-1.2-contributor-free"],
"opencode-go": ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "gpt-5.6-luna", "grok-4.5", "glm-5.3", "glm-5.3-flash", "glm-5.2", "mimo-v2.5-pro", "mimo-v2.5", "minimax-m3", "minimax-m2.7", "qwen3.8-max", "qwen3.7-max", "deepseek-v4-pro", "hy3"],
"huggingface": [
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
"deepseek-ai/DeepSeek-V3.2", "moonshotai/Kimi-K2.5",
],
}
# Config helpers (re-exported; tests patch them on this module).
from hermes_cli.config import ( # noqa: E402
cfg_get,
DEFAULT_CONFIG,
get_hermes_home,
get_config_path,
get_env_path,
load_config,
save_config,
save_env_value,
remove_env_value,
get_env_value,
ensure_hermes_home,
)
# display_hermes_home imported lazily at call sites (stale-module safety during hermes update)
from hermes_cli.colors import Colors, color # noqa: E402
def print_header(title: str):
"""Print a section header."""
print()
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
from hermes_cli.cli_output import ( # noqa: E402
print_error,
print_info,
print_success,
print_warning,
)
from hermes_cli.secret_prompt import masked_secret_prompt # noqa: E402
def _current_reasoning_effort(config: dict) -> str:
agent_cfg = config.get("agent")
if isinstance(agent_cfg, dict):
return str(agent_cfg.get("reasoning_effort") or "").strip().lower()
return ""
def _set_reasoning_effort(config: dict, effort: str) -> None:
agent_cfg = config.get("agent")
if not isinstance(agent_cfg, dict):
agent_cfg = {}
config["agent"] = agent_cfg
agent_cfg["reasoning_effort"] = effort
def is_interactive_stdin() -> bool:
"""Return True when stdin looks like a usable interactive TTY."""
stdin = getattr(sys, "stdin", None)
if stdin is None:
return False
try:
return bool(stdin.isatty())
except Exception:
return False
def print_noninteractive_setup_guidance(reason: str | None = None) -> None:
"""Print guidance for headless/non-interactive setup flows."""
print()
print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD))
print()
if reason:
print_info(reason)
print_info("The interactive wizard cannot be used here.")
print()
print_info("Configure Hermes using environment variables or config commands:")
print_info(" hermes config set model.provider custom")
print_info(" hermes config set model.base_url http://localhost:8080/v1")
print_info(" hermes config set model.default your-model-name")
print()
print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.")
print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.")
print()
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
def _sanitize_pasted_input(value: str) -> str:
"""Strip terminal bracketed-paste control markers from pasted text."""
if not isinstance(value, str) or not value:
return value
return _BRACKETED_PASTE_PATTERN.sub("", value)
def prompt(question: str, default: str = None, password: bool = False) -> str:
"""Prompt for input with optional default."""
display = f"{question} [{default}]: " if default else f"{question}: "
try:
if password:
value = masked_secret_prompt(color(display, Colors.YELLOW))
else:
from hermes_cli.cli_output import line_input
value = line_input(color(display, Colors.YELLOW))
cleaned = _sanitize_pasted_input(value)
return cleaned.strip() or default or ""
except (KeyboardInterrupt, EOFError):
print()
sys.exit(1)
# =============================================================================
# Setup navigation (Escape cancels, Left arrow goes back) — a ContextVar state
# machine shared with the curses menus.
# =============================================================================
class _SetupControlFlow(BaseException):
"""Bypass provider error handlers that intentionally catch ``Exception``.
Provider setup contains broad compatibility boundaries around network,
plugin, and credential integrations. Navigation must cross those layers
unchanged so the outer setup state machine can replay the prior prompt.
"""
class _SetupCancelled(_SetupControlFlow):
"""Internal control flow for cancelling the interactive setup wizard."""
class _SetupGoBack(_SetupControlFlow):
"""Internal control flow for returning to an earlier setup choice."""
def __init__(self, prompt_index: int):
super().__init__(prompt_index)
self.prompt_index = prompt_index
class _SetupNavigationState:
"""Per-invocation navigation state for the synchronous setup wizard."""
def __init__(self, *, section_index: int = -1, prompt_index: int = 0):
self.section_index = section_index
self.prompt_index = prompt_index
self.active_prompt_index = -1
self.resolved_choices: list[object] = []
self.replay_choices: list[object] = []
def reset(self, section_index: int = -1, replay: list | None = None) -> None:
"""Rewind per-section counters (entering a section, or leaving the wizard)."""
self.section_index = section_index
self.prompt_index = 0
self.active_prompt_index = -1
self.resolved_choices = []
self.replay_choices = copy.deepcopy(replay or [])
_SETUP_NAVIGATION: ContextVar[_SetupNavigationState | None] = ContextVar(
"hermes_setup_navigation", default=None
)
def _handle_setup_menu_navigation(
event: MenuNavigationEvent,
value: object = None,
) -> MenuNavigationStart | None:
"""Translate shared curses menu events into setup control flow."""
state = _SETUP_NAVIGATION.get()
if state is None:
return None
if event is MenuNavigationEvent.BEGIN:
if state.section_index < 0:
state.active_prompt_index = -1
return MenuNavigationStart()
state.active_prompt_index = state.prompt_index
state.prompt_index += 1
allow_back = state.section_index > 0 or state.active_prompt_index > 0
if state.active_prompt_index < len(state.replay_choices):
return MenuNavigationStart(
allow_back=allow_back,
replay_value=copy.deepcopy(state.replay_choices[state.active_prompt_index]),
)
return MenuNavigationStart(allow_back=allow_back)
if event is MenuNavigationEvent.RESOLVE:
prompt_index = state.active_prompt_index
if prompt_index < 0:
return None
resolved = copy.deepcopy(value)
if prompt_index < len(state.resolved_choices):
state.resolved_choices[prompt_index] = resolved
del state.resolved_choices[prompt_index + 1 :]
else:
state.resolved_choices.append(resolved)
return None
if event is MenuNavigationEvent.CANCEL:
raise _SetupCancelled()
if event is MenuNavigationEvent.BACK:
raise _SetupGoBack(state.active_prompt_index)
return None
@contextmanager
def _setup_navigation_scope():
"""Install and reliably restore the setup menu navigation context."""
from hermes_cli.curses_ui import reset_menu_navigation_handler, set_menu_navigation_handler
token = _SETUP_NAVIGATION.set(_SetupNavigationState())
menu_token = set_menu_navigation_handler(_handle_setup_menu_navigation)
try:
yield
finally:
reset_menu_navigation_handler(menu_token)
_SETUP_NAVIGATION.reset(token)
def _run_setup_steps(steps: list[tuple[str, Callable[[], None]]]) -> None:
"""Run setup sections with left-arrow navigation between choices.
Left arrow at a section's first choice returns to the previous section.
From a later, nested choice it replays earlier selections invisibly and
reopens only the immediately preceding prompt.
"""
state = _SETUP_NAVIGATION.get()
section_index = 0
answers_by_section: dict[int, list[object]] = {}
replay_by_section: dict[int, list[object]] = {}
try:
while section_index < len(steps):
label, action = steps[section_index]
if state is not None:
state.reset(section_index, replay_by_section.pop(section_index, []))
try:
action()
except _SetupGoBack as navigation:
if state is not None:
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
if navigation.prompt_index > 0:
previous_index = section_index
target_prompt = navigation.prompt_index - 1
else:
previous_index = max(0, section_index - 1)
target_prompt = max(0, len(answers_by_section.get(previous_index, [])) - 1)
replay_by_section[previous_index] = copy.deepcopy(
answers_by_section.get(previous_index, [])[:target_prompt]
)
print()
if previous_index == section_index:
print_info(f"Returning to the previous choice in {label}...")
else:
print_info(f"Returning to {steps[previous_index][0]}...")
section_index = previous_index
continue
if state is not None:
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
section_index += 1
finally:
if state is not None:
state.reset()
def run_setup_action_with_navigation(
label: str,
action: Callable[[], None],
*,
cancelled_message: str = "Setup cancelled.",
) -> None:
"""Run a setup-style menu flow with Escape and nested Left navigation.
Shared commands such as ``hermes model`` use the same provider/model
pickers as the setup wizard, but run outside ``run_setup_wizard``. This
installs the setup navigation context for that standalone command and
reuses the same prompt replay state machine.
"""
with _setup_navigation_scope():
try:
_run_setup_steps([(label, action)])
except _SetupCancelled:
print()
print_info(cancelled_message)
# =============================================================================
# Prompt primitives
# =============================================================================
def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
"""Single-select menu using curses. Delegates to curses_radiolist."""
from hermes_cli.curses_ui import curses_radiolist
return curses_radiolist(question, choices, selected=default, cancel_returns=-1, description=description)
def prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
"""Prompt for a choice from a list with arrow key navigation.
Escape cancels an active setup wizard. Outside setup it keeps the current
default. The curses component owns its own numbered fallback, so a cancel
result must never be mistaken for a request to open another prompt.
Ctrl+C exits the wizard.
"""
idx = _curses_prompt_choice(question, choices, default, description=description)
if idx >= 0:
if idx == default:
print_info(" Skipped (keeping current)")
print()
return default
print()
return idx
return default
def is_noninteractive() -> bool:
"""True when no human is available to answer a prompt.
The dashboard/desktop spawn CLI actions with ``stdin=DEVNULL`` and
``HERMES_NONINTERACTIVE=1`` (see ``hermes_cli/web_server.py``). In that
context an ``input()`` raises ``EOFError`` immediately, so a prompt that
aborts on EOF kills the spawned action — this is what made the desktop
"restart gateway" fail when the Windows gateway service was not yet
installed (the start path asks "Install it now?" with no one to answer).
Honour the explicit env flag here so callers fall back to their default.
"""
return os.environ.get("HERMES_NONINTERACTIVE", "").strip().lower() in {"1", "true", "yes", "on"}
def prompt_yes_no(question: str, default: bool = True) -> bool:
"""Prompt for yes/no. Ctrl+C exits, empty input returns default.
Non-interactive callers (``HERMES_NONINTERACTIVE=1`` or a closed/redirected
stdin) have no one to answer, so fall back to ``default`` instead of
aborting the whole process.
"""
if is_noninteractive():
return default
# Setup owns a scoped curses navigation handler. Route binary selections
# through the same menu surface so ESC and left-arrow work consistently,
# while preserving the traditional line prompt for every other caller.
if _SETUP_NAVIGATION.get() is not None:
return _curses_prompt_choice(question, ["Yes", "No"], 0 if default else 1) == 0
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:
print()
sys.exit(1)
except EOFError:
# No stdin to read (closed/redirected, e.g. a spawned action with
# stdin=DEVNULL). Accept the default rather than exit so the caller
# can proceed unattended instead of failing the whole command.
print()
return default
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:
"""Multi-select checklist; returns the sorted indices of selected items.
Each item is a display string; ``pre_selected`` indices start checked. The user toggles
with Space and confirms with Enter on the appended "Continue →" option. Cancel keeps the
pre-selection. Falls back to a numbered toggle interface when curses is unavailable.
"""
if pre_selected is None:
pre_selected = []
from hermes_cli.curses_ui import curses_checklist
chosen = curses_checklist(title, items, set(pre_selected), cancel_returns=set(pre_selected))
return sorted(chosen)
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()
value = prompt(f" {var.get('prompt', var['name'])}", password=bool(var.get("password")))
if value:
save_env_value(var["name"], value)
print_success(" ✓ Saved")
else:
print_warning(" Skipped (configure later with 'hermes setup')")
def _module_installed(name: str) -> bool:
try:
return importlib.util.find_spec(name) is not None
except Exception:
return False
def _print_banner(*lines: str) -> None:
"""Print the magenta box banner: top border, the given body lines, bottom border."""
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA))
for line in lines:
print(color(line, Colors.MAGENTA))
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA))
# Tool categories and provider config are in tools_config.py (shared
# between `hermes tools` and `hermes setup tools`).
# =============================================================================
# Section 1: Model & Provider Configuration
# =============================================================================
def setup_model_provider(config: dict, *, quick: bool = False):
"""Configure the inference provider and default model.
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
for provider selection, credential prompting, and model picking.
This ensures a single code path for all provider setup — any new
provider added to ``hermes model`` is automatically available here.
When *quick* is True, skips credential rotation, vision, and TTS
configuration — used by the streamlined first-time quick setup.
"""
from hermes_cli.config import load_config, save_config
print_header("Inference Provider")
print_info("Choose how to connect to your main chat model.")
print_info(f" Guide: {_DOCS_BASE}/integrations/providers")
print()
# Delegate to the shared hermes model flow — handles provider picker,
# credential prompting, model selection, and config persistence.
from hermes_cli.main import select_provider_and_model
try:
select_provider_and_model()
except (SystemExit, KeyboardInterrupt):
print()
print_info("Provider setup skipped.")
except Exception as exc:
logger.debug("select_provider_and_model error during setup: %s", exc)
print_warning(f"Provider setup encountered an error: {exc}")
print_info("You can try again later with: hermes model")
# Re-sync the wizard's config dict from what cmd_model saved to disk.
# cmd_model writes via its own load/save cycle, and the wizard's final
# save_config(config) must not overwrite those changes with stale values
# (#4172). Refresh in place so callers holding the same object see every
# section the picker may have changed (model, custom_providers, auxiliary, ...).
config.clear()
config.update(load_config())
# Credential rotation, vision-backend selection, and TTS provider have safe
# defaults (rotation off, vision auto-detected, TTS = Edge) and are configured
# on demand via `hermes auth add`, `hermes setup` vision, and `hermes setup tts`.
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
# =============================================================================
# Section 3: Agent Settings
# =============================================================================
def _apply_default_agent_settings(config: dict):
"""Apply recommended defaults for all agent settings without prompting."""
config.setdefault("agent", {})["max_turns"] = 150
# config.yaml is the authoritative source for max_turns; the gateway
# bridges it into HERMES_MAX_ITERATIONS at startup. We no longer write
# to .env to avoid the dual-source inconsistency that caused the
# 60-vs-500 bug (stale .env entry silently shadowing config.yaml).
remove_env_value("HERMES_MAX_ITERATIONS")
config.setdefault("display", {})["tool_progress"] = "all"
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
# Default: never auto-reset sessions. This matches the gateway's own
# default (SessionResetPolicy.mode = "none"); we still write it
# explicitly so the choice is visible/editable in config.yaml.
config.setdefault("session_reset", {})["mode"] = "none"
save_config(config)
print_success("Applied recommended defaults:")
print_info(" Max iterations: 150")
print_info(" Tool progress: all")
print_info(" Compression threshold: 0.50")
print_info(" Session reset: never (use /reset or compression)")
print_info(" Run `hermes setup agent` later to customize.")
def _prompt_int_setting(section: dict, key: str, label: str, current, accept) -> None:
"""Prompt for an int; store it under *key* only when it parses and *accept* holds."""
try:
value = int(prompt(label, str(current)))
if accept(value):
section[key] = value
except ValueError:
pass
_TOOL_PROGRESS_HELP = (
"Tool Progress Display",
"Controls how much tool activity is shown (CLI and messaging).",
" off — Silent, just the final response",
" new — Show tool name only when it changes (less noise)",
" all — Show every tool call with a short preview",
" verbose — Full args, results, and debug logs",
" log — Silent in chat; write every tool call to ~/.hermes/logs/tool_calls.log (gateway only)",
)
_SESSION_RESET_HELP = (
"Messaging sessions (Telegram, Discord, etc.) accumulate context over time.",
"Each message adds to the conversation history, which means growing API costs.",
"",
"To manage this, sessions can automatically reset after a period of inactivity",
"or at a fixed time each day. When a reset happens, the agent saves important",
"things to its persistent memory first — but the conversation context is cleared.",
"",
"You can also manually reset anytime by typing /reset in chat.",
"",
)
_SESSION_RESET_CHOICES = [
"Inactivity + daily reset (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 (recommended - context lives until /reset or context compression)",
"Keep current settings",
]
_SESSION_RESET_MODES = ("both", "idle", "daily", "none") # index 4 = keep current
def setup_agent_settings(config: dict):
"""Configure agent behavior: iterations, progress display, compression, session reset."""
print_header("Agent Settings")
print_info(f" Guide: {_DOCS_BASE}/user-guide/configuration")
print()
# ── Max Iterations ──
# config.yaml is authoritative; a legacy .env entry (pre-PR#18413) must not
# surface a stale number, so read from config only.
current_max = str(cfg_get(config, "agent", "max_turns", default=90))
print_info("Maximum tool-calling iterations per conversation.")
print_info("Higher = more complex tasks, but costs more tokens.")
print_info(f"Press Enter to keep {current_max}. Use 90 for most tasks or 150+ for open exploration.")
try:
max_iter = int(prompt("Max iterations", current_max))
if max_iter > 0:
# config.yaml only; drop any stale .env entry — gateway/run.py derives
# HERMES_MAX_ITERATIONS from agent.max_turns at startup.
config.setdefault("agent", {})["max_turns"] = max_iter
config.pop("max_turns", None)
remove_env_value("HERMES_MAX_ITERATIONS")
print_success(f"Max iterations set to {max_iter}")
except ValueError:
print_warning("Invalid number, keeping current value")
# ── Tool Progress Display ──
print_info("")
for line in _TOOL_PROGRESS_HELP:
print_info(line)
current_mode = cfg_get(config, "display", "tool_progress", default="all")
mode = prompt("Tool progress mode", current_mode)
if mode.lower() in {"off", "new", "all", "verbose", "log"}:
config.setdefault("display", {})["tool_progress"] = mode.lower()
save_config(config)
print_success(f"Tool progress set to: {mode.lower()}")
else:
print_warning(f"Unknown mode '{mode}', keeping '{current_mode}'")
# ── Context Compression ──
print_header("Context Compression")
print_info("Automatically summarizes old messages when context gets too long.")
print_info("Higher threshold = compress later (use more context). Lower = compress sooner.")
config.setdefault("compression", {})["enabled"] = True
current_threshold = cfg_get(config, "compression", "threshold", default=0.50)
try:
threshold = float(prompt("Compression threshold (0.5-0.95)", str(current_threshold)))
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.50)}")
# ── Session Reset Policy ──
print_header("Session Reset Policy")
for line in _SESSION_RESET_HELP:
print_info(line)
current_policy = config.get("session_reset", {})
current_mode = current_policy.get("mode", "none")
current_idle = current_policy.get("idle_minutes", 1440)
current_hour = current_policy.get("at_hour", 4)
default_reset = _SESSION_RESET_MODES.index(current_mode) if current_mode in _SESSION_RESET_MODES else 3
reset_idx = prompt_choice("Session reset mode:", _SESSION_RESET_CHOICES, default_reset)
reset_cfg = config.setdefault("session_reset", {})
mode = _SESSION_RESET_MODES[reset_idx] if 0 <= reset_idx < len(_SESSION_RESET_MODES) else None
if mode is not None:
reset_cfg["mode"] = mode
if mode in ("both", "idle"):
_prompt_int_setting(reset_cfg, "idle_minutes", " Inactivity timeout (minutes)", current_idle, lambda v: v > 0)
if mode in ("both", "daily"):
_prompt_int_setting(reset_cfg, "at_hour", " Daily reset hour (0-23, local time)", current_hour, lambda v: 0 <= v <= 23)
idle_now, hour_now = reset_cfg.get("idle_minutes", 1440), reset_cfg.get("at_hour", 4)
if mode == "both":
print_success(f"Sessions reset after {idle_now} min idle or daily at {hour_now}:00")
elif mode == "idle":
print_success(f"Sessions reset after {idle_now} min of inactivity")
elif mode == "daily":
print_success(f"Sessions reset daily at {hour_now}:00")
elif 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.")
save_config(config)
# =============================================================================
# Section 5: Tool Configuration (delegates to unified tools_config.py)
# =============================================================================
def setup_tools(config: dict, first_install: bool = False):
"""Configure tools — delegates to the unified tools_command() in tools_config.py.
Both `hermes setup tools` and `hermes tools` use the same flow:
platform selection → toolset toggles → provider/API key configuration.
``first_install`` selects the simplified first-install flow (no platform
menu, prompts for all unconfigured API keys).
"""
from hermes_cli.tools_config import tools_command
tools_command(first_install=first_install, config=config)
# =============================================================================
# Shared Metrics
# =============================================================================
_SEND_CONSENT_EXPLAINER = (
"",
"Sending uploads each daily package to the Nous telemetry",
"service. Packages carry your profile-scoped install ID, a",
"stable random UUID that identifies this profile across days",
"(it contains no personal information and is reset by deleting",
"the shared-metrics directory). Only packages whose entire",
"collection period falls inside a recorded consent window are",
"ever sent — data from before you opt in, or from any gap",
"while sending was off, stays on this machine. Sending can be",
"turned off again at any time.",
)
def setup_telemetry(config: dict):
"""Configure the local shared-metrics subscriber and optional sending."""
print_header("Shared Metrics")
print_info("Shared metrics contain only bounded counters and histograms.")
print_info("Collection is local. Sending them to Nous is a separate opt-in.")
telemetry = config.get("telemetry")
if not isinstance(telemetry, dict):
telemetry = {}
config["telemetry"] = telemetry
shared_metrics = telemetry.get("shared_metrics")
if not isinstance(shared_metrics, dict):
shared_metrics = {}
telemetry["shared_metrics"] = shared_metrics
current = shared_metrics.get("enabled") is True
shared_metrics["enabled"] = prompt_yes_no("Enable local shared metrics?", default=current)
if not shared_metrics["enabled"]:
print_info("Local shared metrics disabled.")
# Sending cannot outlive collection: leaving send=true here would be a
# configuration that logs an error on every run and never transmits.
if shared_metrics.get("send") is True:
shared_metrics["send"] = False
print_info("Sending shared metrics disabled as well.")
# Turning collection off is also a withdrawal of send consent, and it
# has to close the window like any other. Recorded unconditionally:
# the send key may already be false in config while the consent window
# is still open, and that window must not survive to be reopened.
_record_send_consent_change(enabled=False)
return
print_success("Local shared metrics enabled.")
for line in _SEND_CONSENT_EXPLAINER:
print_info(line)
shared_metrics["send"] = prompt_yes_no("Send shared metrics to Nous?", default=shared_metrics.get("send") is True)
_record_send_consent_change(enabled=shared_metrics["send"])
if shared_metrics["send"]:
print_success("Sending shared metrics enabled.")
else:
print_info("Sending shared metrics disabled (collection stays local).")
def _record_send_consent_change(*, enabled: bool) -> None:
"""Reconcile consent windows at the moment the user decides.
Same single writer as the relay and the sender — reconciliation derives
the window state from the observation, so wizard, relay, and mid-pass
callers cannot disagree. The relay's once-per-process reconcile would
catch this on the next hook fire anyway; running it here just makes the
wizard's effect immediate.
"""
try:
from hermes_cli.observability.shared_metrics import SharedMetricsStore
from hermes_cli.observability.shared_metrics_sender import reconcile_send_consent
from hermes_cli.sqlite_util import write_txn
store = SharedMetricsStore()
with store._connection() as connection:
with write_txn(connection):
reconcile_send_consent(connection, enabled)
except Exception:
# Never block the wizard on telemetry bookkeeping. The relay runs the
# same reconciliation on the next lifecycle hook.
logger.debug("Unable to record shared-metrics consent change", exc_info=True)
# =============================================================================
# Extracted sections (re-exported so callers and test patches keep resolving
# through hermes_cli.setup). These modules import this module lazily inside
# function bodies, so the top-level import here is cycle-free.
# =============================================================================
from hermes_cli.setup_tts import ( # noqa: E402,F401
_run_xai_oauth_login_from_setup,
_setup_tts_provider,
_xai_oauth_logged_in_for_setup,
setup_tts,
)
from hermes_cli.setup_terminal import ( # noqa: E402,F401
_prompt_vercel_sandbox_settings,
_read_nearest_vercel_project,
setup_terminal_backend,
)
from hermes_cli.setup_platforms import ( # noqa: E402,F401
_TELEGRAM_BOT_TOKEN_RE,
_profile_name_from_hermes_home,
_setup_bluebubbles,
_setup_qqbot,
_setup_telegram,
_setup_telegram_auto_result,
_setup_webhooks,
setup_gateway,
)
from hermes_cli.setup_summary import _print_setup_summary # noqa: E402,F401
from hermes_cli.setup_migration import ( # noqa: E402,F401
_OPENCLAW_SCRIPT,
_get_section_config_summary,
_load_openclaw_migration_module,
_model_section_has_credentials,
_offer_openclaw_migration,
_print_migration_preview,
_skip_configured_section,
)
from hermes_cli.setup_quick import ( # noqa: E402,F401
_blank_slate_minimal_toolsets,
_blank_slate_minimize_config,
_blank_slate_walkthrough,
_print_macos_fda_tip,
_run_blank_slate_setup,
_run_first_time_quick_setup,
_run_portal_one_shot,
_run_quick_setup,
)
# =============================================================================
# Main Wizard Orchestrator
# =============================================================================
SETUP_SECTIONS = [
("model", "Model & Provider", setup_model_provider),
("tts", "Text-to-Speech", setup_tts),
("terminal", "Terminal Backend", setup_terminal_backend),
("gateway", "Messaging Platforms (Gateway)", setup_gateway),
("tools", "Tools", setup_tools),
("telemetry", "Shared Metrics", setup_telemetry),
("agent", "Agent Settings", setup_agent_settings),
]
def run_setup_wizard(args):
"""Run setup with navigation control scoped to this invocation."""
with _setup_navigation_scope():
try:
return _run_setup_wizard_impl(args)
except _SetupCancelled:
print()
print_info("Setup cancelled. Remaining sections were not changed.")
return None
def _backup_config_file(config_path: Path) -> Path | None:
"""Back up config.yaml before setup modifies it (#3522); None when absent or copy fails."""
if not config_path.exists():
return None
from datetime import datetime as _dt
backup_path = config_path.with_suffix(f".yaml.bak.{_dt.now().strftime('%Y%m%d_%H%M%S')}")
try:
import shutil
shutil.copy2(config_path, backup_path)
except Exception:
return None
return backup_path
def _run_setup_section(config: dict, section: str) -> None:
"""``hermes setup <section>``: run one SETUP_SECTIONS entry under the banner."""
for key, label, func in SETUP_SECTIONS:
if key == section:
_print_banner(f"│ ⚕ Hermes Setup — {label:<34s} │")
_run_setup_steps([(label, lambda setup_func=func: setup_func(config))])
save_config(config)
print()
print_success(f"{label} configuration complete!")
return
print_error(f"Unknown setup section: {section}")
print_info(f"Available sections: {', '.join(k for k, _, _ in SETUP_SECTIONS)}")
def _run_full_setup(config: dict, hermes_home, *, is_existing: bool, migration_ran: bool) -> None:
"""Full Setup — run all sections, honoring post-migration skips."""
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'")
if migration_ran:
print()
print_info("Settings were imported from OpenClaw.")
print_info("Each section below will show what was imported — press Enter to keep,")
print_info("or choose to reconfigure if needed.")
# Agent Settings are no longer prompted. First installs get the recommended
# defaults silently; existing installs keep theirs. Tune via `hermes setup agent`.
if not is_existing:
_apply_default_agent_settings(config)
def _skip(key: str, label: str) -> bool:
return migration_ran and _skip_configured_section(config, key, label)
def _model_step() -> None:
if not _skip("model", "Model & Provider"):
setup_model_provider(config)
def _terminal_step() -> None:
if not _skip("terminal", "Terminal Backend"):
setup_terminal_backend(config)
def _gateway_step() -> None:
if not _skip("gateway", "Messaging Platforms"):
setup_gateway(config)
return
# A migrated gateway section can be skipped, but its service still
# needs to exist so imported platforms and cron jobs become active.
from hermes_cli.gateway import ensure_gateway_service
ensure_gateway_service(context="setup")
def _tools_step() -> None:
if not _skip("tools", "Tools"):
setup_tools(config, first_install=not is_existing)
_run_setup_steps(
[
("Model & Provider", _model_step),
("Terminal Backend", _terminal_step),
("Messaging Platforms", _gateway_step),
("Tools", _tools_step),
]
)
def _run_setup_wizard_impl(args):
"""Run the interactive setup wizard.
Supports full, quick, and section-specific setup:
hermes setup — full or quick (auto-detected)
hermes setup model — just model/provider
hermes setup tts — just text-to-speech
hermes setup terminal — just terminal backend
hermes setup gateway — just messaging platforms
hermes setup tools — just tool configuration
hermes setup telemetry — just local shared metrics
hermes setup agent — just agent settings
"""
from hermes_cli.config import is_managed, managed_error
if is_managed():
managed_error("run setup wizard")
return
ensure_hermes_home()
if bool(getattr(args, "reset", False)):
save_config(copy.deepcopy(DEFAULT_CONFIG))
print_success("Configuration reset to defaults.")
reconfigure_requested = bool(getattr(args, "reconfigure", False))
quick_requested = bool(getattr(args, "quick", False))
config = load_config()
hermes_home = get_hermes_home()
config_path = get_config_path()
_backup_path = _backup_config_file(config_path)
# Detect non-interactive environments (headless SSH, Docker, CI/CD)
if getattr(args, 'non_interactive', False) or not is_interactive_stdin():
print_noninteractive_setup_guidance("Running in a non-interactive environment (no TTY detected).")
return
# --portal: one-shot Nous Portal setup. Skips the rest of the wizard.
if bool(getattr(args, "portal", False)):
_run_portal_one_shot(config)
return
section = getattr(args, "section", None)
if section:
_run_setup_section(config, section)
return
# Check if this is an existing installation with a provider configured
from hermes_cli.auth import get_active_provider
is_existing = (
bool(get_env_value("OPENROUTER_API_KEY"))
or bool(get_env_value("OPENAI_BASE_URL"))
or get_active_provider() is not None
)
_print_banner(
"│ ⚕ Hermes Agent Setup Wizard │",
"├─────────────────────────────────────────────────────────┤",
"│ Let's configure your Hermes Agent installation. │",
"│ Press Ctrl+C at any time to exit. │",
)
migration_ran = False
if is_existing:
# Existing install — default is the full-wizard reconfigure flow.
# Every prompt shows the current value as its default, so pressing
# Enter keeps it. Opt into `--quick` for the narrow "just fill in
# missing items" flow (useful after a partial OpenClaw migration
# or when a required API key got cleared).
if quick_requested:
_run_setup_steps([("Quick Setup", lambda: _run_quick_setup(config, hermes_home))])
return
print()
print_header("Reconfigure")
print_success("You already have Hermes configured.")
print_info("Running the full wizard — each prompt shows your current value.")
print_info("Press Enter to keep it, or type a new value to change it.")
print_info("")
print_info("Tip: jump straight to a section with 'hermes setup model|terminal|")
print_info(" gateway|tools|agent', or fill only missing items with --quick.")
# --reconfigure is the default on existing installs; the flag is kept
# for backwards compatibility and is a no-op here.
else:
# ── First-Time Setup ──
print()
# --reconfigure / --quick on a fresh install are meaningless — fall
# through to the normal first-time flow.
if reconfigure_requested or quick_requested:
print_info("No existing configuration found — running first-time setup.")
print()
# Offer OpenClaw migration before configuration begins
migration_ran = _offer_openclaw_migration(hermes_home)
if migration_ran:
config = load_config()
setup_mode = prompt_choice(
"How would you like to set up Hermes?",
[
"Quick Setup (Nous Portal) — free OAuth login, no API keys, model + tools (recommended)",
"Full setup — configure every provider, tool & option yourself (bring your own keys)",
"Blank Slate — everything off except the bare minimum; opt in to each capability",
],
0,
)
if setup_mode == 0:
_run_setup_steps(
[("Quick Setup", lambda: _run_first_time_quick_setup(config, hermes_home, is_existing))]
)
return
if setup_mode == 2:
_run_setup_steps(
[("Blank Slate", lambda: _run_blank_slate_setup(config, hermes_home, is_existing))]
)
return
_run_full_setup(config, hermes_home, is_existing=is_existing, migration_ran=migration_ran)
# Save and show summary
save_config(config)
if _backup_path and _backup_path.exists():
print_info(f"Previous config backed up to: {_backup_path}")
print_info("If setup changed a value you customized, restore it with:")
print_info(f" cp {_backup_path} {config_path}")
_print_setup_summary(config, hermes_home)