Files
hermes-agent/hermes_cli/web_routers/skills.py
T

511 lines
19 KiB
Python

"""Skills dashboard routes.
Two routers because global route order matters: ``hub_router`` (skills-hub
install/search/scan) was registered before the profiles router include in
web_server, the plain skills CRUD ``router`` after it — each is mounted at its
original registration point. web_server-owned helpers are reached via the
late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
import asyncio
from typing import Optional
from fastapi import APIRouter, HTTPException
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
SkillContentUpdate,
SkillCreate,
SkillInstallRequest,
SkillToggle,
SkillUninstallRequest,
SkillsUpdateRequest,
)
from hermes_cli.web_routers._common import (
_profile_scope,
config_write_scope,
http_failure,
log as _log,
require,
scoped_to_thread,
spawn_profile_action,
)
hub_router = APIRouter()
router = APIRouter()
_config_profile_scope = late("_config_profile_scope")
_hub_action_name = late("_hub_action_name")
_installed_hub_identifiers = late("_installed_hub_identifiers")
load_config = late("load_config")
# Human-readable labels for each hub source id (matches `hermes skills search`
# provenance). Keep in sync with create_source_router()'s source list.
_SKILL_HUB_SOURCE_LABELS = {
"official": "Official (Nous)",
"hermes-index": "Hermes Index",
"skills-sh": "skills.sh",
"well-known": "Well-Known",
"url": "Direct URL",
"github": "GitHub",
"clawhub": "ClawHub",
"lobehub": "LobeHub",
"browse-sh": "browse.sh",
}
def _skill_meta_to_payload(m) -> dict:
return {
"name": m.name,
"description": m.description,
"source": m.source,
"identifier": m.identifier,
"trust_level": m.trust_level,
"repo": m.repo,
"tags": list(m.tags or []),
}
def _clear_skills_prompt_cache() -> None:
"""Best-effort: invalidate the skills system-prompt snapshot after a write.
Mirrors what ``skill_manage`` does so a dashboard-authored skill is picked
up by the next session without a manual cache reset.
"""
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
@hub_router.post("/api/skills/hub/install")
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
identifier = require(body.identifier, "identifier is required")
return spawn_profile_action(
body.profile or profile,
["skills", "install", identifier, "--yes"],
_hub_action_name("install", identifier),
log_msg="Failed to spawn skills install",
prefix="Failed to install skill",
)
@hub_router.post("/api/skills/hub/uninstall")
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
name = require(body.name, "name is required")
return spawn_profile_action(
body.profile or profile,
["skills", "uninstall", name, "--yes"],
_hub_action_name("uninstall", name),
log_msg="Failed to spawn skills uninstall",
prefix="Failed to uninstall skill",
)
@hub_router.post("/api/skills/hub/update")
async def update_skills_hub(
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
):
return spawn_profile_action(
(body.profile if body else None) or profile,
["skills", "update"],
"skills-update",
log_msg="Failed to spawn skills update",
prefix="Failed to update skills",
)
@hub_router.get("/api/skills/hub/official")
async def list_official_skills(profile: Optional[str] = None):
"""The ENTIRE built-in optional-skills catalog (local scan, no network),
each row marked installed-or-not for ``profile``."""
def _run():
from tools.skills_hub import OptionalSkillSource
installed = _installed_hub_identifiers(profile)
out = []
for m in OptionalSkillSource().list_local():
payload = _skill_meta_to_payload(m)
ident = payload.get("identifier") or ""
# identifier format: official/<category>/<skill> — surface the
# category for row subtitles.
rel = ident.split("/", 1)[-1] if "/" in ident else ident
payload["category"] = rel.split("/", 1)[0] if "/" in rel else "general"
payload["installed"] = ident in installed
out.append(payload)
return {"skills": out}
with http_failure("official skills catalog listing failed", 502, "Official catalog failed"):
return await asyncio.to_thread(_run)
@hub_router.get("/api/skills/hub/sources")
async def list_skills_hub_sources(profile: Optional[str] = None):
"""Configured skill-hub sources + installed-skill provenance, so the
Browse-hub tab has something to show before a search runs. ``profile``
scopes the installed-skill provenance."""
def _run():
from tools.skills_hub import create_source_router
with _config_profile_scope(profile):
sources = create_source_router()
out = []
index_available = False
featured = []
for src in sources:
sid = src.source_id()
entry = {
"id": sid,
"label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid),
}
# GitHub exposes a rate-limit flag; the index an availability flag.
if sid == "github":
try:
entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False))
except Exception:
entry["rate_limited"] = False
if sid == "hermes-index":
try:
index_available = bool(getattr(src, "is_available", False))
except Exception:
index_available = False
entry["available"] = index_available
# Empty-query search on the index returns featured/popular skills.
if index_available:
try:
featured = [
_skill_meta_to_payload(m) for m in src.search("", limit=12)
]
except Exception:
featured = []
out.append(entry)
# Which sources are worth searching individually (progressive per-source
# fan-out). Mirrors parallel_search_sources: an available index already
# subsumes the external API sources, so skipping them saves ~70 GitHub
# calls per keystroke. Keep in sync with that function's _api_source_ids.
_api_source_ids = frozenset(
{"github", "skills-sh", "clawhub", "lobehub", "well-known"}
)
for entry in out:
entry["searchable"] = not (index_available and entry["id"] in _api_source_ids)
return {
"sources": out,
"index_available": index_available,
"featured": featured,
"installed": _installed_hub_identifiers(profile),
}
with http_failure("skills hub sources listing failed", 502, "Hub sources failed"):
return await asyncio.to_thread(_run)
@hub_router.get("/api/skills/hub/search")
async def search_skills_hub(
q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None
):
"""Search the skill hub across all configured sources (network-bound,
runs in a thread). Results install by identifier via /hub/install."""
query = (q or "").strip()
if not query:
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
def _run():
from tools.skills_hub import create_source_router, parallel_search_sources
with _config_profile_scope(profile):
sources = create_source_router()
capped = min(max(limit, 1), 50)
all_results, source_counts, timed_out = parallel_search_sources(
sources, query=query, source_filter=source or "all", overall_timeout=30
)
# Dedupe by identifier, preferring higher trust (mirrors unified_search).
_rank = {"builtin": 2, "trusted": 1, "community": 0}
seen = {}
for r in all_results:
if r.identifier not in seen:
seen[r.identifier] = r
elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
deduped = list(seen.values())[:capped]
return {
"results": [_skill_meta_to_payload(m) for m in deduped],
"source_counts": source_counts,
"timed_out": timed_out,
"installed": _installed_hub_identifiers(profile),
}
with http_failure("skills hub search failed", 502, "Hub search failed"):
return await asyncio.to_thread(_run)
@hub_router.get("/api/skills/hub/preview")
async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""A hub skill's SKILL.md + file manifest WITHOUT installing it. Scoped to
``profile`` so a profile with different hub taps resolves against ITS
source router."""
ident = require(identifier, "identifier is required")
def _run():
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
from tools.skills_hub import create_source_router
with _config_profile_scope(profile):
sources = create_source_router()
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
if not bundle and not meta:
return None
files = {}
skill_md = ""
if bundle:
for rel, content in (bundle.files or {}).items():
if isinstance(content, bytes):
# Some sources store every file as bytes; decode text so
# SKILL.md renders, placeholder only for genuinely-binary data.
try:
files[rel] = content.decode("utf-8")
except UnicodeDecodeError:
files[rel] = "(binary file)"
else:
files[rel] = content
skill_md = files.get("SKILL.md", "") or ""
m = meta or bundle
return {
"name": getattr(m, "name", ident),
"description": getattr(m, "description", "") or "",
"source": getattr(m, "source", "") or "",
"identifier": getattr(m, "identifier", ident) or ident,
"trust_level": getattr(m, "trust_level", "community") or "community",
"repo": getattr(m, "repo", None),
"tags": list(getattr(m, "tags", None) or []),
"skill_md": skill_md,
"files": sorted(files.keys()),
}
try:
result = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub preview failed")
raise HTTPException(status_code=502, detail=f"Hub preview failed: {exc}")
if result is None:
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
return result
@hub_router.get("/api/skills/hub/scan")
async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""Run the install-time security scan on a hub skill WITHOUT installing it
(same ``scan_skill`` / ``should_allow_install`` pipeline as the CLI, on a
quarantined bundle that is cleaned up afterwards). Scoped to ``profile``
so the bundle resolves where an install would pull it from."""
ident = require(identifier, "identifier is required")
def _run():
import shutil as _shutil
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
from tools.skills_hub import create_source_router, quarantine_bundle
from tools.skills_guard import scan_skill, should_allow_install
with _config_profile_scope(profile):
sources = create_source_router()
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
if not bundle:
return None
if bundle.source == "official":
scan_source = "official"
else:
scan_source = (
getattr(bundle, "identifier", "")
or getattr(meta, "identifier", "")
or ident
)
q_path = None
tier1 = None
try:
q_path = quarantine_bundle(bundle)
result = scan_skill(q_path, source=scan_source)
# Advisory SkillEvaluator Tier 1 second opinion: optional binary,
# never blocks, errors degrade to no data (same as the CLI installer).
try:
from tools.skillevaluator_scan import (
run_tier1_scan, tier1_advisory_enabled,
)
if tier1_advisory_enabled():
t1 = run_tier1_scan(q_path)
if t1.available:
tier1 = {
"passed": t1.passed,
"incomplete_checks": t1.incomplete_checks,
"findings": [
{
"check": f.check,
"validator": f.validator,
"severity": f.severity,
"message": f.message,
"file": f.file,
"line": f.line,
"secrets_class": f.is_secrets_class,
}
for f in t1.findings
],
}
except Exception:
_log.debug("Tier 1 advisory scan skipped", exc_info=True)
finally:
if q_path is not None:
_shutil.rmtree(q_path, ignore_errors=True)
# `allowed` may be None ("ask") for agent-created/dangerous gates.
allowed, reason = should_allow_install(result, force=False)
findings = [
{
"severity": f.severity,
"category": f.category,
"file": f.file,
"line": f.line,
"description": f.description,
}
for f in result.findings
]
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in result.findings:
if f.severity in counts:
counts[f.severity] += 1
return {
"name": result.skill_name,
"identifier": ident,
"source": result.source,
"trust_level": result.trust_level,
"verdict": result.verdict,
"summary": result.summary,
"policy": "allow" if allowed is True else "ask" if allowed is None else "block",
"policy_reason": reason,
"findings": findings,
"severity_counts": counts,
"tier1": tier1, # None when the optional scanner isn't installed/enabled
}
try:
result = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub scan failed")
raise HTTPException(status_code=502, detail=f"Hub scan failed: {exc}")
if result is None:
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
return result
@router.get("/api/skills")
async def get_skills(profile: Optional[str] = None):
from tools.skills_tool import _find_all_skills
from hermes_cli.skills_config import get_disabled_skills
from tools.skill_usage import (
_read_bundled_manifest_names,
_read_hub_installed_names,
activity_count,
load_usage,
)
def _run():
with _profile_scope(profile):
config = load_config()
disabled = get_disabled_skills(config)
skills = _find_all_skills(skip_disabled=True)
usage = load_usage()
# Set-based provenance (same classification as skill_usage.provenance,
# without a per-skill manifest read): hub > bundled > agent, where
# "agent" covers agent-authored AND local hand-made skills — the ones
# the user may edit/delete from the UI.
bundled_names = _read_bundled_manifest_names()
hub_names = _read_hub_installed_names()
for s in skills:
s["enabled"] = s["name"] not in disabled
s["usage"] = activity_count(usage.get(s["name"], {}))
s["provenance"] = (
"hub" if s["name"] in hub_names
else "bundled" if s["name"] in bundled_names
else "agent"
)
return skills
return await asyncio.to_thread(_run)
@router.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
def _run():
with config_write_scope(body.profile or profile):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
save_disabled_skills(config, disabled)
return {"ok": True, "name": body.name, "enabled": body.enabled}
return await asyncio.to_thread(_run)
@router.get("/api/skills/content")
async def get_skill_content(name: str, profile: Optional[str] = None):
"""Raw SKILL.md text for the dashboard editor."""
from tools.skill_manager_tool import _find_skill
def _read():
found = _find_skill(name)
if not found:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
skill_md = found["path"] / "SKILL.md"
if not skill_md.exists():
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
try:
content = skill_md.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"name": name, "content": content, "path": str(skill_md)}
return await scoped_to_thread(profile, _read)
@router.post("/api/skills")
async def create_skill(body: SkillCreate):
"""Create a custom skill via the same validated write path as the agent's
``skill_manage`` tool, minus the agent write-approval gate — a write from
the authenticated dashboard IS the user acting directly."""
from tools.skill_manager_tool import _create_skill
result = await scoped_to_thread(
body.profile, lambda: _create_skill(body.name, body.content, body.category or None)
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
_clear_skills_prompt_cache()
return result
@router.put("/api/skills/content")
async def update_skill_content(body: SkillContentUpdate):
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
from tools.skill_manager_tool import _edit_skill
result = await scoped_to_thread(body.profile, lambda: _edit_skill(body.name, body.content))
if not result.get("success"):
err = result.get("error", "Failed to update skill.")
status = 404 if "not found" in str(err).lower() else 400
raise HTTPException(status_code=status, detail=err)
_clear_skills_prompt_cache()
return result