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