# NousResearch/hermes-agent · Configurable target directory for newly created agent skills

## Open now

- 0 issues, 2 PRs open on GitHub.
- PR #8177: feat: support configurable default write directory for skills
- PR #23562: fix(skills): create new skills in external skill dir

Nearly all members concern letting skill creation (skill_manage create/edit) write to a user-configured or external skills directory instead of the default ~/.hermes/skills/. They propose various mechanisms, such as skills.external_dirs, skills.create_dir, skills.agent_dir, and HERMES_USER_SKILLS_DIR.

## TL;DR

- 12 member(s): 0 open issue(s), 4 closed issue(s), 2 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #100377. Verify it addresses your case.
- **Recommended:** read PR #100377 (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.14`: 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.

## Closed / duplicate issues

- #4381: \[Feature\]: skills create / skills edit should support writing to a configurable target directory
- #10887: feat: Support separate directory for user-created skills (HERMES_USER_SKILLS_DIR)
- #13963: \[Feature\]: Config support assigning target dir for agent to write it's own skills
- #21810: skill_manage(action='create') ignores skills.external_dirs, always writes to ~/.hermes/skills/

## Prior & competing PRs

- **#17512: fix(skills): let skill_manage modify skills in external_dirs in place**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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)
    ```
- **#21812: fix(skills): skill_manage create respects skills.external_dirs**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#100377: feat(skills): skills.create_dir — new skills land where the config points, instructions included (#13963, salvage #13996)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/prompt_builder.py`, `agent/skill_utils.py`, `hermes_cli/config_defaults.py`, `tests/tools/test_skill_create_dir.py`, `tools/skill_manager_tool.py`, `website/docs/user-guide/features/skills.md`
- **#8177: feat: support configurable default write directory for skills**. open
  - maintainer direction: category `merged_via`; implication `superseded`
  - files: `agent/prompt_builder.py`, `agent/skill_commands.py`, `agent/skill_utils.py`, `hermes_cli/commands.py`, `hermes_cli/config.py`, `tests/agent/test_external_skills.py`, `tests/agent/test_skill_commands.py`, `tests/hermes_cli/test_commands.py`, `tests/tools/test_skill_manager_tool.py`, `tests/tools/test_skill_usage.py`, `tools/skill_manager_tool.py`, `tools/skill_usage.py`, `tools/skills_tool.py`, `website/docs/user-guide/configuration.md`, `website/docs/user-guide/features/curator.md`, `website/docs/user-guide/features/skills.md`
- **#13108: feat(skills): support HERMES_USER_SKILLS_DIR for separate user skill storage**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#13996: feat(skills): add configurable agent skills directory (skills.agent_dir)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `f709bd88b6cc`, `#100377`, `#13963`, `#81002`
- **#22236: fix(skills): skill_manage create respects skills.external_dirs**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#23562: fix(skills): create new skills in external skill dir**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#22236`, `#21810`
  - files: `tests/tools/test_skill_manager_tool.py`, `tools/skill_manager_tool.py`

## Fix status

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