NousResearch/hermes-agent

Configurable target directory for newly created agent skills

Cluster 4381 · skills. Data as of

2 open items. Fix merged in #100377 on 1 Sep 2026. Check it covers your case.

The short answer

0open issues
2open PRs
12items grouped
Known?
Yes. 12 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #100377. 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
0 issues and 2 PRs.

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.

Open issues0

No open issues.

Open PRs2

  • PR#8177feat: support configurable default write directory for skills
  • PR#23562fix(skills): create new skills in external skill dir

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #17512 fix(skills): let skill_manage modify skills in external_dirs in place

    merged to main

    files: tests/tools/test_skill_manager_tool.py, tools/skill_manager_tool.py

    Show 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)
  • #100377 feat(skills): skills.create_dir — new skills land where the config points, instructions included (#13963, salvage #13996)

    merged to main

    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

4 PRs closed without merging

From the close comments: closed without comment 1 · done elsewhere 1 · merged via another change 1 · other 1

  • #13108 feat(skills): support HERMES_USER_SKILLS_DIR for separate user skill storage · closed without comment
  • #13996 feat(skills): add configurable agent skills directory (skills.agent_dir) · merged via another change → f709bd8, #100377, #13963, #81002
  • #21812 fix(skills): skill_manage create respects skills.external_dirs · other
  • #22236 fix(skills): skill_manage create respects skills.external_dirs · done elsewhere
Closed issues · 4
  • Closed#4381[Feature]: skills create / skills edit should support writing to a configurable target directory
  • Closed#10887feat: Support separate directory for user-created skills (HERMES_USER_SKILLS_DIR)
  • Closed#13963[Feature]: Config support assigning target dir for agent to write it's own skills
  • Closed#21810skill_manage(action='create') ignores skills.external_dirs, always writes to ~/.hermes/skills/
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.14: the share of item 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.

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 Lab

At a glance

Subsystem
skills
Merged PRs
#17512, #100377
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
1 Sep 2026

For your agent

The same facts, typed.