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:
teknium1 2026-03-07 00:47:54 -08:00
parent 74fe1e2254
commit f668e9fc75
15 changed files with 803 additions and 2 deletions

View file

@ -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

View 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

View file

@ -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