diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a8d4c3200b..2064b324f5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7734,6 +7734,19 @@ For more help on a command: setup_parser.add_argument( "--reset", action="store_true", help="Reset configuration to defaults" ) + setup_parser.add_argument( + "--reconfigure", + action="store_true", + help="(Default on existing installs.) Re-run the full wizard, " + "showing current values as defaults. Kept for backwards " + "compatibility — a bare 'hermes setup' now does this.", + ) + setup_parser.add_argument( + "--quick", + action="store_true", + help="On existing installs: only prompt for items that are missing " + "or unset, instead of running the full reconfigure wizard.", + ) setup_parser.set_defaults(func=cmd_setup) # ========================================================================= diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index e28acd41b8..0fa1f8abb2 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2863,17 +2863,6 @@ SETUP_SECTIONS = [ ("agent", "Agent Settings", setup_agent_settings), ] -# The returning-user menu intentionally omits standalone TTS because model setup -# already includes TTS selection and tools setup covers the rest of the provider -# configuration. Keep this list in the same order as the visible menu entries. -RETURNING_USER_MENU_SECTION_KEYS = [ - "model", - "terminal", - "gateway", - "tools", - "agent", -] - def run_setup_wizard(args): """Run the interactive setup wizard. @@ -2898,6 +2887,9 @@ def run_setup_wizard(args): 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() @@ -2989,50 +2981,36 @@ def run_setup_wizard(args): migration_ran = False if is_existing: - # ── Returning User Menu ── - print() - print_header("Welcome Back!") - print_success("You already have Hermes configured.") - print() - - menu_choices = [ - "Quick Setup - configure missing items only", - "Full Setup - reconfigure everything", - "Model & Provider", - "Terminal Backend", - "Messaging Platforms (Gateway)", - "Tools", - "Agent Settings", - "Exit", - ] - choice = prompt_choice("What would you like to do?", menu_choices, 0) - - if choice == 0: - # Quick setup + # Existing install — default is the full-wizard reconfigure flow. + # Every prompt shows the current value as its default, so pressing + # Enter keeps it. Opt into `--quick` for the narrow "just fill in + # missing items" flow (useful after a partial OpenClaw migration + # or when a required API key got cleared). + if quick_requested: _run_quick_setup(config, hermes_home) return - elif choice == 1: - # Full setup — fall through to run all sections - pass - elif choice == 7: - print_info("Exiting. Run 'hermes setup' again when ready.") - return - elif 2 <= choice <= 6: - # Individual section — map by key, not by position. - # SETUP_SECTIONS includes TTS but the returning-user menu skips it, - # so positional indexing (choice - 2) would dispatch the wrong section. - section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 2] - section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None) - if section: - _, label, func = section - func(config) - save_config(config) - _print_setup_summary(config, hermes_home) - return + + print() + print_header("Reconfigure") + print_success("You already have Hermes configured.") + print_info("Running the full wizard — each prompt shows your current value.") + print_info("Press Enter to keep it, or type a new value to change it.") + print_info("") + print_info("Tip: jump straight to a section with 'hermes setup model|terminal|") + print_info(" gateway|tools|agent', or fill only missing items with --quick.") + # Fall through to the "Full Setup — run all sections" block below. + # --reconfigure is now the default on existing installs; the flag + # is preserved for backwards compatibility but is a no-op here. else: # ── First-Time Setup ── print() + # --reconfigure / --quick on a fresh install are meaningless — fall + # through to the normal first-time flow. + if reconfigure_requested or quick_requested: + print_info("No existing configuration found — running first-time setup.") + print() + # Offer OpenClaw migration before configuration begins migration_ran = _offer_openclaw_migration(hermes_home) if migration_ran: diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index e3e243b4cc..68f6bd5a20 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -144,91 +144,6 @@ class TestNonInteractiveSetup: out = capsys.readouterr().out assert "hermes config set model.provider custom" in out - def test_returning_user_terminal_menu_choice_dispatches_terminal_section(self, tmp_path): - """Returning-user menu should map Terminal Backend to the terminal setup, not TTS.""" - from hermes_cli import setup as setup_mod - - args = _make_setup_args() - config = {} - model_section = MagicMock() - tts_section = MagicMock() - terminal_section = MagicMock() - gateway_section = MagicMock() - tools_section = MagicMock() - agent_section = MagicMock() - - with ( - patch.object(setup_mod, "ensure_hermes_home"), - patch.object(setup_mod, "load_config", return_value=config), - patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), - patch.object(setup_mod, "is_interactive_stdin", return_value=True), - patch.object( - setup_mod, - "get_env_value", - side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "", - ), - patch("hermes_cli.auth.get_active_provider", return_value=None), - patch.object(setup_mod, "prompt_choice", return_value=3), - patch.object( - setup_mod, - "SETUP_SECTIONS", - [ - ("model", "Model & Provider", model_section), - ("tts", "Text-to-Speech", tts_section), - ("terminal", "Terminal Backend", terminal_section), - ("gateway", "Messaging Platforms (Gateway)", gateway_section), - ("tools", "Tools", tools_section), - ("agent", "Agent Settings", agent_section), - ], - ), - patch.object(setup_mod, "save_config"), - patch.object(setup_mod, "_print_setup_summary"), - ): - setup_mod.run_setup_wizard(args) - - terminal_section.assert_called_once_with(config) - tts_section.assert_not_called() - - def test_returning_user_menu_does_not_show_separator_rows(self, tmp_path): - """Returning-user menu should only show selectable actions.""" - from hermes_cli import setup as setup_mod - - args = _make_setup_args() - captured = {} - - def fake_prompt_choice(question, choices, default=0): - captured["question"] = question - captured["choices"] = list(choices) - return len(choices) - 1 - - with ( - patch.object(setup_mod, "ensure_hermes_home"), - patch.object(setup_mod, "load_config", return_value={}), - patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), - patch.object(setup_mod, "is_interactive_stdin", return_value=True), - patch.object( - setup_mod, - "get_env_value", - side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "", - ), - patch("hermes_cli.auth.get_active_provider", return_value=None), - patch.object(setup_mod, "prompt_choice", side_effect=fake_prompt_choice), - ): - setup_mod.run_setup_wizard(args) - - assert captured["question"] == "What would you like to do?" - assert "---" not in captured["choices"] - assert captured["choices"] == [ - "Quick Setup - configure missing items only", - "Full Setup - reconfigure everything", - "Model & Provider", - "Terminal Backend", - "Messaging Platforms (Gateway)", - "Tools", - "Agent Settings", - "Exit", - ] - def test_main_accepts_tts_setup_section(self, monkeypatch): """`hermes setup tts` should parse and dispatch like other setup sections.""" from hermes_cli import main as main_mod diff --git a/tests/hermes_cli/test_setup_reconfigure.py b/tests/hermes_cli/test_setup_reconfigure.py new file mode 100644 index 0000000000..9f7c97a8c1 --- /dev/null +++ b/tests/hermes_cli/test_setup_reconfigure.py @@ -0,0 +1,287 @@ +"""Tests for the setup wizard's returning-user behavior. + +On an existing install: +- Bare `hermes setup` drops straight into the full reconfigure wizard + (every prompt shows the current value as its default). +- `hermes setup --quick` runs the narrower "fill in missing items" flow. +- `hermes setup --reconfigure` is a backwards-compat alias for the + bare-setup default. + +On a fresh install, all three are no-ops — fall through to first-time setup. +""" + +from argparse import Namespace +from contextlib import ExitStack +from unittest.mock import patch + +import pytest + + +def _make_setup_args(**overrides): + return Namespace( + non_interactive=overrides.get("non_interactive", False), + section=overrides.get("section", None), + reset=overrides.get("reset", False), + reconfigure=overrides.get("reconfigure", False), + quick=overrides.get("quick", False), + ) + + +@pytest.fixture +def existing_install(tmp_path, monkeypatch): + """Simulate a returning user with an existing configured install.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +@pytest.fixture +def fresh_install(tmp_path, monkeypatch): + """Simulate a first-time user with no existing configuration.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def _enter_existing_install_patches(stack, **extra): + """Apply standard existing-install mocks via an ExitStack. + + Returns a dict of mocks from the `extra` kwargs (which map mock-name to + target path) so callers can assert on them. + """ + # Unconditional mocks (no return values to assert against). + for target, kwargs in [ + ("hermes_cli.setup.ensure_hermes_home", {}), + ("hermes_cli.setup.is_interactive_stdin", {"return_value": True}), + ("hermes_cli.config.is_managed", {"return_value": False}), + ("hermes_cli.setup.load_config", {"return_value": {}}), + ("hermes_cli.setup.save_config", {}), + ("hermes_cli.setup.get_env_value", {"return_value": None}), + ("hermes_cli.auth.get_active_provider", {"return_value": "openrouter"}), + ("hermes_cli.setup._print_setup_summary", {}), + ("hermes_cli.setup._offer_launch_chat", {}), + ("hermes_cli.setup._offer_openclaw_migration", {"return_value": False}), + ]: + stack.enter_context(patch(target, **kwargs)) + + # Named mocks caller wants to assert on. + named = {} + for name, target in extra.items(): + named[name] = stack.enter_context(patch(target)) + return named + + +def _enter_fresh_install_patches(stack, **extra): + for target, kwargs in [ + ("hermes_cli.setup.ensure_hermes_home", {}), + ("hermes_cli.setup.is_interactive_stdin", {"return_value": True}), + ("hermes_cli.config.is_managed", {"return_value": False}), + ("hermes_cli.setup.load_config", {"return_value": {}}), + ("hermes_cli.setup.save_config", {}), + ("hermes_cli.auth.get_active_provider", {"return_value": None}), + ("hermes_cli.setup.get_env_value", {"return_value": None}), + ("hermes_cli.setup._offer_openclaw_migration", {"return_value": False}), + ]: + stack.enter_context(patch(target, **kwargs)) + + named = {} + for name, target_spec in extra.items(): + if isinstance(target_spec, tuple): + target, kwargs = target_spec + named[name] = stack.enter_context(patch(target, **kwargs)) + else: + named[name] = stack.enter_context(patch(target_spec)) + return named + + +class TestExistingInstallDefault: + """Bare `hermes setup` on an existing install = full reconfigure wizard.""" + + def test_bare_setup_runs_full_reconfigure_without_menu(self, existing_install): + """No menu, no prompt_choice — just run every section in sequence.""" + args = _make_setup_args() # no flags + + with ExitStack() as stack: + m = _enter_existing_install_patches( + stack, + prompt_choice="hermes_cli.setup.prompt_choice", + quick="hermes_cli.setup._run_quick_setup", + model="hermes_cli.setup.setup_model_provider", + terminal="hermes_cli.setup.setup_terminal_backend", + agent="hermes_cli.setup.setup_agent_settings", + gateway="hermes_cli.setup.setup_gateway", + tools="hermes_cli.setup.setup_tools", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + # No menu shown. + m["prompt_choice"].assert_not_called() + # Quick-setup path NOT taken. + m["quick"].assert_not_called() + # All five sections ran. + m["model"].assert_called_once() + m["terminal"].assert_called_once() + m["agent"].assert_called_once() + m["gateway"].assert_called_once() + m["tools"].assert_called_once() + + def test_reconfigure_flag_is_backwards_compat_noop(self, existing_install): + """`hermes setup --reconfigure` behaves the same as bare `hermes setup`.""" + args = _make_setup_args(reconfigure=True) + + with ExitStack() as stack: + m = _enter_existing_install_patches( + stack, + prompt_choice="hermes_cli.setup.prompt_choice", + model="hermes_cli.setup.setup_model_provider", + terminal="hermes_cli.setup.setup_terminal_backend", + agent="hermes_cli.setup.setup_agent_settings", + gateway="hermes_cli.setup.setup_gateway", + tools="hermes_cli.setup.setup_tools", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + m["prompt_choice"].assert_not_called() + m["model"].assert_called_once() + m["terminal"].assert_called_once() + m["agent"].assert_called_once() + m["gateway"].assert_called_once() + m["tools"].assert_called_once() + + +class TestQuickFlag: + """`--quick` on an existing install runs the fill-missing flow.""" + + def test_quick_flag_runs_quick_setup_only(self, existing_install): + args = _make_setup_args(quick=True) + + with ExitStack() as stack: + m = _enter_existing_install_patches( + stack, + quick="hermes_cli.setup._run_quick_setup", + model="hermes_cli.setup.setup_model_provider", + terminal="hermes_cli.setup.setup_terminal_backend", + agent="hermes_cli.setup.setup_agent_settings", + gateway="hermes_cli.setup.setup_gateway", + tools="hermes_cli.setup.setup_tools", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + m["quick"].assert_called_once() + # Full reconfigure sections must NOT run. + m["model"].assert_not_called() + m["terminal"].assert_not_called() + m["agent"].assert_not_called() + m["gateway"].assert_not_called() + m["tools"].assert_not_called() + + +class TestFreshInstall: + """On a fresh install (no active provider), flags are no-ops.""" + + def test_bare_setup_runs_first_time_flow(self, fresh_install): + args = _make_setup_args() + + with ExitStack() as stack: + m = _enter_fresh_install_patches( + stack, + prompt=("hermes_cli.setup.prompt_choice", {"return_value": 0}), + first="hermes_cli.setup._run_first_time_quick_setup", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + m["prompt"].assert_called_once() # quick-vs-full prompt + m["first"].assert_called_once() + + def test_reconfigure_on_fresh_install_falls_through(self, fresh_install): + args = _make_setup_args(reconfigure=True) + + with ExitStack() as stack: + m = _enter_fresh_install_patches( + stack, + prompt=("hermes_cli.setup.prompt_choice", {"return_value": 0}), + first="hermes_cli.setup._run_first_time_quick_setup", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + m["prompt"].assert_called_once() + m["first"].assert_called_once() + + def test_quick_on_fresh_install_falls_through(self, fresh_install): + args = _make_setup_args(quick=True) + + with ExitStack() as stack: + m = _enter_fresh_install_patches( + stack, + prompt=("hermes_cli.setup.prompt_choice", {"return_value": 0}), + first="hermes_cli.setup._run_first_time_quick_setup", + ) + from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) + + m["prompt"].assert_called_once() + m["first"].assert_called_once() + + +class TestArgparse: + """The flags are plumbed through argparse to cmd_setup.""" + + def test_reconfigure_flag_reaches_cmd_setup(self, monkeypatch): + import sys + from hermes_cli.main import main + + captured = {} + monkeypatch.setattr( + "hermes_cli.setup.run_setup_wizard", + lambda args: captured.setdefault("args", args), + ) + monkeypatch.setattr(sys, "argv", ["hermes", "setup", "--reconfigure"]) + try: + main() + except SystemExit: + pass + assert captured["args"].reconfigure is True + assert captured["args"].quick is False + + def test_quick_flag_reaches_cmd_setup(self, monkeypatch): + import sys + from hermes_cli.main import main + + captured = {} + monkeypatch.setattr( + "hermes_cli.setup.run_setup_wizard", + lambda args: captured.setdefault("args", args), + ) + monkeypatch.setattr(sys, "argv", ["hermes", "setup", "--quick"]) + try: + main() + except SystemExit: + pass + assert captured["args"].quick is True + assert captured["args"].reconfigure is False + + def test_bare_setup_has_both_flags_false(self, monkeypatch): + import sys + from hermes_cli.main import main + + captured = {} + monkeypatch.setattr( + "hermes_cli.setup.run_setup_wizard", + lambda args: captured.setdefault("args", args), + ) + monkeypatch.setattr(sys, "argv", ["hermes", "setup"]) + try: + main() + except SystemExit: + pass + assert captured["args"].reconfigure is False + assert captured["args"].quick is False diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 6ffc41d7f2..947994844b 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -187,10 +187,14 @@ Use `hermes gateway run` instead of `hermes gateway start` — WSL's systemd sup ## `hermes setup` ```bash -hermes setup [model|tts|terminal|gateway|tools|agent] [--non-interactive] [--reset] +hermes setup [model|tts|terminal|gateway|tools|agent] [--non-interactive] [--reset] [--quick] [--reconfigure] ``` -Use the full wizard or jump into one section: +**First run:** launches the first-time wizard. + +**Returning user (already configured):** drops straight into the full reconfigure wizard — every prompt shows your current value as its default, press Enter to keep or type a new value. No menu. + +Jump into one section instead of the full wizard: | Section | Description | |---------|-------------| @@ -204,8 +208,10 @@ Options: | Option | Description | |--------|-------------| +| `--quick` | On returning-user runs: only prompt for items that are missing or unset. Skip items you already have configured. | | `--non-interactive` | Use defaults / environment values without prompts. | | `--reset` | Reset configuration to defaults before setup. | +| `--reconfigure` | Backwards-compat alias — bare `hermes setup` on an existing install now does this by default. | ## `hermes whatsapp`