Skip to content
Closed
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: 1 addition & 1 deletion agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
Excludes ``.git``, ``.github``, ``.hub`` directories.
"""
matches = []
for root, dirs, files in os.walk(skills_dir):
for root, dirs, files in os.walk(skills_dir, followlinks=True):
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
if filename in files:
matches.append(Path(root) / filename)
Expand Down
30 changes: 30 additions & 0 deletions tests/tools/test_skills_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,21 @@ def test_skips_git_directories(self, tmp_path):
assert len(skills) == 1
assert skills[0]["name"] == "real-skill"

@pytest.mark.skipif(os.name == "nt", reason="Directory symlinks require privileges on Windows")
def test_finds_symlinked_skill_directories(self, tmp_path):
source_root = tmp_path / "skill-source"
source_skill = _make_skill(source_root, "linked-skill", category="social-media")
link_root = tmp_path / "skills"
(link_root / "social-media").mkdir(parents=True)
(link_root / "social-media" / "linked-skill").symlink_to(source_skill, target_is_directory=True)

with patch("tools.skills_tool.SKILLS_DIR", link_root):
skills = _find_all_skills()

matching = [skill for skill in skills if skill["name"] == "linked-skill"]
assert len(matching) == 1
assert matching[0]["category"] == "social-media"


# ---------------------------------------------------------------------------
# skills_list
Expand Down Expand Up @@ -356,6 +371,21 @@ def test_view_shows_linked_files(self, tmp_path):
assert result["linked_files"] is not None
assert "references" in result["linked_files"]

@pytest.mark.skipif(os.name == "nt", reason="Directory symlinks require privileges on Windows")
def test_view_bare_name_for_symlinked_skill_directory(self, tmp_path):
source_root = tmp_path / "skill-source"
source_skill = _make_skill(source_root, "follow-builders", category="social-media")
skills_root = tmp_path / "skills"
(skills_root / "social-media").mkdir(parents=True)
(skills_root / "social-media" / "follow-builders").symlink_to(source_skill, target_is_directory=True)

with patch("tools.skills_tool.SKILLS_DIR", skills_root):
result = json.loads(skill_view("follow-builders"))

assert result["success"] is True
assert result["name"] == "follow-builders"
assert "Do the thing" in result["content"]

def test_view_tags_from_metadata(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(
Expand Down
21 changes: 18 additions & 3 deletions tools/skills_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@
_secret_capture_callback = None


def _iter_skill_files(scan_dir: Path, filename: str = "SKILL.md"):
"""Yield skill files under *scan_dir*, following directory symlinks.

``Path.rglob()`` skips recursion into symlinked directories, which causes
symlinked local skills under ``~/.hermes/skills`` to disappear from normal
discovery. Reuse ``iter_skill_index_files()`` so list/view/prompt indexing
all agree on traversal behavior.
"""
from agent.skill_utils import iter_skill_index_files

if not scan_dir.exists():
return
yield from iter_skill_index_files(scan_dir, filename)


def load_env() -> Dict[str, str]:
"""Load profile-scoped environment variables from HERMES_HOME/.env."""
env_path = get_hermes_home() / ".env"
Expand Down Expand Up @@ -535,7 +550,7 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
dirs_to_scan.extend(get_external_skills_dirs())

for scan_dir in dirs_to_scan:
for skill_md in scan_dir.rglob("SKILL.md"):
for skill_md in _iter_skill_files(scan_dir):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue

Expand Down Expand Up @@ -665,7 +680,7 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str:
category_dirs = {}
category_counts: Dict[str, int] = {}
for scan_dir in all_dirs:
for skill_md in scan_dir.rglob("SKILL.md"):
for skill_md in _iter_skill_files(scan_dir):
if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue

Expand Down Expand Up @@ -824,7 +839,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
# Search by directory name across all dirs
if not skill_md:
for search_dir in all_dirs:
for found_skill_md in search_dir.rglob("SKILL.md"):
for found_skill_md in _iter_skill_files(search_dir):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
Expand Down
Loading