/ tests / hermes_cli / test_profiles.py
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"