diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 97ba92b735a8..27251d4f81e4 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -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) diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index 82d8b0dd1ce7..800317421a99 100644 --- a/tests/tools/test_skills_tool.py +++ b/tests/tools/test_skills_tool.py @@ -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 @@ -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( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 94b7c235b7c3..b04b07d60f3e 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -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" @@ -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 @@ -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 @@ -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