/ tests / hermes_cli / test_setup.py
test_setup.py
  1  """Tests for setup.py configuration flows."""
  2  import json
  3  import os
  4  import sys
  5  import types
  6  
  7  import pytest
  8  
  9  from hermes_cli.auth import get_active_provider
 10  from hermes_cli.config import load_config, save_config
 11  from hermes_cli import setup as setup_mod
 12  from hermes_cli.setup import setup_model_provider
 13  
 14  
 15  def _maybe_keep_current_tts(question, choices):
 16      if question != "Select TTS provider:":
 17          return None
 18      assert choices[-1].startswith("Keep current (")
 19      return len(choices) - 1
 20  
 21  
 22  def _clear_provider_env(monkeypatch):
 23      for key in (
 24          "NOUS_API_KEY",
 25          "OPENROUTER_API_KEY",
 26          "OPENAI_BASE_URL",
 27          "OPENAI_API_KEY",
 28          "LLM_MODEL",
 29      ):
 30          monkeypatch.delenv(key, raising=False)
 31  
 32  
 33  def _clear_vercel_env(monkeypatch):
 34      for key in (
 35          "TERMINAL_VERCEL_RUNTIME",
 36          "VERCEL_OIDC_TOKEN",
 37          "VERCEL_TOKEN",
 38          "VERCEL_PROJECT_ID",
 39          "VERCEL_TEAM_ID",
 40      ):
 41          monkeypatch.delenv(key, raising=False)
 42  
 43  
 44  def _stub_tts(monkeypatch):
 45      """Stub out TTS prompts so setup_model_provider doesn't block."""
 46      monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda q, c, d=0: (
 47          _maybe_keep_current_tts(q, c) if _maybe_keep_current_tts(q, c) is not None
 48          else d
 49      ))
 50      monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *a, **kw: False)
 51  
 52  
 53  def _write_model_config(tmp_path, provider, base_url="", model_name="test-model"):
 54      """Simulate what a _model_flow_* function writes to disk."""
 55      cfg = load_config()
 56      m = cfg.get("model")
 57      if not isinstance(m, dict):
 58          m = {"default": m} if m else {}
 59          cfg["model"] = m
 60      m["provider"] = provider
 61      if base_url:
 62          m["base_url"] = base_url
 63      if model_name:
 64          m["default"] = model_name
 65      save_config(cfg)
 66  
 67  
 68  def test_setup_delegates_to_select_provider_and_model(tmp_path, monkeypatch):
 69      """setup_model_provider calls select_provider_and_model and syncs config."""
 70      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 71      _clear_provider_env(monkeypatch)
 72      _stub_tts(monkeypatch)
 73  
 74      config = load_config()
 75  
 76      def fake_select():
 77          _write_model_config(tmp_path, "custom", "http://localhost:11434/v1", "qwen3.5:32b")
 78  
 79      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
 80  
 81      setup_model_provider(config)
 82      save_config(config)
 83  
 84      reloaded = load_config()
 85      assert isinstance(reloaded["model"], dict)
 86      assert reloaded["model"]["provider"] == "custom"
 87      assert reloaded["model"]["base_url"] == "http://localhost:11434/v1"
 88      assert reloaded["model"]["default"] == "qwen3.5:32b"
 89  
 90  
 91  def test_setup_syncs_openrouter_from_disk(tmp_path, monkeypatch):
 92      """When select_provider_and_model saves OpenRouter config to disk,
 93      the wizard's config dict picks it up."""
 94      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
 95      _clear_provider_env(monkeypatch)
 96      _stub_tts(monkeypatch)
 97  
 98      config = load_config()
 99      assert isinstance(config.get("model"), str)  # fresh install
100  
101      def fake_select():
102          _write_model_config(tmp_path, "openrouter", model_name="anthropic/claude-opus-4.6")
103  
104      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
105  
106      setup_model_provider(config)
107      save_config(config)
108  
109      reloaded = load_config()
110      assert isinstance(reloaded["model"], dict)
111      assert reloaded["model"]["provider"] == "openrouter"
112  
113  
114  def test_setup_syncs_nous_from_disk(tmp_path, monkeypatch):
115      """Nous OAuth writes config to disk; wizard config dict must pick it up."""
116      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
117      _clear_provider_env(monkeypatch)
118      _stub_tts(monkeypatch)
119  
120      config = load_config()
121  
122      def fake_select():
123          _write_model_config(tmp_path, "nous", "https://inference.example.com/v1", "gemini-3-flash")
124  
125      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
126  
127      setup_model_provider(config)
128      save_config(config)
129  
130      reloaded = load_config()
131      assert isinstance(reloaded["model"], dict)
132      assert reloaded["model"]["provider"] == "nous"
133      assert reloaded["model"]["base_url"] == "https://inference.example.com/v1"
134  
135  
136  def test_setup_custom_providers_synced(tmp_path, monkeypatch):
137      """custom_providers written by select_provider_and_model must survive."""
138      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
139      _clear_provider_env(monkeypatch)
140      _stub_tts(monkeypatch)
141  
142      config = load_config()
143  
144      def fake_select():
145          _write_model_config(tmp_path, "custom", "http://localhost:8080/v1", "llama3")
146          cfg = load_config()
147          cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
148          save_config(cfg)
149  
150      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
151  
152      setup_model_provider(config)
153      save_config(config)
154  
155      reloaded = load_config()
156      assert reloaded.get("custom_providers") == [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
157  
158  
159  def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, capsys):
160      env = {
161          "TELEGRAM_BOT_TOKEN": "",
162          "TELEGRAM_HOME_CHANNEL": "",
163          "DISCORD_BOT_TOKEN": "",
164          "DISCORD_HOME_CHANNEL": "",
165          "SLACK_BOT_TOKEN": "",
166          "SLACK_HOME_CHANNEL": "",
167          "MATRIX_HOMESERVER": "https://matrix.example.com",
168          "MATRIX_USER_ID": "@alice:example.com",
169          "MATRIX_PASSWORD": "",
170          "MATRIX_ACCESS_TOKEN": "token",
171          "BLUEBUBBLES_SERVER_URL": "",
172          "BLUEBUBBLES_HOME_CHANNEL": "",
173          "WHATSAPP_ENABLED": "",
174          "WEBHOOK_ENABLED": "",
175      }
176  
177      import hermes_cli.gateway as gateway_mod
178  
179      monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
180      monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
181      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
182      monkeypatch.setattr("platform.system", lambda: "Linux")
183  
184      monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
185      monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
186      monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
187      monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
188  
189      setup_mod.setup_gateway({})
190  
191      out = capsys.readouterr().out
192      assert "Messaging platforms configured!" in out
193      assert "Start the gateway to bring your bots online:" in out
194      assert "hermes gateway" in out
195  
196  
197  def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):
198      """setup_gateway() in a Docker container shows Docker-specific restart instructions."""
199      env = {
200          "TELEGRAM_BOT_TOKEN": "",
201          "TELEGRAM_HOME_CHANNEL": "",
202          "DISCORD_BOT_TOKEN": "",
203          "DISCORD_HOME_CHANNEL": "",
204          "SLACK_BOT_TOKEN": "",
205          "SLACK_HOME_CHANNEL": "",
206          "MATRIX_HOMESERVER": "https://matrix.example.com",
207          "MATRIX_USER_ID": "@alice:example.com",
208          "MATRIX_PASSWORD": "",
209          "MATRIX_ACCESS_TOKEN": "token",
210          "BLUEBUBBLES_SERVER_URL": "",
211          "BLUEBUBBLES_HOME_CHANNEL": "",
212          "WHATSAPP_ENABLED": "",
213          "WEBHOOK_ENABLED": "",
214      }
215  
216      import hermes_cli.gateway as gateway_mod
217  
218      monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
219      monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
220      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
221      monkeypatch.setattr("platform.system", lambda: "Linux")
222  
223      monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False)
224      monkeypatch.setattr(gateway_mod, "is_macos", lambda: False)
225      monkeypatch.setattr(gateway_mod, "_is_service_installed", lambda: False)
226      monkeypatch.setattr(gateway_mod, "_is_service_running", lambda: False)
227  
228      # Patch is_container at the import location in setup.py
229      import hermes_constants
230      monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
231  
232      setup_mod.setup_gateway({})
233  
234      out = capsys.readouterr().out
235      assert "Messaging platforms configured!" in out
236      assert "docker" in out.lower() or "Docker" in out
237      assert "restart" in out.lower()
238  
239  
240  def test_setup_syncs_custom_provider_removal_from_disk(tmp_path, monkeypatch):
241      """Removing the last custom provider in model setup should persist."""
242      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
243      _clear_provider_env(monkeypatch)
244      _stub_tts(monkeypatch)
245  
246      config = load_config()
247      config["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
248      save_config(config)
249  
250      def fake_select():
251          cfg = load_config()
252          cfg["model"] = {"provider": "openrouter", "default": "anthropic/claude-opus-4.6"}
253          cfg["custom_providers"] = []
254          save_config(cfg)
255  
256      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
257  
258      setup_model_provider(config)
259      save_config(config)
260  
261      reloaded = load_config()
262      assert reloaded.get("custom_providers") == []
263  
264  
265  def test_setup_cancel_preserves_existing_config(tmp_path, monkeypatch):
266      """When the user cancels provider selection, existing config is preserved."""
267      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
268      _clear_provider_env(monkeypatch)
269      _stub_tts(monkeypatch)
270  
271      # Pre-set a provider
272      _write_model_config(tmp_path, "openrouter", model_name="gpt-4o")
273  
274      config = load_config()
275      assert config["model"]["provider"] == "openrouter"
276  
277      def fake_select():
278          pass  # user cancelled — nothing written to disk
279  
280      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
281  
282      setup_model_provider(config)
283      save_config(config)
284  
285      reloaded = load_config()
286      assert isinstance(reloaded["model"], dict)
287      assert reloaded["model"]["provider"] == "openrouter"
288      assert reloaded["model"]["default"] == "gpt-4o"
289  
290  
291  def test_setup_exception_in_select_gracefully_handled(tmp_path, monkeypatch):
292      """If select_provider_and_model raises, setup continues with existing config."""
293      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
294      _clear_provider_env(monkeypatch)
295      _stub_tts(monkeypatch)
296  
297      config = load_config()
298  
299      def fake_select():
300          raise RuntimeError("something broke")
301  
302      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
303  
304      # Should not raise
305      setup_model_provider(config)
306  
307  
308  def test_setup_keyboard_interrupt_gracefully_handled(tmp_path, monkeypatch):
309      """KeyboardInterrupt during provider selection is handled."""
310      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
311      _clear_provider_env(monkeypatch)
312      _stub_tts(monkeypatch)
313  
314      config = load_config()
315  
316      def fake_select():
317          raise KeyboardInterrupt()
318  
319      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
320  
321      setup_model_provider(config)
322  
323  
324  def test_select_provider_and_model_warns_if_named_custom_provider_disappears(
325      tmp_path, monkeypatch, capsys
326  ):
327      """If a saved custom provider is deleted mid-selection, show a warning instead of silently doing nothing."""
328      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
329      _clear_provider_env(monkeypatch)
330  
331      cfg = load_config()
332      cfg["custom_providers"] = [{"name": "Local", "base_url": "http://localhost:8080/v1"}]
333      save_config(cfg)
334  
335      def fake_prompt_provider_choice(choices, default=0):
336          current = load_config()
337          current["custom_providers"] = []
338          save_config(current)
339          return next(i for i, label in enumerate(choices) if label.startswith("Local (localhost:8080/v1)"))
340  
341      monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda provider: None)
342      monkeypatch.setattr("hermes_cli.main._prompt_provider_choice", fake_prompt_provider_choice)
343      monkeypatch.setattr(
344          "hermes_cli.main._model_flow_named_custom",
345          lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("named custom flow should not run")),
346      )
347  
348      from hermes_cli.main import select_provider_and_model
349  
350      select_provider_and_model()
351  
352      out = capsys.readouterr().out
353      assert "selected saved custom provider is no longer available" in out
354  
355  
356  def test_select_provider_and_model_accepts_named_provider_from_providers_section(
357      tmp_path, monkeypatch, capsys
358  ):
359      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
360      _clear_provider_env(monkeypatch)
361  
362      cfg = load_config()
363      cfg["model"] = {
364          "provider": "volcengine-plan",
365          "default": "doubao-seed-2.0-code",
366      }
367      cfg["providers"] = {
368          "volcengine-plan": {
369              "name": "volcengine-plan",
370              "base_url": "https://ark.cn-beijing.volces.com/api/coding/v3",
371              "default_model": "doubao-seed-2.0-code",
372              "models": {"doubao-seed-2.0-code": {}},
373          }
374      }
375      save_config(cfg)
376  
377      monkeypatch.setattr(
378          "hermes_cli.main._prompt_provider_choice",
379          lambda choices, default=0: len(choices) - 1,
380      )
381  
382      from hermes_cli.main import select_provider_and_model
383  
384      select_provider_and_model()
385  
386      out = capsys.readouterr().out
387      assert "Warning: Unknown provider 'volcengine-plan'" not in out
388      assert "Active provider:  volcengine-plan" in out
389  
390  
391  def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):
392      """Codex model list fetching uses the runtime access token."""
393      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
394      monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
395      _clear_provider_env(monkeypatch)
396      monkeypatch.setenv("OPENROUTER_API_KEY", "or-test-key")
397  
398      config = load_config()
399      _stub_tts(monkeypatch)
400  
401      def fake_select():
402          _write_model_config(tmp_path, "openai-codex", "https://api.openai.com/v1", "gpt-4o")
403  
404      monkeypatch.setattr("hermes_cli.main.select_provider_and_model", fake_select)
405  
406      setup_model_provider(config)
407      save_config(config)
408  
409      reloaded = load_config()
410      assert isinstance(reloaded["model"], dict)
411      assert reloaded["model"]["provider"] == "openai-codex"
412  
413  
414  def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
415      monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True)
416      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
417      config = load_config()
418  
419      def fake_prompt_choice(question, choices, default=0):
420          if question == "Select terminal backend:":
421              return 2
422          if question == "Select how Modal execution should be billed:":
423              return 0
424          raise AssertionError(f"Unexpected prompt_choice call: {question}")
425  
426      def fake_prompt(message, *args, **kwargs):
427          assert "Modal Token" not in message
428          raise AssertionError(f"Unexpected prompt call: {message}")
429  
430      monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
431      monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
432      monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
433      monkeypatch.setattr(
434          "hermes_cli.setup.get_nous_subscription_features",
435          lambda config: type("Features", (), {"nous_auth_present": True})(),
436      )
437      monkeypatch.setitem(
438          sys.modules,
439          "tools.managed_tool_gateway",
440          types.SimpleNamespace(
441              is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
442              resolve_managed_tool_gateway=lambda vendor: None,
443          ),
444      )
445  
446      from hermes_cli.setup import setup_terminal_backend
447  
448      setup_terminal_backend(config)
449  
450      out = capsys.readouterr().out
451      assert config["terminal"]["backend"] == "modal"
452      assert config["terminal"]["modal_mode"] == "managed"
453      assert "bill to your subscription" in out
454  
455  
456  def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
457      monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True)
458      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
459      monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
460      monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
461      config = load_config()
462  
463      def fake_prompt_choice(question, choices, default=0):
464          if question == "Select terminal backend:":
465              return 2
466          if question == "Select how Modal execution should be billed:":
467              return 1
468          raise AssertionError(f"Unexpected prompt_choice call: {question}")
469  
470      prompt_values = iter(["token-id", "token-secret", ""])
471  
472      monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
473      monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
474      monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
475      monkeypatch.setattr(
476          "hermes_cli.setup.get_nous_subscription_features",
477          lambda config: type("Features", (), {"nous_auth_present": True})(),
478      )
479      monkeypatch.setitem(
480          sys.modules,
481          "tools.managed_tool_gateway",
482          types.SimpleNamespace(
483              is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
484              resolve_managed_tool_gateway=lambda vendor: None,
485          ),
486      )
487      monkeypatch.setitem(sys.modules, "swe_rex", object())
488  
489      from hermes_cli.setup import setup_terminal_backend
490  
491      setup_terminal_backend(config)
492  
493      assert config["terminal"]["backend"] == "modal"
494      assert config["terminal"]["modal_mode"] == "direct"
495  
496  
497  def test_vercel_setup_configures_access_token_auth(tmp_path, monkeypatch):
498      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
499      _clear_vercel_env(monkeypatch)
500      monkeypatch.setenv("VERCEL_OIDC_TOKEN", "old-oidc")
501      monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
502      config = load_config()
503  
504      def fake_prompt_choice(question, choices, default=0):
505          if question == "Select terminal backend:":
506              return 5
507          raise AssertionError(f"Unexpected prompt_choice call: {question}")
508  
509      prompt_values = iter(["python3.13", "yes", "2", "4096", "token", "project", "team"])
510  
511      monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
512      monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
513  
514      from hermes_cli.setup import setup_terminal_backend
515  
516      setup_terminal_backend(config)
517  
518      assert config["terminal"]["backend"] == "vercel_sandbox"
519      assert config["terminal"]["vercel_runtime"] == "python3.13"
520      assert config["terminal"]["container_disk"] == 51200
521      assert os.environ["TERMINAL_VERCEL_RUNTIME"] == "python3.13"
522      assert "VERCEL_OIDC_TOKEN" not in os.environ
523      assert os.environ["VERCEL_TOKEN"] == "token"
524      assert os.environ["VERCEL_PROJECT_ID"] == "project"
525      assert os.environ["VERCEL_TEAM_ID"] == "team"
526  
527  
528  def test_vercel_setup_prefills_project_and_team_from_link_file(tmp_path, monkeypatch):
529      monkeypatch.setenv("HERMES_HOME", str(tmp_path))
530      _clear_vercel_env(monkeypatch)
531      project_root = tmp_path / "project"
532      nested = project_root / "app" / "src"
533      nested.mkdir(parents=True)
534      vercel_dir = project_root / ".vercel"
535      vercel_dir.mkdir()
536      (vercel_dir / "project.json").write_text(
537          json.dumps({"projectId": "linked-project", "orgId": "linked-team"}),
538          encoding="utf-8",
539      )
540      monkeypatch.chdir(nested)
541      monkeypatch.setitem(sys.modules, "vercel", types.ModuleType("vercel"))
542      config = load_config()
543      config["terminal"]["container_disk"] = 999
544  
545      def fake_prompt_choice(question, choices, default=0):
546          if question == "Select terminal backend:":
547              return 5
548          raise AssertionError(f"Unexpected prompt_choice call: {question}")
549  
550      prompt_values = iter(["node24", "no", "1", "5120", "token", "", ""])
551      defaults = {}
552  
553      def fake_prompt(message, default="", **kwargs):
554          defaults[message] = default
555          value = next(prompt_values)
556          return value or default
557  
558      monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
559      monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
560  
561      from hermes_cli.setup import setup_terminal_backend
562  
563      setup_terminal_backend(config)
564  
565      assert config["terminal"]["backend"] == "vercel_sandbox"
566      assert config["terminal"]["container_persistent"] is False
567      assert config["terminal"]["container_disk"] == 51200
568      assert "VERCEL_OIDC_TOKEN" not in os.environ
569      assert os.environ["VERCEL_TOKEN"] == "token"
570      assert os.environ["VERCEL_PROJECT_ID"] == "linked-project"
571      assert os.environ["VERCEL_TEAM_ID"] == "linked-team"
572      assert defaults["    Vercel project ID"] == "linked-project"
573      assert defaults["    Vercel team ID"] == "linked-team"
574  
575  
576  def test_offer_launch_chat_relaunches_via_bin(monkeypatch):
577      from hermes_cli import setup as setup_mod
578      from hermes_cli import relaunch as relaunch_mod
579  
580      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
581      monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: "/usr/local/bin/hermes")
582  
583      exec_calls = []
584  
585      def fake_execvp(path, argv):
586          exec_calls.append((path, argv))
587          raise SystemExit(0)
588  
589      monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
590  
591      with pytest.raises(SystemExit):
592          setup_mod._offer_launch_chat()
593  
594      assert exec_calls == [("/usr/local/bin/hermes", ["/usr/local/bin/hermes", "chat"])]
595  
596  
597  def test_offer_launch_chat_falls_back_to_module(monkeypatch):
598      from hermes_cli import setup as setup_mod
599      from hermes_cli import relaunch as relaunch_mod
600  
601      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_args, **_kwargs: True)
602      monkeypatch.setattr(relaunch_mod, "resolve_hermes_bin", lambda: None)
603  
604      exec_calls = []
605  
606      def fake_execvp(path, argv):
607          exec_calls.append((path, argv))
608          raise SystemExit(0)
609  
610      monkeypatch.setattr(relaunch_mod.os, "execvp", fake_execvp)
611  
612      with pytest.raises(SystemExit):
613          setup_mod._offer_launch_chat()
614  
615      assert exec_calls == [(sys.executable, [sys.executable, "-m", "hermes_cli.main", "chat"])]
616  
617  
618  def test_setup_slack_saves_home_channel(monkeypatch):
619      """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
620      saved = {}
621      prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"])
622  
623      monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
624      monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
625      monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
626      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
627      monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
628  
629      setup_mod._setup_slack()
630  
631      assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
632  
633  
634  def test_setup_slack_home_channel_empty_not_saved(monkeypatch):
635      """_setup_slack() does not save SLACK_HOME_CHANNEL when left blank."""
636      saved = {}
637      prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""])
638  
639      monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "")
640      monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v}))
641      monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts))
642      monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False)
643      monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None)
644  
645      setup_mod._setup_slack()
646  
647      assert "SLACK_HOME_CHANNEL" not in saved