Merge pull request #685 from NousResearch/revert-659-feat/skill-prerequisites
Revert "feat: skill prerequisites — hide skills with unmet runtime dependencies"
This commit is contained in:
commit
f016cfca46
17 changed files with 11 additions and 336 deletions
|
|
@ -328,11 +328,6 @@ 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]
|
||||||
|
|
@ -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.
|
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`).
|
||||||
|
|
|
||||||
|
|
@ -170,22 +170,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -207,9 +191,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ platforms: [macos]
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
tags: [iMessage, SMS, messaging, macOS, Apple]
|
tags: [iMessage, SMS, messaging, macOS, Apple]
|
||||||
prerequisites:
|
|
||||||
commands: [imsg]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# iMessage
|
# iMessage
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
---
|
---
|
||||||
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.1.0
|
version: 1.0.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]
|
||||||
|
|
@ -16,43 +13,32 @@ 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 macOS/Linux)
|
- `curl` and `jq` (both standard on 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=${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
|
# 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
|
## 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=${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
|
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=${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
|
## 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 `+`) |
|
| `q` | Search query (URL-encode spaces as `+`) |
|
||||||
| `limit` | Max results (1-50, default 20) |
|
| `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` |
|
| `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. |
|
||||||
|
|
@ -81,6 +67,7 @@ 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: ``
|
- GIF URLs can be used directly in markdown: ``
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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,
|
||||||
|
|
@ -212,69 +211,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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,
|
||||||
|
|
@ -465,124 +464,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,6 @@ 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:
|
||||||
|
|
@ -70,7 +65,6 @@ 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
|
||||||
|
|
@ -124,43 +118,6 @@ 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
|
||||||
|
|
@ -305,19 +262,12 @@ 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)
|
||||||
|
|
||||||
prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter)
|
skills.append({
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -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
|
"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']
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue