Revert "feat: skill prerequisites — hide skills with unmet runtime dependencies"

This commit is contained in:
Teknium 2026-03-08 03:58:13 -07:00 committed by GitHub
parent 0df7df52f3
commit b8120df860
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 11 additions and 336 deletions

View file

@ -328,11 +328,6 @@ 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]
@ -371,25 +366,6 @@ 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`).

View file

@ -170,22 +170,6 @@ 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.
@ -207,9 +191,6 @@ 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:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,9 @@
---
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.1.0
version: 1.0.0
author: Hermes Agent
license: MIT
prerequisites:
env_vars: [TENOR_API_KEY]
commands: [curl, jq]
metadata:
hermes:
tags: [GIF, Media, Search, Tenor, API]
@ -16,43 +13,32 @@ 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 macOS/Linux)
- `TENOR_API_KEY` environment variable
- `curl` and `jq` (both standard on Linux)
## Search for GIFs
```bash
# Search and get GIF URLs
curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.gif.url'
curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | 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=${TENOR_API_KEY}" | jq -r '.results[].media_formats.tinygif.url'
curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | 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=${TENOR_API_KEY}" | jq -r '.results[0].media_formats.gif.url')
URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | 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=${TENOR_API_KEY}" | 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=AIzaSyAyimkuYQYF_FXVALexPuGQctUWRURdCYQ" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}'
```
## API Parameters
@ -61,7 +47,7 @@ curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=${TENOR_API_KE
|-----------|-------------|
| `q` | Search query (URL-encode spaces as `+`) |
| `limit` | Max results (1-50, default 20) |
| `key` | API key (from `$TENOR_API_KEY` env var) |
| `key` | API key (the one above is Tenor's public demo key) |
| `media_filter` | Filter formats: `gif`, `tinygif`, `mp4`, `tinymp4`, `webm` |
| `contentfilter` | Safety: `off`, `low`, `medium`, `high` |
| `locale` | Language: `en_US`, `es`, `fr`, etc. |
@ -81,6 +67,7 @@ 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)`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ 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,
@ -212,69 +211,6 @@ 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

View file

@ -11,7 +11,6 @@ from tools.skills_tool import (
_estimate_tokens,
_find_all_skills,
_load_category_description,
check_skill_prerequisites,
skill_matches_platform,
skills_list,
skills_categories,
@ -465,124 +464,3 @@ 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

View file

@ -34,11 +34,6 @@ 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:
@ -70,7 +65,6 @@ Usage:
import json
import os
import re
import shutil
import sys
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
@ -124,43 +118,6 @@ 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
@ -305,19 +262,12 @@ def _find_all_skills() -> List[Dict[str, Any]]:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
category = _get_category_from_path(skill_md)
prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter)
entry = {
skills.append({
"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
@ -685,17 +635,6 @@ 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']