From f2105102763d80dee886a30bd4ebb8f1364d4630 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:55:09 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20add=20prerequisites=20field=20to=20skil?= =?UTF-8?q?l=20spec=20=E2=80=94=20hide=20skills=20with=20unmet=20dependenc?= =?UTF-8?q?ies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills can now declare runtime prerequisites (env vars, CLI binaries) via YAML frontmatter. Skills with unmet prerequisites are excluded from the system prompt so the agent never claims capabilities it can't deliver, and skill_view() warns the agent about what's missing. Three layers of defense: - build_skills_system_prompt() filters out unavailable skills - _find_all_skills() flags unmet prerequisites in metadata - skill_view() returns prerequisites_warning with actionable details Tagged 12 bundled skills that have hard runtime dependencies: gif-search (TENOR_API_KEY), notion (NOTION_API_KEY), himalaya, imessage, apple-notes, apple-reminders, openhue, duckduckgo-search, codebase-inspection, blogwatcher, songsee, mcporter. Closes #658 Fixes #630 --- CONTRIBUTING.md | 24 ++++ agent/prompt_builder.py | 19 ++++ skills/apple/apple-notes/SKILL.md | 2 + skills/apple/apple-reminders/SKILL.md | 2 + skills/apple/imessage/SKILL.md | 2 + skills/email/himalaya/SKILL.md | 2 + skills/feeds/blogwatcher/SKILL.md | 2 + skills/gifs/gif-search/SKILL.md | 29 +++-- skills/github/codebase-inspection/SKILL.md | 2 + skills/mcp/mcporter/SKILL.md | 2 + skills/music-creation/songsee/SKILL.md | 2 + skills/productivity/notion/SKILL.md | 2 + skills/research/duckduckgo-search/SKILL.md | 2 + skills/smart-home/openhue/SKILL.md | 2 + tests/agent/test_prompt_builder.py | 64 +++++++++++ tests/tools/test_skills_tool.py | 122 +++++++++++++++++++++ tools/skills_tool.py | 67 ++++++++++- 17 files changed, 336 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9679d79d1d..c0400078dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -328,6 +328,11 @@ license: MIT platforms: [macos, linux] # Optional — restrict to specific OS platforms # Valid: macos, linux, windows # Omit to load on all platforms (default) +prerequisites: # Optional — runtime requirements + env_vars: [MY_API_KEY] # Env vars that must be set + commands: [curl, jq] # CLI binaries that must be on PATH + # Skills with unmet prerequisites are hidden + # from the system prompt and flagged in skill_view. metadata: hermes: tags: [Category, Subcategory, Keywords] @@ -366,6 +371,25 @@ 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 prerequisites + +Skills can declare runtime prerequisites via the `prerequisites` frontmatter field. Skills with unmet prerequisites are automatically hidden from the system prompt (the agent won't claim it can use them) and show a clear warning in `skill_view()` telling the agent what's missing. + +```yaml +prerequisites: + env_vars: [TENOR_API_KEY] # Env vars checked via os.getenv() + commands: [curl, jq] # CLI binaries checked via shutil.which() +``` + +Both sub-fields are optional — declare only what applies. If the field is omitted entirely, the skill is always available (backward compatible). + +**When to declare prerequisites:** +- The skill uses a CLI tool that isn't universally installed (e.g., `himalaya`, `openhue`, `ddgs`) +- The skill requires an API key in the environment (e.g., `NOTION_API_KEY`, `TENOR_API_KEY`) +- Without these, the skill's commands will fail — not just degrade gracefully + +See `skills/gifs/gif-search/` and `skills/email/himalaya/` for examples. + ### 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 c933ffe675..09dc6dd436 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -170,6 +170,22 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool: return True # Err on the side of showing the skill +def _skill_prerequisites_met(skill_file: Path) -> bool: + """Check if a SKILL.md's declared prerequisites are satisfied. + + Returns True (show the skill) when prerequisites are met or not declared. + Returns False when the skill explicitly declares prerequisites that are missing. + """ + try: + from tools.skills_tool import _parse_frontmatter, check_skill_prerequisites + raw = skill_file.read_text(encoding="utf-8")[:2000] + frontmatter, _ = _parse_frontmatter(raw) + met, _ = check_skill_prerequisites(frontmatter) + return met + except Exception: + return True + + def build_skills_system_prompt() -> str: """Build a compact skill index for the system prompt. @@ -191,6 +207,9 @@ def build_skills_system_prompt() -> str: # Skip skills incompatible with the current OS platform if not _skill_is_platform_compatible(skill_file): continue + # Skip skills whose prerequisites (env vars, commands) are unmet + if not _skill_prerequisites_met(skill_file): + continue rel_path = skill_file.relative_to(skills_dir) parts = rel_path.parts if len(parts) >= 2: diff --git a/skills/apple/apple-notes/SKILL.md b/skills/apple/apple-notes/SKILL.md index d68c183b5b..33fb3ef76f 100644 --- a/skills/apple/apple-notes/SKILL.md +++ b/skills/apple/apple-notes/SKILL.md @@ -9,6 +9,8 @@ metadata: hermes: tags: [Notes, Apple, macOS, note-taking] related_skills: [obsidian] +prerequisites: + commands: [memo] --- # Apple Notes diff --git a/skills/apple/apple-reminders/SKILL.md b/skills/apple/apple-reminders/SKILL.md index 872cc3f59c..7af3933703 100644 --- a/skills/apple/apple-reminders/SKILL.md +++ b/skills/apple/apple-reminders/SKILL.md @@ -8,6 +8,8 @@ platforms: [macos] metadata: hermes: tags: [Reminders, tasks, todo, macOS, Apple] +prerequisites: + commands: [remindctl] --- # Apple Reminders diff --git a/skills/apple/imessage/SKILL.md b/skills/apple/imessage/SKILL.md index 777461d376..82df6a6ecf 100644 --- a/skills/apple/imessage/SKILL.md +++ b/skills/apple/imessage/SKILL.md @@ -8,6 +8,8 @@ platforms: [macos] metadata: hermes: tags: [iMessage, SMS, messaging, macOS, Apple] +prerequisites: + commands: [imsg] --- # iMessage diff --git a/skills/email/himalaya/SKILL.md b/skills/email/himalaya/SKILL.md index 08517ebc1b..ddbf51aaec 100644 --- a/skills/email/himalaya/SKILL.md +++ b/skills/email/himalaya/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [Email, IMAP, SMTP, CLI, Communication] homepage: https://github.com/pimalaya/himalaya +prerequisites: + commands: [himalaya] --- # Himalaya Email CLI diff --git a/skills/feeds/blogwatcher/SKILL.md b/skills/feeds/blogwatcher/SKILL.md index 4aadfe9432..c1ea4ac240 100644 --- a/skills/feeds/blogwatcher/SKILL.md +++ b/skills/feeds/blogwatcher/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [RSS, Blogs, Feed-Reader, Monitoring] homepage: https://github.com/Hyaxia/blogwatcher +prerequisites: + commands: [blogwatcher] --- # Blogwatcher diff --git a/skills/gifs/gif-search/SKILL.md b/skills/gifs/gif-search/SKILL.md index a255b934d8..ee55cac886 100644 --- a/skills/gifs/gif-search/SKILL.md +++ b/skills/gifs/gif-search/SKILL.md @@ -1,9 +1,12 @@ --- name: gif-search description: Search and download GIFs from Tenor using curl. No dependencies beyond curl and jq. Useful for finding reaction GIFs, creating visual content, and sending GIFs in chat. -version: 1.0.0 +version: 1.1.0 author: Hermes Agent license: MIT +prerequisites: + env_vars: [TENOR_API_KEY] + commands: [curl, jq] metadata: hermes: tags: [GIF, Media, Search, Tenor, API] @@ -13,32 +16,43 @@ metadata: Search and download GIFs directly via the Tenor API using curl. No extra tools needed. +## Setup + +Set your Tenor API key in your environment (add to `~/.hermes/.env`): + +```bash +TENOR_API_KEY=your_key_here +``` + +Get a free API key at https://developers.google.com/tenor/guides/quickstart — the Google Cloud Console Tenor API key is free and has generous rate limits. + ## Prerequisites -- `curl` and `jq` (both standard on Linux) +- `curl` and `jq` (both standard on macOS/Linux) +- `TENOR_API_KEY` environment variable ## Search for GIFs ```bash # Search and get GIF URLs -curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | jq -r '.results[].media_formats.gif.url' +curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.gif.url' # Get smaller/preview versions -curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | jq -r '.results[].media_formats.tinygif.url' +curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.tinygif.url' ``` ## Download a GIF ```bash # Search and download the top result -URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | jq -r '.results[0].media_formats.gif.url') +URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=${TENOR_API_KEY}" | jq -r '.results[0].media_formats.gif.url') curl -sL "$URL" -o celebration.gif ``` ## Get Full Metadata ```bash -curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}' +curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=${TENOR_API_KEY}" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}' ``` ## API Parameters @@ -47,7 +61,7 @@ curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=AIzaSyAyimkuYQ |-----------|-------------| | `q` | Search query (URL-encode spaces as `+`) | | `limit` | Max results (1-50, default 20) | -| `key` | API key (the one above is Tenor's public demo key) | +| `key` | API key (from `$TENOR_API_KEY` env var) | | `media_filter` | Filter formats: `gif`, `tinygif`, `mp4`, `tinymp4`, `webm` | | `contentfilter` | Safety: `off`, `low`, `medium`, `high` | | `locale` | Language: `en_US`, `es`, `fr`, etc. | @@ -67,7 +81,6 @@ Each result has multiple formats under `.media_formats`: ## Notes -- The API key above is Tenor's public demo key — it works but has rate limits - URL-encode the query: spaces as `+`, special chars as `%XX` - For sending in chat, `tinygif` URLs are lighter weight - GIF URLs can be used directly in markdown: `![alt](url)` diff --git a/skills/github/codebase-inspection/SKILL.md b/skills/github/codebase-inspection/SKILL.md index ca71ffdf90..6954ad841a 100644 --- a/skills/github/codebase-inspection/SKILL.md +++ b/skills/github/codebase-inspection/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository] related_skills: [github-repo-management] +prerequisites: + commands: [pygount] --- # Codebase Inspection with pygount diff --git a/skills/mcp/mcporter/SKILL.md b/skills/mcp/mcporter/SKILL.md index 0bb08441c8..acb6fcfb0d 100644 --- a/skills/mcp/mcporter/SKILL.md +++ b/skills/mcp/mcporter/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [MCP, Tools, API, Integrations, Interop] homepage: https://mcporter.dev +prerequisites: + commands: [npx] --- # mcporter diff --git a/skills/music-creation/songsee/SKILL.md b/skills/music-creation/songsee/SKILL.md index 4ad4752e36..11bcca0c7d 100644 --- a/skills/music-creation/songsee/SKILL.md +++ b/skills/music-creation/songsee/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [Audio, Visualization, Spectrogram, Music, Analysis] homepage: https://github.com/steipete/songsee +prerequisites: + commands: [songsee] --- # songsee diff --git a/skills/productivity/notion/SKILL.md b/skills/productivity/notion/SKILL.md index eb6cf1c2b3..c74d0df619 100644 --- a/skills/productivity/notion/SKILL.md +++ b/skills/productivity/notion/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [Notion, Productivity, Notes, Database, API] homepage: https://developers.notion.com +prerequisites: + env_vars: [NOTION_API_KEY] --- # Notion API diff --git a/skills/research/duckduckgo-search/SKILL.md b/skills/research/duckduckgo-search/SKILL.md index 33742ff181..8066b09cc9 100644 --- a/skills/research/duckduckgo-search/SKILL.md +++ b/skills/research/duckduckgo-search/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [search, duckduckgo, web-search, free, fallback] related_skills: [arxiv] +prerequisites: + commands: [ddgs] --- # DuckDuckGo Search (Firecrawl Fallback) diff --git a/skills/smart-home/openhue/SKILL.md b/skills/smart-home/openhue/SKILL.md index 9b22528566..b3efd1700b 100644 --- a/skills/smart-home/openhue/SKILL.md +++ b/skills/smart-home/openhue/SKILL.md @@ -8,6 +8,8 @@ metadata: hermes: tags: [Smart-Home, Hue, Lights, IoT, Automation] homepage: https://www.openhue.io/cli +prerequisites: + commands: [openhue] --- # OpenHue CLI diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a35983b5f3..dbedf184d3 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -8,6 +8,7 @@ from agent.prompt_builder import ( _scan_context_content, _truncate_content, _read_skill_description, + _skill_prerequisites_met, build_skills_system_prompt, build_context_files_prompt, CONTEXT_FILE_MAX_CHARS, @@ -211,6 +212,69 @@ class TestBuildSkillsSystemPrompt: assert "imessage" in result assert "Send iMessages" in result + def test_excludes_skills_with_unmet_prerequisites(self, monkeypatch, tmp_path): + """Skills with missing env var prerequisites should not appear.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False) + skills_dir = tmp_path / "skills" / "media" + + gated = skills_dir / "gated-skill" + gated.mkdir(parents=True) + (gated / "SKILL.md").write_text( + "---\nname: gated-skill\ndescription: Needs a key\n" + "prerequisites:\n env_vars: [MISSING_API_KEY_XYZ]\n---\n" + ) + + available = skills_dir / "free-skill" + available.mkdir(parents=True) + (available / "SKILL.md").write_text( + "---\nname: free-skill\ndescription: No prereqs\n---\n" + ) + + result = build_skills_system_prompt() + assert "free-skill" in result + assert "gated-skill" not in result + + def test_includes_skills_with_met_prerequisites(self, monkeypatch, tmp_path): + """Skills with satisfied prerequisites should appear normally.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("MY_API_KEY", "test_value") + skills_dir = tmp_path / "skills" / "media" + + skill = skills_dir / "ready-skill" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: ready-skill\ndescription: Has key\n" + "prerequisites:\n env_vars: [MY_API_KEY]\n---\n" + ) + + result = build_skills_system_prompt() + assert "ready-skill" in result + + +# ========================================================================= +# _skill_prerequisites_met +# ========================================================================= + + +class TestSkillPrerequisitesMet: + def test_met_or_absent(self, tmp_path, monkeypatch): + """No prereqs, met prereqs, and missing file all return True.""" + monkeypatch.setenv("PRESENT_KEY_123", "val") + basic = tmp_path / "basic.md" + basic.write_text("---\nname: basic\ndescription: basic\n---\n") + ready = tmp_path / "ready.md" + ready.write_text("---\nname: ready\ndescription: ready\nprerequisites:\n env_vars: [PRESENT_KEY_123]\n---\n") + assert _skill_prerequisites_met(basic) is True + assert _skill_prerequisites_met(ready) is True + assert _skill_prerequisites_met(tmp_path / "nope.md") is True + + def test_unmet_returns_false(self, tmp_path, monkeypatch): + monkeypatch.delenv("NONEXISTENT_KEY_ABC", raising=False) + skill = tmp_path / "SKILL.md" + skill.write_text("---\nname: gated\ndescription: gated\nprerequisites:\n env_vars: [NONEXISTENT_KEY_ABC]\n---\n") + assert _skill_prerequisites_met(skill) is False + # ========================================================================= # Context files prompt builder diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index 629d3b4786..aab9ed10ae 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, + check_skill_prerequisites, skill_matches_platform, skills_list, skills_categories, @@ -464,3 +465,124 @@ class TestFindAllSkillsPlatformFiltering: assert len(skills_darwin) == 1 assert len(skills_linux) == 1 assert len(skills_win) == 0 + + +# --------------------------------------------------------------------------- +# check_skill_prerequisites +# --------------------------------------------------------------------------- + + +class TestCheckSkillPrerequisites: + def test_no_or_empty_prerequisites(self): + """No field, empty dict, or non-dict all pass.""" + assert check_skill_prerequisites({})[0] is True + assert check_skill_prerequisites({"prerequisites": {}})[0] is True + assert check_skill_prerequisites({"prerequisites": "curl"})[0] is True + + def test_env_var_present_and_missing(self, monkeypatch): + monkeypatch.setenv("MY_TEST_KEY", "val") + monkeypatch.delenv("NONEXISTENT_TEST_VAR_XYZ", raising=False) + assert check_skill_prerequisites({"prerequisites": {"env_vars": ["MY_TEST_KEY"]}})[0] is True + met, missing = check_skill_prerequisites({"prerequisites": {"env_vars": ["NONEXISTENT_TEST_VAR_XYZ"]}}) + assert met is False + assert "env $NONEXISTENT_TEST_VAR_XYZ" in missing + + def test_command_present_and_missing(self): + assert check_skill_prerequisites({"prerequisites": {"commands": ["python3"]}})[0] is True + met, missing = check_skill_prerequisites({"prerequisites": {"commands": ["nonexistent_binary_xyz_123"]}}) + assert met is False + assert "command `nonexistent_binary_xyz_123`" in missing + + def test_mixed_env_and_commands(self, monkeypatch): + monkeypatch.delenv("MISSING_A", raising=False) + met, missing = check_skill_prerequisites({ + "prerequisites": { + "env_vars": ["MISSING_A"], + "commands": ["python3", "nonexistent_cmd_xyz"], + } + }) + assert met is False + assert len(missing) == 2 + + def test_string_instead_of_list(self, monkeypatch): + """YAML scalar (string) should be coerced to a single-element list.""" + monkeypatch.delenv("SOLO_VAR", raising=False) + assert check_skill_prerequisites({"prerequisites": {"env_vars": "SOLO_VAR"}})[0] is False + assert check_skill_prerequisites({"prerequisites": {"commands": "nonexistent_cmd_xyz_solo"}})[0] is False + + +# --------------------------------------------------------------------------- +# _find_all_skills — prerequisites integration +# --------------------------------------------------------------------------- + + +class TestFindAllSkillsPrerequisites: + def test_skills_with_unmet_prereqs_flagged(self, tmp_path, monkeypatch): + monkeypatch.delenv("NONEXISTENT_API_KEY_XYZ", raising=False) + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, "needs-key", + frontmatter_extra="prerequisites:\n env_vars: [NONEXISTENT_API_KEY_XYZ]\n", + ) + skills = _find_all_skills() + assert len(skills) == 1 + assert skills[0]["prerequisites_met"] is False + assert any("NONEXISTENT_API_KEY_XYZ" in m for m in skills[0]["prerequisites_missing"]) + + def test_skills_with_met_prereqs_no_flag(self, tmp_path, monkeypatch): + monkeypatch.setenv("MY_PRESENT_KEY", "val") + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, "has-key", + frontmatter_extra="prerequisites:\n env_vars: [MY_PRESENT_KEY]\n", + ) + skills = _find_all_skills() + assert len(skills) == 1 + assert "prerequisites_met" not in skills[0] + + def test_skills_without_prereqs_no_flag(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "simple-skill") + skills = _find_all_skills() + assert len(skills) == 1 + assert "prerequisites_met" not in skills[0] + + +# --------------------------------------------------------------------------- +# skill_view — prerequisites warnings +# --------------------------------------------------------------------------- + + +class TestSkillViewPrerequisites: + def test_warns_on_unmet_prerequisites(self, tmp_path, monkeypatch): + monkeypatch.delenv("MISSING_KEY_XYZ", raising=False) + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, "gated-skill", + frontmatter_extra="prerequisites:\n env_vars: [MISSING_KEY_XYZ]\n", + ) + raw = skill_view("gated-skill") + result = json.loads(raw) + assert result["success"] is True + assert result["prerequisites_met"] is False + assert "MISSING_KEY_XYZ" in result["prerequisites_warning"] + + def test_no_warning_when_prereqs_met(self, tmp_path, monkeypatch): + monkeypatch.setenv("PRESENT_KEY", "value") + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill( + tmp_path, "ready-skill", + frontmatter_extra="prerequisites:\n env_vars: [PRESENT_KEY]\n", + ) + raw = skill_view("ready-skill") + result = json.loads(raw) + assert result["success"] is True + assert "prerequisites_warning" not in result + + def test_no_warning_when_no_prereqs(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "plain-skill") + raw = skill_view("plain-skill") + result = json.loads(raw) + assert result["success"] is True + assert "prerequisites_warning" not in result diff --git a/tools/skills_tool.py b/tools/skills_tool.py index e8baa0f595..dce15c4497 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -34,6 +34,11 @@ SKILL.md Format (YAML Frontmatter, agentskills.io compatible): platforms: [macos] # Optional — restrict to specific OS platforms # Valid: macos, linux, windows # Omit to load on all platforms (default) + prerequisites: # Optional — runtime requirements + env_vars: [API_KEY] # Env vars that must be set (checked via os.getenv) + commands: [curl, jq] # CLI binaries that must be on PATH (checked via shutil.which) + # Skills with unmet prerequisites are hidden from the + # system prompt and flagged with a warning in skill_view. compatibility: Requires X # Optional (agentskills.io) metadata: # Optional, arbitrary key-value (agentskills.io) hermes: @@ -65,6 +70,7 @@ Usage: import json import os import re +import shutil import sys from pathlib import Path from typing import Dict, Any, List, Optional, Tuple @@ -118,6 +124,43 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: return False +def check_skill_prerequisites(frontmatter: Dict[str, Any]) -> Tuple[bool, List[str]]: + """Check if a skill's declared prerequisites are satisfied. + + Skills declare prerequisites via a top-level ``prerequisites`` dict + in their YAML frontmatter:: + + prerequisites: + env_vars: [TENOR_API_KEY] + commands: [curl, jq] + + Returns: + (all_met, missing) — True + empty list if all met, else False + list + of human-readable descriptions of what's missing. + """ + prereqs = frontmatter.get("prerequisites") + if not prereqs or not isinstance(prereqs, dict): + return True, [] + + missing: List[str] = [] + + env_vars = prereqs.get("env_vars") or [] + if isinstance(env_vars, str): + env_vars = [env_vars] + for var in env_vars: + if not os.getenv(str(var)): + missing.append(f"env ${var}") + + commands = prereqs.get("commands") or [] + if isinstance(commands, str): + commands = [commands] + for cmd in commands: + if not shutil.which(str(cmd)): + missing.append(f"command `{cmd}`") + + return (len(missing) == 0), missing + + def check_skills_requirements() -> bool: """Skills are always available -- the directory is created on first use if needed.""" return True @@ -262,12 +305,19 @@ def _find_all_skills() -> List[Dict[str, Any]]: description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." category = _get_category_from_path(skill_md) - - skills.append({ + + prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter) + + entry = { "name": name, "description": description, "category": category, - }) + } + if not prereqs_met: + entry["prerequisites_met"] = False + entry["prerequisites_missing"] = prereqs_missing + + skills.append(entry) except Exception: continue @@ -635,6 +685,17 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: "usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files else None } + # Prerequisite check — warn the agent if requirements are unmet + prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter) + if not prereqs_met: + result["prerequisites_met"] = False + result["prerequisites_missing"] = prereqs_missing + result["prerequisites_warning"] = ( + f"This skill requires {', '.join(prereqs_missing)} which " + f"{'is' if len(prereqs_missing) == 1 else 'are'} not available. " + f"Tell the user what's needed before attempting to use this skill." + ) + # Surface agentskills.io optional fields when present if frontmatter.get('compatibility'): result["compatibility"] = frontmatter['compatibility']