"""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// — 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