feat: optional skills — official skills shipped but not activated by default

Add 'optional-skills/' directory for official skills that ship with the repo
but are not copied to ~/.hermes/skills/ during setup. They are:
- NOT shown to the model in the system prompt
- NOT copied during hermes setup/update
- Discoverable via 'hermes skills search' labeled as 'official'
- Installable via 'hermes skills install' with builtin trust (no third-party warning)
- Auto-categorized on install based on directory structure

Implementation:
- OptionalSkillSource adapter in tools/skills_hub.py (search/fetch/inspect)
- Added to create_source_router() as first source (highest priority)
- Trust level 'builtin' for official skills in skills_guard.py
- Friendly install message for official skills (no third-party warning)
- 'official' label in cyan in search results and skill list

First optional skill: Blackbox CLI (autonomous-ai-agents/blackbox)
- Multi-model coding agent with built-in judge/Chairman pattern
- Delegates to Claude, Codex, Gemini, and Blackbox models
- Open-source CLI (GPL-3.0, TypeScript, forked from Gemini CLI)
- Requires paid Blackbox AI API key

Refs: #475
This commit is contained in:
teknium1
2026-03-06 01:24:11 -08:00
parent 8c80b96318
commit f2e24faaca
6 changed files with 358 additions and 14 deletions

View File

@@ -5,6 +5,7 @@ Skills Hub — Source adapters and hub state management for the Hermes Skills Hu
This is a library module (not an agent tool). It provides:
- GitHubAuth: Shared GitHub API authentication (PAT, gh CLI, GitHub App)
- SkillSource ABC: Interface for all skill registry adapters
- OptionalSkillSource: Official optional skills shipped with the repo (not activated by default)
- GitHubSource: Fetch skills from any GitHub repo via the Contents API
- HubLockFile: Track provenance of installed hub skills
- Hub state directory management (quarantine, audit log, taps, index cache)
@@ -941,6 +942,160 @@ class LobeHubSource(SkillSource):
return "\n".join(fm_lines) + "\n\n" + "\n".join(body_lines) + "\n"
# ---------------------------------------------------------------------------
# Official optional skills source adapter
# ---------------------------------------------------------------------------
class OptionalSkillSource(SkillSource):
"""
Fetch skills from the optional-skills/ directory shipped with the repo.
These skills are official (maintained by Nous Research) but not activated
by default — they don't appear in the system prompt and aren't copied to
~/.hermes/skills/ during setup. They are discoverable via the Skills Hub
(search / install / inspect) and labelled "official" with "builtin" trust.
"""
def __init__(self):
self._optional_dir = Path(__file__).parent.parent / "optional-skills"
def source_id(self) -> str:
return "official"
def trust_level_for(self, identifier: str) -> str:
return "builtin"
# -- search -----------------------------------------------------------
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
results: List[SkillMeta] = []
query_lower = query.lower()
for meta in self._scan_all():
searchable = f"{meta.name} {meta.description} {' '.join(meta.tags)}".lower()
if query_lower in searchable:
results.append(meta)
if len(results) >= limit:
break
return results
# -- fetch ------------------------------------------------------------
def fetch(self, identifier: str) -> Optional[SkillBundle]:
# identifier format: "official/category/skill" or "official/skill"
rel = identifier.split("/", 1)[-1] if identifier.startswith("official/") else identifier
skill_dir = self._optional_dir / rel
if not skill_dir.is_dir():
# Try searching by skill name only (last segment)
skill_name = rel.rsplit("/", 1)[-1]
skill_dir = self._find_skill_dir(skill_name)
if not skill_dir:
return None
files: Dict[str, str] = {}
for f in skill_dir.rglob("*"):
if f.is_file() and not f.name.startswith("."):
rel_path = str(f.relative_to(skill_dir))
try:
files[rel_path] = f.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
if not files:
return None
# Determine category from directory structure
name = skill_dir.name
return SkillBundle(
name=name,
files=files,
source="official",
identifier=f"official/{skill_dir.relative_to(self._optional_dir)}",
trust_level="builtin",
)
# -- inspect ----------------------------------------------------------
def inspect(self, identifier: str) -> Optional[SkillMeta]:
rel = identifier.split("/", 1)[-1] if identifier.startswith("official/") else identifier
skill_name = rel.rsplit("/", 1)[-1]
for meta in self._scan_all():
if meta.name == skill_name:
return meta
return None
# -- internal helpers -------------------------------------------------
def _find_skill_dir(self, name: str) -> Optional[Path]:
"""Find a skill directory by name anywhere in optional-skills/."""
if not self._optional_dir.is_dir():
return None
for skill_md in self._optional_dir.rglob("SKILL.md"):
if skill_md.parent.name == name:
return skill_md.parent
return None
def _scan_all(self) -> List[SkillMeta]:
"""Enumerate all optional skills with metadata."""
if not self._optional_dir.is_dir():
return []
results: List[SkillMeta] = []
for skill_md in sorted(self._optional_dir.rglob("SKILL.md")):
parent = skill_md.parent
rel_parts = parent.relative_to(self._optional_dir).parts
if any(part.startswith(".") for part in rel_parts):
continue
try:
content = skill_md.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
fm = self._parse_frontmatter(content)
name = fm.get("name", parent.name)
desc = fm.get("description", "")
tags = []
meta_block = fm.get("metadata", {})
if isinstance(meta_block, dict):
hermes_meta = meta_block.get("hermes", {})
if isinstance(hermes_meta, dict):
tags = hermes_meta.get("tags", [])
rel_path = str(parent.relative_to(self._optional_dir))
results.append(SkillMeta(
name=name,
description=desc[:200],
source="official",
identifier=f"official/{rel_path}",
trust_level="builtin",
path=rel_path,
tags=tags if isinstance(tags, list) else [],
))
return results
@staticmethod
def _parse_frontmatter(content: str) -> dict:
"""Parse YAML frontmatter from SKILL.md content."""
if not content.startswith("---"):
return {}
match = re.search(r'\n---\s*\n', content[3:])
if not match:
return {}
yaml_text = content[3:match.start() + 3]
try:
parsed = yaml.safe_load(yaml_text)
return parsed if isinstance(parsed, dict) else {}
except yaml.YAMLError:
return {}
# ---------------------------------------------------------------------------
# Shared cache helpers (used by multiple adapters)
# ---------------------------------------------------------------------------
@@ -1219,6 +1374,7 @@ def create_source_router(auth: Optional[GitHubAuth] = None) -> List[SkillSource]
extra_taps = taps_mgr.list_taps()
sources: List[SkillSource] = [
OptionalSkillSource(), # Official optional skills (highest priority)
GitHubSource(auth=auth, extra_taps=extra_taps),
ClawHubSource(),
ClaudeMarketplaceSource(auth=auth),