feat: add fast-path setup for nous account

adds a nous account specific fast flow & autolaunches into chat if
gateway isn't set up
This commit is contained in:
Ari Lotter 2026-04-11 20:51:34 -04:00
parent bdc9b07c9d
commit 2f230b5ad9
4 changed files with 79 additions and 48 deletions

View file

@ -2821,6 +2821,7 @@ def _prompt_model_selection(
pricing: Optional[Dict[str, Dict[str, str]]] = None, pricing: Optional[Dict[str, Dict[str, str]]] = None,
unavailable_models: Optional[List[str]] = None, unavailable_models: Optional[List[str]] = None,
portal_url: str = "", portal_url: str = "",
allow_custom = True
) -> Optional[str]: ) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None. """Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
@ -2909,7 +2910,15 @@ def _prompt_model_selection(
from simple_term_menu import TerminalMenu from simple_term_menu import TerminalMenu
choices = [f" {_label(mid)}" for mid in ordered] choices = [f" {_label(mid)}" for mid in ordered]
custom_idx = None
if allow_custom:
custom_idx = len(choices)
choices.append(" Enter custom model name") choices.append(" Enter custom model name")
skip_idx = None
if current_model:
skip_idx = len(choices)
choices.append(" Skip (keep current)") choices.append(" Skip (keep current)")
# Print the unavailable block BEFORE the menu via regular print(). # Print the unavailable block BEFORE the menu via regular print().
@ -2947,21 +2956,29 @@ def _prompt_model_selection(
print() print()
if idx < len(ordered): if idx < len(ordered):
return ordered[idx] return ordered[idx]
elif idx == len(ordered): if idx == custom_idx:
custom = input("Enter model name: ").strip() custom = input("Enter model name: ").strip()
return custom if custom else None return custom if custom else None
if idx == skip_idx:
return None
return None return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass pass
# Fallback: numbered list # Fallback: numbered list
print(menu_title) print(menu_title)
num_width = len(str(len(ordered) + 2)) n = len(ordered)
extra = []
if allow_custom:
extra.append("Enter custom model name")
if current_model:
extra.append("Skip (keep current)")
total = n + len(extra)
num_width = len(str(total))
for i, mid in enumerate(ordered, 1): for i, mid in enumerate(ordered, 1):
print(f" {i:>{num_width}}. {_label(mid)}") print(f" {i:>{num_width}}. {_label(mid)}")
n = len(ordered) for j, label in enumerate(extra, n + 1):
print(f" {n + 1:>{num_width}}. Enter custom model name") print(f" {j:>{num_width}}. {label}")
print(f" {n + 2:>{num_width}}. Skip (keep current)")
if _unavailable: if _unavailable:
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") _upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
@ -2973,18 +2990,19 @@ def _prompt_model_selection(
while True: while True:
try: try:
choice = input(f"Choice [1-{n + 2}] (default: skip): ").strip() choice = input(f"Choice [1-{total}]: ").strip()
if not choice: if not choice:
return None return None
idx = int(choice) val = int(choice)
if 1 <= idx <= n: if 1 <= val <= n:
return ordered[idx - 1] return ordered[val - 1]
elif idx == n + 1: extra_idx = val - n - 1
if 0 <= extra_idx < len(extra):
if extra[extra_idx] == "Enter custom model name":
custom = input("Enter model name: ").strip() custom = input("Enter model name: ").strip()
return custom if custom else None return custom if custom else None
elif idx == n + 2: return None # skip
return None print(f"Please enter 1-{total}")
print(f"Please enter 1-{n + 2}")
except ValueError: except ValueError:
print("Please enter a number") print("Please enter a number")
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
@ -3260,7 +3278,6 @@ def _nous_device_code_login(
open_browser = False open_browser = False
print(f"Starting Hermes login via {pconfig.name}...") print(f"Starting Hermes login via {pconfig.name}...")
print(f"Portal: {portal_base_url}")
if insecure: if insecure:
print("TLS verification: disabled (--insecure)") print("TLS verification: disabled (--insecure)")
elif ca_bundle: elif ca_bundle:
@ -3280,19 +3297,18 @@ def _nous_device_code_login(
interval = int(device_data["interval"]) interval = int(device_data["interval"])
print() print()
print("To continue:")
print(f" 1. Open: {verification_url}")
print(f" 2. If prompted, enter code: {user_code}")
if open_browser: if open_browser:
opened = webbrowser.open(verification_url) opened = webbrowser.open(verification_url)
if opened: if opened:
print(" (Opened browser for verification)") print("If you don't see a browser window open, navigate to this URL:")
else: else:
print(" Could not open browser automatically — use the URL above.") print("Navigate to this URL to continue:")
print(verification_url)
print(f"If you're prompted for a code, use {user_code}")
print()
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS)) effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...") print(f"Waiting for approval (checking every {effective_interval}s)...")
token_data = _poll_for_token( token_data = _poll_for_token(
client=client, client=client,
@ -3357,7 +3373,7 @@ def _nous_device_code_login(
raise raise
def _login_nous(args, pconfig: ProviderConfig) -> None: def login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow.""" """Nous Portal device authorization flow."""
timeout_seconds = getattr(args, "timeout", None) or 15.0 timeout_seconds = getattr(args, "timeout", None) or 15.0
insecure = bool(getattr(args, "insecure", False)) insecure = bool(getattr(args, "insecure", False))
@ -3419,7 +3435,10 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
) )
model_ids = _PROVIDER_MODELS.get("nous", []) model_ids = _PROVIDER_MODELS.get("nous", [])
_portal = auth_state.get("portal_base_url", "")
print() print()
unavailable_models: list = [] unavailable_models: list = []
if model_ids: if model_ids:
pricing = get_pricing_for_provider("nous") pricing = get_pricing_for_provider("nous")
@ -3428,14 +3447,17 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
model_ids, unavailable_models = partition_nous_models_by_tier( model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True, model_ids, pricing, free_tier=True,
) )
_portal = auth_state.get("portal_base_url", "") if not free_tier:
if model_ids:
print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.")
if len(model_ids) > 1:
selected_model = _prompt_model_selection( selected_model = _prompt_model_selection(
model_ids, pricing=pricing, model_ids, pricing=pricing,
unavailable_models=unavailable_models, unavailable_models=unavailable_models,
portal_url=_portal, portal_url=_portal,
allow_custom=not free_tier
) )
else:
selected_model = model_ids[0]
elif unavailable_models: elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/") _url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.") print("No free models currently available.")

View file

@ -2124,7 +2124,7 @@ def _model_flow_nous(config, current_model="", args=None):
resolve_nous_runtime_credentials, resolve_nous_runtime_credentials,
AuthError, AuthError,
format_auth_error, format_auth_error,
_login_nous, login_nous,
PROVIDER_REGISTRY, PROVIDER_REGISTRY,
) )
from hermes_cli.config import ( from hermes_cli.config import (
@ -2137,8 +2137,6 @@ def _model_flow_nous(config, current_model="", args=None):
state = get_provider_auth_state("nous") state = get_provider_auth_state("nous")
if not state or not state.get("access_token"): if not state or not state.get("access_token"):
print("Not logged into Nous Portal. Starting login...")
print()
try: try:
mock_args = argparse.Namespace( mock_args = argparse.Namespace(
portal_url=getattr(args, "portal_url", None), portal_url=getattr(args, "portal_url", None),
@ -2150,7 +2148,7 @@ def _model_flow_nous(config, current_model="", args=None):
ca_bundle=getattr(args, "ca_bundle", None), ca_bundle=getattr(args, "ca_bundle", None),
insecure=bool(getattr(args, "insecure", False)), insecure=bool(getattr(args, "insecure", False)),
) )
_login_nous(mock_args, PROVIDER_REGISTRY["nous"]) login_nous(mock_args, PROVIDER_REGISTRY["nous"])
# Offer Tool Gateway enablement for paid subscribers # Offer Tool Gateway enablement for paid subscribers
try: try:
_refreshed = load_config() or {} _refreshed = load_config() or {}
@ -2201,7 +2199,7 @@ def _model_flow_nous(config, current_model="", args=None):
ca_bundle=None, ca_bundle=None,
insecure=False, insecure=False,
) )
_login_nous(mock_args, PROVIDER_REGISTRY["nous"]) login_nous(mock_args, PROVIDER_REGISTRY["nous"])
except Exception as login_exc: except Exception as login_exc:
print(f"Re-login failed: {login_exc}") print(f"Re-login failed: {login_exc}")
return return

View file

@ -18,9 +18,10 @@ import shutil
import sys import sys
import copy import copy
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Literal, Optional, Dict, Any
from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.main import _model_flow_nous
from tools.tool_backend_helpers import managed_nous_tools_enabled from tools.tool_backend_helpers import managed_nous_tools_enabled
from utils import base_url_hostname from utils import base_url_hostname
from hermes_constants import get_optional_skills_dir from hermes_constants import get_optional_skills_dir
@ -655,7 +656,7 @@ def _prompt_container_resources(config: dict):
def setup_model_provider(config: dict, *, quick: bool = False): def setup_model_provider(config: dict, *, quick: bool | Literal["nous_portal"] = False):
"""Configure the inference provider and default model. """Configure the inference provider and default model.
Delegates to ``cmd_model()`` (the same flow used by ``hermes model``) Delegates to ``cmd_model()`` (the same flow used by ``hermes model``)
@ -677,6 +678,10 @@ def setup_model_provider(config: dict, *, quick: bool = False):
# credential prompting, model selection, and config persistence. # credential prompting, model selection, and config persistence.
from hermes_cli.main import select_provider_and_model from hermes_cli.main import select_provider_and_model
try: try:
if quick == "nous_portal":
config = load_config()
_model_flow_nous(config)
else:
select_provider_and_model() select_provider_and_model()
except (SystemExit, KeyboardInterrupt): except (SystemExit, KeyboardInterrupt):
print() print()
@ -3030,11 +3035,15 @@ def run_setup_wizard(args):
config = load_config() config = load_config()
setup_mode = prompt_choice("How would you like to set up Hermes?", [ setup_mode = prompt_choice("How would you like to set up Hermes?", [
"Quick setup — provider, model & messaging (recommended)", "Nous Account setup — model & messaging (recommended)",
"Quick setup — provider, model & messaging",
"Full setup — configure everything", "Full setup — configure everything",
], 0) ], 0)
if setup_mode == 0: if setup_mode == 0:
_run_first_time_quick_setup(config, hermes_home, is_existing, nous_quick=True)
return
if setup_mode == 1:
_run_first_time_quick_setup(config, hermes_home, is_existing) _run_first_time_quick_setup(config, hermes_home, is_existing)
return return
@ -3095,7 +3104,7 @@ def _resolve_hermes_chat_argv() -> Optional[list[str]]:
return None return None
def _offer_launch_chat(): def _offer_launch_chat(auto_launch = False):
"""Prompt the user to jump straight into chat after setup.""" """Prompt the user to jump straight into chat after setup."""
print() print()
if not prompt_yes_no("Launch hermes chat now?", True): if not prompt_yes_no("Launch hermes chat now?", True):
@ -3109,7 +3118,7 @@ def _offer_launch_chat():
os.execvp(chat_argv[0], chat_argv) os.execvp(chat_argv[0], chat_argv)
def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool): def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool, nous_quick=False):
"""Streamlined first-time setup: provider + model only. """Streamlined first-time setup: provider + model only.
Applies sensible defaults for TTS (Edge), terminal (local), agent Applies sensible defaults for TTS (Edge), terminal (local), agent
@ -3117,7 +3126,7 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
``hermes setup <section>``. ``hermes setup <section>``.
""" """
# Step 1: Model & Provider (essential — skips rotation/vision/TTS) # Step 1: Model & Provider (essential — skips rotation/vision/TTS)
setup_model_provider(config, quick=True) setup_model_provider(config, quick="nous_portal" if nous_quick else True )
# Step 2: Apply defaults for everything else # Step 2: Apply defaults for everything else
_apply_default_agent_settings(config) _apply_default_agent_settings(config)
@ -3150,7 +3159,9 @@ def _run_first_time_quick_setup(config: dict, hermes_home, is_existing: bool):
_print_setup_summary(config, hermes_home) _print_setup_summary(config, hermes_home)
_offer_launch_chat() # if the user hasn't set up the gateway, assume they want to launch chat.
force_launch_chat = gateway_choice == 0
_offer_launch_chat(force_launch_chat)
def _run_quick_setup(config: dict, hermes_home): def _run_quick_setup(config: dict, hermes_home):

View file

@ -571,7 +571,7 @@ def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
captured["ca_bundle"] = login_args.ca_bundle captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login) monkeypatch.setattr("hermes_cli.auth.login_nous", _fake_login)
hermes_main.cmd_model( hermes_main.cmd_model(
SimpleNamespace( SimpleNamespace(