diff --git a/AGENTS.md b/AGENTS.md index 250b22076..2fb9b0989 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -204,7 +204,7 @@ Every installed skill in `~/.hermes/skills/` is automatically registered as a sl The skill name (from frontmatter or folder name) becomes the command: `axolotl` → `/axolotl`. Implementation (`agent/skill_commands.py`, shared between CLI and gateway): -1. `scan_skill_commands()` scans all SKILL.md files at startup +1. `scan_skill_commands()` scans all SKILL.md files at startup, filtering out skills incompatible with the current OS platform (via the `platforms` frontmatter field) 2. `build_skill_invocation_message()` loads the SKILL.md content and builds a user-turn message 3. The message includes the full skill content, a list of supporting files (not loaded), and the user's instruction 4. Supporting files can be loaded on demand via the `skill_view` tool @@ -657,6 +657,7 @@ SKILL.md files use YAML frontmatter (agentskills.io format): name: skill-name description: Brief description for listing version: 1.0.0 +platforms: [macos] # Optional — restrict to specific OS (macos/linux/windows) metadata: hermes: tags: [tag1, tag2] @@ -665,6 +666,8 @@ metadata: # Skill Content... ``` +**Platform filtering** — Skills with a `platforms` field are automatically excluded from the system prompt index, `skills_list()`, and slash commands on incompatible platforms. Skills without the field load everywhere (backward compatible). See `skills/apple/` for macOS-only examples (iMessage, Reminders, Notes, FindMy). + **Skills Hub** — user-driven skill search/install from online registries and official optional skills. Sources: official optional skills (shipped with repo, labeled "official"), GitHub (openai/skills, anthropics/skills, custom taps), ClawHub, Claude marketplace, LobeHub. Not exposed as an agent tool — the model cannot search for or install skills. Users manage skills via `hermes skills browse/search/install` CLI commands or the `/skills` slash command in chat. Key files: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd4922875..9679d79d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -325,6 +325,9 @@ description: Brief description (shown in skill search results) version: 1.0.0 author: Your Name license: MIT +platforms: [macos, linux] # Optional — restrict to specific OS platforms + # Valid: macos, linux, windows + # Omit to load on all platforms (default) metadata: hermes: tags: [Category, Subcategory, Keywords] @@ -351,6 +354,18 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` +### Platform-specific skills + +Skills can declare which OS platforms they support via the `platforms` frontmatter field. Skills with this field are automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. + +```yaml +platforms: [macos] # macOS only (e.g., iMessage, Apple Reminders) +platforms: [macos, linux] # macOS and Linux +platforms: [windows] # Windows only +``` + +If the field is omitted or empty, the skill loads on all platforms (backward compatible). See `skills/apple/` for examples of macOS-only skills. + ### Skill guidelines - **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`). diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 5df99e1ad..c576b55c1 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -142,12 +142,28 @@ def _read_skill_description(skill_file: Path, max_chars: int = 60) -> str: return "" +def _skill_is_platform_compatible(skill_file: Path) -> bool: + """Quick check if a SKILL.md is compatible with the current OS platform. + + Reads just enough to parse the ``platforms`` frontmatter field. + Skills without the field (the vast majority) are always compatible. + """ + try: + from tools.skills_tool import _parse_frontmatter, skill_matches_platform + raw = skill_file.read_text(encoding="utf-8")[:2000] + frontmatter, _ = _parse_frontmatter(raw) + return skill_matches_platform(frontmatter) + except Exception: + return True # Err on the side of showing the skill + + def build_skills_system_prompt() -> str: """Build a compact skill index for the system prompt. Scans ~/.hermes/skills/ for SKILL.md files grouped by category. Includes per-skill descriptions from frontmatter so the model can match skills by meaning, not just name. + Filters out skills incompatible with the current OS platform. """ hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) skills_dir = hermes_home / "skills" @@ -159,6 +175,9 @@ def build_skills_system_prompt() -> str: # Each entry: (skill_name, description) skills_by_category: dict[str, list[tuple[str, str]]] = {} for skill_file in skills_dir.rglob("SKILL.md"): + # Skip skills incompatible with the current OS platform + if not _skill_is_platform_compatible(skill_file): + continue rel_path = skill_file.relative_to(skills_dir) parts = rel_path.parts if len(parts) >= 2: diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 8f02aeb8d..4466ba35c 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -22,7 +22,7 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: global _skill_commands _skill_commands = {} try: - from tools.skills_tool import SKILLS_DIR, _parse_frontmatter + from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform if not SKILLS_DIR.exists(): return _skill_commands for skill_md in SKILLS_DIR.rglob("SKILL.md"): @@ -31,6 +31,9 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]: 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) description = frontmatter.get('description', '') if not description: diff --git a/skills/apple/DESCRIPTION.md b/skills/apple/DESCRIPTION.md new file mode 100644 index 000000000..392bd2d87 --- /dev/null +++ b/skills/apple/DESCRIPTION.md @@ -0,0 +1,3 @@ +--- +description: Apple/macOS-specific skills — iMessage, Reminders, Notes, FindMy, and macOS automation. These skills only load on macOS systems. +--- diff --git a/skills/apple/apple-notes/SKILL.md b/skills/apple/apple-notes/SKILL.md new file mode 100644 index 000000000..d68c183b5 --- /dev/null +++ b/skills/apple/apple-notes/SKILL.md @@ -0,0 +1,88 @@ +--- +name: apple-notes +description: Manage Apple Notes via the memo CLI on macOS (create, view, search, edit). +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [macos] +metadata: + hermes: + tags: [Notes, Apple, macOS, note-taking] + related_skills: [obsidian] +--- + +# Apple Notes + +Use `memo` to manage Apple Notes directly from the terminal. Notes sync across all Apple devices via iCloud. + +## Prerequisites + +- **macOS** with Notes.app +- Install: `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo` +- Grant Automation access to Notes.app when prompted (System Settings → Privacy → Automation) + +## When to Use + +- User asks to create, view, or search Apple Notes +- Saving information to Notes.app for cross-device access +- Organizing notes into folders +- Exporting notes to Markdown/HTML + +## When NOT to Use + +- Obsidian vault management → use the `obsidian` skill +- Bear Notes → separate app (not supported here) +- Quick agent-only notes → use the `memory` tool instead + +## Quick Reference + +### View Notes + +```bash +memo notes # List all notes +memo notes -f "Folder Name" # Filter by folder +memo notes -s "query" # Search notes (fuzzy) +``` + +### Create Notes + +```bash +memo notes -a # Interactive editor +memo notes -a "Note Title" # Quick add with title +``` + +### Edit Notes + +```bash +memo notes -e # Interactive selection to edit +``` + +### Delete Notes + +```bash +memo notes -d # Interactive selection to delete +``` + +### Move Notes + +```bash +memo notes -m # Move note to folder (interactive) +``` + +### Export Notes + +```bash +memo notes -ex # Export to HTML/Markdown +``` + +## Limitations + +- Cannot edit notes containing images or attachments +- Interactive prompts require terminal access (use pty=true if needed) +- macOS only — requires Apple Notes.app + +## Rules + +1. Prefer Apple Notes when user wants cross-device sync (iPhone/iPad/Mac) +2. Use the `memory` tool for agent-internal notes that don't need to sync +3. Use the `obsidian` skill for Markdown-native knowledge management diff --git a/skills/apple/apple-reminders/SKILL.md b/skills/apple/apple-reminders/SKILL.md new file mode 100644 index 000000000..872cc3f59 --- /dev/null +++ b/skills/apple/apple-reminders/SKILL.md @@ -0,0 +1,96 @@ +--- +name: apple-reminders +description: Manage Apple Reminders via remindctl CLI (list, add, complete, delete). +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [macos] +metadata: + hermes: + tags: [Reminders, tasks, todo, macOS, Apple] +--- + +# Apple Reminders + +Use `remindctl` to manage Apple Reminders directly from the terminal. Tasks sync across all Apple devices via iCloud. + +## Prerequisites + +- **macOS** with Reminders.app +- Install: `brew install steipete/tap/remindctl` +- Grant Reminders permission when prompted +- Check: `remindctl status` / Request: `remindctl authorize` + +## When to Use + +- User mentions "reminder" or "Reminders app" +- Creating personal to-dos with due dates that sync to iOS +- Managing Apple Reminders lists +- User wants tasks to appear on their iPhone/iPad + +## When NOT to Use + +- Scheduling agent alerts → use the cronjob tool instead +- Calendar events → use Apple Calendar or Google Calendar +- Project task management → use GitHub Issues, Notion, etc. +- If user says "remind me" but means an agent alert → clarify first + +## Quick Reference + +### View Reminders + +```bash +remindctl # Today's reminders +remindctl today # Today +remindctl tomorrow # Tomorrow +remindctl week # This week +remindctl overdue # Past due +remindctl all # Everything +remindctl 2026-01-04 # Specific date +``` + +### Manage Lists + +```bash +remindctl list # List all lists +remindctl list Work # Show specific list +remindctl list Projects --create # Create list +remindctl list Work --delete # Delete list +``` + +### Create Reminders + +```bash +remindctl add "Buy milk" +remindctl add --title "Call mom" --list Personal --due tomorrow +remindctl add --title "Meeting prep" --due "2026-02-15 09:00" +``` + +### Complete / Delete + +```bash +remindctl complete 1 2 3 # Complete by ID +remindctl delete 4A83 --force # Delete by ID +``` + +### Output Formats + +```bash +remindctl today --json # JSON for scripting +remindctl today --plain # TSV format +remindctl today --quiet # Counts only +``` + +## Date Formats + +Accepted by `--due` and date filters: +- `today`, `tomorrow`, `yesterday` +- `YYYY-MM-DD` +- `YYYY-MM-DD HH:mm` +- ISO 8601 (`2026-01-04T12:34:56Z`) + +## Rules + +1. When user says "remind me", clarify: Apple Reminders (syncs to phone) vs agent cronjob alert +2. Always confirm reminder content and due date before creating +3. Use `--json` for programmatic parsing diff --git a/skills/apple/findmy/SKILL.md b/skills/apple/findmy/SKILL.md new file mode 100644 index 000000000..c009b3e39 --- /dev/null +++ b/skills/apple/findmy/SKILL.md @@ -0,0 +1,131 @@ +--- +name: findmy +description: Track Apple devices and AirTags via FindMy.app on macOS using AppleScript and screen capture. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [macos] +metadata: + hermes: + tags: [FindMy, AirTag, location, tracking, macOS, Apple] +--- + +# Find My (Apple) + +Track Apple devices and AirTags via the FindMy.app on macOS. Since Apple doesn't +provide a CLI for FindMy, this skill uses AppleScript to open the app and +screen capture to read device locations. + +## Prerequisites + +- **macOS** with Find My app and iCloud signed in +- Devices/AirTags already registered in Find My +- Screen Recording permission for terminal (System Settings → Privacy → Screen Recording) +- **Optional but recommended**: Install `peekaboo` for better UI automation: + `brew install steipete/tap/peekaboo` + +## When to Use + +- User asks "where is my [device/cat/keys/bag]?" +- Tracking AirTag locations +- Checking device locations (iPhone, iPad, Mac, AirPods) +- Monitoring pet or item movement over time (AirTag patrol routes) + +## Method 1: AppleScript + Screenshot (Basic) + +### Open FindMy and Navigate + +```bash +# Open Find My app +osascript -e 'tell application "FindMy" to activate' + +# Wait for it to load +sleep 3 + +# Take a screenshot of the Find My window +screencapture -w -o /tmp/findmy.png +``` + +Then use `vision_analyze` to read the screenshot: +``` +vision_analyze(image_url="/tmp/findmy.png", question="What devices/items are shown and what are their locations?") +``` + +### Switch Between Tabs + +```bash +# Switch to Devices tab +osascript -e ' +tell application "System Events" + tell process "FindMy" + click button "Devices" of toolbar 1 of window 1 + end tell +end tell' + +# Switch to Items tab (AirTags) +osascript -e ' +tell application "System Events" + tell process "FindMy" + click button "Items" of toolbar 1 of window 1 + end tell +end tell' +``` + +## Method 2: Peekaboo UI Automation (Recommended) + +If `peekaboo` is installed, use it for more reliable UI interaction: + +```bash +# Open Find My +osascript -e 'tell application "FindMy" to activate' +sleep 3 + +# Capture and annotate the UI +peekaboo see --app "FindMy" --annotate --path /tmp/findmy-ui.png + +# Click on a specific device/item by element ID +peekaboo click --on B3 --app "FindMy" + +# Capture the detail view +peekaboo image --app "FindMy" --path /tmp/findmy-detail.png +``` + +Then analyze with vision: +``` +vision_analyze(image_url="/tmp/findmy-detail.png", question="What is the location shown for this device/item? Include address and coordinates if visible.") +``` + +## Workflow: Track AirTag Location Over Time + +For monitoring an AirTag (e.g., tracking a cat's patrol route): + +```bash +# 1. Open FindMy to Items tab +osascript -e 'tell application "FindMy" to activate' +sleep 3 + +# 2. Click on the AirTag item (stay on page — AirTag only updates when page is open) + +# 3. Periodically capture location +while true; do + screencapture -w -o /tmp/findmy-$(date +%H%M%S).png + sleep 300 # Every 5 minutes +done +``` + +Analyze each screenshot with vision to extract coordinates, then compile a route. + +## Limitations + +- FindMy has **no CLI or API** — must use UI automation +- AirTags only update location while the FindMy page is actively displayed +- Location accuracy depends on nearby Apple devices in the FindMy network +- Screen Recording permission required for screenshots +- AppleScript UI automation may break across macOS versions + +## Rules + +1. Keep FindMy app in the foreground when tracking AirTags (updates stop when minimized) +2. Use `vision_analyze` to read screenshot content — don't try to parse pixels +3. For ongoing tracking, use a cronjob to periodically capture and log locations +4. Respect privacy — only track devices/items the user owns diff --git a/skills/apple/imessage/SKILL.md b/skills/apple/imessage/SKILL.md new file mode 100644 index 000000000..777461d37 --- /dev/null +++ b/skills/apple/imessage/SKILL.md @@ -0,0 +1,100 @@ +--- +name: imessage +description: Send and receive iMessages/SMS via the imsg CLI on macOS. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [macos] +metadata: + hermes: + tags: [iMessage, SMS, messaging, macOS, Apple] +--- + +# iMessage + +Use `imsg` to read and send iMessage/SMS via macOS Messages.app. + +## Prerequisites + +- **macOS** with Messages.app signed in +- Install: `brew install steipete/tap/imsg` +- Grant Full Disk Access for terminal (System Settings → Privacy → Full Disk Access) +- Grant Automation permission for Messages.app when prompted + +## When to Use + +- User asks to send an iMessage or text message +- Reading iMessage conversation history +- Checking recent Messages.app chats +- Sending to phone numbers or Apple IDs + +## When NOT to Use + +- Telegram/Discord/Slack/WhatsApp messages → use the appropriate gateway channel +- Group chat management (adding/removing members) → not supported +- Bulk/mass messaging → always confirm with user first + +## Quick Reference + +### List Chats + +```bash +imsg chats --limit 10 --json +``` + +### View History + +```bash +# By chat ID +imsg history --chat-id 1 --limit 20 --json + +# With attachments info +imsg history --chat-id 1 --limit 20 --attachments --json +``` + +### Send Messages + +```bash +# Text only +imsg send --to "+14155551212" --text "Hello!" + +# With attachment +imsg send --to "+14155551212" --text "Check this out" --file /path/to/image.jpg + +# Force iMessage or SMS +imsg send --to "+14155551212" --text "Hi" --service imessage +imsg send --to "+14155551212" --text "Hi" --service sms +``` + +### Watch for New Messages + +```bash +imsg watch --chat-id 1 --attachments +``` + +## Service Options + +- `--service imessage` — Force iMessage (requires recipient has iMessage) +- `--service sms` — Force SMS (green bubble) +- `--service auto` — Let Messages.app decide (default) + +## Rules + +1. **Always confirm recipient and message content** before sending +2. **Never send to unknown numbers** without explicit user approval +3. **Verify file paths** exist before attaching +4. **Don't spam** — rate-limit yourself + +## Example Workflow + +User: "Text mom that I'll be late" + +```bash +# 1. Find mom's chat +imsg chats --limit 20 --json | jq '.[] | select(.displayName | contains("Mom"))' + +# 2. Confirm with user: "Found Mom at +1555123456. Send 'I'll be late' via iMessage?" + +# 3. Send after confirmation +imsg send --to "+1555123456" --text "I'll be late" +``` diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 80309722b..a35983b5f 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -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 diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py new file mode 100644 index 000000000..3867bf399 --- /dev/null +++ b/tests/agent/test_skill_commands.py @@ -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 diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index 07ea76b87..629d3b478 100644 --- a/tests/tools/test_skills_tool.py +++ b/tests/tools/test_skills_tool.py @@ -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 diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 4972cacd7..e8baa0f59 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -31,6 +31,9 @@ SKILL.md Format (YAML Frontmatter, agentskills.io compatible): description: Brief description # Required, max 1024 chars version: 1.0.0 # Optional license: MIT # Optional (agentskills.io) + platforms: [macos] # Optional — restrict to specific OS platforms + # Valid: macos, linux, windows + # Omit to load on all platforms (default) compatibility: Requires X # Optional (agentskills.io) metadata: # Optional, arbitrary key-value (agentskills.io) hermes: @@ -62,6 +65,7 @@ Usage: import json import os import re +import sys from pathlib import Path from typing import Dict, Any, List, Optional, Tuple @@ -78,6 +82,41 @@ SKILLS_DIR = HERMES_HOME / "skills" MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 +# Platform identifiers for the 'platforms' frontmatter field. +# Maps user-friendly names to sys.platform prefixes. +_PLATFORM_MAP = { + "macos": "darwin", + "linux": "linux", + "windows": "win32", +} + + +def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: + """Check if a skill is compatible with the current OS platform. + + Skills declare platform requirements via a top-level ``platforms`` list + in their YAML frontmatter:: + + platforms: [macos] # macOS only + platforms: [macos, linux] # macOS and Linux + + Valid values: ``macos``, ``linux``, ``windows``. + + If the field is absent or empty the skill is compatible with **all** + platforms (backward-compatible default). + """ + platforms = frontmatter.get("platforms") + if not platforms: + return True # No restriction → loads everywhere + if not isinstance(platforms, list): + platforms = [platforms] + current = sys.platform + for p in platforms: + mapped = _PLATFORM_MAP.get(str(p).lower().strip(), str(p).lower().strip()) + if current.startswith(mapped): + return True + return False + def check_skills_requirements() -> bool: """Skills are always available -- the directory is created on first use if needed.""" @@ -204,6 +243,10 @@ def _find_all_skills() -> List[Dict[str, Any]]: 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_dir.name)[:MAX_NAME_LENGTH] diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index f60124033..bc0272878 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -50,6 +50,9 @@ description: Brief description (shown in skill search results) version: 1.0.0 author: Your Name license: MIT +platforms: [macos, linux] # Optional — restrict to specific OS platforms + # Valid: macos, linux, windows + # Omit to load on all platforms (default) metadata: hermes: tags: [Category, Subcategory, Keywords] @@ -76,6 +79,20 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` +### Platform-Specific Skills + +Skills can restrict themselves to specific operating systems using the `platforms` field: + +```yaml +platforms: [macos] # macOS only (e.g., iMessage, Apple Reminders) +platforms: [macos, linux] # macOS and Linux +platforms: [windows] # Windows only +``` + +When set, the skill is automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. If omitted or empty, the skill loads on all platforms (backward compatible). + +See `skills/apple/` for examples of macOS-only skills. + ## Skill Guidelines ### No External Dependencies diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 053f21717..8eb838d2c 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -50,6 +50,7 @@ The agent only loads the full skill content when it actually needs it. name: my-skill description: Brief description of what this skill does version: 1.0.0 +platforms: [macos, linux] # Optional — restrict to specific OS platforms metadata: hermes: tags: [python, automation] @@ -72,6 +73,23 @@ Trigger conditions for this skill. How to confirm it worked. ``` +### Platform-Specific Skills + +Skills can restrict themselves to specific operating systems using the `platforms` field: + +| Value | Matches | +|-------|---------| +| `macos` | macOS (Darwin) | +| `linux` | Linux | +| `windows` | Windows | + +```yaml +platforms: [macos] # macOS only (e.g., iMessage, Apple Reminders, FindMy) +platforms: [macos, linux] # macOS and Linux +``` + +When set, the skill is automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. If omitted, the skill loads on all platforms. + ## Skill Directory Structure ```