mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
- hermes_cli/web_routers/sessions.py: 14 routes across 3 routers (list_router, search_router, manage_router) mounted at the three original registration points so global route order is preserved exactly. - hermes_cli/web_routers/mcp.py: 11 routes; OAuth flow registry (_mcp_oauth_flows/lock/cap) stays in web_server, reached via new web_deps.LateState live proxies so tests mutating web_server._mcp_oauth_flows keep working. - hermes_cli/web_routers/skills.py: 12 routes across hub_router + router (two original registration points straddle the profiles router include). - hermes_cli/web_routers/tools.py: 12 routes; toolset/terminal catalogs stay in web_server (some are defined after the mount point), reached via LateState. - web_deps.py: add LateState — operation-time proxy for web_server-owned module state (getattr/item/iter/len/contains/context-manager/comparisons). - Handler bodies byte-identical; legacy re-exports keep web_server.<handler> importable for tests. - Verified: ordered route table (method, path) identical to pre-refactor app (291 routes); import smoke; ruff; windows-footguns clean. - test_web_server_sessiondb_eventloop.py: structural AST scan now reads both web_server.py and web_routers/sessions.py (handlers moved; helpers stayed).
490 lines
19 KiB
Python
490 lines
19 KiB
Python
"""Skills dashboard routes (extracted verbatim from web_server.py).
|
|
|
|
Two routers because the original registration points are far apart and global
|
|
route order matters: ``hub_router`` (the skills-hub install/search/scan
|
|
endpoints) 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.
|
|
|
|
Handler bodies are byte-identical; web_server-owned helpers are reached via
|
|
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
|
|
``monkeypatch.setattr(web_server, "_spawn_hermes_action", ...)`` keep
|
|
working.
|
|
"""
|
|
|
|
import asyncio # noqa: F401 — used by handlers
|
|
import logging
|
|
from typing import Optional # noqa: F401
|
|
|
|
from fastapi import APIRouter, HTTPException # noqa: F401
|
|
|
|
from hermes_cli.web_deps import late, LateState
|
|
from hermes_cli.web_models import (
|
|
SkillContentUpdate,
|
|
SkillCreate,
|
|
SkillInstallRequest,
|
|
SkillToggle,
|
|
SkillUninstallRequest,
|
|
SkillsUpdateRequest,
|
|
)
|
|
|
|
# Same logger the handlers used before extraction (identical logger object).
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
|
|
hub_router = APIRouter()
|
|
router = APIRouter()
|
|
|
|
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
|
# monkeypatch-transparent).
|
|
_clear_skills_prompt_cache = late("_clear_skills_prompt_cache")
|
|
_config_profile_scope = late("_config_profile_scope")
|
|
_hub_action_name = late("_hub_action_name")
|
|
_installed_hub_identifiers = late("_installed_hub_identifiers")
|
|
_profile_cli_args = late("_profile_cli_args")
|
|
_profile_scope = late("_profile_scope")
|
|
_skill_meta_to_payload = late("_skill_meta_to_payload")
|
|
_spawn_hermes_action = late("_spawn_hermes_action")
|
|
load_config = late("load_config")
|
|
|
|
# Live proxies for web_server-owned module state (mutations/monkeypatches
|
|
# on web_server remain authoritative; resolved at operation time).
|
|
_SKILL_HUB_SOURCE_LABELS = LateState("_SKILL_HUB_SOURCE_LABELS")
|
|
|
|
|
|
@hub_router.post("/api/skills/hub/install")
|
|
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
|
|
identifier = (body.identifier or "").strip()
|
|
if not identifier:
|
|
raise HTTPException(status_code=400, detail="identifier is required")
|
|
name = _hub_action_name("install", identifier)
|
|
try:
|
|
proc = _spawn_hermes_action(
|
|
_profile_cli_args(body.profile or profile)
|
|
+ ["skills", "install", identifier, "--yes"],
|
|
name,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn skills install")
|
|
raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": name}
|
|
|
|
|
|
@hub_router.post("/api/skills/hub/uninstall")
|
|
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
|
|
name = (body.name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="name is required")
|
|
action = _hub_action_name("uninstall", name)
|
|
try:
|
|
proc = _spawn_hermes_action(
|
|
_profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"],
|
|
action,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn skills uninstall")
|
|
raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": action}
|
|
|
|
|
|
@hub_router.post("/api/skills/hub/update")
|
|
async def update_skills_hub(
|
|
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
|
|
):
|
|
try:
|
|
effective = (body.profile if body else None) or profile
|
|
proc = _spawn_hermes_action(
|
|
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn skills update")
|
|
raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
|
|
|
|
|
@hub_router.get("/api/skills/hub/sources")
|
|
async def list_skills_hub_sources(profile: Optional[str] = None):
|
|
"""List the configured skill-hub sources and installed-skill provenance.
|
|
|
|
Gives the dashboard something to show BEFORE a search runs — which hubs
|
|
are wired up, their trust tier, and a set of featured skills pulled from
|
|
the centralized index (zero extra API calls). Without this the Browse-hub
|
|
tab is a blank page with no indication it's even connected to anything.
|
|
``profile`` scopes the installed-skill provenance to that profile.
|
|
"""
|
|
|
|
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)
|
|
# Tell the UI which sources are worth searching individually (for its
|
|
# progressive per-source fan-out). Mirror parallel_search_sources: when
|
|
# the centralized index is available it already subsumes the external
|
|
# API sources, so they're redundant — skipping them avoids ~70 GitHub
|
|
# calls per keystroke. Keep this set 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),
|
|
}
|
|
|
|
try:
|
|
return await asyncio.to_thread(_run)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("skills hub sources listing failed")
|
|
raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}")
|
|
|
|
|
|
@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 (parallel source search); runs in a thread so the FastAPI
|
|
loop isn't blocked. Returns structured results the UI installs by
|
|
identifier via POST /api/skills/hub/install, previews via
|
|
/api/skills/hub/preview, and scans via /api/skills/hub/scan.
|
|
"""
|
|
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),
|
|
}
|
|
|
|
try:
|
|
return await asyncio.to_thread(_run)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("skills hub search failed")
|
|
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
|
|
|
|
|
@hub_router.get("/api/skills/hub/preview")
|
|
async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None):
|
|
"""Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading.
|
|
|
|
Resolves the identifier across configured sources (same path the CLI
|
|
installer uses), then returns the rendered SKILL.md text and the file
|
|
manifest WITHOUT installing anything. This is the 'read the actual skill
|
|
before installing' affordance the Browse-hub tab was missing.
|
|
|
|
Scoped to ``profile`` so a non-default profile with different hub taps
|
|
resolves against ITS source router, not the default profile's.
|
|
"""
|
|
ident = (identifier or "").strip()
|
|
if not ident:
|
|
raise HTTPException(status_code=400, detail="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 (e.g. official optional skills) store every
|
|
# file as bytes. Decode text so SKILL.md / docs render;
|
|
# only fall back to a placeholder 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.
|
|
|
|
Fetches the bundle, quarantines it, and runs the same `scan_skill` /
|
|
`should_allow_install` pipeline the CLI installer uses — then cleans up the
|
|
quarantine. Returns the verdict, per-finding detail, trust tier, and the
|
|
install-policy decision so the dashboard can show a visual safety result
|
|
on demand (the 'scan' button the Browse-hub tab was missing).
|
|
|
|
Scoped to ``profile`` so the bundle resolves against that profile's hub
|
|
source router, matching where an install would pull it from.
|
|
"""
|
|
ident = (identifier or "").strip()
|
|
if not ident:
|
|
raise HTTPException(status_code=400, detail="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
|
|
try:
|
|
q_path = quarantine_bundle(bundle)
|
|
result = scan_skill(q_path, source=scan_source)
|
|
finally:
|
|
if q_path is not None:
|
|
_shutil.rmtree(q_path, ignore_errors=True)
|
|
|
|
allowed, reason = should_allow_install(result, force=False)
|
|
# `allowed` may be None ("ask") for agent-created/dangerous gates.
|
|
if allowed is True:
|
|
policy = "allow"
|
|
elif allowed is None:
|
|
policy = "ask"
|
|
else:
|
|
policy = "block"
|
|
|
|
findings = [
|
|
{
|
|
"severity": f.severity,
|
|
"category": f.category,
|
|
"file": f.file,
|
|
"line": f.line,
|
|
"description": f.description,
|
|
}
|
|
for f in result.findings
|
|
]
|
|
# Per-severity tally for an at-a-glance summary.
|
|
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": policy,
|
|
"policy_reason": reason,
|
|
"findings": findings,
|
|
"severity_counts": counts,
|
|
}
|
|
|
|
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,
|
|
)
|
|
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
|
|
|
|
|
|
@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
|
|
with _profile_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}
|
|
|
|
|
|
@router.get("/api/skills/content")
|
|
async def get_skill_content(name: str, profile: Optional[str] = None):
|
|
"""Return the raw SKILL.md text for a skill, for the dashboard editor."""
|
|
from tools.skill_manager_tool import _find_skill
|
|
|
|
with _profile_scope(profile):
|
|
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)}
|
|
|
|
|
|
@router.post("/api/skills")
|
|
async def create_skill(body: SkillCreate):
|
|
"""Create a new custom skill (SKILL.md) from the dashboard editor.
|
|
|
|
Calls the same validated write path as the agent's ``skill_manage``
|
|
tool (frontmatter validation, name/category validation, size limit,
|
|
optional security scan) — but bypasses the agent write-approval gate:
|
|
a write from the authenticated dashboard IS the user acting directly.
|
|
"""
|
|
from tools.skill_manager_tool import _create_skill
|
|
|
|
with _profile_scope(body.profile):
|
|
result = _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
|
|
|
|
with _profile_scope(body.profile):
|
|
result = _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
|