diff --git a/README.md b/README.md index 5e7976e..422a66a 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,8 @@ If you already use Hermes, the client-side path is: 3. Keep `Proxy model name exposed to agents` as `skillclaw-model` unless you have a specific reason to change it. 4. Start SkillClaw. On startup, SkillClaw rewrites `~/.hermes/config.yaml` to point Hermes at the local proxy. 5. Hermes uses `~/.hermes/skills` as the default local skill library. SkillClaw prepares that directory automatically and copies in any missing legacy skills from `~/.skillclaw/skills`. + +**Custom Hermes home:** if your Hermes install sets `HERMES_HOME`, SkillClaw follows it automatically — the config rewrite, doctor checks, and skill-dir defaults all resolve against the same home. To point SkillClaw at a specific config (e.g. a profile-scoped install), set `SKILLCLAW_HERMES_HOME` to the directory containing the target `config.yaml`; it wins over `HERMES_HOME`. Resolution order: `SKILLCLAW_HERMES_HOME` → `HERMES_HOME` → `~/.hermes`. 6. If you want to inspect or undo the integration, use `skillclaw doctor hermes` and `skillclaw restore hermes`. Minimal verification: diff --git a/skillclaw/_paths.py b/skillclaw/_paths.py new file mode 100644 index 0000000..03f8395 --- /dev/null +++ b/skillclaw/_paths.py @@ -0,0 +1,26 @@ +"""Shared path resolution for the Hermes integration. + +SkillClaw previously hardcoded `~/.hermes` in several modules. Installs +where Hermes uses a custom home (HERMES_HOME env var) were configured and +inspected at the wrong path. All sites now resolve through one helper. +""" +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_hermes_home() -> Path: + """Resolve the Hermes home directory. + + Order: + 1. SKILLCLAW_HERMES_HOME (explicit override, wins) + 2. HERMES_HOME (matches Hermes' own env var) + 3. ~/.hermes (stock default, unchanged) + + Values are stripped and tilde-expanded; empty values fall through. + """ + raw = os.environ.get("SKILLCLAW_HERMES_HOME") or os.environ.get("HERMES_HOME") + if raw and raw.strip(): + return Path(raw.strip()).expanduser() + return Path.home() / ".hermes" diff --git a/skillclaw/claw_adapter.py b/skillclaw/claw_adapter.py index b054c94..2a40ddd 100644 --- a/skillclaw/claw_adapter.py +++ b/skillclaw/claw_adapter.py @@ -40,7 +40,9 @@ logger = logging.getLogger(__name__) _LEGACY_SKILLCLAW_SKILLS_DIR = Path.home() / ".skillclaw" / "skills" -_HERMES_HOME = Path.home() / ".hermes" +from ._paths import resolve_hermes_home + +_HERMES_HOME = resolve_hermes_home() _HERMES_SKILLS_DIR = _HERMES_HOME / "skills" _HERMES_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "hermes" _CODEX_HOME = Path.home() / ".codex" @@ -530,10 +532,10 @@ def inspect_hermes_config(cfg: "SkillClawConfig") -> dict[str, object]: next_steps: list[str] = [] if not config_path.exists(): - issues.append("Hermes config is missing: ~/.hermes/config.yaml") + issues.append(f"Hermes config is missing: {config_path}") if not proxy_match: issues.append("Hermes model routing is not pointing at the local SkillClaw proxy.") - next_steps.append("Start SkillClaw once so it can rewrite ~/.hermes/config.yaml.") + next_steps.append(f"Start SkillClaw once so it can rewrite {config_path}.") if not expected_skills_dir.is_dir(): issues.append(f"Hermes skills directory is missing: {expected_skills_dir}") next_steps.append(f"Create or prepare the Hermes skills directory: {expected_skills_dir}") @@ -572,7 +574,7 @@ def inspect_hermes_config(cfg: "SkillClawConfig") -> dict[str, object]: def restore_hermes_config(backup_path: Path | None = None) -> dict[str, str]: - """Restore ~/.hermes/config.yaml from the latest or a specified backup.""" + """Restore the Hermes config from the latest or a specified backup.""" source = Path(backup_path).expanduser() if backup_path is not None else _latest_hermes_backup_path() if source is None or not source.exists(): raise FileNotFoundError("No Hermes backup found") diff --git a/skillclaw/cli.py b/skillclaw/cli.py index 529c702..1dd8454 100644 --- a/skillclaw/cli.py +++ b/skillclaw/cli.py @@ -473,7 +473,7 @@ def restore(): help="Restore from a specific backup file instead of the latest Hermes backup.", ) def restore_hermes(backup_path: str | None): - """Restore ~/.hermes/config.yaml from a saved backup.""" + """Restore the Hermes config from a saved backup.""" from .claw_adapter import restore_hermes_config try: diff --git a/skillclaw/config_store.py b/skillclaw/config_store.py index b2aa7c9..82a926f 100644 --- a/skillclaw/config_store.py +++ b/skillclaw/config_store.py @@ -7,6 +7,7 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any @@ -15,7 +16,9 @@ CONFIG_DIR = Path.home() / ".skillclaw" CONFIG_FILE = CONFIG_DIR / "config.yaml" _DEFAULT_SKILLS_DIR = CONFIG_DIR / "skills" -_DEFAULT_HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills" +from ._paths import resolve_hermes_home + +_DEFAULT_HERMES_SKILLS_DIR = resolve_hermes_home() / "skills" _DEFAULT_CODEX_SKILLS_DIR = Path.home() / ".codex" / "skills" _DEFAULT_CLAUDE_SKILLS_DIR = Path.home() / ".claude" / "skills" _DEFAULT_OPENCODE_SKILLS_DIR = Path.home() / ".config" / "opencode" / "skills" diff --git a/skillclaw/skill_hub.py b/skillclaw/skill_hub.py index 3bd16d7..457676f 100644 --- a/skillclaw/skill_hub.py +++ b/skillclaw/skill_hub.py @@ -40,7 +40,11 @@ def _is_hermes_skill_root(skills_dir: str) -> bool: - return os.path.realpath(skills_dir) == os.path.realpath(os.path.join(os.path.expanduser("~"), ".hermes", "skills")) + from ._paths import resolve_hermes_home + + return os.path.realpath(skills_dir) == os.path.realpath( + str(resolve_hermes_home() / "skills") + ) def _skill_dir_for_root(skills_dir: str, skill_name: str, category: str = "general") -> str: diff --git a/skillclaw/skill_manager.py b/skillclaw/skill_manager.py index 55547a2..60ddc4e 100644 --- a/skillclaw/skill_manager.py +++ b/skillclaw/skill_manager.py @@ -332,8 +332,10 @@ def _compute_skills_fingerprint(self) -> tuple[tuple[str, int, int], ...]: return tuple(fingerprint) def _is_hermes_skill_root(self) -> bool: + from ._paths import resolve_hermes_home + return os.path.realpath(self._skills_dir) == os.path.realpath( - os.path.join(os.path.expanduser("~"), ".hermes", "skills") + str(resolve_hermes_home() / "skills") ) def _skill_dir_path(self, skill: dict) -> str: diff --git a/tests/test_hermes_home_resolution.py b/tests/test_hermes_home_resolution.py new file mode 100644 index 0000000..9b442a1 --- /dev/null +++ b/tests/test_hermes_home_resolution.py @@ -0,0 +1,103 @@ +"""Tests for hermes-home resolution in the claw adapter and config store. + +SkillClaw hardcodes the hermes home to ``~/.hermes``. On installs where +Hermes itself uses a custom home (e.g. ``HERMES_HOME=G:\\hermes``), the +integration configures and inspects the wrong file. The adapter should +resolve the hermes home from the environment, mirroring how Hermes itself +does: + + SKILLCLAW_HERMES_HOME (explicit override, wins) + HERMES_HOME (matches Hermes' own env var) + ~/.hermes (stock default, unchanged) + +The skills-dir default in config_store must follow the same rule. + +Module constants are evaluated at import time, so each case runs in a +subprocess with a controlled environment. +""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +CASES = [ + # (env_overrides, module, constant, expected) + ( + {"SKILLCLAW_HERMES_HOME": "/tmp/sc-home"}, + "skillclaw.claw_adapter", + "_HERMES_HOME", + "/tmp/sc-home", + ), + ( + {"HERMES_HOME": "/tmp/hermes-home"}, + "skillclaw.claw_adapter", + "_HERMES_HOME", + "/tmp/hermes-home", + ), + ( + {"SKILLCLAW_HERMES_HOME": "/tmp/sc-wins", "HERMES_HOME": "/tmp/loses"}, + "skillclaw.claw_adapter", + "_HERMES_HOME", + "/tmp/sc-wins", + ), + ( + {"HERMES_HOME": "/tmp/hermes-home"}, + "skillclaw.claw_adapter", + "_HERMES_SKILLS_DIR", + "/tmp/hermes-home/skills", + ), + ( + {"HERMES_HOME": "/tmp/hermes-home"}, + "skillclaw.config_store", + "_DEFAULT_HERMES_SKILLS_DIR", + "/tmp/hermes-home/skills", + ), +] + +_STRIP = ("SKILLCLAW_HERMES_HOME", "HERMES_HOME") + + +def _read_constant(env_overrides: dict, module: str, constant: str) -> str: + env = {k: v for k, v in os.environ.items() if k not in _STRIP} + env.update(env_overrides) + out = subprocess.run( + [sys.executable, "-c", f"from {module} import {constant}; print({constant})"], + capture_output=True, text=True, cwd=REPO, env=env, timeout=60, + ) + assert out.returncode == 0, f"{module} import failed: {out.stderr[-400:]}" + return out.stdout.strip() + + +def test_skillclaw_home_override_wins(): + env, module, const, expected = CASES[0] + assert _read_constant(env, module, const) == str(Path(expected)) + + +def test_hermes_home_env_fallback(): + env, module, const, expected = CASES[1] + assert _read_constant(env, module, const) == str(Path(expected)) + + +def test_skillclaw_home_beats_hermes_home(): + env, module, const, expected = CASES[2] + assert _read_constant(env, module, const) == str(Path(expected)) + + +def test_skills_dir_follows_home(): + env, module, const, expected = CASES[3] + assert _read_constant(env, module, const) == str(Path(expected)) + + +def test_config_store_skills_dir_follows_home(): + env, module, const, expected = CASES[4] + assert _read_constant(env, module, const) == str(Path(expected)) + + +def test_stock_default_preserved(): + """With no env vars set, the stock ~/.hermes behavior must not change.""" + got = _read_constant({}, "skillclaw.claw_adapter", "_HERMES_HOME") + assert got == str(Path.home() / ".hermes") diff --git a/tests/test_hermes_skill_root.py b/tests/test_hermes_skill_root.py new file mode 100644 index 0000000..7ac14b6 --- /dev/null +++ b/tests/test_hermes_skill_root.py @@ -0,0 +1,49 @@ +"""Tests that skill-hub and skill-manager recognize a custom Hermes home. + +Follow-up to the hermes-home env override: `_is_hermes_skill_root` in both +skill_hub.py and skill_manager.py hardcodes `~/.hermes/skills`. Under +SKILLCLAW_HERMES_HOME / HERMES_HOME, skills written to the real skills dir +must still be treated as hermes-root (category subdirectory layout). +""" +from __future__ import annotations + +from skillclaw import skill_hub, skill_manager + + +def _manager(skills_dir: str) -> skill_manager.SkillManager: + """Bare instance — bypasses __init__ (we only need _skills_dir).""" + m = skill_manager.SkillManager.__new__(skill_manager.SkillManager) + m._skills_dir = skills_dir + return m + + +def test_hub_root_recognizes_skillclaw_home(monkeypatch, tmp_path): + home = tmp_path / "custom-home" + monkeypatch.setenv("SKILLCLAW_HERMES_HOME", str(home)) + assert skill_hub._is_hermes_skill_root(str(home / "skills")) is True + + +def test_hub_root_recognizes_hermes_home_env(monkeypatch, tmp_path): + home = tmp_path / "custom-home" + monkeypatch.setenv("HERMES_HOME", str(home)) + assert skill_hub._is_hermes_skill_root(str(home / "skills")) is True + + +def test_manager_root_recognizes_custom_home(monkeypatch, tmp_path): + home = tmp_path / "custom-home" + monkeypatch.setenv("SKILLCLAW_HERMES_HOME", str(home)) + assert _manager(str(home / "skills"))._is_hermes_skill_root() is True + + +def test_hub_root_rejects_unrelated_dir(monkeypatch, tmp_path): + home = tmp_path / "custom-home" + monkeypatch.setenv("SKILLCLAW_HERMES_HOME", str(home)) + assert skill_hub._is_hermes_skill_root(str(tmp_path / "elsewhere")) is False + + +def test_hub_stock_default_preserved(monkeypatch): + monkeypatch.delenv("SKILLCLAW_HERMES_HOME", raising=False) + monkeypatch.delenv("HERMES_HOME", raising=False) + import os + stock = os.path.join(os.path.expanduser("~"), ".hermes", "skills") + assert skill_hub._is_hermes_skill_root(stock) is True