test_profiles.py
1 """Comprehensive tests for hermes_cli.profiles module. 2 3 Tests cover: validation, directory resolution, CRUD operations, active profile 4 management, export/import, renaming, alias collision checks, profile isolation, 5 and shell completion generation. 6 """ 7 8 import json 9 import io 10 import os 11 import tarfile 12 from pathlib import Path 13 from unittest.mock import patch, MagicMock 14 15 import pytest 16 17 from hermes_cli.profiles import ( 18 normalize_profile_name, 19 validate_profile_name, 20 get_profile_dir, 21 create_profile, 22 delete_profile, 23 list_profiles, 24 set_active_profile, 25 get_active_profile, 26 get_active_profile_name, 27 resolve_profile_env, 28 check_alias_collision, 29 rename_profile, 30 export_profile, 31 import_profile, 32 generate_bash_completion, 33 generate_zsh_completion, 34 _get_profiles_root, 35 _get_default_hermes_home, 36 ) 37 38 39 # --------------------------------------------------------------------------- 40 # Shared fixture: redirect Path.home() and HERMES_HOME for profile tests 41 # --------------------------------------------------------------------------- 42 43 @pytest.fixture() 44 def profile_env(tmp_path, monkeypatch): 45 """Set up an isolated environment for profile tests. 46 47 * Path.home() -> tmp_path (so _get_profiles_root() = tmp_path/.hermes/profiles) 48 * HERMES_HOME -> tmp_path/.hermes (so get_hermes_home() agrees) 49 * Creates the bare-minimum ~/.hermes directory. 50 """ 51 monkeypatch.setattr(Path, "home", lambda: tmp_path) 52 default_home = tmp_path / ".hermes" 53 default_home.mkdir(exist_ok=True) 54 monkeypatch.setenv("HERMES_HOME", str(default_home)) 55 return tmp_path 56 57 58 # =================================================================== 59 # TestValidateProfileName 60 # =================================================================== 61 62 class TestNormalizeProfileName: 63 """Tests for normalize_profile_name().""" 64 65 def test_title_case_normalized(self): 66 assert normalize_profile_name("Jules") == "jules" 67 assert normalize_profile_name(" Librarian ") == "librarian" 68 69 def test_default_case_insensitive(self): 70 assert normalize_profile_name("Default") == "default" 71 assert normalize_profile_name("DEFAULT") == "default" 72 73 def test_empty_raises(self): 74 with pytest.raises(ValueError, match="cannot be empty"): 75 normalize_profile_name("") 76 with pytest.raises(ValueError, match="cannot be empty"): 77 normalize_profile_name(" ") 78 79 80 class TestValidateProfileName: 81 """Tests for validate_profile_name().""" 82 83 @pytest.mark.parametrize("name", ["coder", "work-bot", "a1", "my_agent"]) 84 def test_valid_names_accepted(self, name): 85 # Should not raise 86 validate_profile_name(name) 87 88 def test_uppercase_rejected(self): 89 # validate_profile_name is strict — callers normalize first, then validate. 90 with pytest.raises(ValueError): 91 validate_profile_name("Jules") 92 93 @pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"]) 94 def test_invalid_names_rejected(self, name): 95 with pytest.raises(ValueError): 96 validate_profile_name(name) 97 98 def test_too_long_rejected(self): 99 long_name = "a" * 65 100 with pytest.raises(ValueError): 101 validate_profile_name(long_name) 102 103 def test_max_length_accepted(self): 104 # 64 chars total: 1 leading + 63 remaining = 64, within [0,63] range 105 name = "a" * 64 106 validate_profile_name(name) 107 108 def test_default_accepted(self): 109 # 'default' is a special-case pass-through 110 validate_profile_name("default") 111 112 def test_empty_string_rejected(self): 113 with pytest.raises(ValueError): 114 validate_profile_name("") 115 116 117 # =================================================================== 118 # TestGetProfileDir 119 # =================================================================== 120 121 class TestGetProfileDir: 122 """Tests for get_profile_dir().""" 123 124 def test_default_returns_hermes_home(self, profile_env): 125 tmp_path = profile_env 126 result = get_profile_dir("default") 127 assert result == tmp_path / ".hermes" 128 129 def test_named_profile_returns_profiles_subdir(self, profile_env): 130 tmp_path = profile_env 131 result = get_profile_dir("coder") 132 assert result == tmp_path / ".hermes" / "profiles" / "coder" 133 134 def test_named_profile_matching_is_case_insensitive(self, profile_env): 135 tmp_path = profile_env 136 assert get_profile_dir("Coder") == tmp_path / ".hermes" / "profiles" / "coder" 137 138 139 # =================================================================== 140 # TestCreateProfile 141 # =================================================================== 142 143 class TestCreateProfile: 144 """Tests for create_profile().""" 145 146 def test_creates_directory_with_subdirs(self, profile_env): 147 profile_dir = create_profile("coder", no_alias=True) 148 assert profile_dir.is_dir() 149 for subdir in ["memories", "sessions", "skills", "skins", "logs", 150 "plans", "workspace", "cron"]: 151 assert (profile_dir / subdir).is_dir(), f"Missing subdir: {subdir}" 152 153 def test_duplicate_raises_file_exists(self, profile_env): 154 create_profile("coder", no_alias=True) 155 with pytest.raises(FileExistsError): 156 create_profile("coder", no_alias=True) 157 158 def test_default_raises_value_error(self, profile_env): 159 with pytest.raises(ValueError, match="default"): 160 create_profile("default", no_alias=True) 161 162 def test_invalid_name_raises_value_error(self, profile_env): 163 with pytest.raises(ValueError): 164 create_profile("INVALID!", no_alias=True) 165 166 def test_clone_config_copies_files(self, profile_env): 167 tmp_path = profile_env 168 default_home = tmp_path / ".hermes" 169 # Create source config files in default profile 170 (default_home / "config.yaml").write_text("model: test") 171 (default_home / ".env").write_text("KEY=val") 172 (default_home / "SOUL.md").write_text("Be helpful.") 173 174 profile_dir = create_profile("coder", clone_config=True, no_alias=True) 175 176 assert (profile_dir / "config.yaml").read_text() == "model: test" 177 assert (profile_dir / ".env").read_text() == "KEY=val" 178 assert (profile_dir / "SOUL.md").read_text() == "Be helpful." 179 180 def test_clone_config_copies_source_skills(self, profile_env): 181 tmp_path = profile_env 182 default_home = tmp_path / ".hermes" 183 skill_dir = default_home / "skills" / "custom" / "installed-skill" 184 skill_dir.mkdir(parents=True) 185 (skill_dir / "SKILL.md").write_text("---\nname: installed-skill\n---\n") 186 187 profile_dir = create_profile("coder", clone_config=True, no_alias=True) 188 189 assert ( 190 profile_dir 191 / "skills" 192 / "custom" 193 / "installed-skill" 194 / "SKILL.md" 195 ).read_text() == "---\nname: installed-skill\n---\n" 196 197 def test_clone_all_copies_entire_tree(self, profile_env): 198 tmp_path = profile_env 199 default_home = tmp_path / ".hermes" 200 # Populate default with some content 201 (default_home / "memories").mkdir(exist_ok=True) 202 (default_home / "memories" / "note.md").write_text("remember this") 203 (default_home / "config.yaml").write_text("model: gpt-4") 204 # Runtime files that should be stripped 205 (default_home / "gateway.pid").write_text("12345") 206 (default_home / "gateway_state.json").write_text("{}") 207 (default_home / "processes.json").write_text("[]") 208 209 profile_dir = create_profile("coder", clone_all=True, no_alias=True) 210 211 # Content should be copied 212 assert (profile_dir / "memories" / "note.md").read_text() == "remember this" 213 assert (profile_dir / "config.yaml").read_text() == "model: gpt-4" 214 # Runtime files should be stripped 215 assert not (profile_dir / "gateway.pid").exists() 216 assert not (profile_dir / "gateway_state.json").exists() 217 assert not (profile_dir / "processes.json").exists() 218 219 def test_clone_all_excludes_sibling_profiles_tree(self, profile_env): 220 """--clone-all from default ~/.hermes must not copy profiles/* (nested explosion).""" 221 tmp_path = profile_env 222 default_home = tmp_path / ".hermes" 223 profiles_root = default_home / "profiles" 224 profiles_root.mkdir(exist_ok=True) 225 (profiles_root / "other").mkdir(parents=True, exist_ok=True) 226 (profiles_root / "other" / "marker.txt").write_text("sibling data") 227 228 (default_home / "memories").mkdir(exist_ok=True) 229 (default_home / "memories" / "note.md").write_text("remember this") 230 231 profile_dir = create_profile("coder", clone_all=True, no_alias=True) 232 233 assert (profile_dir / "memories" / "note.md").read_text() == "remember this" 234 assert not (profile_dir / "profiles").exists() 235 236 def test_clone_config_missing_files_skipped(self, profile_env): 237 """Clone config gracefully skips files that don't exist in source.""" 238 profile_dir = create_profile("coder", clone_config=True, no_alias=True) 239 # No error; optional files just not copied 240 assert not (profile_dir / "config.yaml").exists() 241 assert not (profile_dir / ".env").exists() 242 # SOUL.md is always seeded with the default even when clone source lacks it 243 assert (profile_dir / "SOUL.md").exists() 244 245 246 # =================================================================== 247 # TestDeleteProfile 248 # =================================================================== 249 250 class TestDeleteProfile: 251 """Tests for delete_profile().""" 252 253 def test_removes_directory(self, profile_env): 254 profile_dir = create_profile("coder", no_alias=True) 255 assert profile_dir.is_dir() 256 # Mock gateway import to avoid real systemd/launchd interaction 257 with patch("hermes_cli.profiles._cleanup_gateway_service"): 258 delete_profile("coder", yes=True) 259 assert not profile_dir.is_dir() 260 261 def test_default_raises_value_error(self, profile_env): 262 with pytest.raises(ValueError, match="default"): 263 delete_profile("default", yes=True) 264 265 def test_nonexistent_raises_file_not_found(self, profile_env): 266 with pytest.raises(FileNotFoundError): 267 delete_profile("nonexistent", yes=True) 268 269 270 # =================================================================== 271 # TestListProfiles 272 # =================================================================== 273 274 class TestListProfiles: 275 """Tests for list_profiles().""" 276 277 def test_returns_default_when_no_named_profiles(self, profile_env): 278 profiles = list_profiles() 279 names = [p.name for p in profiles] 280 assert "default" in names 281 282 def test_includes_named_profiles(self, profile_env): 283 create_profile("alpha", no_alias=True) 284 create_profile("beta", no_alias=True) 285 profiles = list_profiles() 286 names = [p.name for p in profiles] 287 assert "alpha" in names 288 assert "beta" in names 289 290 def test_sorted_alphabetically(self, profile_env): 291 create_profile("zebra", no_alias=True) 292 create_profile("alpha", no_alias=True) 293 create_profile("middle", no_alias=True) 294 profiles = list_profiles() 295 named = [p.name for p in profiles if not p.is_default] 296 assert named == sorted(named) 297 298 def test_default_is_first(self, profile_env): 299 create_profile("alpha", no_alias=True) 300 profiles = list_profiles() 301 assert profiles[0].name == "default" 302 assert profiles[0].is_default is True 303 304 305 # =================================================================== 306 # TestActiveProfile 307 # =================================================================== 308 309 class TestActiveProfile: 310 """Tests for set_active_profile() / get_active_profile().""" 311 312 def test_set_and_get_roundtrip(self, profile_env): 313 create_profile("coder", no_alias=True) 314 set_active_profile("coder") 315 assert get_active_profile() == "coder" 316 317 def test_no_file_returns_default(self, profile_env): 318 assert get_active_profile() == "default" 319 320 def test_empty_file_returns_default(self, profile_env): 321 tmp_path = profile_env 322 active_path = tmp_path / ".hermes" / "active_profile" 323 active_path.write_text("") 324 assert get_active_profile() == "default" 325 326 def test_set_to_default_removes_file(self, profile_env): 327 tmp_path = profile_env 328 create_profile("coder", no_alias=True) 329 set_active_profile("coder") 330 active_path = tmp_path / ".hermes" / "active_profile" 331 assert active_path.exists() 332 333 set_active_profile("default") 334 assert not active_path.exists() 335 336 def test_set_nonexistent_raises(self, profile_env): 337 with pytest.raises(FileNotFoundError): 338 set_active_profile("nonexistent") 339 340 341 # =================================================================== 342 # TestGetActiveProfileName 343 # =================================================================== 344 345 class TestGetActiveProfileName: 346 """Tests for get_active_profile_name().""" 347 348 def test_default_hermes_home_returns_default(self, profile_env): 349 # HERMES_HOME points to tmp_path/.hermes which is the default 350 assert get_active_profile_name() == "default" 351 352 def test_profile_path_returns_profile_name(self, profile_env, monkeypatch): 353 tmp_path = profile_env 354 create_profile("coder", no_alias=True) 355 profile_dir = tmp_path / ".hermes" / "profiles" / "coder" 356 monkeypatch.setenv("HERMES_HOME", str(profile_dir)) 357 assert get_active_profile_name() == "coder" 358 359 def test_custom_path_returns_default(self, profile_env, monkeypatch): 360 """A custom HERMES_HOME (Docker, etc.) IS the default root.""" 361 tmp_path = profile_env 362 custom = tmp_path / "some" / "other" / "path" 363 custom.mkdir(parents=True) 364 monkeypatch.setenv("HERMES_HOME", str(custom)) 365 # With Docker-aware roots, a custom HERMES_HOME is the default — 366 # not "custom". The user is on the default profile of their 367 # custom deployment. 368 assert get_active_profile_name() == "default" 369 370 371 # =================================================================== 372 # TestResolveProfileEnv 373 # =================================================================== 374 375 class TestResolveProfileEnv: 376 """Tests for resolve_profile_env().""" 377 378 def test_existing_profile_returns_path(self, profile_env): 379 tmp_path = profile_env 380 create_profile("coder", no_alias=True) 381 result = resolve_profile_env("coder") 382 assert result == str(tmp_path / ".hermes" / "profiles" / "coder") 383 384 def test_default_returns_default_home(self, profile_env): 385 tmp_path = profile_env 386 result = resolve_profile_env("default") 387 assert result == str(tmp_path / ".hermes") 388 389 def test_nonexistent_raises_file_not_found(self, profile_env): 390 with pytest.raises(FileNotFoundError): 391 resolve_profile_env("nonexistent") 392 393 def test_invalid_name_raises_value_error(self, profile_env): 394 with pytest.raises(ValueError): 395 resolve_profile_env("INVALID!") 396 397 398 # =================================================================== 399 # TestAliasCollision 400 # =================================================================== 401 402 class TestAliasCollision: 403 """Tests for check_alias_collision().""" 404 405 def test_normal_name_returns_none(self, profile_env): 406 # Mock 'which' to return not-found 407 with patch("subprocess.run") as mock_run: 408 mock_run.return_value = MagicMock(returncode=1, stdout="") 409 result = check_alias_collision("mybot") 410 assert result is None 411 412 def test_reserved_name_returns_message(self, profile_env): 413 result = check_alias_collision("hermes") 414 assert result is not None 415 assert "reserved" in result.lower() 416 417 def test_subcommand_returns_message(self, profile_env): 418 result = check_alias_collision("chat") 419 assert result is not None 420 assert "subcommand" in result.lower() 421 422 def test_default_is_reserved(self, profile_env): 423 result = check_alias_collision("default") 424 assert result is not None 425 assert "reserved" in result.lower() 426 427 428 # =================================================================== 429 # TestRenameProfile 430 # =================================================================== 431 432 class TestRenameProfile: 433 """Tests for rename_profile().""" 434 435 def test_renames_directory(self, profile_env): 436 tmp_path = profile_env 437 create_profile("oldname", no_alias=True) 438 old_dir = tmp_path / ".hermes" / "profiles" / "oldname" 439 assert old_dir.is_dir() 440 441 # Mock alias collision to avoid subprocess calls 442 with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): 443 new_dir = rename_profile("oldname", "newname") 444 445 assert not old_dir.is_dir() 446 assert new_dir.is_dir() 447 assert new_dir == tmp_path / ".hermes" / "profiles" / "newname" 448 449 def test_renames_root_honcho_host_without_changing_ai_peer(self, profile_env): 450 tmp_path = profile_env 451 create_profile("ssi_health", no_alias=True) 452 honcho_path = tmp_path / ".hermes" / "honcho.json" 453 honcho_path.write_text(json.dumps({ 454 "hosts": { 455 "hermes.ssi_health": { 456 "recallMode": "hybrid", 457 "writeFrequency": "async", 458 "sessionStrategy": "per-session", 459 "saveMessages": True, 460 "peerName": "user-peer", 461 "aiPeer": "ssi_health", 462 "workspace": "hermes", 463 "enabled": True, 464 } 465 } 466 })) 467 468 with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): 469 rename_profile("ssi_health", "heimdall") 470 471 cfg = json.loads(honcho_path.read_text()) 472 assert "hermes.ssi_health" not in cfg["hosts"] 473 assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" 474 assert cfg["hosts"]["hermes.heimdall"]["peerName"] == "user-peer" 475 476 def test_pins_ai_peer_when_absent_on_honcho_host_rename(self, profile_env): 477 tmp_path = profile_env 478 create_profile("ssi_health", no_alias=True) 479 honcho_path = tmp_path / ".hermes" / "honcho.json" 480 honcho_path.write_text(json.dumps({ 481 "hosts": { 482 "hermes.ssi_health": {"workspace": "hermes", "enabled": True} 483 } 484 })) 485 486 with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): 487 rename_profile("ssi_health", "heimdall") 488 489 cfg = json.loads(honcho_path.read_text()) 490 assert "hermes.ssi_health" not in cfg["hosts"] 491 assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "ssi_health" 492 assert cfg["hosts"]["hermes.heimdall"]["workspace"] == "hermes" 493 494 def test_does_not_overwrite_existing_honcho_host_on_rename(self, profile_env): 495 tmp_path = profile_env 496 create_profile("ssi_health", no_alias=True) 497 honcho_path = tmp_path / ".hermes" / "honcho.json" 498 honcho_path.write_text(json.dumps({ 499 "hosts": { 500 "hermes.ssi_health": {"aiPeer": "ssi_health"}, 501 "hermes.heimdall": {"aiPeer": "heimdall"}, 502 } 503 })) 504 505 with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"): 506 rename_profile("ssi_health", "heimdall") 507 508 cfg = json.loads(honcho_path.read_text()) 509 assert cfg["hosts"]["hermes.ssi_health"]["aiPeer"] == "ssi_health" 510 assert cfg["hosts"]["hermes.heimdall"]["aiPeer"] == "heimdall" 511 512 def test_default_raises_value_error(self, profile_env): 513 with pytest.raises(ValueError, match="default"): 514 rename_profile("default", "newname") 515 516 def test_rename_to_default_raises_value_error(self, profile_env): 517 create_profile("coder", no_alias=True) 518 with pytest.raises(ValueError, match="default"): 519 rename_profile("coder", "default") 520 521 def test_nonexistent_raises_file_not_found(self, profile_env): 522 with pytest.raises(FileNotFoundError): 523 rename_profile("nonexistent", "newname") 524 525 def test_target_exists_raises_file_exists(self, profile_env): 526 create_profile("alpha", no_alias=True) 527 create_profile("beta", no_alias=True) 528 with pytest.raises(FileExistsError): 529 rename_profile("alpha", "beta") 530 531 532 # =================================================================== 533 # TestExportImport 534 # =================================================================== 535 536 class TestExportImport: 537 """Tests for export_profile() / import_profile().""" 538 539 def test_export_creates_tar_gz(self, profile_env, tmp_path): 540 create_profile("coder", no_alias=True) 541 # Put a marker file so we can verify content 542 profile_dir = get_profile_dir("coder") 543 (profile_dir / "marker.txt").write_text("hello") 544 545 output = tmp_path / "export" / "coder.tar.gz" 546 output.parent.mkdir(parents=True, exist_ok=True) 547 result = export_profile("coder", str(output)) 548 549 assert Path(result).exists() 550 assert tarfile.is_tarfile(str(result)) 551 552 def test_import_restores_from_archive(self, profile_env, tmp_path): 553 # Create and export a profile 554 create_profile("coder", no_alias=True) 555 profile_dir = get_profile_dir("coder") 556 (profile_dir / "marker.txt").write_text("hello") 557 558 archive_path = tmp_path / "export" / "coder.tar.gz" 559 archive_path.parent.mkdir(parents=True, exist_ok=True) 560 export_profile("coder", str(archive_path)) 561 562 # Delete the profile, then import it back under a new name 563 import shutil 564 shutil.rmtree(profile_dir) 565 assert not profile_dir.is_dir() 566 567 imported = import_profile(str(archive_path), name="coder") 568 assert imported.is_dir() 569 assert (imported / "marker.txt").read_text() == "hello" 570 571 def test_import_to_existing_name_raises(self, profile_env, tmp_path): 572 create_profile("coder", no_alias=True) 573 profile_dir = get_profile_dir("coder") 574 575 archive_path = tmp_path / "export" / "coder.tar.gz" 576 archive_path.parent.mkdir(parents=True, exist_ok=True) 577 export_profile("coder", str(archive_path)) 578 579 # Importing to same existing name should fail 580 with pytest.raises(FileExistsError): 581 import_profile(str(archive_path), name="coder") 582 583 def test_import_with_explicit_name_does_not_mutate_existing_archive_root_profile( 584 self, profile_env, tmp_path 585 ): 586 create_profile("victim", no_alias=True) 587 victim_dir = get_profile_dir("victim") 588 (victim_dir / "marker.txt").write_text("original") 589 590 archive_path = tmp_path / "export" / "victim.tar.gz" 591 archive_path.parent.mkdir(parents=True, exist_ok=True) 592 with tarfile.open(archive_path, "w:gz") as tf: 593 data = b"imported" 594 info = tarfile.TarInfo("victim/marker.txt") 595 info.size = len(data) 596 tf.addfile(info, io.BytesIO(data)) 597 598 imported = import_profile(str(archive_path), name="renamed") 599 600 assert imported == get_profile_dir("renamed") 601 assert (imported / "marker.txt").read_text() == "imported" 602 assert (victim_dir / "marker.txt").read_text() == "original" 603 604 def test_import_rejects_archive_with_multiple_top_level_directories( 605 self, profile_env, tmp_path 606 ): 607 archive_path = tmp_path / "export" / "multi-root.tar.gz" 608 archive_path.parent.mkdir(parents=True, exist_ok=True) 609 610 with tarfile.open(archive_path, "w:gz") as tf: 611 for member_name, data in ( 612 ("alpha/marker.txt", b"a"), 613 ("beta/marker.txt", b"b"), 614 ): 615 info = tarfile.TarInfo(member_name) 616 info.size = len(data) 617 tf.addfile(info, io.BytesIO(data)) 618 619 with pytest.raises(ValueError, match="exactly one top-level directory"): 620 import_profile(str(archive_path), name="coder") 621 622 assert not get_profile_dir("coder").exists() 623 624 def test_import_rejects_traversal_archive_member(self, profile_env, tmp_path): 625 archive_path = tmp_path / "export" / "evil.tar.gz" 626 archive_path.parent.mkdir(parents=True, exist_ok=True) 627 escape_path = tmp_path / "escape.txt" 628 629 with tarfile.open(archive_path, "w:gz") as tf: 630 info = tarfile.TarInfo("../../escape.txt") 631 data = b"pwned" 632 info.size = len(data) 633 tf.addfile(info, io.BytesIO(data)) 634 635 with pytest.raises(ValueError, match="Unsafe archive member path"): 636 import_profile(str(archive_path), name="coder") 637 638 assert not escape_path.exists() 639 assert not get_profile_dir("coder").exists() 640 641 def test_import_rejects_absolute_archive_member(self, profile_env, tmp_path): 642 archive_path = tmp_path / "export" / "evil-abs.tar.gz" 643 archive_path.parent.mkdir(parents=True, exist_ok=True) 644 absolute_target = tmp_path / "abs-escape.txt" 645 646 with tarfile.open(archive_path, "w:gz") as tf: 647 info = tarfile.TarInfo(str(absolute_target)) 648 data = b"pwned" 649 info.size = len(data) 650 tf.addfile(info, io.BytesIO(data)) 651 652 with pytest.raises(ValueError, match="Unsafe archive member path"): 653 import_profile(str(archive_path), name="coder") 654 655 assert not absolute_target.exists() 656 assert not get_profile_dir("coder").exists() 657 658 def test_export_nonexistent_raises(self, profile_env, tmp_path): 659 with pytest.raises(FileNotFoundError): 660 export_profile("nonexistent", str(tmp_path / "out.tar.gz")) 661 662 # --------------------------------------------------------------- 663 # Default profile export / import 664 # --------------------------------------------------------------- 665 666 def test_export_default_creates_valid_archive(self, profile_env, tmp_path): 667 """Exporting the default profile produces a valid tar.gz.""" 668 default_dir = get_profile_dir("default") 669 (default_dir / "config.yaml").write_text("model: test") 670 671 output = tmp_path / "export" / "default.tar.gz" 672 output.parent.mkdir(parents=True, exist_ok=True) 673 result = export_profile("default", str(output)) 674 675 assert Path(result).exists() 676 assert tarfile.is_tarfile(str(result)) 677 678 def test_export_default_includes_profile_data(self, profile_env, tmp_path): 679 """Profile data files end up in the archive (credentials excluded).""" 680 default_dir = get_profile_dir("default") 681 (default_dir / "config.yaml").write_text("model: test") 682 (default_dir / ".env").write_text("KEY=val") 683 (default_dir / "SOUL.md").write_text("Be nice.") 684 mem_dir = default_dir / "memories" 685 mem_dir.mkdir(exist_ok=True) 686 (mem_dir / "MEMORY.md").write_text("remember this") 687 688 output = tmp_path / "export" / "default.tar.gz" 689 output.parent.mkdir(parents=True, exist_ok=True) 690 export_profile("default", str(output)) 691 692 with tarfile.open(str(output), "r:gz") as tf: 693 names = tf.getnames() 694 695 assert "default/config.yaml" in names 696 assert "default/.env" not in names # credentials excluded 697 assert "default/SOUL.md" in names 698 assert "default/memories/MEMORY.md" in names 699 700 def test_export_default_excludes_infrastructure(self, profile_env, tmp_path): 701 """Repo checkout, worktrees, profiles, databases are excluded.""" 702 default_dir = get_profile_dir("default") 703 (default_dir / "config.yaml").write_text("ok") 704 705 # Create dirs/files that should be excluded 706 for d in ("hermes-agent", ".worktrees", "profiles", "bin", 707 "image_cache", "logs", "sandboxes", "checkpoints"): 708 sub = default_dir / d 709 sub.mkdir(exist_ok=True) 710 (sub / "marker.txt").write_text("excluded") 711 712 for f in ("state.db", "gateway.pid", "gateway_state.json", 713 "processes.json", "errors.log", ".hermes_history", 714 "active_profile", ".update_check", "auth.lock"): 715 (default_dir / f).write_text("excluded") 716 717 output = tmp_path / "export" / "default.tar.gz" 718 output.parent.mkdir(parents=True, exist_ok=True) 719 export_profile("default", str(output)) 720 721 with tarfile.open(str(output), "r:gz") as tf: 722 names = tf.getnames() 723 724 # Config is present 725 assert "default/config.yaml" in names 726 727 # Infrastructure excluded 728 excluded_prefixes = [ 729 "default/hermes-agent", "default/.worktrees", "default/profiles", 730 "default/bin", "default/image_cache", "default/logs", 731 "default/sandboxes", "default/checkpoints", 732 ] 733 for prefix in excluded_prefixes: 734 assert not any(n.startswith(prefix) for n in names), \ 735 f"Expected {prefix} to be excluded but found it in archive" 736 737 excluded_files = [ 738 "default/state.db", "default/gateway.pid", 739 "default/gateway_state.json", "default/processes.json", 740 "default/errors.log", "default/.hermes_history", 741 "default/active_profile", "default/.update_check", 742 "default/auth.lock", 743 ] 744 for f in excluded_files: 745 assert f not in names, f"Expected {f} to be excluded" 746 747 def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_path): 748 """__pycache__ dirs are excluded even inside nested directories.""" 749 default_dir = get_profile_dir("default") 750 (default_dir / "config.yaml").write_text("ok") 751 nested = default_dir / "skills" / "my-skill" / "__pycache__" 752 nested.mkdir(parents=True) 753 (nested / "cached.pyc").write_text("bytecode") 754 755 output = tmp_path / "export" / "default.tar.gz" 756 output.parent.mkdir(parents=True, exist_ok=True) 757 export_profile("default", str(output)) 758 759 with tarfile.open(str(output), "r:gz") as tf: 760 names = tf.getnames() 761 762 assert not any("__pycache__" in n for n in names) 763 764 def test_import_default_without_name_raises(self, profile_env, tmp_path): 765 """Importing a default export without --name gives clear guidance.""" 766 default_dir = get_profile_dir("default") 767 (default_dir / "config.yaml").write_text("ok") 768 769 archive = tmp_path / "export" / "default.tar.gz" 770 archive.parent.mkdir(parents=True, exist_ok=True) 771 export_profile("default", str(archive)) 772 773 with pytest.raises(ValueError, match="Cannot import as 'default'"): 774 import_profile(str(archive)) 775 776 def test_import_default_with_explicit_default_name_raises(self, profile_env, tmp_path): 777 """Explicitly importing as 'default' is also rejected.""" 778 default_dir = get_profile_dir("default") 779 (default_dir / "config.yaml").write_text("ok") 780 781 archive = tmp_path / "export" / "default.tar.gz" 782 archive.parent.mkdir(parents=True, exist_ok=True) 783 export_profile("default", str(archive)) 784 785 with pytest.raises(ValueError, match="Cannot import as 'default'"): 786 import_profile(str(archive), name="default") 787 788 def test_import_default_export_with_new_name_roundtrip(self, profile_env, tmp_path): 789 """Export default → import under a different name → data preserved.""" 790 default_dir = get_profile_dir("default") 791 (default_dir / "config.yaml").write_text("model: opus") 792 mem_dir = default_dir / "memories" 793 mem_dir.mkdir(exist_ok=True) 794 (mem_dir / "MEMORY.md").write_text("important fact") 795 796 archive = tmp_path / "export" / "default.tar.gz" 797 archive.parent.mkdir(parents=True, exist_ok=True) 798 export_profile("default", str(archive)) 799 800 imported = import_profile(str(archive), name="backup") 801 assert imported.is_dir() 802 assert (imported / "config.yaml").read_text() == "model: opus" 803 assert (imported / "memories" / "MEMORY.md").read_text() == "important fact" 804 805 806 # =================================================================== 807 # TestProfileIsolation 808 # =================================================================== 809 810 class TestProfileIsolation: 811 """Verify that two profiles have completely separate paths.""" 812 813 def test_separate_config_paths(self, profile_env): 814 create_profile("alpha", no_alias=True) 815 create_profile("beta", no_alias=True) 816 alpha_dir = get_profile_dir("alpha") 817 beta_dir = get_profile_dir("beta") 818 assert alpha_dir / "config.yaml" != beta_dir / "config.yaml" 819 assert str(alpha_dir) not in str(beta_dir) 820 821 def test_separate_state_db_paths(self, profile_env): 822 alpha_dir = get_profile_dir("alpha") 823 beta_dir = get_profile_dir("beta") 824 assert alpha_dir / "state.db" != beta_dir / "state.db" 825 826 def test_separate_skills_paths(self, profile_env): 827 create_profile("alpha", no_alias=True) 828 create_profile("beta", no_alias=True) 829 alpha_dir = get_profile_dir("alpha") 830 beta_dir = get_profile_dir("beta") 831 assert alpha_dir / "skills" != beta_dir / "skills" 832 # Verify both exist and are independent dirs 833 assert (alpha_dir / "skills").is_dir() 834 assert (beta_dir / "skills").is_dir() 835 836 837 # =================================================================== 838 # TestCompletion 839 # =================================================================== 840 841 class TestCompletion: 842 """Tests for bash/zsh completion generators.""" 843 844 def test_bash_completion_contains_complete(self): 845 script = generate_bash_completion() 846 assert len(script) > 0 847 assert "complete" in script 848 849 def test_zsh_completion_contains_compdef(self): 850 script = generate_zsh_completion() 851 assert len(script) > 0 852 assert "compdef" in script 853 854 def test_bash_completion_has_hermes_profiles_function(self): 855 script = generate_bash_completion() 856 assert "_hermes_profiles" in script 857 858 def test_zsh_completion_has_hermes_function(self): 859 script = generate_zsh_completion() 860 assert "_hermes" in script 861 862 863 # =================================================================== 864 # TestGetProfilesRoot / TestGetDefaultHermesHome (internal helpers) 865 # =================================================================== 866 867 class TestInternalHelpers: 868 """Tests for _get_profiles_root() and _get_default_hermes_home().""" 869 870 def test_profiles_root_under_home(self, profile_env): 871 tmp_path = profile_env 872 root = _get_profiles_root() 873 assert root == tmp_path / ".hermes" / "profiles" 874 875 def test_default_hermes_home(self, profile_env): 876 tmp_path = profile_env 877 home = _get_default_hermes_home() 878 assert home == tmp_path / ".hermes" 879 880 def test_profiles_root_docker_deployment(self, tmp_path, monkeypatch): 881 """In Docker (HERMES_HOME outside ~/.hermes), profiles go under HERMES_HOME.""" 882 docker_home = tmp_path / "opt" / "data" 883 docker_home.mkdir(parents=True) 884 monkeypatch.setattr(Path, "home", lambda: tmp_path) 885 monkeypatch.setenv("HERMES_HOME", str(docker_home)) 886 root = _get_profiles_root() 887 assert root == docker_home / "profiles" 888 889 def test_default_hermes_home_docker(self, tmp_path, monkeypatch): 890 """In Docker, _get_default_hermes_home() returns HERMES_HOME itself.""" 891 docker_home = tmp_path / "opt" / "data" 892 docker_home.mkdir(parents=True) 893 monkeypatch.setattr(Path, "home", lambda: tmp_path) 894 monkeypatch.setenv("HERMES_HOME", str(docker_home)) 895 home = _get_default_hermes_home() 896 assert home == docker_home 897 898 def test_profiles_root_profile_mode(self, tmp_path, monkeypatch): 899 """In profile mode (HERMES_HOME under ~/.hermes), profiles root is still ~/.hermes/profiles.""" 900 native = tmp_path / ".hermes" 901 profile_dir = native / "profiles" / "coder" 902 profile_dir.mkdir(parents=True) 903 monkeypatch.setattr(Path, "home", lambda: tmp_path) 904 monkeypatch.setenv("HERMES_HOME", str(profile_dir)) 905 root = _get_profiles_root() 906 assert root == native / "profiles" 907 908 def test_active_profile_path_docker(self, tmp_path, monkeypatch): 909 """In Docker, active_profile file lives under HERMES_HOME.""" 910 from hermes_cli.profiles import _get_active_profile_path 911 docker_home = tmp_path / "opt" / "data" 912 docker_home.mkdir(parents=True) 913 monkeypatch.setattr(Path, "home", lambda: tmp_path) 914 monkeypatch.setenv("HERMES_HOME", str(docker_home)) 915 path = _get_active_profile_path() 916 assert path == docker_home / "active_profile" 917 918 def test_create_profile_docker(self, tmp_path, monkeypatch): 919 """Profile created in Docker lands under HERMES_HOME/profiles/.""" 920 docker_home = tmp_path / "opt" / "data" 921 docker_home.mkdir(parents=True) 922 monkeypatch.setattr(Path, "home", lambda: tmp_path) 923 monkeypatch.setenv("HERMES_HOME", str(docker_home)) 924 result = create_profile("orchestrator", no_alias=True) 925 expected = docker_home / "profiles" / "orchestrator" 926 assert result == expected 927 assert expected.is_dir() 928 929 def test_active_profile_name_docker_default(self, tmp_path, monkeypatch): 930 """In Docker (no profile active), get_active_profile_name() returns 'default'.""" 931 docker_home = tmp_path / "opt" / "data" 932 docker_home.mkdir(parents=True) 933 monkeypatch.setattr(Path, "home", lambda: tmp_path) 934 monkeypatch.setenv("HERMES_HOME", str(docker_home)) 935 assert get_active_profile_name() == "default" 936 937 def test_active_profile_name_docker_profile(self, tmp_path, monkeypatch): 938 """In Docker with a profile active, get_active_profile_name() returns the profile name.""" 939 docker_home = tmp_path / "opt" / "data" 940 profile = docker_home / "profiles" / "orchestrator" 941 profile.mkdir(parents=True) 942 monkeypatch.setattr(Path, "home", lambda: tmp_path) 943 monkeypatch.setenv("HERMES_HOME", str(profile)) 944 assert get_active_profile_name() == "orchestrator" 945 946 947 # =================================================================== 948 # Edge cases and additional coverage 949 # =================================================================== 950 951 class TestEdgeCases: 952 """Additional edge-case tests.""" 953 954 def test_create_profile_returns_correct_path(self, profile_env): 955 tmp_path = profile_env 956 result = create_profile("mybot", no_alias=True) 957 expected = tmp_path / ".hermes" / "profiles" / "mybot" 958 assert result == expected 959 960 def test_list_profiles_default_info_fields(self, profile_env): 961 profiles = list_profiles() 962 default = [p for p in profiles if p.name == "default"][0] 963 assert default.is_default is True 964 assert default.gateway_running is False 965 assert default.skill_count == 0 966 967 def test_gateway_running_check_with_pid_file(self, profile_env): 968 """Verify _check_gateway_running uses the shared gateway PID validator.""" 969 from hermes_cli.profiles import _check_gateway_running 970 tmp_path = profile_env 971 default_home = tmp_path / ".hermes" 972 973 with patch("gateway.status.get_running_pid", return_value=99999) as mock_get_running_pid: 974 assert _check_gateway_running(default_home) is True 975 mock_get_running_pid.assert_called_once_with( 976 default_home / "gateway.pid", 977 cleanup_stale=False, 978 ) 979 980 def test_gateway_running_check_plain_pid(self, profile_env): 981 """Shared PID validator returning None means the profile is not running.""" 982 from hermes_cli.profiles import _check_gateway_running 983 tmp_path = profile_env 984 default_home = tmp_path / ".hermes" 985 986 with patch("gateway.status.get_running_pid", return_value=None) as mock_get_running_pid: 987 assert _check_gateway_running(default_home) is False 988 mock_get_running_pid.assert_called_once_with( 989 default_home / "gateway.pid", 990 cleanup_stale=False, 991 ) 992 993 def test_profile_name_boundary_single_char(self): 994 """Single alphanumeric character is valid.""" 995 validate_profile_name("a") 996 validate_profile_name("1") 997 998 def test_profile_name_boundary_all_hyphens(self): 999 """Name starting with hyphen is invalid.""" 1000 with pytest.raises(ValueError): 1001 validate_profile_name("-abc") 1002 1003 def test_profile_name_underscore_start(self): 1004 """Name starting with underscore is invalid (must start with [a-z0-9]).""" 1005 with pytest.raises(ValueError): 1006 validate_profile_name("_abc") 1007 1008 def test_clone_from_named_profile(self, profile_env): 1009 """Clone config from a named (non-default) profile.""" 1010 tmp_path = profile_env 1011 # Create source profile with config 1012 source_dir = create_profile("source", no_alias=True) 1013 (source_dir / "config.yaml").write_text("model: cloned") 1014 (source_dir / ".env").write_text("SECRET=yes") 1015 1016 target_dir = create_profile( 1017 "target", clone_from="source", clone_config=True, no_alias=True, 1018 ) 1019 assert (target_dir / "config.yaml").read_text() == "model: cloned" 1020 assert (target_dir / ".env").read_text() == "SECRET=yes" 1021 1022 def test_delete_clears_active_profile(self, profile_env): 1023 """Deleting the active profile resets active to default.""" 1024 tmp_path = profile_env 1025 create_profile("coder", no_alias=True) 1026 set_active_profile("coder") 1027 assert get_active_profile() == "coder" 1028 1029 with patch("hermes_cli.profiles._cleanup_gateway_service"): 1030 delete_profile("coder", yes=True) 1031 1032 assert get_active_profile() == "default"