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:
Teknium
2026-09-01 07:17:51 -07:00
parent 42c2838674
commit f709bd88b6
4 changed files with 220 additions and 3 deletions

View File

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

View File

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

View 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()

View File

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