diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index eb7175ec44..d636e78be8 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index ea3ef6cefc..ae73c74eb5 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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, ``/.hermes/skills/`` and ``/.agents/skills/`` # are sourced as the highest-precedence skill tier — but ONLY when the diff --git a/tests/tools/test_skill_create_dir.py b/tests/tools/test_skill_create_dir.py new file mode 100644 index 0000000000..8ed0b6bfd8 --- /dev/null +++ b/tests/tools/test_skill_create_dir.py @@ -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() diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 105c7da7a2..b751f30487 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -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: