diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index c8335ef3..8bc01251 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -18,6 +18,7 @@ from typing import Optional from agent.skill_utils import ( extract_skill_conditions, extract_skill_description, + get_all_skills_dirs, get_disabled_skill_names, iter_skill_index_files, parse_frontmatter, @@ -444,16 +445,23 @@ def build_skills_system_prompt( mtime/size manifest — survives process restarts Falls back to a full filesystem scan when both layers miss. + + 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. """ hermes_home = get_hermes_home() skills_dir = hermes_home / "skills" + external_dirs = get_all_skills_dirs()[1:] # skip local (index 0) - if not skills_dir.exists(): + if not skills_dir.exists() and not external_dirs: return "" # ── Layer 1: in-process LRU cache ───────────────────────────────── cache_key = ( str(skills_dir.resolve()), + tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), ) @@ -540,6 +548,56 @@ def build_skills_system_prompt( category_descriptions, ) + # ── External skill directories ───────────────────────────────────── + # Scan external dirs directly (no snapshot caching — they're read-only + # and typically small). Local skills already in skills_by_category take + # precedence: we track seen names and skip duplicates from external dirs. + seen_skill_names: set[str] = set() + for cat_skills in skills_by_category.values(): + for name, _desc in cat_skills: + seen_skill_names.add(name) + + for ext_dir in external_dirs: + if not ext_dir.exists(): + continue + for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"): + try: + is_compatible, frontmatter, desc = _parse_skill_file(skill_file) + if not is_compatible: + continue + entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc) + skill_name = entry["skill_name"] + if skill_name in seen_skill_names: + continue + if entry["frontmatter_name"] in disabled or skill_name in disabled: + continue + if not _skill_should_show( + extract_skill_conditions(frontmatter), + available_tools, + available_toolsets, + ): + continue + seen_skill_names.add(skill_name) + skills_by_category.setdefault(entry["category"], []).append( + (skill_name, entry["description"]) + ) + except Exception as e: + logger.debug("Error reading external skill %s: %s", skill_file, e) + + # External category descriptions + for desc_file in iter_skill_index_files(ext_dir, "DESCRIPTION.md"): + try: + content = desc_file.read_text(encoding="utf-8") + fm, _ = parse_frontmatter(content) + cat_desc = fm.get("description") + if not cat_desc: + continue + rel = desc_file.relative_to(ext_dir) + cat = "/".join(rel.parts[:-1]) if len(rel.parts) > 1 else "general" + category_descriptions.setdefault(cat, str(cat_desc).strip().strip("'\"")) + except Exception as e: + logger.debug("Could not read external skill description %s: %s", desc_file, e) + if not skills_by_category: result = "" else: diff --git a/agent/skill_commands.py b/agent/skill_commands.py index b266ad25..8a434ea7 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -128,7 +128,11 @@ def _build_skill_message( supporting.append(rel) if supporting and skill_dir: - skill_view_target = str(skill_dir.relative_to(SKILLS_DIR)) + try: + skill_view_target = str(skill_dir.relative_to(SKILLS_DIR)) + except ValueError: + # Skill is from an external dir — use the skill name instead + skill_view_target = skill_dir.name parts.append("") parts.append("[This skill has supporting files you can load with the skill_view tool:]") for sf in supporting: @@ -158,38 +162,49 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: _skill_commands = {} try: from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names - if not SKILLS_DIR.exists(): - return _skill_commands + from agent.skill_utils import get_external_skills_dirs disabled = _get_disabled_skill_names() - for skill_md in SKILLS_DIR.rglob("SKILL.md"): - if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): - continue - try: - content = skill_md.read_text(encoding='utf-8') - frontmatter, body = _parse_frontmatter(content) - # Skip skills incompatible with the current OS platform - if not skill_matches_platform(frontmatter): + seen_names: set = set() + + # Scan local dir first, then external dirs + dirs_to_scan = [] + if SKILLS_DIR.exists(): + dirs_to_scan.append(SKILLS_DIR) + dirs_to_scan.extend(get_external_skills_dirs()) + + for scan_dir in dirs_to_scan: + for skill_md in scan_dir.rglob("SKILL.md"): + if any(part in ('.git', '.github', '.hub') for part in skill_md.parts): continue - name = frontmatter.get('name', skill_md.parent.name) - # Respect user's disabled skills config - if name in disabled: + try: + content = skill_md.read_text(encoding='utf-8') + frontmatter, body = _parse_frontmatter(content) + # Skip skills incompatible with the current OS platform + if not skill_matches_platform(frontmatter): + continue + name = frontmatter.get('name', skill_md.parent.name) + if name in seen_names: + continue + # Respect user's disabled skills config + if name in disabled: + continue + description = frontmatter.get('description', '') + if not description: + for line in body.strip().split('\n'): + line = line.strip() + if line and not line.startswith('#'): + description = line[:80] + break + seen_names.add(name) + cmd_name = name.lower().replace(' ', '-').replace('_', '-') + _skill_commands[f"/{cmd_name}"] = { + "name": name, + "description": description or f"Invoke the {name} skill", + "skill_md_path": str(skill_md), + "skill_dir": str(skill_md.parent), + } + except Exception: continue - description = frontmatter.get('description', '') - if not description: - for line in body.strip().split('\n'): - line = line.strip() - if line and not line.startswith('#'): - description = line[:80] - break - cmd_name = name.lower().replace(' ', '-').replace('_', '-') - _skill_commands[f"/{cmd_name}"] = { - "name": name, - "description": description or f"Invoke the {name} skill", - "skill_md_path": str(skill_md), - "skill_dir": str(skill_md.parent), - } - except Exception: - continue except Exception: pass return _skill_commands diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 5cb2a710..c11bc5e2 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -158,6 +158,73 @@ def _normalize_string_set(values) -> Set[str]: return {str(v).strip() for v in values if str(v).strip()} +# ── External skills directories ────────────────────────────────────────── + + +def get_external_skills_dirs() -> List[Path]: + """Read ``skills.external_dirs`` from config.yaml and return validated paths. + + Each entry is expanded (``~`` and ``${VAR}``) and resolved to an absolute + path. Only directories that actually exist are returned. Duplicates and + paths that resolve to the local ``~/.hermes/skills/`` are silently skipped. + """ + config_path = get_hermes_home() / "config.yaml" + if not config_path.exists(): + return [] + try: + parsed = yaml_load(config_path.read_text(encoding="utf-8")) + except Exception: + return [] + if not isinstance(parsed, dict): + return [] + + skills_cfg = parsed.get("skills") + if not isinstance(skills_cfg, dict): + return [] + + raw_dirs = skills_cfg.get("external_dirs") + if not raw_dirs: + return [] + if isinstance(raw_dirs, str): + raw_dirs = [raw_dirs] + if not isinstance(raw_dirs, list): + return [] + + local_skills = (get_hermes_home() / "skills").resolve() + seen: Set[Path] = set() + result: List[Path] = [] + + for entry in raw_dirs: + entry = str(entry).strip() + if not entry: + continue + # Expand ~ and environment variables + expanded = os.path.expanduser(os.path.expandvars(entry)) + p = Path(expanded).resolve() + if p == local_skills: + continue + if p in seen: + continue + if p.is_dir(): + seen.add(p) + result.append(p) + else: + logger.debug("External skills dir does not exist, skipping: %s", p) + + return result + + +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. + """ + dirs = [get_hermes_home() / "skills"] + dirs.extend(get_external_skills_dirs()) + return dirs + + # ── Condition extraction ────────────────────────────────────────────────── diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 036efbc3..1f7a515c 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -402,6 +402,15 @@ skills: # Set to 0 to disable. creation_nudge_interval: 15 + # External skill directories — share skills across tools/agents without + # copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR}) + # and resolved to an absolute path. External dirs are read-only: skill + # creation always writes to ~/.hermes/skills/. Local skills take precedence + # when names collide. + # external_dirs: + # - ~/.agents/skills + # - /home/shared/team-skills + # ============================================================================= # Agent Behavior # ============================================================================= diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a8843321..7486f34b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -366,6 +366,13 @@ DEFAULT_CONFIG = { # Never saved to sessions, logs, or trajectories. "prefill_messages_file": "", + # 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/. + "skills": { + "external_dirs": [], # e.g. ["~/.agents/skills", "/shared/team-skills"] + }, + # Honcho AI-native memory -- reads ~/.honcho/config.json as single source of truth. # This section is only needed for hermes-specific overrides; everything else # (apiKey, workspace, peerName, sessions, enabled) comes from the global config. diff --git a/tests/agent/test_external_skills.py b/tests/agent/test_external_skills.py new file mode 100644 index 00000000..1a9cd63d --- /dev/null +++ b/tests/agent/test_external_skills.py @@ -0,0 +1,157 @@ +"""Tests for external skill directories (skills.external_dirs config).""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def external_skills_dir(tmp_path): + """Create a temp dir with a sample external skill.""" + ext_dir = tmp_path / "external-skills" + skill_dir = ext_dir / "my-external-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: my-external-skill\ndescription: A skill from an external directory\n---\n\n# My External Skill\n\nDo external things.\n" + ) + return ext_dir + + +@pytest.fixture +def hermes_home(tmp_path): + """Create a minimal HERMES_HOME with config.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "skills").mkdir() + return home + + +class TestGetExternalSkillsDirs: + def test_empty_config(self, hermes_home): + (hermes_home / "config.yaml").write_text("skills:\n external_dirs: []\n") + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert result == [] + + def test_nonexistent_dir_skipped(self, hermes_home): + (hermes_home / "config.yaml").write_text( + "skills:\n external_dirs:\n - /nonexistent/path\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert result == [] + + def test_valid_dir_returned(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert len(result) == 1 + assert result[0] == external_skills_dir.resolve() + + def test_duplicate_dirs_deduplicated(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n - {external_skills_dir}\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert len(result) == 1 + + def test_local_skills_dir_excluded(self, hermes_home): + local_skills = hermes_home / "skills" + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {local_skills}\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert result == [] + + def test_no_config_file(self, hermes_home): + # No config.yaml at all + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert result == [] + + def test_string_value_converted_to_list(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs: {external_skills_dir}\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_external_skills_dirs + result = get_external_skills_dirs() + assert len(result) == 1 + + +class TestGetAllSkillsDirs: + def test_local_always_first(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n" + ) + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + from agent.skill_utils import get_all_skills_dirs + result = get_all_skills_dirs() + assert result[0] == hermes_home / "skills" + assert result[1] == external_skills_dir.resolve() + + +class TestExternalSkillsInFindAll: + def test_external_skills_found(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + names = [s["name"] for s in skills] + assert "my-external-skill" in names + + def test_local_takes_precedence(self, hermes_home, external_skills_dir): + """If the same skill name exists locally and externally, local wins.""" + local_skills = hermes_home / "skills" + local_skill = local_skills / "my-external-skill" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text( + "---\nname: my-external-skill\ndescription: Local version\n---\n\nLocal.\n" + ) + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n" + ) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + matching = [s for s in skills if s["name"] == "my-external-skill"] + assert len(matching) == 1 + assert matching[0]["description"] == "Local version" + + +class TestExternalSkillView: + def test_skill_view_finds_external(self, hermes_home, external_skills_dir): + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {external_skills_dir}\n" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + ): + from tools.skills_tool import skill_view + result = json.loads(skill_view("my-external-skill")) + assert result["success"] is True + assert "external things" in result["content"] diff --git a/tools/skills_tool.py b/tools/skills_tool.py index bce54316..61e045f0 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -494,7 +494,7 @@ def _is_skill_disabled(name: str, platform: str = None) -> bool: def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: - """Recursively find all skills in ~/.hermes/skills/. + """Recursively find all skills in ~/.hermes/skills/ and external dirs. Args: skip_disabled: If True, return ALL skills regardless of disabled @@ -504,59 +504,68 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: Returns: List of skill metadata dicts (name, description, category). """ - skills = [] + from agent.skill_utils import get_external_skills_dirs - if not SKILLS_DIR.exists(): - return skills + skills = [] + seen_names: set = set() # Load disabled set once (not per-skill) disabled = set() if skip_disabled else _get_disabled_skill_names() + # Scan local dir first, then external dirs (local takes precedence) + dirs_to_scan = [] + if SKILLS_DIR.exists(): + dirs_to_scan.append(SKILLS_DIR) + dirs_to_scan.extend(get_external_skills_dirs()) - for skill_md in SKILLS_DIR.rglob("SKILL.md"): - if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): - continue - - skill_dir = skill_md.parent - - try: - content = skill_md.read_text(encoding="utf-8")[:4000] - frontmatter, body = _parse_frontmatter(content) - - if not skill_matches_platform(frontmatter): + for scan_dir in dirs_to_scan: + for skill_md in scan_dir.rglob("SKILL.md"): + if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): continue - name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH] - if name in disabled: + skill_dir = skill_md.parent + + try: + content = skill_md.read_text(encoding="utf-8")[:4000] + frontmatter, body = _parse_frontmatter(content) + + if not skill_matches_platform(frontmatter): + continue + + name = frontmatter.get("name", skill_dir.name)[:MAX_NAME_LENGTH] + if name in seen_names: + continue + if name in disabled: + continue + + description = frontmatter.get("description", "") + if not description: + for line in body.strip().split("\n"): + line = line.strip() + if line and not line.startswith("#"): + description = line + break + + if len(description) > MAX_DESCRIPTION_LENGTH: + description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." + + category = _get_category_from_path(skill_md) + + seen_names.add(name) + skills.append({ + "name": name, + "description": description, + "category": category, + }) + + except (UnicodeDecodeError, PermissionError) as e: + logger.debug("Failed to read skill file %s: %s", skill_md, e) + continue + except Exception as e: + logger.debug( + "Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True + ) continue - - description = frontmatter.get("description", "") - if not description: - for line in body.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#"): - description = line - break - - if len(description) > MAX_DESCRIPTION_LENGTH: - description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." - - category = _get_category_from_path(skill_md) - - skills.append({ - "name": name, - "description": description, - "category": category, - }) - - except (UnicodeDecodeError, PermissionError) as e: - logger.debug("Failed to read skill file %s: %s", skill_md, e) - continue - except Exception as e: - logger.debug( - "Skipping skill at %s: failed to parse: %s", skill_md, e, exc_info=True - ) - continue return skills @@ -756,7 +765,15 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: JSON string with skill content or error message """ try: - if not SKILLS_DIR.exists(): + from agent.skill_utils import get_external_skills_dirs + + # Build list of all skill directories to search + all_dirs = [] + if SKILLS_DIR.exists(): + all_dirs.append(SKILLS_DIR) + all_dirs.extend(get_external_skills_dirs()) + + if not all_dirs: return json.dumps( { "success": False, @@ -768,27 +785,37 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: skill_dir = None skill_md = None - # Try direct path first (e.g., "mlops/axolotl") - direct_path = SKILLS_DIR / name - if direct_path.is_dir() and (direct_path / "SKILL.md").exists(): - skill_dir = direct_path - skill_md = direct_path / "SKILL.md" - elif direct_path.with_suffix(".md").exists(): - skill_md = direct_path.with_suffix(".md") + # Search all dirs: local first, then external (first match wins) + for search_dir in all_dirs: + # Try direct path first (e.g., "mlops/axolotl") + direct_path = search_dir / name + if direct_path.is_dir() and (direct_path / "SKILL.md").exists(): + skill_dir = direct_path + skill_md = direct_path / "SKILL.md" + break + elif direct_path.with_suffix(".md").exists(): + skill_md = direct_path.with_suffix(".md") + break - # Search by directory name + # Search by directory name across all dirs if not skill_md: - for found_skill_md in SKILLS_DIR.rglob("SKILL.md"): - if found_skill_md.parent.name == name: - skill_dir = found_skill_md.parent - skill_md = found_skill_md + for search_dir in all_dirs: + for found_skill_md in search_dir.rglob("SKILL.md"): + if found_skill_md.parent.name == name: + skill_dir = found_skill_md.parent + skill_md = found_skill_md + break + if skill_md: break # Legacy: flat .md files if not skill_md: - for found_md in SKILLS_DIR.rglob(f"{name}.md"): - if found_md.name != "SKILL.md": - skill_md = found_md + for search_dir in all_dirs: + for found_md in search_dir.rglob(f"{name}.md"): + if found_md.name != "SKILL.md": + skill_md = found_md + break + if skill_md: break if not skill_md or not skill_md.exists(): @@ -815,12 +842,21 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: ensure_ascii=False, ) - # Security: warn if skill is loaded from outside the trusted skills directory + # Security: warn if skill is loaded from outside trusted directories + # (local skills dir + configured external_dirs are all trusted) + _outside_skills_dir = True + _trusted_dirs = [SKILLS_DIR.resolve()] try: - skill_md.resolve().relative_to(SKILLS_DIR.resolve()) - _outside_skills_dir = False - except ValueError: - _outside_skills_dir = True + _trusted_dirs.extend(d.resolve() for d in all_dirs[1:]) + except Exception: + pass + for _td in _trusted_dirs: + try: + skill_md.resolve().relative_to(_td) + _outside_skills_dir = False + break + except ValueError: + continue # Security: detect common prompt injection patterns _INJECTION_PATTERNS = [ @@ -1058,7 +1094,11 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: if script_files: linked_files["scripts"] = script_files - rel_path = str(skill_md.relative_to(SKILLS_DIR)) + try: + rel_path = str(skill_md.relative_to(SKILLS_DIR)) + except ValueError: + # External skill — use path relative to the skill's own parent dir + rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name skill_name = frontmatter.get( "name", skill_md.stem if not skill_dir else skill_dir.name ) diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index edd61f73..3d166b97 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -8,7 +8,9 @@ description: "On-demand knowledge documents — progressive disclosure, agent-ma Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard. -All skills live in **`~/.hermes/skills/`** — a single directory that serves as the source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill. +All skills live in **`~/.hermes/skills/`** — the primary directory and source of truth. On fresh install, bundled skills are copied from the repo. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill. + +You can also point Hermes at **external skill directories** — additional folders scanned alongside the local one. See [External Skill Directories](#external-skill-directories) below. See also: @@ -164,6 +166,47 @@ Once set, declared env vars are **automatically passed through** to `execute_cod └── .bundled_manifest # Tracks seeded bundled skills ``` +## External Skill Directories + +If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too. + +Add `external_dirs` under the `skills` section in `~/.hermes/config.yaml`: + +```yaml +skills: + external_dirs: + - ~/.agents/skills + - /home/shared/team-skills + - ${SKILLS_REPO}/skills +``` + +Paths support `~` expansion and `${VAR}` environment variable substitution. + +### How it works + +- **Read-only**: External dirs are only scanned for skill discovery. When the agent creates or edits a skill, it always writes to `~/.hermes/skills/`. +- **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. +- **Non-existent paths are silently skipped**: If a configured directory doesn't exist, Hermes ignores it without errors. Useful for optional shared directories that may not be present on every machine. + +### Example + +```text +~/.hermes/skills/ # Local (primary, read-write) +├── devops/deploy-k8s/ +│ └── SKILL.md +└── mlops/axolotl/ + └── SKILL.md + +~/.agents/skills/ # External (read-only, shared) +├── my-custom-workflow/ +│ └── SKILL.md +└── team-conventions/ + └── SKILL.md +``` + +All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version. + ## Agent-Managed Skills (skill_manage tool) The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** — when it figures out a non-trivial workflow, it saves the approach as a skill for future reuse.