Symlink support in skill discovery and management
NousResearch/hermes-agent · cluster 4759 · skills
Data as of
8 open items. Fix merged in #17512. Check it covers your case.
The short answer
- Known?
- Yes. 20 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #17512. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #17512. That is separate from the fix event. - Still open
- 2 issues and 6 PRs.
Next step: read #17512, then check whether your report is one of the 2 still open.
Read #17512Pick up a Lab taskThese issues and pull requests address a recurring problem where skill discovery and management tools fail to follow symbolic links, causing symlinked skills to be invisible or unmodifiable. Multiple approaches and fixes target rglob behavior, external directories, and related lookup paths.
Open issues2
Open PRs6
- PR#35213fix(skills): follow symlinks during skill discovery (#35184)
- PR#35244fix(skill_manager): follow symlinks when discovering skills (#35184)
- PR#60960Fix _find_skill: use iter_skill_index_files to follow symlinks
- PR#67748fix(skills): follow symlinks in skill management
- PR#110349fix(skills): follow symlinks in skill_manage discovery and resolution
- PR#112321fix(skills): skill_manage follows directory symlinks; rollback and ownership stay on the link
Fixes on record
-
#17512 fix(skills): let skill_manage modify skills in external_dirs in place
files:
tests/tools/test_skill_manager_tool.py,tools/skill_manager_tool.pyShow the diff
-
tests/tools/test_skill_manager_tool.py@@ -566,3 +566,151 @@ def test_guard_flag_handles_config_error(self): with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): assert _guard_agent_created_enabled() is False + + +# --------------------------------------------------------------------------- +# External skills directories (skills.external_dirs) — mutations in place +# --------------------------------------------------------------------------- + + +@contextmanager +def _two_roots(local_dir: Path, external_dir: Path): + """Patch the skill manager so local SKILLS_DIR = local_dir and + get_all_skills_dirs() returns [local_dir, external_dir] in order.""" + with patch("tools.skill_manager_tool.SKILLS_DIR", local_dir), \ + patch("agent.skill_utils.get_all_skills_dirs", + return_value=[local_dir, external_dir]): + yield + + +def _write_external_skill(external_dir: Path, name: str = "ext-skill") -> Path: + skill_dir = external_dir / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: An external skill.\n---\n\n" + "# External\n\nBody with OLD_MARKER here.\n" + ) + return skill_dir + + +class TestExternalSkillMutations: + """Verify skill_manage can patch/edit/write/remove/delete skills that live + under skills.external_dirs — in place, without duplicating to local. + + Regression for issues #4759 and #4381: the read-only gate used to refuse + with 'Skill X is in an external directory and cannot be modified', which + caused agents to create duplicate copies in ~/.hermes/skills/ as a + workaround. + """ + + def test_patch_external_skill_writes_in_place(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _patch_skill("ext-skill", "OLD_MARKER", "NEW_MARKER") + + assert result["success"] is True, result + assert "NEW_MARKER" in (skill_dir / "SKILL.md").read_text() + # No duplicate in local + assert not (local / "ext-skill").exists() + + def test_edit_external_skill_writes_in_place(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + new_content = ( + "---\nname: ext-skill\ndescription: Rewritten.\n---\n\n" + "# Rewritten\n\nBrand new body.\n" + ) + with _two_roots(local, external): + result = _edit_skill("ext-skill", new_content) + + assert result["success"] is True, result + assert "Brand new body" in (skill_dir / "SKILL.md").read_text() + assert not (local / "ext-skill").exists() + + def test_write_file_on_external_skill(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _write_file("ext-skill", "references/notes.md", "# Notes\n") + + assert result["success"] is True, result + assert (skill_dir / "references" / "notes.md").read_text() == "# Notes\n" + assert not (local / "ext-skill").exists() + + def test_remove_file_on_external_skill(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + (skill_dir / "references").mkdir() + (skill_dir / "references" / "notes.md").write_text("# Notes\n") + + with _two_roots(local, external): + result = _remove_file("ext-skill", "references/notes.md") + + assert result["success"] is True, result + assert not (skill_dir / "references" / "notes.md").exists() + + def test_delete_external_skill_removes_skill_not_root(self, tmp_path): + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + skill_dir = _write_external_skill(external) + + with _two_roots(local, external): + result = _delete_skill("ext-skill") + + assert result["success"] is True, result + assert not skill_dir.exists() + # The external root must NOT be rmdir'd, even when empty after deletion + assert external.exists() and external.is_dir() + + def test_delete_external_skill_cleans_empty_category(self, tmp_path): + """When a skill lives under external/<category>/<name>, deleting the + last skill in the category should rmdir the empty category dir but + stop at the external root.""" + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + cat_dir = external / "team" + cat_dir.mkdir() + skill_dir = cat_dir / "ext-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: ext-skill\ndescription: An external skill.\n---\n\n" + "# External\n\nBody.\n" + ) + + with _two_roots(local, external): + result = _delete_skill("ext-skill") + + assert result["success"] is True, result + assert not skill_dir.exists() + assert not cat_dir.exists() # empty category cleaned up + assert external.exists() # but never the external root + + def test_create_still_writes_to_local_root(self, tmp_path): + """Creating a new skill always lands in local SKILLS_DIR, never + external_dirs — create is unchanged by this PR.""" + local = tmp_path / "local" + external = tmp_path / "vault" + local.mkdir(); external.mkdir() + + with _two_roots(local, external): + result = _create_skill("fresh-skill", VALID_SKILL_CONTENT.replace( + "name: test-skill", "name: fresh-skill")) + + assert result["success"] is True, result + assert (local / "fresh-skill" / "SKILL.md").exists() + assert not (external / "fresh-skill").exists() + -
tools/skill_manager_tool.py@@ -109,16 +109,28 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]: MAX_DESCRIPTION_LENGTH = 1024 -def _is_local_skill(skill_path: Path) -> bool: - """Check if a skill path is within the local SKILLS_DIR. - - Skills found in external_dirs are read-only from the agent's perspective. +def _containing_skills_root(skill_path: Path) -> Path: + """Return the skills root directory (local or external_dirs entry) that + contains ``skill_path``. Falls back to the local ``SKILLS_DIR`` if no + match is found (defensive — callers should have located the skill via + ``_find_skill`` first). """ + from agent.skill_utils import get_all_skills_dirs + try: - skill_path.resolve().relative_to(SKILLS_DIR.resolve()) - return True - except ValueError: - return False + resolved = skill_path.resolve() + except OSError: + resolved = skill_path + + for root in get_all_skills_dirs(): + try: + resolved.relative_to(root.resolve()) + return root + except (ValueError, OSError): + continue + return SKILLS_DIR + + MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file @@ -397,9 +409,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} - skill_md = existing["path"] / "SKILL.md" # Back up original content for rollback original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None @@ -440,9 +449,6 @@ def _patch_skill( if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} - skill_dir = existing["path"] if file_path: @@ -522,15 +528,13 @@ def _delete_skill(name: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be deleted."} - skill_dir = existing["path"] + skills_root = _containing_skills_root(skill_dir) shutil.rmtree(skill_dir) - # Clean up empty category directories (don't remove SKILLS_DIR itself) + # Clean up empty category directories (don't remove the skills root itself) parent = skill_dir.parent - if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()): + if parent != skills_root and parent.exists() and not any(parent.iterdir()): parent.rmdir() return { @@ -567,9 +571,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first."} - target, err = _resolve_skill_target(existing["path"], file_path) if err: return {"success": False, "error": err} @@ -604,9 +605,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]: if not existing: return {"success": False, "error": f"Skill '{name}' not found."} - if not _is_local_skill(existing["path"]): - return {"success": False, "error": f"Skill '{name}' is in an external directory and cannot be modified."} - skill_dir = existing["path"] target, err = _resolve_skill_target(skill_dir, file_path)
-
8 PRs closed without merging
From the close comments: done elsewhere 3 · duplicate 3 · superseded by another PR 2
-
#4868 fix: skill_manage now finds skills from external_dirs · done elsewhere →
ad4feea,#4759 -
#5284 fix(skills): respect local skill dir in skill manager · done elsewhere →
ad4feea -
#8357 fix(skills): follow symlinked local skill dirs · duplicate →
#14230,#8769,#7634,#12624 -
#8769 fix(skills): discover symlinked skill directories · duplicate →
#14230,#7634,#12624 - #12624 fix(skills): follow symlinks when discovering SKILL.md · superseded by another PR
Show 3 more
-
#23484 fix(skill-tools): follow symlinks in skill discovery via rglob_follow · duplicate →
#12624 -
#54200 fix(skills): follow symlinks when discovering skills in external dirs · superseded by another PR →
#35244 -
#108040 fix(skills): classify external_dirs as external and default-deny in-place writes · done elsewhere →
8c8fc6c,#128387,#17512
Closed issues · 3
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- skills
- Merged PRs
- #17512
- Closed issues
- 3
- Attempts
- 8 PRs closed without merging
- Fix link checked
- 29 Apr 2026