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
64 changes: 64 additions & 0 deletions tests/tools/test_skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
_validate_category,
_validate_frontmatter,
_validate_file_path,
_find_skill,
_find_skill_in_other_profiles,
_create_skill,
_edit_skill,
_patch_skill,
Expand Down Expand Up @@ -198,6 +200,68 @@ def test_other_root_md_still_rejected(self):
assert "File must be under one of:" in err


# ---------------------------------------------------------------------------
# Skill discovery — symlinked skill directories
# ---------------------------------------------------------------------------


class TestSkillDiscovery:
@staticmethod
def _make_skill(root: Path, name: str) -> Path:
skill_dir = root / name
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: A symlink test skill.\n---\n\n"
"# Symlink Test\n\nBody.\n"
)
return skill_dir

def test_find_skill_follows_direct_skill_symlink(self, tmp_path):
source = self._make_skill(tmp_path / "source", "source-skill")
link = tmp_path / "direct-skill"
try:
link.symlink_to(source, target_is_directory=True)
except OSError:
pytest.skip("Symlinks not supported")

with _skill_dir(tmp_path):
result = _find_skill("direct-skill")

assert result == {"path": link}

def test_find_skill_follows_category_skill_symlink(self, tmp_path):
source = self._make_skill(tmp_path / "source", "source-skill")
link = tmp_path / "category" / "category-skill"
link.parent.mkdir()
try:
link.symlink_to(source, target_is_directory=True)
except OSError:
pytest.skip("Symlinks not supported")

with _skill_dir(tmp_path):
result = _find_skill("category-skill")

assert result == {"path": link}

def test_find_skill_in_other_profiles_follows_skill_symlink(self, tmp_path):
hermes_root = tmp_path / "hermes"
active_skills = hermes_root / "profiles" / "active" / "skills"
other_skills = hermes_root / "profiles" / "other" / "skills"
source = self._make_skill(tmp_path / "source", "source-skill")
link = other_skills / "category" / "other-skill"
link.parent.mkdir(parents=True)
try:
link.symlink_to(source, target_is_directory=True)
except OSError:
pytest.skip("Symlinks not supported")

with patch("tools.skill_manager_tool.SKILLS_DIR", active_skills), \
patch("hermes_constants.get_default_hermes_root", return_value=hermes_root):
result = _find_skill_in_other_profiles("other-skill")

assert result == [("other", link)]


# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------
Expand Down
17 changes: 9 additions & 8 deletions tools/skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,17 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
Searches the local skills dir (~/.hermes/skills/) first, then any
external dirs configured via skills.external_dirs. Returns
{"path": Path} or None.

Uses os.walk with followlinks=True instead of Path.rglob so that
symlinked skill directories (e.g. shared skills under ~/.agents/skills/)
are visible — Python 3.11's rglob does not follow directory symlinks
(the follow_symlinks parameter was added in 3.12).
"""
from agent.skill_utils import get_all_skills_dirs, is_excluded_skill_path
from agent.skill_utils import get_all_skills_dirs, iter_skill_index_files
for skills_dir in get_all_skills_dirs():
if not skills_dir.exists():
continue
for skill_md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
for skill_md in iter_skill_index_files(skills_dir, "SKILL.md"):
if skill_md.parent.name == name:
return {"path": skill_md.parent}
return None
Expand All @@ -307,7 +310,7 @@ def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]:
matches: List[Tuple[str, Path]] = []
try:
from hermes_constants import get_default_hermes_root
from agent.skill_utils import is_excluded_skill_path
from agent.skill_utils import iter_skill_index_files
except Exception:
return matches

Expand Down Expand Up @@ -350,9 +353,7 @@ def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]:
if not skills_dir.is_dir():
continue
try:
for skill_md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(skill_md):
continue
for skill_md in iter_skill_index_files(skills_dir, "SKILL.md"):
if skill_md.parent.name == name:
matches.append((profile_name, skill_md.parent))
break # one match per profile is enough
Expand Down
Loading