{"repo":"NousResearch/hermes-agent","public_id":"4381","display_label":"Configurable target directory for newly created agent skills","summary":"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.","subsystem":"skills","counts":{"members":12,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":4,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":100377,"merged_at":"2026-09-01T14:32:46+00:00"},"resolution":{"state":"partially_resolved","by":"pr-17512"},"freshness":{"state":"verified","last_verified_at":"2026-09-01T14:32:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14303030303030304},"fix_edges":[{"issue_number":4381,"pr_number":17512,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-29T15:16:52+00:00"},{"issue_number":4759,"pr_number":17512,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-29T15:16:52+00:00"},{"issue_number":13963,"pr_number":100377,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-09-01T14:32:46+00:00"}],"members":[{"kind":"issue","number":4381,"title":"[Feature]: skills create / skills edit should support writing to a configurable target directory","url":null,"state":"closed"},{"kind":"issue","number":10887,"title":"feat: Support separate directory for user-created skills (HERMES_USER_SKILLS_DIR)","url":null,"state":"closed"},{"kind":"issue","number":13963,"title":"[Feature]: Config support assigning target dir for agent to write it's own skills","url":null,"state":"closed"},{"kind":"issue","number":21810,"title":"skill_manage(action='create') ignores skills.external_dirs, always writes to ~/.hermes/skills/","url":null,"state":"closed"},{"kind":"pr","number":8177,"title":"feat: support configurable default write directory for skills","url":null,"state":"open","close_reason":{"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13108,"title":"feat(skills): support HERMES_USER_SKILLS_DIR for separate user skill storage","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":13996,"title":"feat(skills): add configurable agent skills directory (skills.agent_dir)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f709bd88b6cc","number":0},{"kind":"pr","value":"#100377","number":100377},{"kind":"pr","value":"#13963","number":13963},{"kind":"pr","value":"#81002","number":81002}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17512,"title":"fix(skills): let skill_manage modify skills in external_dirs in place","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_skill_manager_tool.py","tools/skill_manager_tool.py"],"fix_hunks":[{"file":"tests/tools/test_skill_manager_tool.py","hunks":["@@ -566,3 +566,151 @@ def test_guard_flag_handles_config_error(self):\n \n         with patch(\"hermes_cli.config.load_config\", side_effect=RuntimeError(\"boom\")):\n             assert _guard_agent_created_enabled() is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# External skills directories (skills.external_dirs) — mutations in place\n+# ---------------------------------------------------------------------------\n+\n+\n+@contextmanager\n+def _two_roots(local_dir: Path, external_dir: Path):\n+    \"\"\"Patch the skill manager so local SKILLS_DIR = local_dir and\n+    get_all_skills_dirs() returns [local_dir, external_dir] in order.\"\"\"\n+    with patch(\"tools.skill_manager_tool.SKILLS_DIR\", local_dir), \\\n+         patch(\"agent.skill_utils.get_all_skills_dirs\",\n+               return_value=[local_dir, external_dir]):\n+        yield\n+\n+\n+def _write_external_skill(external_dir: Path, name: str = \"ext-skill\") -> Path:\n+    skill_dir = external_dir / name\n+    skill_dir.mkdir(parents=True)\n+    (skill_dir / \"SKILL.md\").write_text(\n+        f\"---\\nname: {name}\\ndescription: An external skill.\\n---\\n\\n\"\n+        \"# External\\n\\nBody with OLD_MARKER here.\\n\"\n+    )\n+    return skill_dir\n+\n+\n+class TestExternalSkillMutations:\n+    \"\"\"Verify skill_manage can patch/edit/write/remove/delete skills that live\n+    under skills.external_dirs — in place, without duplicating to local.\n+\n+    Regression for issues #4759 and #4381: the read-only gate used to refuse\n+    with 'Skill X is in an external directory and cannot be modified', which\n+    caused agents to create duplicate copies in ~/.hermes/skills/ as a\n+    workaround.\n+    \"\"\"\n+\n+    def test_patch_external_skill_writes_in_place(self, tmp_path):\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+\n+        with _two_roots(local, external):\n+            result = _patch_skill(\"ext-skill\", \"OLD_MARKER\", \"NEW_MARKER\")\n+\n+        assert result[\"success\"] is True, result\n+        assert \"NEW_MARKER\" in (skill_dir / \"SKILL.md\").read_text()\n+        # No duplicate in local\n+        assert not (local / \"ext-skill\").exists()\n+\n+    def test_edit_external_skill_writes_in_place(self, tmp_path):\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+\n+        new_content = (\n+            \"---\\nname: ext-skill\\ndescription: Rewritten.\\n---\\n\\n\"\n+            \"# Rewritten\\n\\nBrand new body.\\n\"\n+        )\n+        with _two_roots(local, external):\n+            result = _edit_skill(\"ext-skill\", new_content)\n+\n+        assert result[\"success\"] is True, result\n+        assert \"Brand new body\" in (skill_dir / \"SKILL.md\").read_text()\n+        assert not (local / \"ext-skill\").exists()\n+\n+    def test_write_file_on_external_skill(self, tmp_path):\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+\n+        with _two_roots(local, external):\n+            result = _write_file(\"ext-skill\", \"references/notes.md\", \"# Notes\\n\")\n+\n+        assert result[\"success\"] is True, result\n+        assert (skill_dir / \"references\" / \"notes.md\").read_text() == \"# Notes\\n\"\n+        assert not (local / \"ext-skill\").exists()\n+\n+    def test_remove_file_on_external_skill(self, tmp_path):\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+        (skill_dir / \"references\").mkdir()\n+        (skill_dir / \"references\" / \"notes.md\").write_text(\"# Notes\\n\")\n+\n+        with _two_roots(local, external):\n+            result = _remove_file(\"ext-skill\", \"references/notes.md\")\n+\n+        assert result[\"success\"] is True, result\n+        assert not (skill_dir / \"references\" / \"notes.md\").exists()\n+\n+    def test_delete_external_skill_removes_skill_not_root(self, tmp_path):\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        skill_dir = _write_external_skill(external)\n+\n+        with _two_roots(local, external):\n+            result = _delete_skill(\"ext-skill\")\n+\n+        assert result[\"success\"] is True, result\n+        assert not skill_dir.exists()\n+        # The external root must NOT be rmdir'd, even when empty after deletion\n+        assert external.exists() and external.is_dir()\n+\n+    def test_delete_external_skill_cleans_empty_category(self, tmp_path):\n+        \"\"\"When a skill lives under external/<category>/<name>, deleting the\n+        last skill in the category should rmdir the empty category dir but\n+        stop at the external root.\"\"\"\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+        cat_dir = external / \"team\"\n+        cat_dir.mkdir()\n+        skill_dir = cat_dir / \"ext-skill\"\n+        skill_dir.mkdir()\n+        (skill_dir / \"SKILL.md\").write_text(\n+            \"---\\nname: ext-skill\\ndescription: An external skill.\\n---\\n\\n\"\n+            \"# External\\n\\nBody.\\n\"\n+        )\n+\n+        with _two_roots(local, external):\n+            result = _delete_skill(\"ext-skill\")\n+\n+        assert result[\"success\"] is True, result\n+        assert not skill_dir.exists()\n+        assert not cat_dir.exists()  # empty category cleaned up\n+        assert external.exists()     # but never the external root\n+\n+    def test_create_still_writes_to_local_root(self, tmp_path):\n+        \"\"\"Creating a new skill always lands in local SKILLS_DIR, never\n+        external_dirs — create is unchanged by this PR.\"\"\"\n+        local = tmp_path / \"local\"\n+        external = tmp_path / \"vault\"\n+        local.mkdir(); external.mkdir()\n+\n+        with _two_roots(local, external):\n+            result = _create_skill(\"fresh-skill\", VALID_SKILL_CONTENT.replace(\n+                \"name: test-skill\", \"name: fresh-skill\"))\n+\n+        assert result[\"success\"] is True, result\n+        assert (local / \"fresh-skill\" / \"SKILL.md\").exists()\n+        assert not (external / \"fresh-skill\").exists()\n+"]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -109,16 +109,28 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:\n MAX_DESCRIPTION_LENGTH = 1024\n \n \n-def _is_local_skill(skill_path: Path) -> bool:\n-    \"\"\"Check if a skill path is within the local SKILLS_DIR.\n-\n-    Skills found in external_dirs are read-only from the agent's perspective.\n+def _containing_skills_root(skill_path: Path) -> Path:\n+    \"\"\"Return the skills root directory (local or external_dirs entry) that\n+    contains ``skill_path``.  Falls back to the local ``SKILLS_DIR`` if no\n+    match is found (defensive — callers should have located the skill via\n+    ``_find_skill`` first).\n     \"\"\"\n+    from agent.skill_utils import get_all_skills_dirs\n+\n     try:\n-        skill_path.resolve().relative_to(SKILLS_DIR.resolve())\n-        return True\n-    except ValueError:\n-        return False\n+        resolved = skill_path.resolve()\n+    except OSError:\n+        resolved = skill_path\n+\n+    for root in get_all_skills_dirs():\n+        try:\n+            resolved.relative_to(root.resolve())\n+            return root\n+        except (ValueError, OSError):\n+            continue\n+    return SKILLS_DIR\n+\n+\n MAX_SKILL_CONTENT_CHARS = 100_000   # ~36k tokens at 2.75 chars/token\n MAX_SKILL_FILE_BYTES = 1_048_576    # 1 MiB per supporting file\n ","@@ -397,9 +409,6 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]:\n     if not existing:\n         return {\"success\": False, \"error\": f\"Skill '{name}' not found. Use skills_list() to see available skills.\"}\n \n-    if not _is_local_skill(existing[\"path\"]):\n-        return {\"success\": False, \"error\": f\"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first.\"}\n-\n     skill_md = existing[\"path\"] / \"SKILL.md\"\n     # Back up original content for rollback\n     original_content = skill_md.read_text(encoding=\"utf-8\") if skill_md.exists() else None","@@ -440,9 +449,6 @@ def _patch_skill(\n     if not existing:\n         return {\"success\": False, \"error\": f\"Skill '{name}' not found.\"}\n \n-    if not _is_local_skill(existing[\"path\"]):\n-        return {\"success\": False, \"error\": f\"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first.\"}\n-\n     skill_dir = existing[\"path\"]\n \n     if file_path:","@@ -522,15 +528,13 @@ def _delete_skill(name: str) -> Dict[str, Any]:\n     if not existing:\n         return {\"success\": False, \"error\": f\"Skill '{name}' not found.\"}\n \n-    if not _is_local_skill(existing[\"path\"]):\n-        return {\"success\": False, \"error\": f\"Skill '{name}' is in an external directory and cannot be deleted.\"}\n-\n     skill_dir = existing[\"path\"]\n+    skills_root = _containing_skills_root(skill_dir)\n     shutil.rmtree(skill_dir)\n \n-    # Clean up empty category directories (don't remove SKILLS_DIR itself)\n+    # Clean up empty category directories (don't remove the skills root itself)\n     parent = skill_dir.parent\n-    if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()):\n+    if parent != skills_root and parent.exists() and not any(parent.iterdir()):\n         parent.rmdir()\n \n     return {","@@ -567,9 +571,6 @@ def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:\n     if not existing:\n         return {\"success\": False, \"error\": f\"Skill '{name}' not found. Create it first with action='create'.\"}\n \n-    if not _is_local_skill(existing[\"path\"]):\n-        return {\"success\": False, \"error\": f\"Skill '{name}' is in an external directory and cannot be modified. Copy it to your local skills directory first.\"}\n-\n     target, err = _resolve_skill_target(existing[\"path\"], file_path)\n     if err:\n         return {\"success\": False, \"error\": err}","@@ -604,9 +605,6 @@ def _remove_file(name: str, file_path: str) -> Dict[str, Any]:\n     if not existing:\n         return {\"success\": False, \"error\": f\"Skill '{name}' not found.\"}\n \n-    if not _is_local_skill(existing[\"path\"]):\n-        return {\"success\": False, \"error\": f\"Skill '{name}' is in an external directory and cannot be modified.\"}\n-\n     skill_dir = existing[\"path\"]\n \n     target, err = _resolve_skill_target(skill_dir, file_path)\n"]}]},{"kind":"pr","number":21812,"title":"fix(skills): skill_manage create respects skills.external_dirs","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22236,"title":"fix(skills): skill_manage create respects skills.external_dirs","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":23562,"title":"fix(skills): create new skills in external skill dir","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#22236","number":22236},{"kind":"pr","value":"#21810","number":21810}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":100377,"title":"feat(skills): skills.create_dir — new skills land where the config points, instructions included (#13963, salvage #13996)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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"]}],"trust":{"default_tier":"verified","includes_suggested":false}}