feat: platform-conditional skill loading + Apple/macOS skills
Add a 'platforms' field to SKILL.md frontmatter that restricts skills to specific operating systems. Skills with platforms: [macos] only appear in the system prompt, skills_list(), and slash commands on macOS. Skills without the field load everywhere (backward compatible). Implementation: - skill_matches_platform() in tools/skills_tool.py — core filter - Wired into all 3 discovery paths: prompt_builder.py, skills_tool.py, skill_commands.py - 28 new tests across 3 test files New bundled Apple/macOS skills (all platforms: [macos]): - imessage — Send/receive iMessages via imsg CLI - apple-reminders — Manage Reminders via remindctl CLI - apple-notes — Manage Notes via memo CLI - findmy — Track devices/AirTags via AppleScript + screen capture Docs updated: CONTRIBUTING.md, AGENTS.md, creating-skills.md, skills.md (user guide)
This commit is contained in:
parent
74fe1e2254
commit
f668e9fc75
15 changed files with 803 additions and 2 deletions
|
|
@ -165,6 +165,52 @@ class TestBuildSkillsSystemPrompt:
|
|||
# "search" should appear only once per category
|
||||
assert result.count("- search") == 1
|
||||
|
||||
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
|
||||
"""Skills with platforms: [macos] should not appear on Linux."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
skills_dir = tmp_path / "skills" / "apple"
|
||||
skills_dir.mkdir(parents=True)
|
||||
|
||||
# macOS-only skill
|
||||
mac_skill = skills_dir / "imessage"
|
||||
mac_skill.mkdir()
|
||||
(mac_skill / "SKILL.md").write_text(
|
||||
"---\nname: imessage\ndescription: Send iMessages\nplatforms: [macos]\n---\n"
|
||||
)
|
||||
|
||||
# Universal skill
|
||||
uni_skill = skills_dir / "web-search"
|
||||
uni_skill.mkdir()
|
||||
(uni_skill / "SKILL.md").write_text(
|
||||
"---\nname: web-search\ndescription: Search the web\n---\n"
|
||||
)
|
||||
|
||||
from unittest.mock import patch
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
result = build_skills_system_prompt()
|
||||
|
||||
assert "web-search" in result
|
||||
assert "imessage" not in result
|
||||
|
||||
def test_includes_matching_platform_skills(self, monkeypatch, tmp_path):
|
||||
"""Skills with platforms: [macos] should appear on macOS."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
skills_dir = tmp_path / "skills" / "apple"
|
||||
mac_skill = skills_dir / "imessage"
|
||||
mac_skill.mkdir(parents=True)
|
||||
(mac_skill / "SKILL.md").write_text(
|
||||
"---\nname: imessage\ndescription: Send iMessages\nplatforms: [macos]\n---\n"
|
||||
)
|
||||
|
||||
from unittest.mock import patch
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
result = build_skills_system_prompt()
|
||||
|
||||
assert "imessage" in result
|
||||
assert "Send iMessages" in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Context files prompt builder
|
||||
|
|
|
|||
87
tests/agent/test_skill_commands.py
Normal file
87
tests/agent/test_skill_commands.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Tests for agent/skill_commands.py — skill slash command scanning and platform filtering."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.skill_commands import scan_skill_commands, build_skill_invocation_message
|
||||
|
||||
|
||||
def _make_skill(skills_dir, name, frontmatter_extra="", body="Do the thing.", category=None):
|
||||
"""Helper to create a minimal skill directory with SKILL.md."""
|
||||
if category:
|
||||
skill_dir = skills_dir / category / name
|
||||
else:
|
||||
skill_dir = skills_dir / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
content = f"""\
|
||||
---
|
||||
name: {name}
|
||||
description: Description for {name}.
|
||||
{frontmatter_extra}---
|
||||
|
||||
# {name}
|
||||
|
||||
{body}
|
||||
"""
|
||||
(skill_dir / "SKILL.md").write_text(content)
|
||||
return skill_dir
|
||||
|
||||
|
||||
class TestScanSkillCommands:
|
||||
def test_finds_skills(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
_make_skill(tmp_path, "my-skill")
|
||||
result = scan_skill_commands()
|
||||
assert "/my-skill" in result
|
||||
assert result["/my-skill"]["name"] == "my-skill"
|
||||
|
||||
def test_empty_dir(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
result = scan_skill_commands()
|
||||
assert result == {}
|
||||
|
||||
def test_excludes_incompatible_platform(self, tmp_path):
|
||||
"""macOS-only skills should not register slash commands on Linux."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
_make_skill(tmp_path, "imessage", frontmatter_extra="platforms: [macos]\n")
|
||||
_make_skill(tmp_path, "web-search")
|
||||
result = scan_skill_commands()
|
||||
assert "/web-search" in result
|
||||
assert "/imessage" not in result
|
||||
|
||||
def test_includes_matching_platform(self, tmp_path):
|
||||
"""macOS-only skills should register slash commands on macOS."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
_make_skill(tmp_path, "imessage", frontmatter_extra="platforms: [macos]\n")
|
||||
result = scan_skill_commands()
|
||||
assert "/imessage" in result
|
||||
|
||||
def test_universal_skill_on_any_platform(self, tmp_path):
|
||||
"""Skills without platforms field should register on any platform."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
_make_skill(tmp_path, "generic-tool")
|
||||
result = scan_skill_commands()
|
||||
assert "/generic-tool" in result
|
||||
|
||||
|
||||
class TestBuildSkillInvocationMessage:
|
||||
def test_builds_message(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
_make_skill(tmp_path, "test-skill")
|
||||
scan_skill_commands()
|
||||
msg = build_skill_invocation_message("/test-skill", "do stuff")
|
||||
assert msg is not None
|
||||
assert "test-skill" in msg
|
||||
assert "do stuff" in msg
|
||||
|
||||
def test_returns_none_for_unknown(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
|
||||
scan_skill_commands()
|
||||
msg = build_skill_invocation_message("/nonexistent")
|
||||
assert msg is None
|
||||
|
|
@ -11,6 +11,7 @@ from tools.skills_tool import (
|
|||
_estimate_tokens,
|
||||
_find_all_skills,
|
||||
_load_category_description,
|
||||
skill_matches_platform,
|
||||
skills_list,
|
||||
skills_categories,
|
||||
skill_view,
|
||||
|
|
@ -332,3 +333,134 @@ class TestSkillsCategories:
|
|||
result = json.loads(raw)
|
||||
assert result["success"] is True
|
||||
assert result["categories"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# skill_matches_platform
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillMatchesPlatform:
|
||||
"""Tests for the platforms frontmatter field filtering."""
|
||||
|
||||
def test_no_platforms_field_matches_everything(self):
|
||||
"""Skills without a platforms field should load on any OS."""
|
||||
assert skill_matches_platform({}) is True
|
||||
assert skill_matches_platform({"name": "foo"}) is True
|
||||
|
||||
def test_empty_platforms_matches_everything(self):
|
||||
"""Empty platforms list should load on any OS."""
|
||||
assert skill_matches_platform({"platforms": []}) is True
|
||||
assert skill_matches_platform({"platforms": None}) is True
|
||||
|
||||
def test_macos_on_darwin(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
assert skill_matches_platform({"platforms": ["macos"]}) is True
|
||||
|
||||
def test_macos_on_linux(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": ["macos"]}) is False
|
||||
|
||||
def test_linux_on_linux(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": ["linux"]}) is True
|
||||
|
||||
def test_linux_on_darwin(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
assert skill_matches_platform({"platforms": ["linux"]}) is False
|
||||
|
||||
def test_windows_on_win32(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
assert skill_matches_platform({"platforms": ["windows"]}) is True
|
||||
|
||||
def test_windows_on_linux(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": ["windows"]}) is False
|
||||
|
||||
def test_multi_platform_match(self):
|
||||
"""Skills listing multiple platforms should match any of them."""
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
assert skill_matches_platform({"platforms": ["macos", "linux"]}) is True
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": ["macos", "linux"]}) is True
|
||||
mock_sys.platform = "win32"
|
||||
assert skill_matches_platform({"platforms": ["macos", "linux"]}) is False
|
||||
|
||||
def test_string_instead_of_list(self):
|
||||
"""A single string value should be treated as a one-element list."""
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
assert skill_matches_platform({"platforms": "macos"}) is True
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": "macos"}) is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
assert skill_matches_platform({"platforms": ["MacOS"]}) is True
|
||||
assert skill_matches_platform({"platforms": ["MACOS"]}) is True
|
||||
|
||||
def test_unknown_platform_no_match(self):
|
||||
with patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert skill_matches_platform({"platforms": ["freebsd"]}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_all_skills — platform filtering integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindAllSkillsPlatformFiltering:
|
||||
"""Test that _find_all_skills respects the platforms field."""
|
||||
|
||||
def test_excludes_incompatible_platform(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
_make_skill(tmp_path, "universal-skill")
|
||||
_make_skill(tmp_path, "mac-only", frontmatter_extra="platforms: [macos]\n")
|
||||
skills = _find_all_skills()
|
||||
names = {s["name"] for s in skills}
|
||||
assert "universal-skill" in names
|
||||
assert "mac-only" not in names
|
||||
|
||||
def test_includes_matching_platform(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "darwin"
|
||||
_make_skill(tmp_path, "mac-only", frontmatter_extra="platforms: [macos]\n")
|
||||
skills = _find_all_skills()
|
||||
names = {s["name"] for s in skills}
|
||||
assert "mac-only" in names
|
||||
|
||||
def test_no_platforms_always_included(self, tmp_path):
|
||||
"""Skills without platforms field should appear on any platform."""
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
mock_sys.platform = "win32"
|
||||
_make_skill(tmp_path, "generic-skill")
|
||||
skills = _find_all_skills()
|
||||
assert len(skills) == 1
|
||||
assert skills[0]["name"] == "generic-skill"
|
||||
|
||||
def test_multi_platform_skill(self, tmp_path):
|
||||
with patch("tools.skills_tool.SKILLS_DIR", tmp_path), \
|
||||
patch("tools.skills_tool.sys") as mock_sys:
|
||||
_make_skill(tmp_path, "cross-plat", frontmatter_extra="platforms: [macos, linux]\n")
|
||||
mock_sys.platform = "darwin"
|
||||
skills_darwin = _find_all_skills()
|
||||
mock_sys.platform = "linux"
|
||||
skills_linux = _find_all_skills()
|
||||
mock_sys.platform = "win32"
|
||||
skills_win = _find_all_skills()
|
||||
assert len(skills_darwin) == 1
|
||||
assert len(skills_linux) == 1
|
||||
assert len(skills_win) == 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue