Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 69 additions & 227 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,175 +737,15 @@ def setup_model_provider(config: dict, *, quick: bool = False):
if isinstance(_m, dict):
selected_provider = _m.get("provider")

# ── Same-provider fallback & rotation setup (full setup only) ──
if not quick and _supports_same_provider_pool_setup(selected_provider):
try:
from types import SimpleNamespace
from agent.credential_pool import load_pool
from hermes_cli.auth_commands import auth_add_command

pool = load_pool(selected_provider)
entries = pool.entries()
entry_count = len(entries)
manual_count = sum(1 for entry in entries if str(getattr(entry, "source", "")).startswith("manual"))
auto_count = entry_count - manual_count
print()
print_header("Same-Provider Fallback & Rotation")
print_info(
"Hermes can keep multiple credentials for one provider and rotate between"
)
print_info(
"them when a credential is exhausted or rate-limited. This preserves"
)
print_info(
"your primary provider while reducing interruptions from quota issues."
)
print()
if auto_count > 0:
print_info(
f"Current pooled credentials for {selected_provider}: {entry_count} "
f"({manual_count} manual, {auto_count} auto-detected from env/shared auth)"
)
else:
print_info(f"Current pooled credentials for {selected_provider}: {entry_count}")

while prompt_yes_no("Add another credential for same-provider fallback?", False):
auth_add_command(
SimpleNamespace(
provider=selected_provider,
auth_type="",
label=None,
api_key=None,
portal_url=None,
inference_url=None,
client_id=None,
scope=None,
no_browser=False,
timeout=15.0,
insecure=False,
ca_bundle=None,
)
)
pool = load_pool(selected_provider)
entry_count = len(pool.entries())
print_info(f"Provider pool now has {entry_count} credential(s).")

if entry_count > 1:
strategy_labels = [
"Fill-first / sticky — keep using the first healthy credential until it is exhausted",
"Round robin — rotate to the next healthy credential after each selection",
"Random — pick a random healthy credential each time",
]
current_strategy = _get_credential_pool_strategies(config).get(selected_provider, "fill_first")
default_strategy_idx = {
"fill_first": 0,
"round_robin": 1,
"random": 2,
}.get(current_strategy, 0)
strategy_idx = prompt_choice(
"Select same-provider rotation strategy:",
strategy_labels,
default_strategy_idx,
)
strategy_value = ["fill_first", "round_robin", "random"][strategy_idx]
_set_credential_pool_strategy(config, selected_provider, strategy_value)
print_success(f"Saved {selected_provider} rotation strategy: {strategy_value}")
except Exception as exc:
logger.debug("Could not configure same-provider fallback in setup: %s", exc)

# ── Vision & Image Analysis Setup (full setup only) ──
if quick:
_vision_needs_setup = False
else:
try:
from agent.auxiliary_client import get_available_vision_backends
_vision_backends = set(get_available_vision_backends())
except Exception:
_vision_backends = set()

_vision_needs_setup = not bool(_vision_backends)

if selected_provider in _vision_backends:
_vision_needs_setup = False

if _vision_needs_setup:
_prov_names = {
"nous-api": "Nous Portal API key",
"copilot": "GitHub Copilot",
"copilot-acp": "GitHub Copilot ACP",
"zai": "Z.AI / GLM",
"kimi-coding": "Kimi / Moonshot",
"kimi-coding-cn": "Kimi / Moonshot (China)",
"stepfun": "StepFun Step Plan",
"minimax": "MiniMax",
"minimax-cn": "MiniMax CN",
"anthropic": "Anthropic",
"custom": "your custom endpoint",
}
_prov_display = _prov_names.get(selected_provider, selected_provider or "your provider")

print()
print_header("Vision & Image Analysis (optional)")
print_info(f"Vision uses a separate multimodal backend. {_prov_display}")
print_info("doesn't currently provide one Hermes can auto-use for vision,")
print_info("so choose a backend now or skip and configure later.")
print()

_vision_choices = [
"OpenRouter — uses Gemini (free tier at openrouter.ai/keys)",
"OpenAI-compatible endpoint — base URL, API key, and vision model",
"Skip for now",
]
_vision_idx = prompt_choice("Configure vision:", _vision_choices, 2)

if _vision_idx == 0: # OpenRouter
_or_key = prompt(" OpenRouter API key", password=True).strip()
if _or_key:
save_env_value("OPENROUTER_API_KEY", _or_key)
print_success("OpenRouter key saved — vision will use Gemini")
else:
print_info("Skipped — vision won't be available")
elif _vision_idx == 1: # OpenAI-compatible endpoint
_base_url = prompt(" Base URL (blank for OpenAI)").strip() or "https://api.openai.com/v1"
_api_key_label = " API key"
_is_native_openai = base_url_hostname(_base_url) == "api.openai.com"
if _is_native_openai:
_api_key_label = " OpenAI API key"
_oai_key = prompt(_api_key_label, password=True).strip()
if _oai_key:
save_env_value("OPENAI_API_KEY", _oai_key)
# Save vision base URL to config (not .env — only secrets go there)
_vaux = config.setdefault("auxiliary", {}).setdefault("vision", {})
_vaux["base_url"] = _base_url
if _is_native_openai:
_oai_vision_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"]
_vm_choices = _oai_vision_models + ["Use default (gpt-4o-mini)"]
_vm_idx = prompt_choice("Select vision model:", _vm_choices, 0)
_selected_vision_model = (
_oai_vision_models[_vm_idx]
if _vm_idx < len(_oai_vision_models)
else "gpt-4o-mini"
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
)
else:
print_info("Skipped — vision won't be available")
else:
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")

# Credential rotation, vision-backend selection, and TTS provider are no
# longer prompted here. They have safe defaults (rotation off, vision
# auto-detected from the main provider, TTS = Edge) and are configurable
# on demand via `hermes auth add`, `hermes setup` vision, and
# `hermes setup tts`. This keeps both quick and full setup thin.

# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)

if not quick and selected_provider != "nous":
_setup_tts_provider(config)


# =============================================================================
# Section 1b: TTS Provider Configuration
Expand Down Expand Up @@ -1341,29 +1181,9 @@ def setup_terminal_backend(config: dict):
if selected_backend == "local":
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")

# Gateway/cron working directory
print()
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd

# Sudo support
print()
existing_sudo = get_env_value("SUDO_PASSWORD")
if existing_sudo:
print_info("Sudo password: configured")
elif prompt_yes_no(
"Enable sudo support? (stores password for apt install, etc.)", False
):
sudo_pass = prompt(" Sudo password", password=True)
if sudo_pass:
save_env_value("SUDO_PASSWORD", sudo_pass)
print_success("Sudo password saved")
# 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")
Expand All @@ -1376,13 +1196,10 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Docker found: {docker_bin}")

# Docker image
current_image = cfg_get(config, "terminal", "docker_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Docker image", current_image)
config["terminal"]["docker_image"] = image
save_env_value("TERMINAL_DOCKER_IMAGE", image)

_prompt_container_resources(config)
# Image and resource limits use defaults; tune via `hermes setup terminal`.
config["terminal"].setdefault(
"docker_image", "nikolaik/python-nodejs:python3.11-nodejs20"
)

elif selected_backend == "singularity":
print_success("Terminal backend: Singularity/Apptainer")
Expand All @@ -1397,12 +1214,11 @@ def setup_terminal_backend(config: dict):
else:
print_info(f"Found: {sing_bin}")

current_image = cfg_get(config, "terminal", "singularity_image", default="docker://nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Container image", current_image)
config["terminal"]["singularity_image"] = image
save_env_value("TERMINAL_SINGULARITY_IMAGE", image)

_prompt_container_resources(config)
# 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")
Expand Down Expand Up @@ -1501,8 +1317,6 @@ def setup_terminal_backend(config: dict):
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)

_prompt_container_resources(config)

elif selected_backend == "daytona":
print_success("Terminal backend: Daytona")
print_info("Persistent cloud development environments.")
Expand Down Expand Up @@ -1552,13 +1366,10 @@ def setup_terminal_backend(config: dict):
save_env_value("DAYTONA_API_KEY", api_key)
print_success(" Configured")

# Daytona image
current_image = cfg_get(config, "terminal", "daytona_image", default="nikolaik/python-nodejs:python3.11-nodejs20")
image = prompt(" Sandbox image", current_image)
config["terminal"]["daytona_image"] = image
save_env_value("TERMINAL_DAYTONA_IMAGE", image)

_prompt_container_resources(config)
# 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 == "ssh":
print_success("Terminal backend: SSH")
Expand Down Expand Up @@ -1625,7 +1436,7 @@ def setup_terminal_backend(config: dict):

def _apply_default_agent_settings(config: dict):
"""Apply recommended defaults for all agent settings without prompting."""
config.setdefault("agent", {})["max_turns"] = 90
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
Expand All @@ -1637,18 +1448,17 @@ def _apply_default_agent_settings(config: dict):
config.setdefault("compression", {})["enabled"] = True
config["compression"]["threshold"] = 0.50

config.setdefault("session_reset", {}).update({
"mode": "both",
"idle_minutes": 1440,
"at_hour": 4,
})
# Default to never auto-resetting sessions. The gateway treats absent
# session_reset as "both", so we must write "none" explicitly to make
# the no-auto-reset default actually take effect.
config.setdefault("session_reset", {})["mode"] = "none"

save_config(config)
print_success("Applied recommended defaults:")
print_info(" Max iterations: 90")
print_info(" Max iterations: 150")
print_info(" Tool progress: all")
print_info(" Compression threshold: 0.50")
print_info(" Session reset: inactivity (1440 min) + daily (4:00)")
print_info(" Session reset: never (use /reset or compression)")
print_info(" Run `hermes setup agent` later to customize.")


Expand Down Expand Up @@ -3197,7 +3007,7 @@ def run_setup_wizard(args):
config = load_config()

setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)",
"Quick Setup (Nous Portal) — OAuth login, model & messaging (recommended)",
"Full setup — configure everything",
], 0)

Expand Down Expand Up @@ -3228,9 +3038,11 @@ def run_setup_wizard(args):
if not (migration_ran and _skip_configured_section(config, "terminal", "Terminal Backend")):
setup_terminal_backend(config)

# Section 3: Agent Settings
if not (migration_ran and _skip_configured_section(config, "agent", "Agent Settings")):
setup_agent_settings(config)
# Section 3: Agent Settings — no longer prompted. First installs get the
# recommended defaults silently; existing installs keep whatever they have.
# Tune later with `hermes setup agent`.
if not is_existing:
_apply_default_agent_settings(config)

# Section 4: Messaging Platforms
if not (migration_ran and _skip_configured_section(config, "gateway", "Messaging Platforms")):
Expand All @@ -3250,13 +3062,43 @@ def run_setup_wizard(args):


def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
"""Streamlined first-time setup: provider, model, terminal & messaging.
"""Streamlined first-time setup via Nous Portal: OAuth, model, terminal & messaging.

Applies sensible defaults for TTS (Edge), agent settings, and tools —
the user can customize later via ``hermes setup <section>``.
Routes straight to the Nous Portal provider — runs the device-code OAuth
login, picks a Nous model, then configures the terminal backend and (optionally)
a messaging platform. Applies sensible defaults for everything else (agent
settings, tools); the user can customize later via ``hermes setup <section>``
or switch providers with ``hermes model``.
"""
# Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True)
from hermes_cli.config import load_config

# Step 1: Nous Portal — OAuth login + model selection.
# _model_flow_nous() handles both the logged-out path (device-code OAuth,
# which selects a model internally) and the already-logged-in path (curated
# Nous model picker). Provider is set to "nous" by the login/model save.
print()
print_header("Nous Portal")
print_info("One subscription, 300+ models, plus the Tool Gateway:")
print_info(" web search, image generation, TTS, browser automation.")
print_info("Sign up: https://portal.nousresearch.com/manage-subscription")
print()
try:
from hermes_cli.main import _model_flow_nous
_model_flow_nous(config)
except (KeyboardInterrupt, EOFError):
print()
print_info("Nous Portal setup cancelled.")
except Exception as exc:
logger.debug("_model_flow_nous error during quick setup: %s", exc)
print_warning(f"Nous Portal setup encountered an error: {exc}")
print_info("You can try again later with: hermes model")

# Re-sync the wizard's config dict from disk — _model_flow_nous (and the
# underlying login/model save) write via their own load/save cycle, and the
# wizard's later save_config(config) must not clobber those values (#4172).
_refreshed = load_config()
config.clear()
config.update(_refreshed)

# Step 2: Terminal Backend — where commands run is a core decision
setup_terminal_backend(config)
Expand Down
Loading
Loading