feat(skills): render the configured create dir in every instruction that names the path
The skill_manage tool schema description, prompt-builder docs, and the skills docs page now derive the creation path from skills.create_dir (display_skill_create_dir()) instead of hardcoding ~/.hermes/skills/ — so pointing the config at e.g. /opt/brain/skills changes what the agent is told everywhere, with no SOUL.md fights or read-only chmod tricks. Adds config default + docs section + 16 tests (incl. a read-only profile-skills-dir scenario).
This commit is contained in:
@@ -1778,7 +1778,8 @@ def build_skills_system_prompt(
|
||||
External skill directories (``skills.external_dirs`` in config.yaml) are
|
||||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
in the local dir (or ``skills.create_dir`` when configured). Local skills
|
||||
take precedence when names collide.
|
||||
|
||||
``compact_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) demotes whole categories to a names-only line in
|
||||
|
||||
@@ -2267,9 +2267,17 @@ DEFAULT_CONFIG = {
|
||||
|
||||
# Skills — external skill directories for sharing skills across tools/agents.
|
||||
# Each path is expanded (~, ${VAR}) and resolved. Read-only — skill creation
|
||||
# always goes to ~/.hermes/skills/.
|
||||
# goes to ~/.hermes/skills/ unless create_dir (below) redirects it.
|
||||
"skills": {
|
||||
"external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"]
|
||||
# Where agent-created skills (skill_manage action=create) are written.
|
||||
# Empty = the profile-local skills dir (~/.hermes/skills/). When set,
|
||||
# new skills land here AND every agent-facing instruction that names
|
||||
# the creation path (tool schema text, prompts) renders this directory
|
||||
# instead of the default. Expanded (~, ${VAR}); relative paths resolve
|
||||
# against HERMES_HOME. The directory is scanned for skills alongside
|
||||
# the local dir. e.g. "/opt/brain/skills"
|
||||
"create_dir": "",
|
||||
# Project-local skill discovery: when a session starts inside a git
|
||||
# checkout, ``<root>/.hermes/skills/`` and ``<root>/.agents/skills/``
|
||||
# are sourced as the highest-precedence skill tier — but ONLY when the
|
||||
|
||||
189
tests/tools/test_skill_create_dir.py
Normal file
189
tests/tools/test_skill_create_dir.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Tests for ``skills.create_dir`` — config-driven skill creation directory.
|
||||
|
||||
When configured, agent-created skills (skill_manage action=create) land in
|
||||
``skills.create_dir`` instead of the profile-local skills dir, the directory
|
||||
is scanned for discovery like the local dir, and the instruction text that
|
||||
names the creation path renders the configured directory.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Fresh HERMES_HOME with an empty local skills dir."""
|
||||
home = tmp_path / ".hermes"
|
||||
(home / "skills").mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(hermes_constants, "_hermes_home_cache", None, raising=False)
|
||||
|
||||
from agent import skill_utils as su
|
||||
su._external_dirs_cache_clear()
|
||||
|
||||
import tools.skills_tool as skills_tool
|
||||
import tools.skill_manager_tool as smt
|
||||
monkeypatch.setattr(skills_tool, "SKILLS_DIR", home / "skills")
|
||||
monkeypatch.setattr(smt, "SKILLS_DIR", home / "skills")
|
||||
yield home
|
||||
su._external_dirs_cache_clear()
|
||||
|
||||
|
||||
def _write_config(home: Path, body: str):
|
||||
(home / "config.yaml").write_text(body, encoding="utf-8")
|
||||
from agent import skill_utils as su
|
||||
su._raw_config_cache_clear()
|
||||
su._external_dirs_cache_clear()
|
||||
|
||||
|
||||
def _skill_md(name: str) -> str:
|
||||
return (
|
||||
f"---\nname: {name}\n"
|
||||
f"description: Use when testing create dir routing. One-line behavior.\n"
|
||||
f"---\n\n# {name}\n\nBody.\n"
|
||||
)
|
||||
|
||||
|
||||
class TestGetSkillCreateDir:
|
||||
def test_unset_returns_none(self, isolated_home):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
_write_config(isolated_home, "skills:\n external_dirs: []\n")
|
||||
assert get_skill_create_dir() is None
|
||||
|
||||
def test_absolute_path(self, isolated_home, tmp_path):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
brain = tmp_path / "brain-skills"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
assert get_skill_create_dir() == brain.resolve()
|
||||
|
||||
def test_relative_path_resolves_against_home(self, isolated_home):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
_write_config(isolated_home, "skills:\n create_dir: brain\n")
|
||||
assert get_skill_create_dir() == (isolated_home / "brain").resolve()
|
||||
|
||||
def test_tilde_expansion(self, isolated_home):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
_write_config(isolated_home, "skills:\n create_dir: ~/brain-skills\n")
|
||||
assert get_skill_create_dir() == (Path.home() / "brain-skills").resolve()
|
||||
|
||||
def test_local_skills_dir_treated_as_unset(self, isolated_home):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
_write_config(
|
||||
isolated_home, f"skills:\n create_dir: {isolated_home / 'skills'}\n"
|
||||
)
|
||||
assert get_skill_create_dir() is None
|
||||
|
||||
def test_empty_string_treated_as_unset(self, isolated_home):
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
_write_config(isolated_home, "skills:\n create_dir: ''\n")
|
||||
assert get_skill_create_dir() is None
|
||||
|
||||
|
||||
class TestDisplaySkillCreateDir:
|
||||
def test_default_renders_local_skills_path(self, isolated_home):
|
||||
from agent.skill_utils import display_skill_create_dir
|
||||
_write_config(isolated_home, "skills: {}\n")
|
||||
assert display_skill_create_dir().endswith("/skills/")
|
||||
|
||||
def test_configured_renders_configured_path(self, isolated_home, tmp_path):
|
||||
from agent.skill_utils import display_skill_create_dir
|
||||
brain = tmp_path / "opt-brain"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
assert "opt-brain" in display_skill_create_dir()
|
||||
|
||||
def test_schema_helper_follows_config(self, isolated_home, tmp_path):
|
||||
from tools.skill_manager_tool import _display_create_dir
|
||||
brain = tmp_path / "opt-brain"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
assert "opt-brain" in _display_create_dir()
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
def test_create_dir_in_all_skills_dirs(self, isolated_home, tmp_path):
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
brain = tmp_path / "brain-skills"
|
||||
brain.mkdir()
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
dirs = [d.resolve() for d in get_all_skills_dirs()]
|
||||
assert dirs[0] == (isolated_home / "skills").resolve()
|
||||
assert brain.resolve() in dirs
|
||||
|
||||
def test_missing_create_dir_not_scanned(self, isolated_home, tmp_path):
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
brain = tmp_path / "does-not-exist"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
assert brain.resolve() not in [d.resolve() for d in get_all_skills_dirs()]
|
||||
|
||||
def test_no_duplicate_when_also_in_external_dirs(self, isolated_home, tmp_path):
|
||||
from agent.skill_utils import get_all_skills_dirs
|
||||
brain = tmp_path / "brain-skills"
|
||||
brain.mkdir()
|
||||
_write_config(
|
||||
isolated_home,
|
||||
f"skills:\n create_dir: {brain}\n external_dirs:\n - {brain}\n",
|
||||
)
|
||||
dirs = [d.resolve() for d in get_all_skills_dirs()]
|
||||
assert dirs.count(brain.resolve()) == 1
|
||||
|
||||
|
||||
class TestCreateRouting:
|
||||
def test_create_lands_in_create_dir(self, isolated_home, tmp_path):
|
||||
from tools.skill_manager_tool import skill_manage
|
||||
brain = tmp_path / "brain-skills"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
res = json.loads(skill_manage("", "", operations=[{
|
||||
"action": "create", "name": "routed-skill",
|
||||
"content": _skill_md("routed-skill"),
|
||||
}]))
|
||||
assert res.get("success"), res
|
||||
assert (brain / "routed-skill" / "SKILL.md").exists()
|
||||
assert not (isolated_home / "skills" / "routed-skill").exists()
|
||||
# Out-of-root creation reports an absolute path, not a relative_to
|
||||
# crash (single-op legacy shape surfaces the path field).
|
||||
res_flat = json.loads(skill_manage(
|
||||
"create", "routed-skill-flat", content=_skill_md("routed-skill-flat"),
|
||||
))
|
||||
assert res_flat.get("success"), res_flat
|
||||
assert str(brain / "routed-skill-flat") == res_flat["path"]
|
||||
|
||||
def test_create_with_category(self, isolated_home, tmp_path):
|
||||
from tools.skill_manager_tool import skill_manage
|
||||
brain = tmp_path / "brain-skills"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
res = json.loads(skill_manage("", "", operations=[{
|
||||
"action": "create", "name": "cat-skill", "category": "devops",
|
||||
"content": _skill_md("cat-skill"),
|
||||
}]))
|
||||
assert res.get("success"), res
|
||||
assert (brain / "devops" / "cat-skill" / "SKILL.md").exists()
|
||||
|
||||
def test_default_create_still_local(self, isolated_home):
|
||||
from tools.skill_manager_tool import skill_manage
|
||||
_write_config(isolated_home, "skills: {}\n")
|
||||
res = json.loads(skill_manage("", "", operations=[{
|
||||
"action": "create", "name": "local-skill",
|
||||
"content": _skill_md("local-skill"),
|
||||
}]))
|
||||
assert res.get("success"), res
|
||||
assert (isolated_home / "skills" / "local-skill" / "SKILL.md").exists()
|
||||
|
||||
def test_created_skill_is_findable_and_patchable(self, isolated_home, tmp_path):
|
||||
from tools.skill_manager_tool import skill_manage, _find_skill
|
||||
brain = tmp_path / "brain-skills"
|
||||
_write_config(isolated_home, f"skills:\n create_dir: {brain}\n")
|
||||
json.loads(skill_manage("", "", operations=[{
|
||||
"action": "create", "name": "patchable-skill",
|
||||
"content": _skill_md("patchable-skill"),
|
||||
}]))
|
||||
found = _find_skill("patchable-skill")
|
||||
assert found is not None
|
||||
res = json.loads(skill_manage("", "", operations=[{
|
||||
"action": "patch", "name": "patchable-skill",
|
||||
"old_string": "Body.", "new_string": "Patched.",
|
||||
}]))
|
||||
assert res.get("success"), res
|
||||
assert "Patched." in (brain / "patchable-skill" / "SKILL.md").read_text()
|
||||
@@ -388,7 +388,7 @@ Paths support `~` expansion and `${VAR}` environment variable substitution.
|
||||
|
||||
### How it works
|
||||
|
||||
- **Create locally, update in place**: New agent-created skills are written to `~/.hermes/skills/`. Existing skills are modified where they are found, including skills under `external_dirs`, when the agent uses `skill_manage` actions such as `patch`, `edit`, `write_file`, `remove_file`, or `delete`.
|
||||
- **Create locally, update in place**: New agent-created skills are written to `~/.hermes/skills/` (or `skills.create_dir` when configured — see below). Existing skills are modified where they are found, including skills under `external_dirs`, when the agent uses `skill_manage` actions such as `patch`, `edit`, `write_file`, `remove_file`, or `delete`.
|
||||
- **External dirs are not a write-protection boundary**: If an external skill directory is writable by the Hermes process, agent-managed skill updates can change files in that directory. Use filesystem permissions or a separate profile/toolset setup if shared external skills must stay read-only.
|
||||
- **Local precedence**: If the same skill name exists in both the local dir and an external dir, the local version wins.
|
||||
- **Full integration**: External skills appear in the system prompt index, `skills_list`, `skill_view`, and as `/skill-name` slash commands — no different from local skills.
|
||||
@@ -412,6 +412,25 @@ Paths support `~` expansion and `${VAR}` environment variable substitution.
|
||||
|
||||
All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version.
|
||||
|
||||
## Redirecting Skill Creation (`skills.create_dir`)
|
||||
|
||||
By default the agent writes new skills to the profile-local `~/.hermes/skills/`. If you want agent-created skills to land somewhere else — a shared "brain" directory, a git-tracked repo, or a fleet-wide skills volume — set `create_dir` under the `skills` section:
|
||||
|
||||
```yaml
|
||||
skills:
|
||||
create_dir: /opt/brain/skills
|
||||
```
|
||||
|
||||
What this changes:
|
||||
|
||||
- **`skill_manage` create writes there.** New skills (including category subdirectories) are created under `create_dir` instead of the local skills dir. The directory is created on first write if it doesn't exist.
|
||||
- **The agent's instructions follow the config.** Every agent-facing instruction that names the skill-creation path — the `skill_manage` tool description and related prompt text — dynamically renders the configured directory, so the agent is told to create skills there. No system-prompt overrides or filesystem tricks needed.
|
||||
- **The directory is fully integrated.** Skills under `create_dir` are scanned alongside the local dir: they appear in the skill index, `skills_list`, `skill_view`, slash commands, and can be patched or deleted like any local skill.
|
||||
- **Everything else stays local.** Existing skills are still modified in place wherever they live; bundled skill sync, the hub, and the curator keep operating on the profile-local dir.
|
||||
|
||||
Paths support `~` expansion and `${VAR}` substitution; relative paths resolve against your Hermes home. Setting `create_dir` to the local skills dir is the same as leaving it unset.
|
||||
|
||||
|
||||
## Project-Local Skills
|
||||
|
||||
Repos can carry their own skills, active only for sessions started inside that project — the same pattern other agent harnesses use for repo-local configuration. When you launch Hermes inside a git checkout, it looks for skills in:
|
||||
|
||||
Reference in New Issue
Block a user