# NousResearch/hermes-agent · Symlink support in skill discovery and management

## Open now

- 2 issues, 6 PRs open on GitHub.
- #35184: skill_manager_tool._find_skill uses Path.rglob() which does not follow symlinks, making symlinked skills invisible to skill_manage
- #54195: skill_manage cannot find symlinked skills in skills.external_dirs
- PR #35213: fix(skills): follow symlinks during skill discovery (#35184)
- PR #35244: fix(skill_manager): follow symlinks when discovering skills (#35184)
- PR #60960: Fix _find_skill: use iter_skill_index_files to follow symlinks
- PR #67748: fix(skills): follow symlinks in skill management
- PR #110349: fix(skills): follow symlinks in skill_manage discovery and resolution
- PR #112321: fix(skills): skill_manage follows directory symlinks; rollback and ownership stay on the link

These 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.

## TL;DR

- 20 member(s): 2 open issue(s), 3 closed issue(s), 6 open PR(s), 1 merged PR(s), 8 PR(s) closed without merging.
- A fix was merged to main in PR #17512. Verify it addresses your case.
- **Recommended:** read PR #17512 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-17512`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #35184: skill_manager_tool._find_skill uses Path.rglob() which does not follow symlinks, making symlinked skills invisible to skill_manage
- #54195: skill_manage cannot find symlinked skills in skills.external_dirs

## Closed / duplicate issues

- #4759: skill_manage patch/edit/delete can't find skills from external_dirs
- #8293: Bug: symlinked skills under ~/.hermes/skills are omitted from skills_list and bare-name skill_view lookup
- #44609: skill_manage _find_skill uses rglob which silently skips symlinked skills (Python 3.12)

## Prior & competing PRs

- **#4868: fix: skill_manage now finds skills from external_dirs**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ad4feeaf0`, `ad4feeaf0d617010bb18d7efa1dbfcfce6a812b6`, `#4759`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12624: fix(skills): follow symlinks when discovering SKILL.md**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#17512: fix(skills): let skill_manage modify skills in external_dirs in place**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`
  - fix hunks:
  - `tests/tools/test_skill_manager_tool.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -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)
    ```
- **#54200: fix(skills): follow symlinks when discovering skills in external dirs**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#35244`, `#35244`, `#35244`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#5284: fix(skills): respect local skill dir in skill manager**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ad4feeaf0`, `ad4feeaf0d617010bb18d7efa1dbfcfce6a812b6`, `ad4feeaf0`
- **#8357: fix(skills): follow symlinked local skill dirs**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#14230`, `#8769`, `#7634`, `#12624`
- **#8769: fix(skills): discover symlinked skill directories**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#14230`, `#7634`, `#12624`
- **#23484: fix(skill-tools): follow symlinks in skill discovery via rglob_follow**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#12624`, `#12624`
- **#35213: fix(skills): follow symlinks during skill discovery (#35184)**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#35184`, `#35213`, `#35244`, `#35299`
  - files: `tests/tools/test_skill_symlink_discovery.py`, `tools/skill_manager_tool.py`
- **#35244: fix(skill_manager): follow symlinks when discovering skills (#35184)**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#35184`, `#35213`, `#35244`, `#35299`
  - files: `scripts/release.py`, `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`
- **#60960: Fix _find_skill: use iter_skill_index_files to follow symlinks**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#35244`, `#35184`, `#56423`, `#35299`
  - files: `tests/tools/test_delegate.py`, `tests/tools/test_skill_manager_tool.py`, `tools/delegate_tool.py`, `tools/skill_manager_tool.py`
- **#67748: fix(skills): follow symlinks in skill management**. open
  - maintainer direction: category `partial_fix`; implication `needs_llm`; refs `ad4feeaf0`, `ad4feeaf0`, `e683c9db9`, `02aba4a72`, `a884f6d5d`, `02aba4a72`, `a884f6d5d`, `a7e516e97ff6`, `#67748`, `#4759`, `#35184`, `#44609`, `#54195`, `#4868`, `#5255`, `#5284`, `#5407`, `#7634`, `#8177`, `#4381`, `#13963`, `#21810`, `#8357`, `#8769`, `#9253`, `#11847`, `#14230`, `#12624`, `#13996`, `#13963`, `#8177`, `#14230`, `#11847`, `#14476`, `#14668`, `#14740`, `#14740`, `#8293`, `#17512`, `#4759`, `#21812`, `#21810`, `#22236`, `#21810`, `#23484`, `#12624`, `#23562`, `#29411`, `#29444`, `#29444`, `#17512`, `#35213`, `#35244`, `#67748`, `#35299`, `#44129`, `#44210`, `#67748`, `#52433`, `#54200`, `#35244`, `#54195`, `#56420`, `#67748`, `#56423`, `#56420`, `#67748`, `#60960`, `#67748`, `#67748`, `#4759`, `#35184`, `#44609`, `#54195`, `#75225`, `#75130`, `#35213`, `#35244`, `#35299`, `#44210`, `#54200`, `#56420`, `#56423`, `#60960`, `#67748`, `#75225`, `#67748`, `#8357`, `#8769`, `#9253`, `#11847`, `#12624`, `#14230`, `#14476`, `#14668`, `#14740`, `#8177`, `#13996`, `#21812`, `#22236`, `#23562`, `#67748`, `#35213`, `#35244`, `#35299`, `#44210`, `#54200`, `#56423`, `#60960`, `#67748`, `#8177`, `#75225`, `#75130`, `#67748`, `#374151`, `#4759`, `#35184`, `#44609`, `#54195`, `#35213`, `#35244`, `#44210`, `#54200`, `#56420`, `#56423`, `#60960`, `#67748`, `#75225`
  - files: `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`
- **#108040: fix(skills): classify external_dirs as external and default-deny in-place writes**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `8c8fc6c1ec`, `#128387`, `#17512`
- **#110349: fix(skills): follow symlinks in skill_manage discovery and resolution**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `6580056cbc`, `#118550`
  - files: `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`
- **#112321: fix(skills): skill_manage follows directory symlinks; rollback and ownership stay on the link**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#118691`, `#118692`
  - files: `agent/skill_utils.py`, `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_batch.py`, `tools/skill_manager_tool.py`

## Fix status

- Merged to main in PR #17512 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-29`.
- Subsystem: `skills`
