From d7522118efa196c474eebbfa80d1b1c9094707ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:48:24 -0700 Subject: [PATCH] fix(cli): route keyless first run into provider onboarding instead of a broken chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completely unconfigured install previously booted into a working-looking chat (banner showed model 'unknown'), accepted a message, spun ~30s, then failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose — and never offered setup. - HermesCLI.run() now probes provider readiness at startup (TTY only) and offers the shared provider picker (hermes model flow, which fronts Quick Setup / Nous Portal OAuth) when nothing is configured. Decline is respected; picker state re-syncs into the live CLI so the next turn works without a restart. - New silent probe _runtime_credentials_ready(): no printing, no state mutation; handles keyless local endpoints and callable bearer providers. - The empty-api-key error is provider-aware: names the actual resolved provider and points at 'hermes model' / 'hermes setup' instead of hardcoding OPENROUTER_API_KEY. - Banner: unconfigured installs render 'no model configured — run /model' in red instead of the silent 'unknown' model slug. Consumer-onboarding audit finding #2 (sev 5), Aug 2026. --- cli.py | 12 ++ hermes_cli/banner.py | 23 ++- hermes_cli/cli_agent_setup_mixin.py | 107 ++++++++++- tests/cli/test_cli_first_run_setup.py | 252 ++++++++++++++++++++++++++ 4 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 tests/cli/test_cli_first_run_setup.py diff --git a/cli.py b/cli.py index 0dfe7b34fc..f9d0abae02 100644 --- a/cli.py +++ b/cli.py @@ -14846,6 +14846,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Surface any active supply-chain security advisories right after the # welcome banner. Quiet/single-query paths call this themselves. self._show_security_advisories() + + # First-run: a completely unconfigured install must route into + # provider onboarding, not a chat that cannot work. Previously a + # keyless `hermes` accepted a message, spun for ~30s, then failed + # with a provider-specific error the user never chose. Only fires + # on a real TTY; quiet/single-query paths keep their own handling. + try: + if sys.stdin.isatty() and not self._runtime_credentials_ready(): + self._offer_first_run_setup() + except Exception: + logger.debug("first-run setup offer failed", exc_info=True) + # If resuming a session, load history and display it immediately # so the user has context before typing their first message. if self._resumed: diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 7811899aeb..3f4f893be7 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -659,13 +659,22 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else "" left_lines.append(f"[{accent}]MoA: {preset_name}[/]{agg_str}{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]") else: - model_short = model.split("/")[-1] if "/" in model else model - if model_short.endswith(".gguf"): - model_short = model_short[:-5] - if len(model_short) > 28: - model_short = model_short[:25] + "..." - ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else "" - left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]") + if not (model or "").strip() or (model or "").strip().lower() == "unknown": + # Unconfigured install: say so in red instead of a blank/"unknown" + # slug — this is the single clearest place to tell the user what + # is wrong and how to fix it. + left_lines.append( + f"[bold red]no model configured[/] " + f"[dim {dim}]— run /model or hermes setup[/]" + ) + else: + model_short = model.split("/")[-1] if "/" in model else model + if model_short.endswith(".gguf"): + model_short = model_short[:-5] + if len(model_short) > 28: + model_short = model_short[:25] + "..." + ctx_str = f" [dim {dim}]·[/] [dim {dim}]{_format_context_length(context_length)} context[/]" if context_length else "" + left_lines.append(f"[{accent}]{model_short}[/]{ctx_str} [dim {dim}]·[/] [dim {dim}]Nous Research[/]") if os.getenv("HERMES_YOLO_MODE"): left_lines.append(f"[bold red]⚠ YOLO mode[/] [dim {dim}]— all approval prompts bypassed[/]") diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 759c58c2ed..050a60c2ff 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -111,8 +111,13 @@ class CLIAgentSetupMixin: base_url, _source, ) else: - print("\n⚠️ Provider resolver returned an empty API key. " - "Set OPENROUTER_API_KEY or run: hermes setup") + _prov = (resolved_provider or self.requested_provider or "").strip() + if _prov and _prov != "auto": + print(f"\n⚠️ No API key found for provider '{_prov}'.") + else: + print("\n⚠️ No inference provider is configured.") + print(" Run 'hermes model' to choose a provider, or " + "'hermes setup' for first-time setup.") return False if not isinstance(base_url, str) or not base_url: print("\n⚠️ Provider resolver returned an empty base URL. " @@ -179,6 +184,104 @@ class CLIAgentSetupMixin: return True + def _runtime_credentials_ready(self) -> bool: + """Silently probe whether any inference provider can be resolved. + + Unlike ``_ensure_runtime_credentials`` this never prints and never + mutates CLI state — it exists so the interactive first-run path can + detect a completely unconfigured install *before* the user types a + message into a chat that cannot work (#62935-adjacent UX class: + keyless first run must route into onboarding, not a broken chat). + """ + from hermes_cli.runtime_provider import resolve_runtime_provider + + try: + runtime = resolve_runtime_provider( + requested=self.requested_provider, + explicit_api_key=self._explicit_api_key, + explicit_base_url=self._explicit_base_url, + ) + except Exception: + return False + if not isinstance(runtime, dict): + return False + api_key = runtime.get("api_key") + base_url = runtime.get("base_url") + if callable(api_key) and not isinstance(api_key, str): + return bool(base_url) + if isinstance(api_key, str) and api_key: + return bool(base_url) + # Keyless custom/local endpoints (ollama, llama.cpp, vLLM…) are fine. + return bool( + isinstance(base_url, str) + and base_url + and "openrouter.ai" not in base_url + ) + + def _offer_first_run_setup(self) -> bool: + """Offer the provider picker when no provider is configured at all. + + Called from the interactive startup path when + ``_runtime_credentials_ready()`` is False and stdin is a TTY. Runs the + exact same flow as ``hermes model`` (which fronts Quick Setup / Nous + Portal OAuth as the first, recommended option) so there is a single + source of truth for provider onboarding. Returns True when a provider + was configured. + """ + from cli import _cprint, logger + + _cprint("") + _cprint("⚕ No inference provider is configured yet — let's fix that.") + _cprint(" You'll pick a provider (Nous Portal OAuth is the fastest; " + "no API key needed) and a model.") + try: + answer = input(" Set up a provider now? [Y/n]: ").strip().lower() + except (KeyboardInterrupt, EOFError): + print() + answer = "n" + if answer in {"n", "no"}: + _cprint(" Skipped. Run 'hermes model' or 'hermes setup' any time.") + return False + + try: + from hermes_cli.main import select_provider_and_model + select_provider_and_model() + except (KeyboardInterrupt, EOFError, SystemExit): + print() + _cprint(" Setup cancelled. Run 'hermes model' any time.") + return False + except Exception as exc: + logger.debug("first-run provider setup failed: %s", exc) + _cprint(f" ⚠️ Provider setup failed: {exc}") + _cprint(" Run 'hermes model' to try again.") + return False + + # Re-sync CLI state from what the picker persisted so the very next + # turn uses the new provider without a restart. + try: + from hermes_cli.config import load_config + _model_cfg = (load_config().get("model") or {}) + if isinstance(_model_cfg, dict): + _new_provider = (_model_cfg.get("provider") or "").strip() + if _new_provider: + self.requested_provider = _new_provider + _new_model = ( + _model_cfg.get("default") or _model_cfg.get("model") or "" + ).strip() + if _new_model: + self.model = _new_model + except Exception as exc: + logger.debug("first-run config re-sync failed: %s", exc) + # Force credential re-resolution + agent rebuild on next use. + self.agent = None + self._active_agent_route_signature = None + + if self._runtime_credentials_ready(): + _cprint(" ✓ Provider configured — you're ready to chat.") + return True + _cprint(" Provider setup didn't complete. Run 'hermes model' to retry.") + return False + def _resolve_turn_agent_config(self, user_message: str) -> dict: """Build the effective model/runtime config for a single user turn. diff --git a/tests/cli/test_cli_first_run_setup.py b/tests/cli/test_cli_first_run_setup.py new file mode 100644 index 0000000000..625e4382d4 --- /dev/null +++ b/tests/cli/test_cli_first_run_setup.py @@ -0,0 +1,252 @@ +"""First-run onboarding routing for a completely unconfigured install. + +Regression tests for the "keyless first run boots into a broken chat" bug: +a fresh install with zero providers accepted a message, spun for ~30s, then +failed with a provider-specific error ("Set OPENROUTER_API_KEY") the user +never chose, and never offered setup. + +Covers: +- ``_runtime_credentials_ready()`` silent probe semantics +- ``_offer_first_run_setup()`` routing into the shared provider picker +- the provider-aware (non-OpenRouter-specific) empty-key error message +""" + +import importlib +import sys +import types + +import pytest + +from hermes_cli.auth import AuthError + + +def _reset_modules(prefixes: tuple[str, ...]): + for name in list(sys.modules): + if any(name == p or name.startswith(p + ".") for p in prefixes): + sys.modules.pop(name, None) + + +@pytest.fixture(autouse=True) +def _restore_cli_and_tool_modules(): + prefixes = ("tools", "cli", "run_agent") + original_modules = { + name: module + for name, module in sys.modules.items() + if any(name == p or name.startswith(p + ".") for p in prefixes) + } + try: + yield + finally: + _reset_modules(prefixes) + sys.modules.update(original_modules) + + +def _import_cli(): + for name in list(sys.modules): + if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."): + sys.modules.pop(name, None) + if "firecrawl" not in sys.modules: + sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object) + return importlib.import_module("cli") + + +def _make_shell(cli, monkeypatch): + shell = cli.HermesCLI(compact=True, max_turns=1) + return shell + + +# --------------------------------------------------------------------------- +# _runtime_credentials_ready +# --------------------------------------------------------------------------- + + +def test_credentials_ready_false_when_no_provider(monkeypatch): + cli = _import_cli() + + def _raise(**kwargs): + raise AuthError("No inference provider configured.", code="no_provider_configured") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise) + shell = _make_shell(cli, monkeypatch) + assert shell._runtime_credentials_ready() is False + + +def test_credentials_ready_false_on_empty_openrouter_key(monkeypatch): + """The exact broken-chat state: provider resolves but api_key is empty.""" + cli = _import_cli() + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "openrouter", + "api_key": "", + "base_url": "https://openrouter.ai/api/v1", + "source": "env/config", + }, + ) + shell = _make_shell(cli, monkeypatch) + assert shell._runtime_credentials_ready() is False + + +def test_credentials_ready_true_with_key(monkeypatch): + cli = _import_cli() + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "openrouter", + "api_key": "sk-test", + "base_url": "https://openrouter.ai/api/v1", + "source": "env/config", + }, + ) + shell = _make_shell(cli, monkeypatch) + assert shell._runtime_credentials_ready() is True + + +def test_credentials_ready_true_for_keyless_local_endpoint(monkeypatch): + """ollama/llama.cpp-style custom endpoints need no key.""" + cli = _import_cli() + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "custom", + "api_key": "", + "base_url": "http://localhost:11434/v1", + "source": "custom_provider", + }, + ) + shell = _make_shell(cli, monkeypatch) + assert shell._runtime_credentials_ready() is True + + +def test_credentials_ready_true_for_callable_bearer_provider(monkeypatch): + cli = _import_cli() + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "azure-foundry", + "api_key": lambda: "tok", + "base_url": "https://foundry.example/v1", + "source": "entra", + }, + ) + shell = _make_shell(cli, monkeypatch) + assert shell._runtime_credentials_ready() is True + + +def test_credentials_ready_never_prints(monkeypatch, capsys): + cli = _import_cli() + + def _raise(**kwargs): + raise AuthError("No inference provider configured.", code="no_provider_configured") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _raise) + shell = _make_shell(cli, monkeypatch) + capsys.readouterr() # drain construction output + shell._runtime_credentials_ready() + out = capsys.readouterr() + assert out.out == "" + + +# --------------------------------------------------------------------------- +# _offer_first_run_setup +# --------------------------------------------------------------------------- + + +def test_offer_first_run_setup_routes_into_shared_picker(monkeypatch): + cli = _import_cli() + shell = _make_shell(cli, monkeypatch) + + picker_calls = {"count": 0} + + def _fake_picker(): + picker_calls["count"] += 1 + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _fake_picker) + monkeypatch.setattr("builtins.input", lambda *a, **k: "y") + # After the picker "runs", config has a provider and creds resolve. + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"provider": "nous", "default": "hermes-4-405b"}}, + ) + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "nous", + "api_key": "portal-token", + "base_url": "https://inference-api.nousresearch.com/v1", + "source": "oauth", + }, + ) + + assert shell._offer_first_run_setup() is True + assert picker_calls["count"] == 1 + assert shell.requested_provider == "nous" + assert shell.model == "hermes-4-405b" + # Agent must be rebuilt with the new credentials on next use. + assert shell.agent is None + + +def test_offer_first_run_setup_declined(monkeypatch): + cli = _import_cli() + shell = _make_shell(cli, monkeypatch) + + def _fail_picker(): + raise AssertionError("picker must not run when declined") + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _fail_picker) + monkeypatch.setattr("builtins.input", lambda *a, **k: "n") + assert shell._offer_first_run_setup() is False + + +def test_offer_first_run_setup_picker_cancel_is_graceful(monkeypatch): + cli = _import_cli() + shell = _make_shell(cli, monkeypatch) + + def _cancel_picker(): + raise KeyboardInterrupt() + + monkeypatch.setattr("hermes_cli.main.select_provider_and_model", _cancel_picker) + monkeypatch.setattr("builtins.input", lambda *a, **k: "") + # Empty answer defaults to yes -> picker runs -> cancels -> False, no raise. + assert shell._offer_first_run_setup() is False + + +# --------------------------------------------------------------------------- +# Provider-aware empty-key error (replaces the OpenRouter-specific one) +# --------------------------------------------------------------------------- + + +def test_empty_key_error_names_actual_provider(monkeypatch, capsys): + cli = _import_cli() + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "fireworks", + "api_key": "", + "base_url": "https://api.fireworks.ai/inference/v1/extra", + "source": "env/config", + }, + ) + shell = _make_shell(cli, monkeypatch) + # A custom base_url would get the no-key placeholder; force the + # openrouter-shaped branch by pointing base_url at openrouter. + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + lambda **kw: { + "provider": "fireworks", + "api_key": "", + "base_url": "https://openrouter.ai/api/v1", + "source": "env/config", + }, + ) + capsys.readouterr() + assert shell._ensure_runtime_credentials() is False + out = capsys.readouterr().out + assert "fireworks" in out + assert "OPENROUTER_API_KEY" not in out + assert "hermes model" in out or "hermes setup" in out