feat(dashboard): rehaul Skills hub browser — connected hubs, featured, preview + security scan (#40384)

The Browse-hub tab was a blank search box with sparse result cards (name +
source + one Install button), no way to read a skill before installing, no
visual security scan, and no indication it was even connected to any hubs.

Backend (web_server.py):
- GET /api/skills/hub/sources — lists the configured hubs (label + trust
  tier + GitHub rate-limit + index availability) and featured skills pulled
  from the centralized index (zero extra API calls), plus installed-skill
  provenance so the UI can mark already-installed results.
- GET /api/skills/hub/preview — fetches a skill's SKILL.md text + file
  manifest WITHOUT installing (decodes byte-stored text, masks binaries).
- GET /api/skills/hub/scan — runs the SAME quarantine + scan_skill +
  should_allow_install pipeline the CLI installer uses, then cleans up
  quarantine, returning verdict / per-finding detail / severity tally /
  install-policy decision.
- search now returns per-source counts + timed-out sources + installed map.

Frontend (SkillsPage HubBrowser):
- Landing state: connected-hubs strip + featured skill grid (no more blank
  page).
- Rich cards: trust-level color coding, source, tags, identifier,
  Details + Install (or Installed state).
- Detail dialog: read the actual SKILL.md, on-demand visual security scan
  (verdict pill, severity tally, per-finding list, allow/block policy),
  GitHub repo link.
- Search meta line: result count + timing + per-source breakdown (the
  'feels slow / no feedback' complaint).

Tests: 4 new endpoint test classes (sources/preview/scan + updated search
shape) in test_dashboard_admin_endpoints.py.
This commit is contained in:
Teknium
2026-06-06 02:44:50 -07:00
committed by GitHub
parent 5af899c7ca
commit 56236b16e3
4 changed files with 1282 additions and 73 deletions
+286 -19
View File
@@ -6721,44 +6721,311 @@ async def update_skills_hub():
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
# 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",
"claude-marketplace": "Claude Marketplace",
"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 _installed_hub_identifiers() -> dict:
"""Map identifier -> installed lock entry for hub-installed skills.
Lets the UI mark search results that are already installed. Best-effort:
returns an empty dict if the lock file can't be read.
"""
try:
from tools.skills_hub import HubLockFile
out = {}
for entry in HubLockFile().list_installed():
ident = entry.get("identifier")
if ident:
out[ident] = {
"name": entry.get("name"),
"trust_level": entry.get("trust_level"),
"scan_verdict": entry.get("scan_verdict"),
}
return out
except Exception:
return {}
@app.get("/api/skills/hub/sources")
async def list_skills_hub_sources():
"""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.
"""
def _run():
from tools.skills_hub import create_source_router
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)
return {
"sources": out,
"index_available": index_available,
"featured": featured,
"installed": _installed_hub_identifiers(),
}
try:
return await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub sources listing failed")
raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}")
@app.get("/api/skills/hub/search")
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
"""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.
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": []}
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
def _run():
from tools.skills_hub import create_source_router, unified_search
from tools.skills_hub import create_source_router, parallel_search_sources
sources = create_source_router()
metas = unified_search(
query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50)
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
)
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 []),
}
for m in metas
]
# 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(),
}
try:
results = await asyncio.to_thread(_run)
return await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
return {"results": results}
@app.get("/api/skills/hub/preview")
async def preview_skill_hub(identifier: str = ""):
"""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.
"""
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
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
@app.get("/api/skills/hub/scan")
async def scan_skill_hub(identifier: str = ""):
"""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).
"""
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
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
# ---------------------------------------------------------------------------