feat(skills): skills.create_dir routes agent-created skills to a configured directory

Salvaged from PR #13996 (@giwaov, issue #13963), modernized onto current
main: config key renamed to skills.create_dir (per PR #81002's naming),
resolution centralized in agent/skill_utils.get_skill_create_dir() with
~/${VAR} expansion and HERMES_HOME-relative paths, and the directory is
folded into get_all_skills_dirs() so created skills are discovered,
trusted, findable, and patchable like local ones. Out-of-root creations
report their absolute path instead of crashing relative_to().
This commit is contained in:
giwaov
2026-09-01 07:17:50 -07:00
committed by Teknium
parent 86b50fb43a
commit 42c2838674
2 changed files with 111 additions and 9 deletions
+73 -4
View File
@@ -613,11 +613,76 @@ def get_external_skills_dirs() -> List[Path]:
return result
def get_skill_create_dir() -> Optional[Path]:
"""Return the configured ``skills.create_dir``, or ``None`` when unset.
When set, agent-created skills (``skill_manage`` action=create) land in
this directory instead of the profile-local ``~/.hermes/skills/``, and
every user-facing instruction string that names the creation path renders
this directory instead of the default.
The entry is expanded (``~`` and ``${VAR}``); relative paths resolve
against HERMES_HOME. A value that resolves to the local skills dir is
treated as unset (that is already the default behaviour). The directory
does NOT need to exist yet — skill creation mkdirs it on first write.
"""
parsed = _load_raw_config()
if not parsed:
return None
skills_cfg = parsed.get("skills")
if not isinstance(skills_cfg, dict):
return None
raw = skills_cfg.get("create_dir")
if not raw or not isinstance(raw, (str, os.PathLike)):
return None
entry = str(raw).strip()
if not entry:
return None
from hermes_constants import get_hermes_home
expanded = os.path.expanduser(os.path.expandvars(entry))
p = Path(expanded)
if not p.is_absolute():
p = get_hermes_home() / p
try:
resolved = p.resolve()
except OSError:
resolved = p
try:
if resolved == get_skills_dir().resolve():
return None
except OSError:
pass
return resolved
def display_skill_create_dir() -> str:
"""User-facing display string for where new skills are created.
Renders the configured ``skills.create_dir`` (with ``~/`` shorthand when
under the user's home) or the default ``<home>/skills/`` path. Used by
instruction text (tool schema descriptions, prompts, docs strings) so a
configured creation dir changes every instruction that names the path.
"""
from hermes_constants import display_hermes_home
create_dir = get_skill_create_dir()
if create_dir is None:
return f"{display_hermes_home()}/skills/"
try:
return "~/" + create_dir.relative_to(Path.home()).as_posix() + "/"
except ValueError:
return create_dir.as_posix() + "/"
def get_all_skills_dirs() -> List[Path]:
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
The local dir is always first (and always included even if it doesn't exist
yet — callers handle that). External dirs follow in config order.
yet — callers handle that). When ``skills.create_dir`` is configured, it
follows immediately after the local dir (so agent-created skills are
discovered, trusted, and modifiable). External dirs follow in config order.
NOTE: trusted project-local dirs (``./.hermes/skills`` at the git root) are
NOT part of this list — they have *higher* precedence than the local dir,
@@ -626,7 +691,12 @@ def get_all_skills_dirs() -> List[Path]:
precedence-ordered list.
"""
dirs = [get_skills_dir()]
dirs.extend(get_external_skills_dirs())
create_dir = get_skill_create_dir()
if create_dir is not None and create_dir.is_dir():
dirs.append(create_dir)
for d in get_external_skills_dirs():
if d not in dirs:
dirs.append(d)
return dirs
@@ -810,8 +880,7 @@ def get_scan_ordered_skills_dirs() -> List[Path]:
priority over profile-local and external ones.
"""
dirs = list(get_project_skills_dirs())
dirs.append(get_skills_dir())
dirs.extend(get_external_skills_dirs())
dirs.extend(get_all_skills_dirs())
return dirs
+38 -5
View File
@@ -195,6 +195,20 @@ MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
def _display_create_dir() -> str:
"""Display string for the skill-creation directory (schema/instruction text).
Renders ``skills.create_dir`` when configured so every instruction that
names the creation path follows the config, falling back to the
profile-local skills dir.
"""
try:
from agent.skill_utils import display_skill_create_dir
return display_skill_create_dir()
except Exception:
return f"{display_hermes_home()}/skills/"
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
@@ -670,10 +684,23 @@ def _validate_content_size(content: str, label: str = "SKILL.md") -> Optional[st
def _resolve_skill_dir(name: str, category: str = None) -> Path:
"""Build the directory path for a new skill, optionally under a category."""
"""Build the directory path for a new skill, optionally under a category.
Honors ``skills.create_dir`` from config.yaml: when configured, new
skills are created there (e.g. a shared brain/fleet directory) instead
of the profile-local skills dir. Falls back to the local dir when unset.
"""
base = _skills_dir()
try:
from agent.skill_utils import get_skill_create_dir
create_dir = get_skill_create_dir()
if create_dir is not None:
base = create_dir
except Exception:
logger.debug("skills.create_dir lookup failed", exc_info=True)
if category:
return _skills_dir() / category / name
return _skills_dir() / name
return base / category / name
return base / name
def _find_skill(name: str) -> Optional[Dict[str, Any]]:
@@ -1028,10 +1055,16 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
except Exception:
pass
try:
_display_path = str(skill_dir.relative_to(_skills_dir()))
except ValueError:
# Skill created under skills.create_dir — not relative to the
# profile-local root, so show the absolute path.
_display_path = str(skill_dir)
result = {
"success": True,
"message": f"Skill '{name}' created.",
"path": str(skill_dir.relative_to(_skills_dir())),
"path": _display_path,
"skill_md": str(skill_md),
"_change": {"description": _desc},
}
@@ -2107,7 +2140,7 @@ SKILL_MANAGE_SCHEMA = {
"recurring task types. The call is an operations array (a single "
"edit is a list of one); it applies atomically — any failure rolls "
"every touched skill back. Ops: create (full SKILL.md; lands in "
f"{display_hermes_home()}/skills/; must precede that skill's other "
f"{_display_create_dir()}; must precede that skill's other "
"ops), patch (targeted old_string/new_string fix — preferred; "
"content alone REPLACES the whole file, read it via skill_view() "
"first), write_file/remove_file (supporting files), delete (sole "