feat: add prerequisites field to skill spec — hide skills with unmet dependencies

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
This commit is contained in:
kshitij 2026-03-08 12:55:09 +05:30
parent 76545ab365
commit f210510276
17 changed files with 336 additions and 11 deletions

View file

@ -328,6 +328,11 @@ license: MIT
platforms: [macos, linux] # Optional — restrict to specific OS platforms platforms: [macos, linux] # Optional — restrict to specific OS platforms
# Valid: macos, linux, windows # Valid: macos, linux, windows
# Omit to load on all platforms (default) # 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: metadata:
hermes: hermes:
tags: [Category, Subcategory, Keywords] 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. 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 ### Skill guidelines
- **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`). - **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`).

View file

@ -170,6 +170,22 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool:
return True # Err on the side of showing the skill 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: def build_skills_system_prompt() -> str:
"""Build a compact skill index for the system prompt. """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 # Skip skills incompatible with the current OS platform
if not _skill_is_platform_compatible(skill_file): if not _skill_is_platform_compatible(skill_file):
continue 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) rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts parts = rel_path.parts
if len(parts) >= 2: if len(parts) >= 2:

View file

@ -9,6 +9,8 @@ metadata:
hermes: hermes:
tags: [Notes, Apple, macOS, note-taking] tags: [Notes, Apple, macOS, note-taking]
related_skills: [obsidian] related_skills: [obsidian]
prerequisites:
commands: [memo]
--- ---
# Apple Notes # Apple Notes

View file

@ -8,6 +8,8 @@ platforms: [macos]
metadata: metadata:
hermes: hermes:
tags: [Reminders, tasks, todo, macOS, Apple] tags: [Reminders, tasks, todo, macOS, Apple]
prerequisites:
commands: [remindctl]
--- ---
# Apple Reminders # Apple Reminders

View file

@ -8,6 +8,8 @@ platforms: [macos]
metadata: metadata:
hermes: hermes:
tags: [iMessage, SMS, messaging, macOS, Apple] tags: [iMessage, SMS, messaging, macOS, Apple]
prerequisites:
commands: [imsg]
--- ---
# iMessage # iMessage

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [Email, IMAP, SMTP, CLI, Communication] tags: [Email, IMAP, SMTP, CLI, Communication]
homepage: https://github.com/pimalaya/himalaya homepage: https://github.com/pimalaya/himalaya
prerequisites:
commands: [himalaya]
--- ---
# Himalaya Email CLI # Himalaya Email CLI

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [RSS, Blogs, Feed-Reader, Monitoring] tags: [RSS, Blogs, Feed-Reader, Monitoring]
homepage: https://github.com/Hyaxia/blogwatcher homepage: https://github.com/Hyaxia/blogwatcher
prerequisites:
commands: [blogwatcher]
--- ---
# Blogwatcher # Blogwatcher

View file

@ -1,9 +1,12 @@
--- ---
name: gif-search 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. 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 author: Hermes Agent
license: MIT license: MIT
prerequisites:
env_vars: [TENOR_API_KEY]
commands: [curl, jq]
metadata: metadata:
hermes: hermes:
tags: [GIF, Media, Search, Tenor, API] 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. 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 ## Prerequisites
- `curl` and `jq` (both standard on Linux) - `curl` and `jq` (both standard on macOS/Linux)
- `TENOR_API_KEY` environment variable
## Search for GIFs ## Search for GIFs
```bash ```bash
# Search and get GIF URLs # 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 # 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 ## Download a GIF
```bash ```bash
# Search and download the top result # 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 curl -sL "$URL" -o celebration.gif
``` ```
## Get Full Metadata ## Get Full Metadata
```bash ```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 ## 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 `+`) | | `q` | Search query (URL-encode spaces as `+`) |
| `limit` | Max results (1-50, default 20) | | `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` | | `media_filter` | Filter formats: `gif`, `tinygif`, `mp4`, `tinymp4`, `webm` |
| `contentfilter` | Safety: `off`, `low`, `medium`, `high` | | `contentfilter` | Safety: `off`, `low`, `medium`, `high` |
| `locale` | Language: `en_US`, `es`, `fr`, etc. | | `locale` | Language: `en_US`, `es`, `fr`, etc. |
@ -67,7 +81,6 @@ Each result has multiple formats under `.media_formats`:
## Notes ## 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` - URL-encode the query: spaces as `+`, special chars as `%XX`
- For sending in chat, `tinygif` URLs are lighter weight - For sending in chat, `tinygif` URLs are lighter weight
- GIF URLs can be used directly in markdown: `![alt](url)` - GIF URLs can be used directly in markdown: `![alt](url)`

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository] tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository]
related_skills: [github-repo-management] related_skills: [github-repo-management]
prerequisites:
commands: [pygount]
--- ---
# Codebase Inspection with pygount # Codebase Inspection with pygount

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [MCP, Tools, API, Integrations, Interop] tags: [MCP, Tools, API, Integrations, Interop]
homepage: https://mcporter.dev homepage: https://mcporter.dev
prerequisites:
commands: [npx]
--- ---
# mcporter # mcporter

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [Audio, Visualization, Spectrogram, Music, Analysis] tags: [Audio, Visualization, Spectrogram, Music, Analysis]
homepage: https://github.com/steipete/songsee homepage: https://github.com/steipete/songsee
prerequisites:
commands: [songsee]
--- ---
# songsee # songsee

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [Notion, Productivity, Notes, Database, API] tags: [Notion, Productivity, Notes, Database, API]
homepage: https://developers.notion.com homepage: https://developers.notion.com
prerequisites:
env_vars: [NOTION_API_KEY]
--- ---
# Notion API # Notion API

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [search, duckduckgo, web-search, free, fallback] tags: [search, duckduckgo, web-search, free, fallback]
related_skills: [arxiv] related_skills: [arxiv]
prerequisites:
commands: [ddgs]
--- ---
# DuckDuckGo Search (Firecrawl Fallback) # DuckDuckGo Search (Firecrawl Fallback)

View file

@ -8,6 +8,8 @@ metadata:
hermes: hermes:
tags: [Smart-Home, Hue, Lights, IoT, Automation] tags: [Smart-Home, Hue, Lights, IoT, Automation]
homepage: https://www.openhue.io/cli homepage: https://www.openhue.io/cli
prerequisites:
commands: [openhue]
--- ---
# OpenHue CLI # OpenHue CLI

View file

@ -8,6 +8,7 @@ from agent.prompt_builder import (
_scan_context_content, _scan_context_content,
_truncate_content, _truncate_content,
_read_skill_description, _read_skill_description,
_skill_prerequisites_met,
build_skills_system_prompt, build_skills_system_prompt,
build_context_files_prompt, build_context_files_prompt,
CONTEXT_FILE_MAX_CHARS, CONTEXT_FILE_MAX_CHARS,
@ -211,6 +212,69 @@ class TestBuildSkillsSystemPrompt:
assert "imessage" in result assert "imessage" in result
assert "Send iMessages" 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 # Context files prompt builder

View file

@ -11,6 +11,7 @@ from tools.skills_tool import (
_estimate_tokens, _estimate_tokens,
_find_all_skills, _find_all_skills,
_load_category_description, _load_category_description,
check_skill_prerequisites,
skill_matches_platform, skill_matches_platform,
skills_list, skills_list,
skills_categories, skills_categories,
@ -464,3 +465,124 @@ class TestFindAllSkillsPlatformFiltering:
assert len(skills_darwin) == 1 assert len(skills_darwin) == 1
assert len(skills_linux) == 1 assert len(skills_linux) == 1
assert len(skills_win) == 0 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

View file

@ -34,6 +34,11 @@ SKILL.md Format (YAML Frontmatter, agentskills.io compatible):
platforms: [macos] # Optional — restrict to specific OS platforms platforms: [macos] # Optional — restrict to specific OS platforms
# Valid: macos, linux, windows # Valid: macos, linux, windows
# Omit to load on all platforms (default) # 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) compatibility: Requires X # Optional (agentskills.io)
metadata: # Optional, arbitrary key-value (agentskills.io) metadata: # Optional, arbitrary key-value (agentskills.io)
hermes: hermes:
@ -65,6 +70,7 @@ Usage:
import json import json
import os import os
import re import re
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
@ -118,6 +124,43 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False 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: def check_skills_requirements() -> bool:
"""Skills are always available -- the directory is created on first use if needed.""" """Skills are always available -- the directory is created on first use if needed."""
return True return True
@ -262,12 +305,19 @@ def _find_all_skills() -> List[Dict[str, Any]]:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md) category = _get_category_from_path(skill_md)
skills.append({ prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter)
entry = {
"name": name, "name": name,
"description": description, "description": description,
"category": category, "category": category,
}) }
if not prereqs_met:
entry["prerequisites_met"] = False
entry["prerequisites_missing"] = prereqs_missing
skills.append(entry)
except Exception: except Exception:
continue 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 "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 # Surface agentskills.io optional fields when present
if frontmatter.get('compatibility'): if frontmatter.get('compatibility'):
result["compatibility"] = frontmatter['compatibility'] result["compatibility"] = frontmatter['compatibility']