Files
hermes-agent/hermes_cli/setup.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

2010 lines
86 KiB
Python

"""Interactive setup wizard for Hermes Agent (config lives in ~/.hermes/).
Independently-runnable sections: Model & Provider, Terminal Backend, Agent Settings, Messaging
Platforms, Tools. Section bodies live in sibling setup_* modules 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
# Config helpers are re-exported (tests patch them on this module). display_hermes_home is
# imported lazily at call sites (stale-module safety during hermes update).
from hermes_cli.config import (
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,
)
from hermes_cli.colors import Colors, color
from hermes_cli.cli_output import print_error, print_info, print_success, print_warning
from hermes_cli.secret_prompt import masked_secret_prompt
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
_DOCS_BASE = "https://hermes-agent.nousresearch.com/docs"
_BRACKETED_PASTE_PATTERN = re.compile(r"\x1b\[\s*200~|\x1b\[\s*201~")
def print_header(title: str, *, gap: bool = False):
"""Print a section header (``gap`` adds an extra blank line before it)."""
if gap:
print()
print()
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
def _info(*lines: str | None) -> None:
"""print_info each line in order; ``None`` emits a bare blank ``print()``."""
for line in lines:
print() if line is None else print_info(line)
def _sub_dict(parent: dict, key: str) -> dict:
"""``parent[key]`` as a dict, replacing a missing or non-dict value with ``{}``."""
child = parent.get(key)
if not isinstance(child, dict):
child = parent[key] = {}
return child
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:
_sub_dict(config, "agent")["reasoning_effort"] = effort
def is_interactive_stdin() -> bool:
"""Return True when stdin looks like a usable interactive TTY."""
try:
return bool(sys.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)
_info("The interactive wizard cannot be used here.", None,
"Configure Hermes using environment variables or config commands:",
" hermes config set model.provider custom",
" hermes config set model.base_url http://localhost:8080/v1",
" hermes config set model.default your-model-name", None,
"Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.",
"Run 'hermes setup' in an interactive terminal to use the full wizard.", None)
def _sanitize_pasted_input(value: str) -> str:
"""Strip terminal bracketed-paste control markers from pasted text."""
return _BRACKETED_PASTE_PATTERN.sub("", value) if isinstance(value, str) and value else value
def prompt(question: str, default: str = None, password: bool = False) -> str:
"""Prompt for input with optional default."""
display = color(f"{question} [{default}]: " if default else f"{question}: ", Colors.YELLOW)
try:
if password:
value = masked_secret_prompt(display)
else:
from hermes_cli.cli_output import line_input
value = line_input(display)
return _sanitize_pasted_input(value).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`` so navigation reaches
the outer state machine unchanged and it 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.reset(section_index)
self.prompt_index = prompt_index
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: list[object] = []
self.replay_choices: list[object] = 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()
idx = state.active_prompt_index = state.prompt_index
state.prompt_index += 1
allow_back = state.section_index > 0 or idx > 0
if idx < len(state.replay_choices):
return MenuNavigationStart(allow_back=allow_back, replay_value=copy.deepcopy(state.replay_choices[idx]))
return MenuNavigationStart(allow_back=allow_back)
if event is MenuNavigationEvent.RESOLVE:
prompt_index = state.active_prompt_index
if prompt_index >= 0: # replace this answer and drop every later one
state.resolved_choices[prompt_index:] = [copy.deepcopy(value)]
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: at a section's first choice it returns to
the previous section; from a later choice it replays earlier selections invisibly and reopens
only the preceding prompt."""
state = _SETUP_NAVIGATION.get()
section_index = 0
answers_by_section: dict[int, list[object]] = {}
replay_by_section: dict[int, list[object]] = {}
def _record_answers() -> None:
if state is not None:
answers_by_section[section_index] = copy.deepcopy(state.resolved_choices)
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:
_record_answers()
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
_record_answers()
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 — for commands such as
``hermes model`` that use the wizard's pickers outside ``run_setup_wizard``."""
with _setup_navigation_scope():
try:
_run_setup_steps([(label, action)])
except _SetupCancelled:
_info(None, 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 default (the curses component owns its own numbered
fallback, so a cancel result must never open another prompt). Ctrl+C exits the wizard."""
idx = _curses_prompt_choice(question, choices, default, description=description)
if idx < 0:
return default
if idx == default:
_info(" Skipped (keeping current)", None)
return default
print()
return idx
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`` (``hermes_cli/web_server.py``), where a
prompt that aborts on EOF would kill the spawned action — 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, ``HERMES_NONINTERACTIVE=1`` or a
closed/redirected stdin return ``default`` instead of aborting the whole process."""
if is_noninteractive():
return default
# Inside setup, route binary selections through the curses menu so ESC and left-arrow work
# consistently; every other caller keeps the traditional line prompt.
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 (closed/redirected, e.g. stdin=DEVNULL): accept the default so the caller
# proceeds unattended instead of failing the whole command.
print()
return default
answer = {"": default, "y": True, "yes": True, "n": False, "no": False}.get(value)
if answer is not None:
return answer
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. ``pre_selected``
start checked; Space toggles, Enter confirms, cancel keeps the pre-selection."""
from hermes_cli.curses_ui import curses_checklist
pre = set(pre_selected or [])
return sorted(curses_checklist(title, items, pre, cancel_returns=pre))
def _section_rule(title: str) -> None:
"""Blank-padded cyan ``─── title ───`` divider used by the key-entry screens."""
print()
print(color(f" ─── {title} ───", Colors.CYAN))
print()
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"
_section_rule(var.get("description", var["name"]))
if tools_str:
print_info(f" Enables: {tools_str}")
if var.get("url"):
print_info(f" Get your key at: {var['url']}")
print()
_prompt_and_save_env_var(var, " ✓ Saved", " Skipped (configure later with 'hermes setup')")
def _prompt_and_save_env_var(var: dict, saved_msg: str, skipped_msg: str) -> None:
"""Prompt for one env-var value (masked when secret); persist and confirm, or report the skip."""
value = prompt(f" {var.get('prompt', var['name'])}", password=bool(var.get("password")))
if value:
save_env_value(var["name"], value)
print_success(saved_msg)
else:
print_warning(skipped_msg)
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))
# Web tools (Exa, Parallel, Firecrawl, Tavily, or Keenable)
if subscription_features.web.managed_by_nous:
tool_status.append(("Web Search & Extract (Nous subscription)", True, None))
elif subscription_features.web.available:
label = "Web Search & Extract"
if subscription_features.web.current_provider:
label = f"Web Search & Extract ({subscription_features.web.current_provider})"
tool_status.append((label, True, None))
else:
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, TAVILY_API_KEY, KEENABLE_API_KEY, or SEARXNG_URL"))
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
browser_provider = subscription_features.browser.current_provider
if subscription_features.browser.managed_by_nous:
tool_status.append(("Browser Automation (Nous Browser Use)", True, None))
elif subscription_features.browser.available:
label = "Browser Automation"
if browser_provider:
label = f"Browser Automation ({browser_provider})"
tool_status.append((label, True, None))
else:
missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browser Use or Browserbase"
if browser_provider == "Browserbase":
missing_browser_hint = (
"npm install -g agent-browser and set "
"BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID"
)
elif browser_provider == "Browser Use":
missing_browser_hint = (
"npm install -g agent-browser and set BROWSER_USE_API_KEY"
)
elif browser_provider == "Camofox":
missing_browser_hint = "CAMOFOX_URL"
elif browser_provider == "Local browser":
missing_browser_hint = (
"npm install -g agent-browser && agent-browser install --with-deps"
)
tool_status.append(
("Browser Automation", False, missing_browser_hint)
)
# Image generation — FAL (direct or via Nous), or any plugin-registered
# provider (OpenAI, etc.)
if subscription_features.image_gen.managed_by_nous:
tool_status.append(("Image Generation (Nous subscription)", True, None))
elif subscription_features.image_gen.available:
tool_status.append(("Image Generation", True, None))
else:
# Fall back to probing plugin-registered providers so OpenAI-only
# setups don't show as "missing FAL_KEY".
_img_backend = None
try:
from agent.image_gen_registry import list_providers
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
for _p in list_providers():
if _p.name == "fal":
continue
try:
if _p.is_available():
_img_backend = _p.display_name
break
except Exception:
continue
except Exception:
pass
if _img_backend:
tool_status.append((f"Image Generation ({_img_backend})", True, None))
else:
tool_status.append(("Image Generation", False, "FAL_KEY or OPENAI_API_KEY"))
# Video generation — opt-in via `hermes tools` → Video Generation.
# Only show the row when a plugin reports available so we don't badger
# users who don't care about video gen with a "missing" status line.
if subscription_features.video_gen.managed_by_nous:
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
else:
try:
from agent.video_gen_registry import list_providers as _list_video_providers
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
_ensure_plugins()
_video_backend = None
for _vp in _list_video_providers():
try:
if _vp.is_available():
_video_backend = _vp.display_name
break
except Exception:
continue
except Exception:
_video_backend = None
if _video_backend:
tool_status.append((f"Video Generation ({_video_backend})", True, None))
# TTS — show configured provider
tts_provider = cfg_get(config, "tts", "provider", default="edge")
if subscription_features.tts.managed_by_nous:
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
elif tts_provider == "openai" and (
get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
):
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
elif tts_provider == "minimax" and get_env_value("MINIMAX_API_KEY"):
tool_status.append(("Text-to-Speech (MiniMax)", True, None))
elif tts_provider == "mistral" and get_env_value("MISTRAL_API_KEY"):
tool_status.append(("Text-to-Speech (Mistral Voxtral)", True, None))
elif tts_provider == "gemini" and (get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")):
tool_status.append(("Text-to-Speech (Google Gemini)", True, None))
elif tts_provider == "neutts":
try:
neutts_ok = importlib.util.find_spec("neutts") is not None
except Exception:
neutts_ok = False
if neutts_ok:
tool_status.append(("Text-to-Speech (NeuTTS local)", True, None))
else:
tool_status.append(("Text-to-Speech (NeuTTS — not installed)", False, "run 'hermes setup tts'"))
elif tts_provider == "kittentts":
try:
kittentts_ok = importlib.util.find_spec("kittentts") is not None
except Exception:
kittentts_ok = False
if kittentts_ok:
tool_status.append(("Text-to-Speech (KittenTTS local)", True, None))
else:
tool_status.append(("Text-to-Speech (KittenTTS — not installed)", False, "run 'hermes setup tts'"))
else:
tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
# STT — show configured provider
stt_provider = cfg_get(config, "stt", "provider", default="local") or "local"
_stt_feature = subscription_features.features.get("stt")
if _stt_feature is not None and _stt_feature.managed_by_nous:
tool_status.append(("Speech-to-Text (OpenAI via Nous subscription)", True, None))
elif stt_provider == "openai" and (
get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
):
tool_status.append(("Speech-to-Text (OpenAI)", True, None))
elif stt_provider == "groq" and get_env_value("GROQ_API_KEY"):
tool_status.append(("Speech-to-Text (Groq Whisper)", True, None))
elif stt_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
tool_status.append(("Speech-to-Text (ElevenLabs Scribe)", True, None))
elif stt_provider == "xai":
tool_status.append(("Speech-to-Text (xAI)", True, None))
elif stt_provider == "deepinfra" and get_env_value("DEEPINFRA_API_KEY"):
tool_status.append(("Speech-to-Text (DeepInfra)", True, None))
else:
try:
fw_ok = importlib.util.find_spec("faster_whisper") is not None
except Exception:
fw_ok = False
if fw_ok:
tool_status.append(("Speech-to-Text (Local Whisper)", True, None))
else:
tool_status.append(
("Speech-to-Text (Local Whisper — not installed)", False, "run 'hermes tools' → Speech-to-Text")
)
if subscription_features.modal.managed_by_nous:
tool_status.append(("Modal Execution (Nous subscription)", True, None))
elif cfg_get(config, "terminal", "backend") == "modal":
if subscription_features.modal.direct_override:
tool_status.append(("Modal Execution (direct Modal)", True, None))
else:
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
elif managed_nous_tools_enabled() and subscription_features.nous_auth_present:
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
# Home Assistant
if get_env_value("HASS_TOKEN"):
tool_status.append(("Smart Home (Home Assistant)", True, None))
# Spotify (OAuth via hermes auth spotify — check auth.json, not env vars)
try:
from hermes_cli.auth import get_provider_auth_state
_spotify_state = get_provider_auth_state("spotify") or {}
if _spotify_state.get("access_token") or _spotify_state.get("refresh_token"):
tool_status.append(("Spotify (PKCE OAuth)", True, None))
except Exception:
pass
# Skills Hub
if get_env_value("GITHUB_TOKEN"):
tool_status.append(("Skills Hub (GitHub)", True, None))
else:
tool_status.append(("Skills Hub (GitHub)", False, "GITHUB_TOKEN"))
# Terminal (always available if system deps met)
tool_status.append(("Terminal/Commands", True, None))
# Task planning (always available, in-memory)
tool_status.append(("Task Planning (todo)", True, None))
# Skills (always available -- bundled skills + user-created skills)
tool_status.append(("Skills (view, create, edit)", True, None))
# Print status
available_count = sum(1 for _, avail, _ in tool_status if avail)
total_count = len(tool_status)
print_info(f"{available_count}/{total_count} tool categories available:")
print()
for name, available, missing_var in tool_status:
if available:
print(f" {color('✓', Colors.GREEN)} {name}")
else:
print(
f" {color('✗', Colors.RED)} {name} {color(f'(missing {missing_var})', Colors.DIM)}"
)
print()
disabled_tools = [(name, var) for name, avail, var in tool_status if not avail]
if disabled_tools:
print_warning(
"Some tools are disabled. Run 'hermes setup tools' to configure them,"
)
from hermes_constants import display_hermes_home as _dhh
print_warning(f"or edit {_dhh()}/.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
from hermes_constants import display_hermes_home as _dhh
print(color(f"📁 All your files are in {_dhh()}/:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('Settings:', Colors.YELLOW)} {get_config_path()}")
print(f" {color('API Keys:', Colors.YELLOW)} {get_env_path()}")
print(
f" {color('Data:', Colors.YELLOW)} {hermes_home}/cron/, sessions/, logs/"
)
print()
print(color("─" * 60, Colors.DIM))
print()
print(color("📝 To edit your configuration:", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes setup', Colors.GREEN)} Re-run the full wizard")
print(f" {color('hermes setup model', Colors.GREEN)} Change model/provider")
print(f" {color('hermes setup terminal', Colors.GREEN)} Change terminal backend")
print(f" {color('hermes setup gateway', Colors.GREEN)} Configure messaging")
print(f" {color('hermes setup tools', Colors.GREEN)} Configure tool providers")
print()
print(f" {color('hermes config', Colors.GREEN)} View current settings")
print(
f" {color('hermes config edit', Colors.GREEN)} Open config in your editor"
)
print(f" {color('hermes config set <key> <value>', Colors.GREEN)}")
print(" Set a specific value")
print()
print(" Or edit the files directly:")
print(f" {color(f'nano {get_config_path()}', Colors.DIM)}")
print(f" {color(f'nano {get_env_path()}', Colors.DIM)}")
print()
print(color("─" * 60, Colors.DIM))
print()
print(color("🚀 Ready to go!", Colors.CYAN, Colors.BOLD))
print()
print(f" {color('hermes', Colors.GREEN)} Start chatting")
print(f" {color('hermes gateway', Colors.GREEN)} Start messaging gateway")
print(f" {color('hermes doctor', Colors.GREEN)} Check for issues")
print()
def _prompt_container_resources(config: dict):
"""Prompt for container resource settings (Docker, Singularity, Modal, Daytona)."""
terminal = config.setdefault("terminal", {})
print()
print_info("Container Resource Settings:")
# Persistence
current_persist = terminal.get("container_persistent", True)
persist_label = "yes" if current_persist else "no"
print_info(" Persistent filesystem keeps files between sessions.")
print_info(" Set to 'no' for ephemeral sandboxes that reset each time.")
persist_str = prompt(
" 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(" 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(" 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(" Disk in MB (51200 = 50GB)", str(current_disk))
try:
terminal["container_disk"] = int(disk_str)
except ValueError:
pass
def _prompt_vercel_sandbox_settings(config: dict):
"""Prompt for Vercel Sandbox settings without exposing unsupported disk sizing."""
terminal = config.setdefault("terminal", {})
print()
print_info("Vercel Sandbox settings:")
print_info(" Filesystem persistence uses Vercel snapshots.")
print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.")
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
current_runtime = terminal.get("vercel_runtime") or "node24"
supported_label = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
runtime = prompt(f" Runtime ({supported_label})", current_runtime).strip() or current_runtime
if runtime not in _SUPPORTED_VERCEL_RUNTIMES:
print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.")
runtime = current_runtime if current_runtime in _SUPPORTED_VERCEL_RUNTIMES else "node24"
terminal["vercel_runtime"] = runtime
save_env_value("TERMINAL_VERCEL_RUNTIME", runtime)
current_persist = terminal.get("container_persistent", True)
persist_label = "yes" if current_persist else "no"
terminal["container_persistent"] = prompt(
" Persist filesystem with snapshots? (yes/no)", persist_label
).lower() in {"yes", "true", "y", "1"}
current_cpu = terminal.get("container_cpu", 1)
cpu_str = prompt(" CPU cores", str(current_cpu))
try:
terminal["container_cpu"] = float(cpu_str)
except ValueError:
pass
current_mem = terminal.get("container_memory", 5120)
mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem))
try:
terminal["container_memory"] = int(mem_str)
except ValueError:
pass
if terminal.get("container_disk", 51200) not in {0, 51200}:
print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.")
terminal["container_disk"] = 51200
print()
print_info("Vercel authentication:")
print_info(" Use a long-lived Vercel access token plus project/team IDs.")
linked_project = _read_nearest_vercel_project()
if linked_project:
print_info(" Found defaults in nearest .vercel/project.json.")
remove_env_value("VERCEL_OIDC_TOKEN")
token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True)
project = prompt(
" Vercel project ID",
get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""),
)
team = prompt(
" Vercel team ID",
get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""),
)
if token:
save_env_value("VERCEL_TOKEN", token)
if project:
save_env_value("VERCEL_PROJECT_ID", project)
if team:
save_env_value("VERCEL_TEAM_ID", team)
def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]:
"""Read project/team defaults from the nearest Vercel link file."""
current = (start or Path.cwd()).resolve()
if current.is_file():
current = current.parent
for directory in (current, *current.parents):
project_file = directory / ".vercel" / "project.json"
if not project_file.exists():
continue
try:
data = json.loads(project_file.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return {
key: value
for key, value in {
"projectId": data.get("projectId"),
"orgId": data.get("orgId"),
}.items()
if isinstance(value, str) and value.strip()
}
return {}
# Tool categories and provider config are now in tools_config.py (shared
# between `hermes tools` and `hermes setup tools`).
# =============================================================================
# Section 1: Model & Provider Configuration
# =============================================================================
def setup_model_provider(config: dict, *, quick: bool = False):
"""Configure the inference provider and default model via the ``hermes model`` flow (one code
path — any provider added there is available here). *quick* is accepted for the first-time
quick setup caller; rotation, vision and TTS keep safe defaults either way."""
from hermes_cli.config import load_config, save_config
print_header("Inference Provider")
_info("Choose how to connect to your main chat model.",
f" Guide: {_DOCS_BASE}/integrations/providers", None)
from hermes_cli.main import select_provider_and_model
try:
select_provider_and_model()
except (SystemExit, KeyboardInterrupt):
_info(None, "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 from disk in place: cmd_model saved via its own load/save cycle and the wizard's
# final save_config(config) must not clobber it with stale values. Rotation, vision and TTS
# keep safe defaults (configure via `hermes auth add` / `hermes setup tts`).
config.clear()
config.update(load_config())
save_config(config)
# =============================================================================
# Section 1b: TTS Provider Configuration
def _check_espeak_ng() -> bool:
"""Check if espeak-ng is installed."""
return shutil.which("espeak-ng") is not None or shutil.which("espeak") is not None
def _install_neutts_deps() -> bool:
"""Install NeuTTS dependencies with user approval. Returns True on success."""
import subprocess
import sys
# Check espeak-ng
if not _check_espeak_ng():
print()
print_warning("NeuTTS requires espeak-ng for phonemization.")
if sys.platform == "darwin":
print_info("Install with: brew install espeak-ng")
elif sys.platform == "win32":
print_info("Install with: choco install espeak-ng")
else:
print_info("Install with: sudo apt install espeak-ng")
print()
if prompt_yes_no("Install espeak-ng now?", True):
try:
if sys.platform == "darwin":
subprocess.run(["brew", "install", "espeak-ng"], check=True)
elif sys.platform == "win32":
subprocess.run(["choco", "install", "espeak-ng", "-y"], check=True)
else:
subprocess.run(["sudo", "apt", "install", "-y", "espeak-ng"], check=True)
print_success("espeak-ng installed")
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print_warning(f"Could not install espeak-ng automatically: {e}")
print_info("Please install it manually and re-run setup.")
return False
else:
print_warning("espeak-ng is required for NeuTTS. Install it manually before using NeuTTS.")
# Install neutts Python package
print()
print_info("Installing neutts Python package...")
print_info("This will also download the TTS model (~300MB) on first use.")
print()
# Route through the canonical uv → pip → ensurepip ladder so pip-less
# venvs (Ubuntu 25.10 `python -m venv`, `uv venv`) work out of the box.
from hermes_cli.tools_config import _pip_install
try:
result = _pip_install(["-U", "neutts[all]", "--quiet"], timeout=300)
except Exception as e:
print_error(f"Failed to install neutts: {e}")
print_info("Try manually: uv pip install -U 'neutts[all]'")
return False
if result.returncode == 0:
print_success("neutts installed successfully")
return True
err = (result.stderr or "").strip()
print_error(f"Failed to install neutts: {err[:300] if err else 'install failed'}")
print_info("Try manually: uv pip install -U 'neutts[all]'")
return False
def _install_kittentts_deps() -> bool:
"""Install KittenTTS dependencies with user approval. Returns True on success."""
wheel_url = (
"https://github.com/KittenML/KittenTTS/releases/download/"
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
)
print()
print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...")
print()
from hermes_cli.tools_config import _pip_install
try:
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
except Exception as e:
print_error(f"Failed to install kittentts: {e}")
print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile")
return False
if result.returncode == 0:
print_success("kittentts installed successfully")
return True
err = (result.stderr or "").strip()
print_error(f"Failed to install kittentts: {err[:300] if err else 'install failed'}")
print_info(f"Try manually: uv pip install -U '{wheel_url}' soundfile")
return False
def _xai_oauth_logged_in_for_setup() -> bool:
"""True iff xAI Grok OAuth credentials are already stored locally.
Lets TTS / STT setup skip the API-key prompt for users who logged in
through ``hermes model`` -> xAI Grok OAuth (SuperGrok / Premium+).
"""
try:
from hermes_cli.auth import get_xai_oauth_auth_status
return bool(get_xai_oauth_auth_status().get("logged_in"))
except Exception:
return False
def _run_xai_oauth_login_from_setup() -> bool:
"""Run the xAI Grok OAuth device-code login from inside the setup wizard.
Saves OAuth tokens only. Does **not** switch the active inference
provider or rewrite ``model.provider`` — callers (TTS setup, tools
config) only need credentials for side tools.
Returns True on success, False on any failure (the caller falls back
to whatever the user picked next, e.g. Edge TTS).
"""
try:
from hermes_cli.auth import (
_is_remote_session,
_save_xai_oauth_tokens,
_xai_oauth_device_code_login,
unsuppress_credential_source,
)
except Exception as exc:
print_warning(f"xAI Grok OAuth helpers unavailable: {exc}")
return False
open_browser = not _is_remote_session()
print()
print_info("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
try:
creds = _xai_oauth_device_code_login(open_browser=open_browser)
_save_xai_oauth_tokens(
creds["tokens"],
discovery=creds.get("discovery"),
redirect_uri=creds.get("redirect_uri", ""),
last_refresh=creds.get("last_refresh"),
auth_mode="oauth_device_code",
set_active=False,
)
# Mirror model/dashboard re-login: clear device_code suppression so
# the pool can seed from the singleton after a prior `auth remove`.
unsuppress_credential_source("xai-oauth", "device_code")
return True
except Exception as exc:
print_warning(f"xAI Grok OAuth login failed: {exc}")
return False
def _setup_tts_provider(config: dict):
"""Interactive TTS provider selection with install flow for NeuTTS."""
tts_config = config.get("tts", {})
current_provider = tts_config.get("provider", "edge")
subscription_features = get_nous_subscription_features(config)
provider_labels = {
"edge": "Edge TTS",
"elevenlabs": "ElevenLabs",
"openai": "OpenAI TTS",
"xai": "xAI TTS",
"minimax": "MiniMax TTS",
"mistral": "Mistral Voxtral TTS",
"gemini": "Google Gemini TTS",
"neutts": "NeuTTS",
"kittentts": "KittenTTS",
}
current_label = provider_labels.get(current_provider, current_provider)
print()
print_header("Text-to-Speech Provider (optional)")
print_info(f"Current: {current_label}")
print()
choices = []
providers = []
if managed_nous_tools_enabled() and subscription_features.nous_auth_present:
choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
providers.append("nous-openai")
choices.extend(
[
"Edge TTS (free, cloud-based, no setup needed)",
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"xAI TTS (Grok voices — OAuth login or API key)",
"MiniMax TTS (high quality with voice cloning, needs API key)",
"Mistral Voxtral TTS (multilingual, native Opus, needs API key)",
"Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)",
"NeuTTS (local on-device, free, ~300MB model download)",
"KittenTTS (local on-device, free, lightweight ~25-80MB ONNX)",
]
)
providers.extend(["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "gemini", "neutts", "kittentts"])
choices.append(f"Keep current ({current_label})")
keep_current_idx = len(choices) - 1
idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
if idx == keep_current_idx:
return
selected = providers[idx]
selected_via_nous = selected == "nous-openai"
if selected == "nous-openai":
selected = "openai"
print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.")
if get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY"):
print_warning(
"Direct OpenAI credentials are still configured and may take precedence until removed from ~/.hermes/.env."
)
if selected == "neutts":
# Check if already installed
try:
already_installed = importlib.util.find_spec("neutts") is not None
except Exception:
already_installed = False
if already_installed:
print_success("NeuTTS is already installed")
else:
print()
print_info("NeuTTS requires:")
print_info(" • Python package: neutts (~50MB install + ~300MB model on first use)")
print_info(" • System package: espeak-ng (phonemizer)")
print()
if prompt_yes_no("Install NeuTTS dependencies now?", True):
if not _install_neutts_deps():
print_warning("NeuTTS installation incomplete. Falling back to Edge TTS.")
selected = "edge"
else:
print_info("Skipping install. Set tts.provider to 'neutts' after installing manually.")
selected = "edge"
elif selected == "elevenlabs":
existing = get_env_value("ELEVENLABS_API_KEY")
if not existing:
print()
api_key = prompt("ElevenLabs API key", password=True)
if api_key:
save_env_value("ELEVENLABS_API_KEY", api_key)
print_success("ElevenLabs API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "openai" and not selected_via_nous:
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
if not existing:
print()
api_key = prompt("OpenAI API key for TTS", password=True)
if api_key:
save_env_value("VOICE_TOOLS_OPENAI_KEY", api_key)
print_success("OpenAI TTS API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "xai":
# Resolution order: existing OAuth tokens (free for SuperGrok subscribers
# via the Hermes auth store) > existing XAI_API_KEY > prompt the user.
# When neither is configured, offer both options instead of forcing the
# API-key path — xAI TTS works fine with OAuth bearer tokens too.
oauth_logged_in = _xai_oauth_logged_in_for_setup()
existing_api_key = get_env_value("XAI_API_KEY")
if oauth_logged_in:
print_success(
"xAI TTS will use your xAI Grok OAuth (SuperGrok / Premium+) "
"credentials"
)
elif existing_api_key:
print_success("xAI TTS will use your existing XAI_API_KEY")
else:
print()
choice_idx = prompt_choice(
"How do you want xAI TTS to authenticate?",
choices=[
"Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
"Paste an xAI API key (console.x.ai)",
"Skip → fallback to Edge TTS",
],
default=0,
)
if choice_idx == 0:
if _run_xai_oauth_login_from_setup():
print_success(
"Logged in — xAI TTS will use these OAuth credentials"
)
else:
print_warning(
"xAI Grok OAuth login did not complete. "
"Falling back to Edge TTS."
)
selected = "edge"
elif choice_idx == 1:
api_key = prompt("xAI API key for TTS", password=True)
if api_key:
save_env_value("XAI_API_KEY", api_key)
print_success("xAI TTS API key saved")
else:
from hermes_constants import display_hermes_home as _dhh
print_warning(
"No xAI API key provided for TTS. Configure XAI_API_KEY "
f"via hermes setup model or {_dhh()}/.env to use xAI TTS. "
"Falling back to Edge TTS."
)
selected = "edge"
else:
print_warning("xAI TTS skipped. Falling back to Edge TTS.")
selected = "edge"
if selected == "xai":
print()
voice_id = prompt("xAI voice_id (Enter for 'eve', or paste a custom voice ID)")
if voice_id and voice_id.strip():
config.setdefault("tts", {}).setdefault("xai", {})["voice_id"] = voice_id.strip()
print_success(f"xAI voice_id set to: {voice_id.strip()}")
elif selected == "minimax":
existing = get_env_value("MINIMAX_API_KEY")
if not existing:
print()
api_key = prompt("MiniMax API key for TTS", password=True)
if api_key:
save_env_value("MINIMAX_API_KEY", api_key)
print_success("MiniMax TTS API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "mistral":
existing = get_env_value("MISTRAL_API_KEY")
if not existing:
print()
api_key = prompt("Mistral API key for TTS", password=True)
if api_key:
save_env_value("MISTRAL_API_KEY", api_key)
print_success("Mistral TTS API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "gemini":
existing = get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")
if not existing:
print()
print_info("Get a free API key at https://aistudio.google.com/app/apikey")
api_key = prompt("Gemini API key for TTS", password=True)
if api_key:
save_env_value("GEMINI_API_KEY", api_key)
print_success("Gemini TTS API key saved")
else:
print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge"
elif selected == "kittentts":
# Check if already installed
try:
already_installed = importlib.util.find_spec("kittentts") is not None
except Exception:
already_installed = False
if already_installed:
print_success("KittenTTS is already installed")
else:
print()
print_info("KittenTTS is lightweight (~25-80MB, CPU-only, no API key required).")
print_info("Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
print()
if prompt_yes_no("Install KittenTTS now?", True):
if not _install_kittentts_deps():
print_warning("KittenTTS installation incomplete. Falling back to Edge TTS.")
selected = "edge"
else:
print_info("Skipping install. Set tts.provider to 'kittentts' after installing manually.")
selected = "edge"
# Save the selection
if "tts" not in config:
config["tts"] = {}
config["tts"]["provider"] = selected
save_config(config)
print_success(f"TTS provider set to: {provider_labels.get(selected, selected)}")
def setup_tts(config: dict):
"""Standalone TTS setup (for 'hermes setup tts')."""
_setup_tts_provider(config)
# =============================================================================
# Section 2: Terminal Backend Configuration
# =============================================================================
def setup_terminal_backend(config: dict):
"""Configure the terminal execution backend."""
import platform as _platform
print_header("Terminal Backend")
print_info("Choose where Hermes runs shell commands and code.")
print_info("This affects tool execution, file access, and isolation.")
print_info(f" Guide: {_DOCS_BASE}/user-guide/configuration#terminal-backend-configuration")
print()
current_backend = cfg_get(config, "terminal", "backend", default="local")
is_linux = _platform.system() == "Linux"
# Build backend choices with descriptions
terminal_choices = [
"Local - run directly on this machine (default)",
"Docker - isolated container with configurable resources",
"Modal - serverless cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
"Vercel Sandbox - cloud microVM with snapshot filesystem persistence",
]
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5}
next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
backend_to_idx["singularity"] = next_idx
next_idx += 1
# Plugin-registered terminal backends (standalone plugin repos installed
# under ~/.hermes/plugins/). Fail-soft: a broken plugin must not take the
# setup wizard down.
plugin_backend_names = []
try:
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent — plugin state may not be loaded yet
from agent.terminal_env_registry import list_providers
for _provider in list_providers():
_pname = _provider.name.strip().lower()
terminal_choices.append(f"{_provider.display_name} - {_provider.description}")
idx_to_backend[next_idx] = _pname
backend_to_idx[_pname] = next_idx
plugin_backend_names.append(_pname)
next_idx += 1
except Exception:
pass
# Add keep current option
keep_current_idx = next_idx
terminal_choices.append(f"Keep current ({current_backend})")
idx_to_backend[keep_current_idx] = current_backend
terminal_idx = prompt_choice(
"Select terminal backend:", terminal_choices, keep_current_idx
)
selected_backend = idx_to_backend.get(terminal_idx)
if terminal_idx == keep_current_idx:
print_info(f"Keeping current backend: {current_backend}")
return
config.setdefault("terminal", {})["backend"] = selected_backend
if selected_backend == "local":
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# Gateway working directory defaults to home; sudo stays off. Both are
# configurable later via `hermes setup terminal` / config.yaml.
config["terminal"].setdefault("cwd", str(Path.home()))
elif selected_backend == "docker":
print_success("Terminal backend: Docker")
# Check if Docker is available
docker_bin = shutil.which("docker")
if not docker_bin:
print_warning("Docker not found in PATH!")
print_info("Install Docker: https://docs.docker.com/get-docker/")
else:
print_info(f"Docker found: {docker_bin}")
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
print()
print_info("Docker sandboxes can be protected with the egress credential firewall.")
print_info(
"It routes sandbox traffic through iron-proxy so containers receive "
"proxy tokens instead of real API keys."
)
print_info(
" Docker only for now; Modal, SSH, Daytona, and Singularity are not wired yet."
)
if prompt_yes_no(" Enable egress firewall for Docker sandboxes?", False):
proxy_cfg = config.setdefault("proxy", {})
proxy_cfg["enabled"] = True
proxy_cfg.setdefault("enforce_on_docker", True)
print_success("Egress firewall enabled in config")
print_info(
"Run `hermes egress setup` then `hermes egress start` to mint "
"tokens and launch the proxy."
)
else:
print_info(
"Skipping egress firewall. You can enable it later with `hermes egress setup`."
)
elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
# Check if singularity/apptainer is available
sing_bin = shutil.which("apptainer") or shutil.which("singularity")
if not sing_bin:
print_warning("Singularity/Apptainer not found in PATH!")
print_info(
"Install: https://apptainer.org/docs/admin/main/installation.html"
)
else:
print_info(f"Found: {sing_bin}")
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"singularity_image",
"docker://nikolaik/python-nodejs:python3.11-nodejs20",
)
elif selected_backend == "modal":
print_success("Terminal backend: Modal")
print_info("Serverless cloud sandboxes. Each session gets its own container.")
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import normalize_modal_mode
managed_modal_available = bool(
managed_nous_tools_enabled()
and
get_nous_subscription_features(config).nous_auth_present
and is_managed_tool_gateway_ready("modal")
)
modal_mode = normalize_modal_mode(cfg_get(config, "terminal", "modal_mode"))
use_managed_modal = False
if managed_modal_available:
modal_choices = [
"Use my Nous subscription",
"Use my own Modal account",
]
if modal_mode == "managed":
default_modal_idx = 0
elif modal_mode == "direct":
default_modal_idx = 1
else:
default_modal_idx = 1 if get_env_value("MODAL_TOKEN_ID") else 0
modal_mode_idx = prompt_choice(
"Select how Modal execution should be billed:",
modal_choices,
default_modal_idx,
)
use_managed_modal = modal_mode_idx == 0
if use_managed_modal:
config["terminal"]["modal_mode"] = "managed"
print_info("Modal execution will use the managed Nous gateway and bill to your subscription.")
if get_env_value("MODAL_TOKEN_ID") or get_env_value("MODAL_TOKEN_SECRET"):
print_info(
"Direct Modal credentials are still configured, but this backend is pinned to managed mode."
)
else:
config["terminal"]["modal_mode"] = "direct"
print_info("Requires a Modal account: https://modal.com")
# Check if modal SDK is installed
try:
__import__("modal")
except ImportError:
print_info("Installing modal SDK...")
from hermes_cli.tools_config import _pip_install
result = _pip_install(["modal"])
if result.returncode == 0:
print_success("modal SDK installed")
else:
print_warning("Install failed — run manually: uv pip install modal")
# Modal token
print()
print_info("Modal authentication:")
print_info(" Get your token at: https://modal.com/settings")
existing_token = get_env_value("MODAL_TOKEN_ID")
if existing_token:
print_info(" Modal token: already configured")
if prompt_yes_no(" Update Modal credentials?", False):
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
else:
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
elif selected_backend == "daytona":
print_success("Terminal backend: Daytona")
print_info("Persistent cloud development environments.")
print_info("Each session gets a dedicated sandbox with filesystem persistence.")
print_info("Sign up at: https://daytona.io")
# Check if daytona SDK is installed
try:
__import__("daytona")
except ImportError:
print_info("Installing daytona SDK...")
from hermes_cli.tools_config import _pip_install
result = _pip_install(["daytona"])
if result.returncode == 0:
print_success("daytona SDK installed")
else:
print_warning("Install failed — run manually: uv pip install daytona")
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
# Daytona API key
print()
existing_key = get_env_value("DAYTONA_API_KEY")
if existing_key:
print_info(" Daytona API key: already configured")
if prompt_yes_no(" Update API key?", False):
api_key = prompt(" Daytona API key", password=True)
if api_key:
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Updated")
else:
api_key = prompt(" Daytona API key", password=True)
if api_key:
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Configured")
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)
elif selected_backend == "vercel_sandbox":
print_success("Terminal backend: Vercel Sandbox")
print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.")
print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'")
try:
__import__("vercel")
except ImportError:
print_info("Installing vercel SDK...")
import subprocess
# Managed uv first: the store is never on PATH, so a bare
# which() misses the uv Hermes installed. Realizing one is
# welcome here — this is the interactive setup wizard, already
# mid-install, and the alternative tier is a pip that a `uv venv`
# venv may not even have.
import pm
uv_bin, uv_env = pm.uv()
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "vercel"],
capture_output=True,
text=True,
env=uv_env,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "vercel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("vercel SDK installed")
else:
print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'")
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
_prompt_vercel_sandbox_settings(config)
elif selected_backend in plugin_backend_names:
try:
from agent.terminal_env_registry import get_provider
_provider = get_provider(selected_backend)
print_success(f"Terminal backend: {_provider.display_name}")
for _line in _provider.setup_instructions():
print_info(_line)
_provider.post_setup()
except Exception as exc:
print_warning(f"Backend plugin setup hook failed: {exc}")
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")
# SSH host
current_host = get_env_value("TERMINAL_SSH_HOST") or ""
host = prompt(" SSH host (hostname or IP)", current_host)
if host:
save_env_value("TERMINAL_SSH_HOST", host)
# SSH user
current_user = get_env_value("TERMINAL_SSH_USER") or ""
user = prompt(" SSH user", current_user or os.getenv("USER", ""))
if user:
save_env_value("TERMINAL_SSH_USER", user)
# SSH port
current_port = get_env_value("TERMINAL_SSH_PORT") or "22"
port = prompt(" SSH port", current_port)
if port and port != "22":
save_env_value("TERMINAL_SSH_PORT", port)
# SSH key
current_key = get_env_value("TERMINAL_SSH_KEY") or ""
default_key = str(Path.home() / ".ssh" / "id_rsa")
ssh_key = prompt(" SSH private key path", current_key or default_key)
if ssh_key:
save_env_value("TERMINAL_SSH_KEY", ssh_key)
# Test connection
if host and prompt_yes_no(" Test SSH connection?", True):
print_info(" Testing connection...")
import subprocess
ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5"]
if ssh_key:
ssh_cmd.extend(["-i", ssh_key])
if port and port != "22":
ssh_cmd.extend(["-p", port])
ssh_cmd.append(f"{user}@{host}" if user else host)
ssh_cmd.append("echo ok")
result = subprocess.run(ssh_cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10)
if result.returncode == 0:
print_success(" SSH connection successful!")
else:
print_warning(f" SSH connection failed: {result.stderr.strip()}")
print_info(" Check your SSH key and host settings.")
# Sync terminal backend to .env so terminal_tool picks it up directly.
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
save_env_value("TERMINAL_ENV", selected_backend)
if selected_backend == "modal":
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
if selected_backend == "vercel_sandbox":
save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24"))
save_config(config)
print()
print_success(f"Terminal backend set to: {selected_backend}")
# =============================================================================
# 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 authoritative for max_turns (the gateway bridges it into HERMES_MAX_ITERATIONS);
# a stale .env entry silently shadowing it caused the 60-vs-500 bug, so drop it.
remove_env_value("HERMES_MAX_ITERATIONS")
config.setdefault("display", {})["tool_progress"] = "all"
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50
# Never auto-reset (the gateway default); written explicitly so it is visible in config.yaml.
config.setdefault("session_reset", {})["mode"] = "none"
save_config(config)
print_success("Applied recommended defaults:")
_info(" Max iterations: 150", " Tool progress: all", " Compression threshold: 0.50",
" Session reset: never (use /reset or compression)",
" Run `hermes setup agent` later to customize.")
def _prompt_number(label: str, current, cast=int):
"""Prompt for a number; ``None`` when the answer does not parse."""
try:
return cast(prompt(label, str(current)))
except ValueError:
return None
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."""
value = _prompt_number(label, current)
if value is not None and accept(value):
section[key] = value
_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")
_info(f" Guide: {_DOCS_BASE}/user-guide/configuration", None)
# ── Max Iterations ── (config.yaml is authoritative; never surface a stale legacy .env value)
# If a legacy .env entry is still around (from pre-PR#18413 setups), prefer the config value so we don't
# surface a stale number to the user.
current_max = str(cfg_get(config, "agent", "max_turns", default=90))
_info("Maximum tool-calling iterations per conversation.",
"Higher = more complex tasks, but costs more tokens.",
f"Press Enter to keep {current_max}. Use 90 for most tasks or 150+ for open exploration.")
max_iter = _prompt_number("Max iterations", current_max)
if max_iter is None:
print_warning("Invalid number, keeping current value")
elif max_iter > 0:
# config.yaml only; gateway/run.py derives HERMES_MAX_ITERATIONS from agent.max_turns.
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}")
# ── Tool Progress Display ──
_info("", *_TOOL_PROGRESS_HELP)
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")
_info("Automatically summarizes old messages when context gets too long.",
"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)
threshold = _prompt_number("Compression threshold (0.5-0.95)", current_threshold, float)
if threshold is not None and 0.5 <= threshold <= 0.95:
config["compression"]["threshold"] = threshold
print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.50)}")
# ── Session Reset Policy ──
print_header("Session Reset Policy")
_info(*_SESSION_RESET_HELP)
_prompt_session_reset(config.setdefault("session_reset", {}))
save_config(config)
def _prompt_session_reset(reset_cfg: dict) -> None:
"""Pick the session reset mode and its idle/daily parameters in place."""
current_mode = reset_cfg.get("mode", "none")
current_idle, current_hour = reset_cfg.get("idle_minutes", 1440), reset_cfg.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)
mode = _SESSION_RESET_MODES[reset_idx] if 0 <= reset_idx < len(_SESSION_RESET_MODES) else None
if mode is None: # keep current settings
return
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 == "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:
print_success({
"both": f"Sessions reset after {idle_now} min idle or daily at {hour_now}:00",
"idle": f"Sessions reset after {idle_now} min of inactivity",
"daily": f"Sessions reset daily at {hour_now}:00",
}[mode])
# ── Section 5: Tool Configuration (delegates to unified tools_config.py) ──
def setup_tools(config: dict, first_install: bool = False):
"""`hermes setup tools` == `hermes tools`: platform selection → toolset toggles → provider keys.
``first_install`` selects the simplified flow (no platform menu, prompts for all missing 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")
_info("Shared metrics contain only bounded counters and histograms.",
"Collection is local. Sending them to Nous is a separate opt-in.")
shared_metrics = _sub_dict(_sub_dict(config, "telemetry"), "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 (send=true would log an error every run, never send).
if shared_metrics.get("send") is True:
shared_metrics["send"] = False
print_info("Sending shared metrics disabled as well.")
# Turning collection off withdraws send consent too. Recorded unconditionally: the send
# key may already be false while the consent window is still open, and it must close.
_record_send_consent_change(enabled=False)
return
print_success("Local shared metrics enabled.")
_info(*_SEND_CONSENT_EXPLAINER)
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, so wizard, relay and mid-pass callers cannot disagree."""
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
with SharedMetricsStore()._connection() as connection, write_txn(connection):
reconcile_send_consent(connection, enabled)
except Exception:
# Never block the wizard on telemetry bookkeeping; the relay reconciles on the next 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. They import this module lazily inside bodies, so this is cycle-free.
from hermes_cli.setup_tts import setup_tts # noqa: E402
from hermes_cli.setup_terminal import setup_terminal_backend # noqa: E402
from hermes_cli.setup_platforms import setup_gateway # noqa: E402
from hermes_cli.setup_summary import _print_setup_summary # noqa: E402,F401
from hermes_cli.setup_migration import _offer_openclaw_migration, _skip_configured_section # noqa: E402
from hermes_cli.setup_quick import _run_portal_one_shot, _run_quick_setup # noqa: E402
# ── 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:
_info(None, "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; None when absent or copy fails."""
if not config_path.exists():
return None
import shutil
from datetime import datetime
backup_path = config_path.with_suffix(f".yaml.bak.{datetime.now().strftime('%Y%m%d_%H%M%S')}")
try:
shutil.copy2(config_path, backup_path)
return backup_path
except Exception:
return None
def _run_setup_section(config: dict, section: str) -> None:
"""``hermes setup <section>``: run one SETUP_SECTIONS entry under the banner."""
entry = next(((label, func) for key, label, func in SETUP_SECTIONS if key == section), None)
if entry is None:
print_error(f"Unknown setup section: {section}")
print_info(f"Available sections: {', '.join(k for k, _, _ in SETUP_SECTIONS)}")
return
label, func = entry
_print_banner(f"│ ⚕ Hermes Setup — {label:<34s} │")
_run_setup_steps([(label, lambda: func(config))])
save_config(config)
print()
print_success(f"{label} configuration complete!")
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")
_info(f"Config file: {get_config_path()}", f"Secrets file: {get_env_path()}",
f"Data folder: {hermes_home}", f"Install dir: {PROJECT_ROOT}", None,
"You can edit these files directly or use 'hermes config edit'")
if migration_ran:
_info(None, "Settings were imported from OpenClaw.",
"Each section below will show what was imported — press Enter to keep,",
"or choose to reconfigure if needed.")
# Agent Settings are not prompted: first installs get defaults, existing keep theirs.
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 _gateway_step() -> None:
if not _skip("gateway", "Messaging Platforms"):
setup_gateway(config)
return
# A skipped (migrated) gateway section still needs its service so imported platforms
# and cron jobs become active.
from hermes_cli.gateway import ensure_gateway_service
ensure_gateway_service(context="setup")
def _step(key: str, label: str, run) -> tuple:
return label, lambda: None if _skip(key, label) else run()
_run_setup_steps([
_step("model", "Model & Provider", lambda: setup_model_provider(config)),
_step("terminal", "Terminal Backend", lambda: setup_terminal_backend(config)),
("Messaging Platforms", _gateway_step),
_step("tools", "Tools", lambda: setup_tools(config, first_install=not is_existing))])
# First-time mode picker: (menu label, setup_quick runner name) — None falls through to Full Setup.
_FIRST_TIME_MODES = (
("Quick Setup (Nous Portal) — free OAuth login, no API keys, model + tools (recommended)",
"_run_first_time_quick_setup"),
("Full setup — configure every provider, tool & option yourself (bring your own keys)", None),
("Blank Slate — everything off except the bare minimum; opt in to each capability", "_run_blank_slate_setup"),
)
def _run_setup_wizard_impl(args):
"""Run the interactive setup wizard: full/quick (auto-detected), ``--portal``, or one
``hermes setup <section>`` from SETUP_SECTIONS."""
from hermes_cli.config import is_managed, managed_error
if is_managed():
managed_error("run setup wizard")
return
ensure_hermes_home()
if 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()
# Back up existing config before setup modifies it (#3522)
config_path = get_config_path()
_backup_path = _backup_config_file(config_path)
# 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
if getattr(args, "portal", False): # one-shot Nous Portal setup; skips the rest
_run_portal_one_shot(config)
return
section = getattr(args, "section", None)
if section:
_run_setup_section(config, section)
return
# Existing installation == a provider is configured
from hermes_cli.auth import get_active_provider
is_existing = bool(get_env_value("OPENROUTER_API_KEY") or 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:
# Full reconfigure wizard is the default (Enter keeps each current value); `--quick`
# narrows it to missing items (partial OpenClaw import, cleared key). --reconfigure is a
# backwards-compatible no-op here.
if quick_requested:
_run_setup_steps([("Quick Setup", lambda: _run_quick_setup(config, hermes_home))])
return
print_header("Reconfigure", gap=True)
print_success("You already have Hermes configured.")
_info("Running the full wizard — each prompt shows your current value.",
"Press Enter to keep it, or type a new value to change it.", "",
"Tip: jump straight to a section with 'hermes setup model|terminal|",
" gateway|tools|agent', or fill only missing items with --quick.")
else:
# First-time setup (--reconfigure / --quick are meaningless here; fall through)
print()
if reconfigure_requested or quick_requested:
_info("No existing configuration found — running first-time setup.", None)
migration_ran = _offer_openclaw_migration(hermes_home) # before configuration begins
if migration_ran:
config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [label for label, _ in _FIRST_TIME_MODES], 0)
label, runner = _FIRST_TIME_MODES[setup_mode]
if runner is not None:
from hermes_cli import setup_quick
_run_setup_steps([(label, lambda: getattr(setup_quick, runner)(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():
_info(f"Previous config backed up to: {_backup_path}",
"If setup changed a value you customized, restore it with:",
f" cp {_backup_path} {config_path}")
_print_setup_summary(config, hermes_home)
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Any # noqa: F401,E402
from typing import Dict # noqa: F401,E402
from typing import Optional # noqa: F401,E402
import json # noqa: F401,E402
import shutil # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'get_nous_subscription_features': ('hermes_cli.nous_subscription', 'get_nous_subscription_features'),
'get_optional_skills_dir': ('hermes_constants', 'get_optional_skills_dir'),
'managed_nous_tools_enabled': ('tools.tool_backend_helpers', 'managed_nous_tools_enabled'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----