Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions skillclaw/_paths.py
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 6 additions & 4 deletions skillclaw/claw_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion skillclaw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion skillclaw/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import os
from pathlib import Path
from typing import Any

Expand All @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion skillclaw/skill_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion skillclaw/skill_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
103 changes: 103 additions & 0 deletions tests/test_hermes_home_resolution.py
Original file line number Diff line number Diff line change
@@ -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")
49 changes: 49 additions & 0 deletions tests/test_hermes_skill_root.py
Original file line number Diff line number Diff line change
@@ -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