diff --git a/skills/productivity/google-workspace/scripts/_hermes_home.py b/skills/productivity/google-workspace/scripts/_hermes_home.py new file mode 100644 index 000000000..456eaa930 --- /dev/null +++ b/skills/productivity/google-workspace/scripts/_hermes_home.py @@ -0,0 +1,42 @@ +"""Resolve HERMES_HOME for standalone skill scripts. + +Skill scripts may run outside the Hermes process (e.g. system Python, +nix env, CI) where ``hermes_constants`` is not importable. This module +provides the same ``get_hermes_home()`` and ``display_hermes_home()`` +contracts as ``hermes_constants`` without requiring it on ``sys.path``. + +When ``hermes_constants`` IS available it is used directly so that any +future enhancements (profile resolution, Docker detection, etc.) are +picked up automatically. The fallback path replicates the core logic +from ``hermes_constants.py`` using only the stdlib. + +All scripts under ``google-workspace/scripts/`` should import from here +instead of duplicating the ``HERMES_HOME = Path(os.getenv(...))`` pattern. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +try: + from hermes_constants import display_hermes_home as display_hermes_home + from hermes_constants import get_hermes_home as get_hermes_home +except (ModuleNotFoundError, ImportError): + + def get_hermes_home() -> Path: + """Return the Hermes home directory (default: ~/.hermes). + + Mirrors ``hermes_constants.get_hermes_home()``.""" + val = os.environ.get("HERMES_HOME", "").strip() + return Path(val) if val else Path.home() / ".hermes" + + def display_hermes_home() -> str: + """Return a user-friendly ``~/``-shortened display string. + + Mirrors ``hermes_constants.display_hermes_home()``.""" + home = get_hermes_home() + try: + return "~/" + str(home.relative_to(Path.home())) + except ValueError: + return str(home) diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 6504c098b..0c39e091f 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -31,7 +31,14 @@ from datetime import datetime, timedelta, timezone from email.mime.text import MIMEText from pathlib import Path -HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +# Ensure sibling modules (_hermes_home) are importable when run standalone. +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +from _hermes_home import get_hermes_home + +HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py index 0477749d7..e3cc9f147 100755 --- a/skills/productivity/google-workspace/scripts/gws_bridge.py +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -10,9 +10,12 @@ import sys from datetime import datetime, timezone from pathlib import Path +# Ensure sibling modules (_hermes_home) are importable when run standalone. +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) -def get_hermes_home() -> Path: - return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) +from _hermes_home import get_hermes_home def get_token_path() -> Path: diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index bf4fb39ca..70d7b0a10 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -28,13 +28,12 @@ import subprocess import sys from pathlib import Path -try: - from hermes_constants import display_hermes_home, get_hermes_home -except ModuleNotFoundError: - HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4] - if HERMES_AGENT_ROOT.exists(): - sys.path.insert(0, str(HERMES_AGENT_ROOT)) - from hermes_constants import display_hermes_home, get_hermes_home +# Ensure sibling modules (_hermes_home) are importable when run standalone. +_SCRIPTS_DIR = str(Path(__file__).resolve().parent) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +from _hermes_home import display_hermes_home, get_hermes_home HERMES_HOME = get_hermes_home() TOKEN_PATH = HERMES_HOME / "google_token.json" diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index 445ed82de..0e1fe6d7f 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -240,3 +240,69 @@ class TestExchangeAuthCode: assert setup_module.TOKEN_PATH.exists() # Pending auth is cleaned up assert not setup_module.PENDING_AUTH_PATH.exists() + + +class TestHermesConstantsFallback: + """Tests for _hermes_home.py fallback when hermes_constants is unavailable.""" + + HELPER_PATH = ( + Path(__file__).resolve().parents[2] + / "skills/productivity/google-workspace/scripts/_hermes_home.py" + ) + + def _load_helper(self, monkeypatch): + """Load _hermes_home.py with hermes_constants blocked.""" + monkeypatch.setitem(sys.modules, "hermes_constants", None) + spec = importlib.util.spec_from_file_location("_hermes_home_test", self.HELPER_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + def test_fallback_uses_hermes_home_env_var(self, monkeypatch, tmp_path): + """When hermes_constants is missing, HERMES_HOME comes from env var.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "custom-hermes")) + module = self._load_helper(monkeypatch) + assert module.get_hermes_home() == tmp_path / "custom-hermes" + + def test_fallback_defaults_to_dot_hermes(self, monkeypatch): + """When hermes_constants is missing and HERMES_HOME unset, default to ~/.hermes.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + module = self._load_helper(monkeypatch) + assert module.get_hermes_home() == Path.home() / ".hermes" + + def test_fallback_ignores_empty_hermes_home(self, monkeypatch): + """Empty/whitespace HERMES_HOME is treated as unset.""" + monkeypatch.setenv("HERMES_HOME", " ") + module = self._load_helper(monkeypatch) + assert module.get_hermes_home() == Path.home() / ".hermes" + + def test_fallback_display_hermes_home_shortens_path(self, monkeypatch): + """Fallback display_hermes_home() uses ~/ shorthand like the real one.""" + monkeypatch.delenv("HERMES_HOME", raising=False) + module = self._load_helper(monkeypatch) + assert module.display_hermes_home() == "~/.hermes" + + def test_fallback_display_hermes_home_profile_path(self, monkeypatch): + """Fallback display_hermes_home() handles profile paths under ~/.""" + monkeypatch.setenv("HERMES_HOME", str(Path.home() / ".hermes/profiles/coder")) + module = self._load_helper(monkeypatch) + assert module.display_hermes_home() == "~/.hermes/profiles/coder" + + def test_fallback_display_hermes_home_custom_path(self, monkeypatch): + """Fallback display_hermes_home() returns full path for non-home locations.""" + monkeypatch.setenv("HERMES_HOME", "/opt/hermes-custom") + module = self._load_helper(monkeypatch) + assert module.display_hermes_home() == "/opt/hermes-custom" + + def test_delegates_to_hermes_constants_when_available(self): + """When hermes_constants IS importable, _hermes_home delegates to it.""" + spec = importlib.util.spec_from_file_location( + "_hermes_home_happy", self.HELPER_PATH + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + import hermes_constants + assert module.get_hermes_home is hermes_constants.get_hermes_home + assert module.display_hermes_home is hermes_constants.display_hermes_home