diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 8f93f2de6..69b1a6df8 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -251,18 +251,18 @@ SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration" def _profile_suffix() -> str: """Derive a service-name suffix from the current HERMES_HOME. - Returns ``""`` for the default ``~/.hermes``, the profile name for - ``~/.hermes/profiles/``, or a short hash for any other custom - HERMES_HOME path. + Returns ``""`` for the default root, the profile name for + ``/profiles/``, or a short hash for any other path. + Works correctly in Docker (HERMES_HOME=/opt/data) and standard deployments. """ import hashlib import re - from pathlib import Path as _Path + from hermes_constants import get_default_hermes_root home = get_hermes_home().resolve() - default = (_Path.home() / ".hermes").resolve() + default = get_default_hermes_root().resolve() if home == default: return "" - # Detect ~/.hermes/profiles/ pattern → use the profile name + # Detect /profiles/ pattern → use the profile name profiles_root = (default / "profiles").resolve() try: rel = home.relative_to(profiles_root) @@ -287,9 +287,9 @@ def _profile_arg(hermes_home: str | None = None) -> str: service definition for a different user (e.g. system service). """ import re - from pathlib import Path as _Path + from hermes_constants import get_default_hermes_root home = Path(hermes_home or str(get_hermes_home())).resolve() - default = (_Path.home() / ".hermes").resolve() + default = get_default_hermes_root().resolve() if home == default: return "" profiles_root = (default / "profiles").resolve() diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 860f74bb5..e1c8cb1cc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -97,10 +97,11 @@ def _apply_profile_override() -> None: consume = 1 break - # 2. If no flag, check ~/.hermes/active_profile + # 2. If no flag, check active_profile in the hermes root if profile_name is None: try: - active_path = Path.home() / ".hermes" / "active_profile" + from hermes_constants import get_default_hermes_root + active_path = get_default_hermes_root() / "active_profile" if active_path.exists(): name = active_path.read_text().strip() if name and name != "default": @@ -3313,10 +3314,11 @@ def _invalidate_update_cache(): ``hermes update``, every profile is now current. """ homes = [] - # Default profile home - default_home = Path.home() / ".hermes" + # Default profile home (Docker-aware — uses /opt/data in Docker) + from hermes_constants import get_default_hermes_root + default_home = get_default_hermes_root() homes.append(default_home) - # Named profiles under ~/.hermes/profiles/ + # Named profiles under /profiles/ profiles_root = default_home / "profiles" if profiles_root.is_dir(): for entry in profiles_root.iterdir(): @@ -4053,7 +4055,10 @@ def cmd_profile(args): print(f" {name} chat Start chatting") print(f" {name} gateway start Start the messaging gateway") if clone or clone_all: - profile_dir_display = f"~/.hermes/profiles/{name}" + try: + profile_dir_display = "~/" + str(profile_dir.relative_to(Path.home())) + except ValueError: + profile_dir_display = str(profile_dir) print(f"\n Edit {profile_dir_display}/.env for different API keys") print(f" Edit {profile_dir_display}/SOUL.md for different personality") print() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 9be25e100..75f98b276 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -115,16 +115,26 @@ _HERMES_SUBCOMMANDS = frozenset({ def _get_profiles_root() -> Path: """Return the directory where named profiles are stored. - Always ``~/.hermes/profiles/`` — anchored to the user's home, - NOT to the current HERMES_HOME (which may itself be a profile). - This ensures ``coder profile list`` can see all profiles. + Anchored to the hermes root, NOT to the current HERMES_HOME + (which may itself be a profile). This ensures ``coder profile list`` + can see all profiles. + + In Docker/custom deployments where HERMES_HOME points outside + ``~/.hermes``, profiles live under ``HERMES_HOME/profiles/`` so + they persist on the mounted volume. """ - return Path.home() / ".hermes" / "profiles" + return _get_default_hermes_home() / "profiles" def _get_default_hermes_home() -> Path: - """Return the default (pre-profile) HERMES_HOME path.""" - return Path.home() / ".hermes" + """Return the default (pre-profile) HERMES_HOME path. + + In standard deployments this is ``~/.hermes``. + In Docker/custom deployments where HERMES_HOME is outside ``~/.hermes`` + (e.g. ``/opt/data``), returns HERMES_HOME directly. + """ + from hermes_constants import get_default_hermes_root + return get_default_hermes_root() def _get_active_profile_path() -> Path: diff --git a/hermes_constants.py b/hermes_constants.py index 17584c598..1d06afcc5 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -17,6 +17,45 @@ def get_hermes_home() -> Path: return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +def get_default_hermes_root() -> Path: + """Return the root Hermes directory for profile-level operations. + + In standard deployments this is ``~/.hermes``. + + In Docker or custom deployments where ``HERMES_HOME`` points outside + ``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly + — that IS the root. + + In profile mode where ``HERMES_HOME`` is ``/profiles/``, + returns ```` so that ``profile list`` can see all profiles. + Works both for standard (``~/.hermes/profiles/coder``) and Docker + (``/opt/data/profiles/coder``) layouts. + + Import-safe — no dependencies beyond stdlib. + """ + native_home = Path.home() / ".hermes" + env_home = os.environ.get("HERMES_HOME", "") + if not env_home: + return native_home + env_path = Path(env_home) + try: + env_path.resolve().relative_to(native_home.resolve()) + # HERMES_HOME is under ~/.hermes (normal or profile mode) + return native_home + except ValueError: + pass + + # Docker / custom deployment. + # Check if this is a profile path: /profiles/ + # If the immediate parent dir is named "profiles", the root is + # the grandparent — this covers Docker profiles correctly. + if env_path.parent.name == "profiles": + return env_path.parent.parent + + # Not a profile path — HERMES_HOME itself is the root + return env_path + + def get_optional_skills_dir(default: Path | None = None) -> Path: """Return the optional-skills directory, honoring package-manager wrappers. diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 3a543693e..b32c7fe78 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -755,6 +755,7 @@ class TestProfileArg: hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) result = gateway_cli._profile_arg(str(hermes_home)) assert result == "" @@ -763,6 +764,7 @@ class TestProfileArg: profile_dir = tmp_path / ".hermes" / "profiles" / "mybot" profile_dir.mkdir(parents=True) monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) result = gateway_cli._profile_arg(str(profile_dir)) assert result == "--profile mybot" @@ -771,6 +773,7 @@ class TestProfileArg: custom_home = tmp_path / "custom" / "hermes" custom_home.mkdir(parents=True) monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) result = gateway_cli._profile_arg(str(custom_home)) assert result == "" @@ -779,6 +782,7 @@ class TestProfileArg: nested = tmp_path / ".hermes" / "profiles" / "mybot" / "subdir" nested.mkdir(parents=True) monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) result = gateway_cli._profile_arg(str(nested)) assert result == "" @@ -787,6 +791,7 @@ class TestProfileArg: bad_profile = tmp_path / ".hermes" / "profiles" / "My Bot!" bad_profile.mkdir(parents=True) monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) result = gateway_cli._profile_arg(str(bad_profile)) assert result == "" diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 50b5e2311..c970cb6c5 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -293,12 +293,16 @@ class TestGetActiveProfileName: monkeypatch.setenv("HERMES_HOME", str(profile_dir)) assert get_active_profile_name() == "coder" - def test_custom_path_returns_custom(self, profile_env, monkeypatch): + def test_custom_path_returns_default(self, profile_env, monkeypatch): + """A custom HERMES_HOME (Docker, etc.) IS the default root.""" tmp_path = profile_env custom = tmp_path / "some" / "other" / "path" custom.mkdir(parents=True) monkeypatch.setenv("HERMES_HOME", str(custom)) - assert get_active_profile_name() == "custom" + # With Docker-aware roots, a custom HERMES_HOME is the default — + # not "custom". The user is on the default profile of their + # custom deployment. + assert get_active_profile_name() == "default" # =================================================================== @@ -706,6 +710,72 @@ class TestInternalHelpers: home = _get_default_hermes_home() assert home == tmp_path / ".hermes" + def test_profiles_root_docker_deployment(self, tmp_path, monkeypatch): + """In Docker (HERMES_HOME outside ~/.hermes), profiles go under HERMES_HOME.""" + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + root = _get_profiles_root() + assert root == docker_home / "profiles" + + def test_default_hermes_home_docker(self, tmp_path, monkeypatch): + """In Docker, _get_default_hermes_home() returns HERMES_HOME itself.""" + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + home = _get_default_hermes_home() + assert home == docker_home + + def test_profiles_root_profile_mode(self, tmp_path, monkeypatch): + """In profile mode (HERMES_HOME under ~/.hermes), profiles root is still ~/.hermes/profiles.""" + native = tmp_path / ".hermes" + profile_dir = native / "profiles" / "coder" + profile_dir.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile_dir)) + root = _get_profiles_root() + assert root == native / "profiles" + + def test_active_profile_path_docker(self, tmp_path, monkeypatch): + """In Docker, active_profile file lives under HERMES_HOME.""" + from hermes_cli.profiles import _get_active_profile_path + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + path = _get_active_profile_path() + assert path == docker_home / "active_profile" + + def test_create_profile_docker(self, tmp_path, monkeypatch): + """Profile created in Docker lands under HERMES_HOME/profiles/.""" + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + result = create_profile("orchestrator", no_alias=True) + expected = docker_home / "profiles" / "orchestrator" + assert result == expected + assert expected.is_dir() + + def test_active_profile_name_docker_default(self, tmp_path, monkeypatch): + """In Docker (no profile active), get_active_profile_name() returns 'default'.""" + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + assert get_active_profile_name() == "default" + + def test_active_profile_name_docker_profile(self, tmp_path, monkeypatch): + """In Docker with a profile active, get_active_profile_name() returns the profile name.""" + docker_home = tmp_path / "opt" / "data" + profile = docker_home / "profiles" / "orchestrator" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + assert get_active_profile_name() == "orchestrator" + # =================================================================== # Edge cases and additional coverage diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 368bb1b07..84d547522 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -1,6 +1,7 @@ """Tests for the update check mechanism in hermes_cli.banner.""" import json +import os import threading import time from pathlib import Path @@ -144,7 +145,8 @@ def test_invalidate_update_cache_clears_all_profiles(tmp_path): p.mkdir(parents=True) (p / ".update_check").write_text('{"ts":1,"behind":50}') - with patch.object(Path, "home", return_value=tmp_path): + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(default_home)}): _invalidate_update_cache() # All three caches should be gone @@ -161,7 +163,8 @@ def test_invalidate_update_cache_no_profiles_dir(tmp_path): default_home.mkdir() (default_home / ".update_check").write_text('{"ts":1,"behind":5}') - with patch.object(Path, "home", return_value=tmp_path): + with patch.object(Path, "home", return_value=tmp_path), \ + patch.dict(os.environ, {"HERMES_HOME": str(default_home)}): _invalidate_update_cache() assert not (default_home / ".update_check").exists() diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py new file mode 100644 index 000000000..b3438596b --- /dev/null +++ b/tests/test_hermes_constants.py @@ -0,0 +1,62 @@ +"""Tests for hermes_constants module.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_constants import get_default_hermes_root + + +class TestGetDefaultHermesRoot: + """Tests for get_default_hermes_root() — Docker/custom deployment awareness.""" + + def test_no_hermes_home_returns_native(self, tmp_path, monkeypatch): + """When HERMES_HOME is not set, returns ~/.hermes.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + assert get_default_hermes_root() == tmp_path / ".hermes" + + def test_hermes_home_is_native(self, tmp_path, monkeypatch): + """When HERMES_HOME = ~/.hermes, returns ~/.hermes.""" + native = tmp_path / ".hermes" + native.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(native)) + assert get_default_hermes_root() == native + + def test_hermes_home_is_profile(self, tmp_path, monkeypatch): + """When HERMES_HOME is a profile under ~/.hermes, returns ~/.hermes.""" + native = tmp_path / ".hermes" + profile = native / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + assert get_default_hermes_root() == native + + def test_hermes_home_is_docker(self, tmp_path, monkeypatch): + """When HERMES_HOME points outside ~/.hermes (Docker), returns HERMES_HOME.""" + docker_home = tmp_path / "opt" / "data" + docker_home.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(docker_home)) + assert get_default_hermes_root() == docker_home + + def test_hermes_home_is_custom_path(self, tmp_path, monkeypatch): + """Any HERMES_HOME outside ~/.hermes is treated as the root.""" + custom = tmp_path / "my-hermes-data" + custom.mkdir() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(custom)) + assert get_default_hermes_root() == custom + + def test_docker_profile_active(self, tmp_path, monkeypatch): + """When a Docker profile is active (HERMES_HOME=/profiles/), + returns the Docker root, not the profile dir.""" + docker_root = tmp_path / "opt" / "data" + profile = docker_root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setenv("HERMES_HOME", str(profile)) + assert get_default_hermes_root() == docker_root