diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 8a434ea7990c..a5ebb68192a0 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -162,14 +162,15 @@ 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 - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs disabled = _get_disabled_skill_names() seen_names: set = set() - # Scan local dir first, then external dirs + # Scan local dir first, then project-local, then external dirs dirs_to_scan = [] if SKILLS_DIR.exists(): dirs_to_scan.append(SKILLS_DIR) + dirs_to_scan.extend(get_project_skills_dirs()) dirs_to_scan.extend(get_external_skills_dirs()) for scan_dir in dirs_to_scan: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 9f54eb0fd8cf..fb909c290ac1 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -214,13 +214,80 @@ def get_external_skills_dirs() -> List[Path]: return result +def get_project_skills_dirs() -> List[Path]: + """Discover project-local skill directories from the working directory. + + Checks the current working directory (or nearest git root) for skill + directories in a well-known set of paths, checked in order: + + 1. ``/.hermes/skills/`` — Hermes-native, project-scoped + 2. ``/.agents/skills/`` — Agent-agnostic convention + 3. ``/.claude/skills/`` — Interop with Claude Code / ``npx skills`` + + These directories are **read-only** — ``skill_manage`` always writes to + ``~/.hermes/skills/``. Project skills shadow global skills of the same + name (local > project > external precedence). + + Returns only directories that actually exist. Duplicates and paths that + resolve to the local ``~/.hermes/skills/`` are silently skipped. + """ + _PROJECT_SKILL_SUBDIRS = ( + Path(".hermes") / "skills", + Path(".agents") / "skills", + Path(".claude") / "skills", + ) + + project_root = _find_project_root() + if project_root is None: + return [] + + local_skills = (get_hermes_home() / "skills").resolve() + seen: Set[Path] = set() + result: List[Path] = [] + + for subdir in _PROJECT_SKILL_SUBDIRS: + candidate = (project_root / subdir).resolve() + if candidate == local_skills: + continue + if candidate in seen: + continue + if candidate.is_dir(): + seen.add(candidate) + result.append(candidate) + + return result + + +def _find_project_root() -> Optional[Path]: + """Find the project root by walking up from cwd looking for ``.git``. + + Returns the directory containing ``.git``, or ``cwd`` itself if no git + root is found (so project-local skills still work outside git repos). + """ + try: + cwd = Path.cwd().resolve() + except (OSError, ValueError): + return None + + for parent in [cwd, *cwd.parents]: + if (parent / ".git").exists(): + return parent + + # Not in a git repo — use cwd as project root + return cwd + + def get_all_skills_dirs() -> List[Path]: - """Return all skill directories: local ``~/.hermes/skills/`` first, then external. + """Return all skill directories: local first, then project-local, then external. + + The local dir (``~/.hermes/skills/``) is always first (and always included + even if it doesn't exist yet — callers handle that). Project-local dirs + follow, then external dirs in config order. - 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. + Precedence for name collisions: local > project > external. """ dirs = [get_hermes_home() / "skills"] + dirs.extend(get_project_skills_dirs()) dirs.extend(get_external_skills_dirs()) return dirs diff --git a/tests/agent/test_project_local_skills.py b/tests/agent/test_project_local_skills.py new file mode 100644 index 000000000000..fb384fab6694 --- /dev/null +++ b/tests/agent/test_project_local_skills.py @@ -0,0 +1,306 @@ +"""Tests for project-local skill discovery (auto-discover from working directory). + +Covers: +- get_project_skills_dirs() discovery from .hermes/skills/, .agents/skills/, .claude/skills/ +- _find_project_root() git root detection and cwd fallback +- Integration with _find_all_skills() — source tagging, precedence +- Integration with skill_view() — project skills are viewable +- Integration with scan_skill_commands() — project skills become slash commands +""" + +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def project_root(tmp_path): + """Create a fake project root with a .git directory.""" + root = tmp_path / "my-project" + root.mkdir() + (root / ".git").mkdir() + return root + + +@pytest.fixture +def hermes_home(tmp_path): + """Create a minimal HERMES_HOME with config.""" + home = tmp_path / ".hermes" + home.mkdir() + (home / "skills").mkdir() + (home / "config.yaml").write_text("skills:\n external_dirs: []\n") + return home + + +def _create_project_skill(project_root, subdir, name, description): + """Helper to create a skill in a project-local directory.""" + skill_dir = project_root / subdir / name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\n{description}\n" + ) + return skill_dir + + +class TestFindProjectRoot: + def test_finds_git_root(self, project_root): + subdir = project_root / "src" / "deep" / "nested" + subdir.mkdir(parents=True) + with patch("os.getcwd", return_value=str(subdir)): + from agent.skill_utils import _find_project_root + result = _find_project_root() + assert result == project_root.resolve() + + def test_cwd_fallback_when_no_git(self, tmp_path): + no_git_dir = tmp_path / "no-git-project" + no_git_dir.mkdir() + with patch("os.getcwd", return_value=str(no_git_dir)): + from agent.skill_utils import _find_project_root + result = _find_project_root() + assert result == no_git_dir.resolve() + + def test_returns_none_on_oserror(self): + with patch("pathlib.Path.cwd", side_effect=OSError("no cwd")): + from agent.skill_utils import _find_project_root + result = _find_project_root() + assert result is None + + +class TestGetProjectSkillsDirs: + def test_discovers_hermes_skills(self, hermes_home, project_root): + skills_dir = project_root / ".hermes" / "skills" + skills_dir.mkdir(parents=True) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + assert skills_dir.resolve() in [d.resolve() for d in result] + + def test_discovers_agents_skills(self, hermes_home, project_root): + skills_dir = project_root / ".agents" / "skills" + skills_dir.mkdir(parents=True) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + assert skills_dir.resolve() in [d.resolve() for d in result] + + def test_discovers_claude_skills(self, hermes_home, project_root): + skills_dir = project_root / ".claude" / "skills" + skills_dir.mkdir(parents=True) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + assert skills_dir.resolve() in [d.resolve() for d in result] + + def test_discovers_multiple_dirs(self, hermes_home, project_root): + (project_root / ".hermes" / "skills").mkdir(parents=True) + (project_root / ".claude" / "skills").mkdir(parents=True) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + assert len(result) == 2 + + def test_nonexistent_dirs_skipped(self, hermes_home, project_root): + # No skill subdirs created + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + assert result == [] + + def test_local_skills_dir_excluded(self, hermes_home): + """If project root IS hermes home, the skills dir should be excluded.""" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(hermes_home.parent)), + patch("agent.skill_utils._find_project_root", return_value=hermes_home.parent), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + # .hermes/skills in hermes_home should be excluded as it's the local skills dir + for d in result: + assert d.resolve() != (hermes_home / "skills").resolve() + + def test_deduplication(self, hermes_home, project_root): + """Same resolved path shouldn't appear twice.""" + skills_dir = project_root / ".hermes" / "skills" + skills_dir.mkdir(parents=True) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_project_skills_dirs + result = get_project_skills_dirs() + resolved = [d.resolve() for d in result] + assert len(resolved) == len(set(resolved)) + + +class TestGetAllSkillsDirsWithProject: + def test_project_dirs_after_local_before_external(self, hermes_home, project_root, tmp_path): + # Create project skill dir + project_skills = project_root / ".claude" / "skills" + project_skills.mkdir(parents=True) + # Create external dir + ext_dir = tmp_path / "external-skills" + ext_dir.mkdir() + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {ext_dir}\n" + ) + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_utils import get_all_skills_dirs + result = get_all_skills_dirs() + # Local is first + assert result[0] == hermes_home / "skills" + # Project is second + assert project_skills.resolve() in [d.resolve() for d in result[1:-1]] + # External is last + assert result[-1] == ext_dir.resolve() + + +class TestProjectSkillsInFindAll: + def test_project_skills_found_with_source_tag(self, hermes_home, project_root): + _create_project_skill( + project_root, Path(".claude") / "skills", "langfuse-patterns", + "Langfuse instrumentation patterns for this project" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + patch("os.getcwd", return_value=str(project_root)), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + matching = [s for s in skills if s["name"] == "langfuse-patterns"] + assert len(matching) == 1 + assert matching[0]["source"] == "project" + + def test_local_takes_precedence_over_project(self, hermes_home, project_root): + """If the same skill name exists locally and in project, local wins.""" + # Create local skill + local_skill = hermes_home / "skills" / "my-skill" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text( + "---\nname: my-skill\ndescription: Local version\n---\n\nLocal.\n" + ) + # Create project skill with same name + _create_project_skill( + project_root, Path(".claude") / "skills", "my-skill", + "Project version" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + patch("os.getcwd", return_value=str(project_root)), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + matching = [s for s in skills if s["name"] == "my-skill"] + assert len(matching) == 1 + assert matching[0]["description"] == "Local version" + assert matching[0]["source"] == "local" + + def test_project_takes_precedence_over_external(self, hermes_home, project_root, tmp_path): + """If the same skill name exists in project and external, project wins.""" + # Create project skill + _create_project_skill( + project_root, Path(".agents") / "skills", "shared-skill", + "Project version" + ) + # Create external skill with same name + ext_dir = tmp_path / "external-skills" + ext_skill = ext_dir / "shared-skill" + ext_skill.mkdir(parents=True) + (ext_skill / "SKILL.md").write_text( + "---\nname: shared-skill\ndescription: External version\n---\n\nExternal.\n" + ) + (hermes_home / "config.yaml").write_text( + f"skills:\n external_dirs:\n - {ext_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), + patch("os.getcwd", return_value=str(project_root)), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + matching = [s for s in skills if s["name"] == "shared-skill"] + assert len(matching) == 1 + assert matching[0]["description"] == "Project version" + assert matching[0]["source"] == "project" + + def test_local_skills_tagged_as_local(self, hermes_home, project_root): + """Local skills should have source='local'.""" + local_skill = hermes_home / "skills" / "local-skill" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text( + "---\nname: local-skill\ndescription: A local skill\n---\n\nLocal.\n" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + patch("os.getcwd", return_value=str(project_root)), + ): + from tools.skills_tool import _find_all_skills + skills = _find_all_skills() + matching = [s for s in skills if s["name"] == "local-skill"] + assert len(matching) == 1 + assert matching[0]["source"] == "local" + + +class TestProjectSkillView: + def test_skill_view_finds_project_skill(self, hermes_home, project_root): + _create_project_skill( + project_root, Path(".claude") / "skills", "project-patterns", + "Project-specific patterns" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + patch("os.getcwd", return_value=str(project_root)), + ): + from tools.skills_tool import skill_view + result = json.loads(skill_view("project-patterns")) + assert result["success"] is True + assert "Project-specific patterns" in result["content"] + + +class TestProjectSkillCommands: + def test_scan_finds_project_skills(self, hermes_home, project_root): + _create_project_skill( + project_root, Path(".hermes") / "skills", "code-review", + "Code review guidelines for this project" + ) + local_skills = hermes_home / "skills" + with ( + patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), + patch("tools.skills_tool.SKILLS_DIR", local_skills), + patch("os.getcwd", return_value=str(project_root)), + ): + from agent.skill_commands import scan_skill_commands + commands = scan_skill_commands() + assert "/code-review" in commands + assert commands["/code-review"]["name"] == "code-review" diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 6c9e2441a43a..c9c7ad2f277a 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -510,7 +510,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/ and external dirs. + """Recursively find all skills in ~/.hermes/skills/, project-local dirs, and external dirs. Args: skip_disabled: If True, return ALL skills regardless of disabled @@ -518,9 +518,10 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: filters out disabled skills. Returns: - List of skill metadata dicts (name, description, category). + List of skill metadata dicts (name, description, category, source). + The ``source`` field is ``"local"``, ``"project"``, or ``"external"``. """ - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs skills = [] seen_names: set = set() @@ -528,13 +529,17 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: # 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 = [] + # Build ordered scan list with source labels + # Precedence: local > project > external (first seen name wins) + dirs_with_source: List[tuple] = [] if SKILLS_DIR.exists(): - dirs_to_scan.append(SKILLS_DIR) - dirs_to_scan.extend(get_external_skills_dirs()) + dirs_with_source.append((SKILLS_DIR, "local")) + for d in get_project_skills_dirs(): + dirs_with_source.append((d, "project")) + for d in get_external_skills_dirs(): + dirs_with_source.append((d, "external")) - for scan_dir in dirs_to_scan: + for scan_dir, source in dirs_with_source: for skill_md in scan_dir.rglob("SKILL.md"): if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): continue @@ -572,6 +577,7 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: "name": name, "description": description, "category": category, + "source": source, }) except (UnicodeDecodeError, PermissionError) as e: @@ -781,12 +787,14 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: JSON string with skill content or error message """ try: - from agent.skill_utils import get_external_skills_dirs + from agent.skill_utils import get_external_skills_dirs, get_project_skills_dirs # Build list of all skill directories to search + # Precedence: local > project > external all_dirs = [] if SKILLS_DIR.exists(): all_dirs.append(SKILLS_DIR) + all_dirs.extend(get_project_skills_dirs()) all_dirs.extend(get_external_skills_dirs()) if not all_dirs: