From 42c2838674ad65eedcd4accdc7b1d774ef2b315d Mon Sep 17 00:00:00 2001 From: giwaov Date: Tue, 1 Sep 2026 07:17:50 -0700 Subject: [PATCH] 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(). --- agent/skill_utils.py | 77 +++++++++++++++++++++++++++++++++++-- tools/skill_manager_tool.py | 43 ++++++++++++++++++--- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 8b3a23b7c7..47837d14e4 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -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 ``/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 diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 058b21f390..28f4058f8d 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -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 "