fix(skills): factor HERMES_HOME resolution into shared _hermes_home helper

The three google-workspace scripts (setup.py, google_api.py, gws_bridge.py)
each had their own way of resolving HERMES_HOME:

- setup.py imported hermes_constants (crashes outside Hermes process)
- google_api.py used os.getenv inline (no strip, no empty handling)
- gws_bridge.py defined its own local get_hermes_home() (duplicate)

Extract the common logic into _hermes_home.py which:
- Delegates to hermes_constants when available (profile support, etc.)
- Falls back to os.getenv with .strip() + empty-as-unset handling
- Provides display_hermes_home() with ~/ shortening for profiles

All three scripts now import from _hermes_home instead of duplicating.

7 regression tests cover the fallback path: env var override, default
~/.hermes, empty env var, display shortening, profile paths, and
custom non-home paths.

Closes #12722
This commit is contained in:
Jérôme Benoit 2026-04-20 12:03:26 +02:00 committed by Teknium
parent f14264c438
commit c34d3f4807
5 changed files with 127 additions and 10 deletions

View file

@ -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)

View file

@ -31,7 +31,14 @@ from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText from email.mime.text import MIMEText
from pathlib import Path 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" TOKEN_PATH = HERMES_HOME / "google_token.json"
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json" CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"

View file

@ -10,9 +10,12 @@ import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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: from _hermes_home import get_hermes_home
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
def get_token_path() -> Path: def get_token_path() -> Path:

View file

@ -28,13 +28,12 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
try: # Ensure sibling modules (_hermes_home) are importable when run standalone.
from hermes_constants import display_hermes_home, get_hermes_home _SCRIPTS_DIR = str(Path(__file__).resolve().parent)
except ModuleNotFoundError: if _SCRIPTS_DIR not in sys.path:
HERMES_AGENT_ROOT = Path(__file__).resolve().parents[4] sys.path.insert(0, _SCRIPTS_DIR)
if HERMES_AGENT_ROOT.exists():
sys.path.insert(0, str(HERMES_AGENT_ROOT)) from _hermes_home import display_hermes_home, get_hermes_home
from hermes_constants import display_hermes_home, get_hermes_home
HERMES_HOME = get_hermes_home() HERMES_HOME = get_hermes_home()
TOKEN_PATH = HERMES_HOME / "google_token.json" TOKEN_PATH = HERMES_HOME / "google_token.json"

View file

@ -240,3 +240,69 @@ class TestExchangeAuthCode:
assert setup_module.TOKEN_PATH.exists() assert setup_module.TOKEN_PATH.exists()
# Pending auth is cleaned up # Pending auth is cleaned up
assert not setup_module.PENDING_AUTH_PATH.exists() 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