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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1282 additions and 73 deletions

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
# ---------------------------------------------------------------------------

View file

@ -352,9 +352,212 @@ class TestSkillsHubSearchEndpoint:
self.client, _ = _client()
def test_empty_query_returns_empty(self):
# Empty query short-circuits (no network) and returns no results.
# Empty query short-circuits (no network) and returns the enriched
# empty shape (results + per-source counts + timeouts + installed map).
r = self.client.get("/api/skills/hub/search?q=")
assert r.status_code == 200 and r.json() == {"results": []}
assert r.status_code == 200
body = r.json()
assert body["results"] == []
assert body["source_counts"] == {}
assert body["timed_out"] == []
assert body["installed"] == {}
class _FakeMeta:
"""Minimal SkillMeta stand-in for monkeypatched source search."""
def __init__(self, identifier, trust_level="community", source="github"):
self.name = identifier.rsplit("/", 1)[-1]
self.description = "desc"
self.source = source
self.identifier = identifier
self.trust_level = trust_level
self.repo = "owner/repo"
self.tags = ["a", "b"]
# Used by the preview endpoint's getattr() fallbacks.
self.files = {}
class _FakeBundle:
def __init__(self, identifier, source="github", trust_level="community"):
self.name = identifier.rsplit("/", 1)[-1]
self.identifier = identifier
self.source = source
self.trust_level = trust_level
self.description = "desc"
self.repo = "owner/repo"
self.tags = ["a", "b"]
# Mix str + bytes to exercise the decode-or-placeholder branch.
self.files = {
"SKILL.md": b"---\nname: x\n---\nbody text",
"icon.png": b"\xff\xd8\xff\xe0binary",
"notes.txt": "plain string content",
}
self.metadata = {}
class TestSkillsHubSourcesEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_sources_lists_configured_hubs(self, monkeypatch):
# The endpoint should enumerate the configured hub sources without
# requiring any live network — monkeypatch the router.
class _Src:
is_available = False
def __init__(self, sid):
self._sid = sid
def source_id(self):
return self._sid
def search(self, q, limit=10):
return [_FakeMeta("hermes-index/featured-skill", "trusted")]
def _fake_router():
srcs = [_Src("official"), _Src("github")]
# hermes-index source advertises availability + featured search.
idx = _Src("hermes-index")
idx.is_available = True
srcs.insert(1, idx)
return srcs
monkeypatch.setattr(
"tools.skills_hub.create_source_router", _fake_router
)
r = self.client.get("/api/skills/hub/sources")
assert r.status_code == 200
body = r.json()
ids = {s["id"] for s in body["sources"]}
assert {"official", "github", "hermes-index"} <= ids
# Every source carries a human label.
assert all(s.get("label") for s in body["sources"])
assert body["index_available"] is True
# Featured pulled from the index (zero extra API calls).
assert len(body["featured"]) == 1
assert body["featured"][0]["trust_level"] == "trusted"
assert isinstance(body["installed"], dict)
class TestSkillsHubPreviewEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_preview_requires_identifier(self):
r = self.client.get("/api/skills/hub/preview?identifier=")
assert r.status_code == 400
def test_preview_returns_skill_md_text(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
bundle = _FakeBundle("github/owner/repo/x")
meta = _FakeMeta("github/owner/repo/x")
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (meta, bundle, None),
)
r = self.client.get(
"/api/skills/hub/preview?identifier=github/owner/repo/x"
)
assert r.status_code == 200
body = r.json()
# Bytes-stored SKILL.md decodes to text.
assert "body text" in body["skill_md"]
# Binary file is masked, text files decode.
assert "icon.png" in body["files"]
assert sorted(body["files"]) == ["SKILL.md", "icon.png", "notes.txt"]
def test_preview_404_when_unresolved(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, None, None),
)
r = self.client.get("/api/skills/hub/preview?identifier=nope/x")
assert r.status_code == 404
class TestSkillsHubScanEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_scan_requires_identifier(self):
r = self.client.get("/api/skills/hub/scan?identifier=")
assert r.status_code == 400
def test_scan_returns_verdict_and_policy(self, monkeypatch):
from tools.skills_guard import ScanResult, Finding
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
bundle = _FakeBundle("github/owner/repo/x", trust_level="community")
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, bundle, None),
)
from pathlib import Path
monkeypatch.setattr(
"tools.skills_hub.quarantine_bundle", lambda b: Path("/tmp/_fake_q")
)
fake_result = ScanResult(
skill_name="x",
source="github/owner/repo/x",
trust_level="community",
verdict="caution",
findings=[
Finding(
pattern_id="p",
severity="high",
category="exfiltration",
file="SKILL.md",
line=10,
match="m",
description="leaks data",
)
],
summary="s",
)
monkeypatch.setattr(
"tools.skills_guard.scan_skill",
lambda path, source="community": fake_result,
)
# Avoid touching the filesystem during cleanup.
monkeypatch.setattr("shutil.rmtree", lambda *a, **k: None)
r = self.client.get(
"/api/skills/hub/scan?identifier=github/owner/repo/x"
)
assert r.status_code == 200
body = r.json()
assert body["verdict"] == "caution"
assert body["trust_level"] == "community"
# community + caution => blocked by install policy.
assert body["policy"] == "block"
assert body["severity_counts"]["high"] == 1
assert body["findings"][0]["category"] == "exfiltration"
assert body["findings"][0]["file"] == "SKILL.md"
def test_scan_404_when_no_bundle(self, monkeypatch):
monkeypatch.setattr(
"tools.skills_hub.create_source_router", lambda: []
)
monkeypatch.setattr(
"hermes_cli.skills_hub._resolve_source_meta_and_bundle",
lambda ident, sources: (None, None, None),
)
r = self.client.get("/api/skills/hub/scan?identifier=nope/x")
assert r.status_code == 404

View file

@ -916,9 +916,19 @@ export const api = {
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
fetchJSON<{ results: SkillHubResult[] }>(
fetchJSON<SkillHubSearchResponse>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
),
getSkillHubSources: () =>
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
previewSkillFromHub: (identifier: string) =>
fetchJSON<SkillHubPreview>(
`/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
),
scanSkillFromHub: (identifier: string) =>
fetchJSON<SkillHubScan>(
`/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
),
};
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
@ -975,6 +985,77 @@ export interface SkillHubResult {
tags: string[];
}
/** Lock-entry summary for an already-installed hub skill (keyed by identifier). */
export interface SkillHubInstalledEntry {
name: string | null;
trust_level: string | null;
scan_verdict: string | null;
}
export interface SkillHubSearchResponse {
results: SkillHubResult[];
/** source_id -> number of results returned by that source. */
source_counts: Record<string, number>;
/** source ids that didn't return within the parallel-search timeout. */
timed_out: string[];
/** identifier -> installed lock entry (for "already installed" badges). */
installed: Record<string, SkillHubInstalledEntry>;
}
export interface SkillHubSource {
id: string;
label: string;
/** GitHub only: whether the API is currently rate-limited. */
rate_limited?: boolean;
/** hermes-index only: whether the centralized index loaded. */
available?: boolean;
}
export interface SkillHubSourcesResponse {
sources: SkillHubSource[];
index_available: boolean;
/** Featured/popular skills from the centralized index (zero extra API calls). */
featured: SkillHubResult[];
installed: Record<string, SkillHubInstalledEntry>;
}
export interface SkillHubPreview {
name: string;
description: string;
source: string;
identifier: string;
trust_level: string;
repo: string | null;
tags: string[];
/** Rendered SKILL.md content (the actual skill text). */
skill_md: string;
/** Relative paths of every file in the bundle. */
files: string[];
}
export interface SkillHubScanFinding {
severity: string;
category: string;
file: string;
line: number;
description: string;
}
export interface SkillHubScan {
name: string;
identifier: string;
source: string;
trust_level: string;
/** "safe" | "caution" | "dangerous". */
verdict: string;
summary: string;
/** Install-policy decision for this trust+verdict combo. */
policy: "allow" | "ask" | "block";
policy_reason: string;
findings: SkillHubScanFinding[];
severity_counts: Record<string, number>;
}
// ── Admin types ───────────────────────────────────────────────────────
export interface McpServer {

View file

@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import { useEffect, useLayoutEffect, useState, useMemo, useCallback } from "react";
import {
Package,
Search,
@ -7,6 +7,9 @@ import {
Cpu,
Globe,
Shield,
ShieldCheck,
ShieldAlert,
ShieldQuestion,
Eye,
Paintbrush,
Brain,
@ -16,9 +19,23 @@ import {
Filter,
Download,
RefreshCw,
FileText,
ExternalLink,
CheckCircle2,
AlertTriangle,
Sparkles,
Loader2,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo, SkillHubResult } from "@/lib/api";
import type {
SkillInfo,
ToolsetInfo,
SkillHubResult,
SkillHubSource,
SkillHubInstalledEntry,
SkillHubPreview,
SkillHubScan,
} from "@/lib/api";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
@ -27,6 +44,13 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@nous-research/ui/ui/components/dialog";
import { cn } from "@/lib/utils";
import { Input } from "@nous-research/ui/ui/components/input";
import { useI18n } from "@/i18n";
@ -568,9 +592,51 @@ interface SkillRowProps {
}
/* ------------------------------------------------------------------ */
/* Hub browser — search the skill hub, install by identifier */
/* Hub browser — search the skill hub, preview, scan, install */
/* ------------------------------------------------------------------ */
/** Map a trust level to a Badge tone + label + icon. */
function trustVisual(level: string): {
tone: "success" | "secondary" | "warning" | "outline";
label: string;
} {
switch (level) {
case "trusted":
return { tone: "success", label: "trusted" };
case "builtin":
return { tone: "secondary", label: "builtin" };
case "community":
return { tone: "warning", label: "community" };
default:
return { tone: "outline", label: level || "unknown" };
}
}
/** Map a scan verdict to tone + icon. */
function verdictVisual(verdict: string): {
tone: "success" | "warning" | "destructive";
Icon: React.ComponentType<{ className?: string }>;
label: string;
} {
switch (verdict) {
case "safe":
return { tone: "success", Icon: ShieldCheck, label: "Safe" };
case "caution":
return { tone: "warning", Icon: ShieldAlert, label: "Caution" };
case "dangerous":
return { tone: "destructive", Icon: ShieldAlert, label: "Dangerous" };
default:
return { tone: "warning", Icon: ShieldQuestion, label: verdict };
}
}
const SEVERITY_TONE: Record<string, "destructive" | "warning" | "secondary" | "outline"> = {
critical: "destructive",
high: "destructive",
medium: "warning",
low: "secondary",
};
function HubBrowser({
showToast,
}: {
@ -580,28 +646,73 @@ function HubBrowser({
const [results, setResults] = useState<SkillHubResult[]>([]);
const [searching, setSearching] = useState(false);
const [searched, setSearched] = useState(false);
// Live action log for the most recent install/update (tailed via action status).
const [sourceCounts, setSourceCounts] = useState<Record<string, number>>({});
const [timedOut, setTimedOut] = useState<string[]>([]);
const [searchMs, setSearchMs] = useState<number | null>(null);
// Landing state: which hubs are wired up + featured skills.
const [sources, setSources] = useState<SkillHubSource[]>([]);
const [featured, setFeatured] = useState<SkillHubResult[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(true);
// identifier -> installed entry (drives "Installed" badges).
const [installed, setInstalled] = useState<Record<string, SkillHubInstalledEntry>>({});
// Live action log for the most recent install/update.
const [action, setAction] = useState<string | null>(null);
const [actionLog, setActionLog] = useState<string[]>([]);
const [actionRunning, setActionRunning] = useState(false);
const runSearch = async () => {
// Detail dialog (preview + scan for a single skill).
const [detail, setDetail] = useState<SkillHubResult | null>(null);
/* ---- Load connected hubs + featured skills on mount ---- */
useEffect(() => {
let cancelled = false;
api
.getSkillHubSources()
.then((r) => {
if (cancelled) return;
setSources(r.sources);
setFeatured(r.featured);
setInstalled(r.installed);
})
.catch(() => {
/* leave landing minimal on failure */
})
.finally(() => {
if (!cancelled) setSourcesLoading(false);
});
return () => {
cancelled = true;
};
}, []);
/* ---- Search ---- */
const runSearch = useCallback(async () => {
const q = query.trim();
if (!q) return;
setSearching(true);
setSearched(true);
const t0 = performance.now();
try {
const r = await api.searchSkillsHub(q);
setResults(r.results);
setSourceCounts(r.source_counts || {});
setTimedOut(r.timed_out || []);
setInstalled((prev) => ({ ...prev, ...(r.installed || {}) }));
} catch (e) {
showToast(`Hub search failed: ${e}`, "error");
setResults([]);
setSourceCounts({});
setTimedOut([]);
} finally {
setSearchMs(Math.round(performance.now() - t0));
setSearching(false);
}
};
}, [query, showToast]);
// Poll a spawned action's log until it exits.
/* ---- Poll a spawned action's log until it exits ---- */
useEffect(() => {
if (!action) return;
let cancelled = false;
@ -612,7 +723,15 @@ function HubBrowser({
if (cancelled) return;
setActionLog(st.lines);
setActionRunning(st.running);
if (st.running) timer = setTimeout(poll, 1200);
if (st.running) {
timer = setTimeout(poll, 1200);
} else {
// Install finished — refresh installed-state so badges update.
api
.getSkillHubSources()
.then((r) => !cancelled && setInstalled(r.installed))
.catch(() => {});
}
} catch {
if (!cancelled) setActionRunning(false);
}
@ -624,19 +743,23 @@ function HubBrowser({
};
}, [action]);
const install = async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
} catch (e) {
showToast(`Install failed: ${e}`, "error");
}
};
const install = useCallback(
async (identifier: string) => {
try {
const res = await api.installSkillFromHub(identifier);
showToast(`Installing ${identifier}`, "success");
setActionLog([]);
setActionRunning(true);
setAction(res.name);
setDetail(null);
} catch (e) {
showToast(`Install failed: ${e}`, "error");
}
},
[showToast],
);
const updateAll = async () => {
const updateAll = useCallback(async () => {
try {
const res = await api.updateSkillsFromHub();
showToast("Updating installed skills…", "success");
@ -646,10 +769,18 @@ function HubBrowser({
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
};
}, [showToast]);
const isInstalled = useCallback(
(identifier: string) => Boolean(installed[identifier]),
[installed],
);
const showLanding = !searched && !searching;
return (
<div className="flex flex-col gap-3">
{/* ── Search bar ── */}
<Card className="rounded-none">
<CardContent className="py-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
@ -682,13 +813,13 @@ function HubBrowser({
Update all
</Button>
</div>
<p className="text-xs text-muted-foreground">
Results come from the same sources as <span className="font-mono">hermes skills search</span>.
Installs run in the background; the log streams below.
</p>
{/* Connected hubs strip — proves the tab is wired up. */}
<ConnectedHubs sources={sources} loading={sourcesLoading} />
</CardContent>
</Card>
{/* ── Install/update action log ── */}
{action && (
<Card className="rounded-none">
<CardContent className="py-3">
@ -700,6 +831,17 @@ function HubBrowser({
) : (
<Badge tone="success">done</Badge>
)}
{!actionRunning && (
<Button
ghost
size="xs"
className="ml-auto text-muted-foreground"
onClick={() => setAction(null)}
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-2 text-xs font-mono text-muted-foreground">
{actionLog.length ? actionLog.join("\n") : "Starting…"}
@ -708,46 +850,562 @@ function HubBrowser({
</Card>
)}
{/* ── Landing: featured skills (before any search) ── */}
{showLanding && (
<>
{sourcesLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="text-xl text-primary" />
</div>
) : featured.length > 0 ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="font-mondwest text-display text-xs tracking-[0.12em] text-text-secondary uppercase">
Featured skills
</span>
<span className="text-xs text-text-tertiary">
from the Hermes index search above for thousands more
</span>
</div>
{featured.map((r) => (
<HubResultCard
key={r.identifier}
result={r}
installed={isInstalled(r.identifier)}
onOpen={() => setDetail(r)}
onInstall={() => void install(r.identifier)}
/>
))}
</div>
) : (
<Card className="rounded-none">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
Search the hub above to browse installable skills from the
connected sources.
</CardContent>
</Card>
)}
</>
)}
{/* ── Searching spinner ── */}
{searching && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{!searching && searched && results.length === 0 && (
<Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No matching skills found in the hub.
</CardContent>
</Card>
{/* ── Search results ── */}
{!searching && searched && (
<>
<SearchMeta
count={results.length}
sourceCounts={sourceCounts}
timedOut={timedOut}
ms={searchMs}
/>
{results.length === 0 ? (
<Card className="rounded-none">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No matching skills found in the hub.
</CardContent>
</Card>
) : (
results.map((r) => (
<HubResultCard
key={r.identifier}
result={r}
installed={isInstalled(r.identifier)}
onOpen={() => setDetail(r)}
onInstall={() => void install(r.identifier)}
/>
))
)}
</>
)}
{results.map((r) => (
<Card key={r.identifier} className="rounded-none">
<CardContent className="py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono-ui text-sm">{r.name}</span>
<Badge tone="secondary" className="text-xs">{r.source}</Badge>
<Badge tone="outline" className="text-xs">{r.trust_level}</Badge>
</div>
<p className="text-xs text-text-secondary">{r.description}</p>
<p className="text-xs font-mono text-text-tertiary truncate mt-0.5">
{r.identifier}
</p>
</div>
{/* ── Detail dialog: preview + scan ── */}
{detail && (
<SkillDetailDialog
result={detail}
installed={isInstalled(detail.identifier)}
onClose={() => setDetail(null)}
onInstall={() => void install(detail.identifier)}
showToast={showToast}
/>
)}
</div>
);
}
/* ---- Connected hubs strip ---- */
function ConnectedHubs({
sources,
loading,
}: {
sources: SkillHubSource[];
loading: boolean;
}) {
if (loading) {
return (
<p className="text-xs text-muted-foreground">Connecting to skill hubs</p>
);
}
if (sources.length === 0) {
return (
<p className="text-xs text-muted-foreground">
Results come from the same sources as{" "}
<span className="font-mono">hermes skills search</span>.
</p>
);
}
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="flex items-center gap-1 text-xs text-text-tertiary">
<Globe className="h-3 w-3" />
Connected hubs:
</span>
{sources.map((s) => {
const down =
(s.id === "hermes-index" && s.available === false) ||
(s.id === "github" && s.rate_limited === true);
return (
<Badge
key={s.id}
tone={down ? "outline" : "secondary"}
className={cn("text-xs", down && "opacity-60")}
title={
s.id === "github" && s.rate_limited
? "GitHub API rate-limited — set GITHUB_TOKEN to raise the limit"
: s.id === "hermes-index" && s.available === false
? "Centralized index unavailable — falling back to live sources"
: undefined
}
>
{s.label}
{s.id === "github" && s.rate_limited ? " (rate-limited)" : ""}
</Badge>
);
})}
</div>
);
}
/* ---- Search result-count + per-source breakdown ---- */
function SearchMeta({
count,
sourceCounts,
timedOut,
ms,
}: {
count: number;
sourceCounts: Record<string, number>;
timedOut: string[];
ms: number | null;
}) {
const entries = Object.entries(sourceCounts).filter(([, n]) => n > 0);
return (
<div className="flex flex-wrap items-center gap-2 px-1 text-xs text-text-tertiary">
<Badge tone="secondary" className="text-xs">
{count} result{count !== 1 ? "s" : ""}
</Badge>
{ms != null && <span>{(ms / 1000).toFixed(1)}s</span>}
{entries.length > 0 && (
<span className="flex flex-wrap items-center gap-1.5">
{entries.map(([sid, n]) => (
<span key={sid} className="font-mono">
{sid}:{n}
</span>
))}
</span>
)}
{timedOut.length > 0 && (
<span className="flex items-center gap-1 text-amber-400">
<AlertTriangle className="h-3 w-3" />
{timedOut.join(", ")} timed out
</span>
)}
</div>
);
}
/* ---- One result card ---- */
function HubResultCard({
result,
installed,
onOpen,
onInstall,
}: {
result: SkillHubResult;
installed: boolean;
onOpen: () => void;
onInstall: () => void;
}) {
const trust = trustVisual(result.trust_level);
return (
<Card className="rounded-none transition-colors hover:bg-muted/30">
<CardContent className="py-3 flex items-start gap-3">
<button
type="button"
className="flex-1 min-w-0 text-left"
onClick={onOpen}
aria-label={`Open ${result.name}`}
>
<div className="flex flex-wrap items-center gap-2 mb-0.5">
<span className="font-mono-ui text-sm hover:underline">
{result.name}
</span>
<Badge tone={trust.tone} className="text-xs">
{trust.label}
</Badge>
<Badge tone="secondary" className="text-xs">
{result.source}
</Badge>
{installed && (
<Badge tone="success" className="text-xs">
installed
</Badge>
)}
</div>
<p className="text-xs text-text-secondary line-clamp-2">
{result.description}
</p>
<div className="flex flex-wrap items-center gap-1 mt-1">
{result.tags.slice(0, 5).map((tag) => (
<span
key={tag}
className="text-[0.65rem] font-mono text-text-tertiary border border-border px-1 py-px"
>
{tag}
</span>
))}
</div>
<p className="text-xs font-mono text-text-tertiary truncate mt-1">
{result.identifier}
</p>
</button>
<div className="flex shrink-0 flex-col gap-1.5">
<Button
size="sm"
outlined
onClick={onOpen}
prefix={<FileText className="h-3.5 w-3.5" />}
>
Details
</Button>
{installed ? (
<Button size="sm" ghost disabled prefix={<CheckCircle2 className="h-3.5 w-3.5" />}>
Installed
</Button>
) : (
<Button
size="sm"
outlined
className="shrink-0"
onClick={() => void install(r.identifier)}
onClick={onInstall}
prefix={<Download className="h-3.5 w-3.5" />}
>
Install
</Button>
</CardContent>
</Card>
))}
)}
</div>
</CardContent>
</Card>
);
}
/* ---- Detail dialog: SKILL.md preview + on-demand security scan ---- */
function SkillDetailDialog({
result,
installed,
onClose,
onInstall,
showToast,
}: {
result: SkillHubResult;
installed: boolean;
onClose: () => void;
onInstall: () => void;
showToast: (msg: string, kind: "success" | "error") => void;
}) {
const [tab, setTab] = useState<"readme" | "scan">("readme");
const [preview, setPreview] = useState<SkillHubPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(true);
const [scan, setScan] = useState<SkillHubScan | null>(null);
const [scanning, setScanning] = useState(false);
const trust = trustVisual(result.trust_level);
useEffect(() => {
let cancelled = false;
setPreviewLoading(true);
api
.previewSkillFromHub(result.identifier)
.then((p) => !cancelled && setPreview(p))
.catch((e) => {
if (!cancelled) showToast(`Preview failed: ${e}`, "error");
})
.finally(() => !cancelled && setPreviewLoading(false));
return () => {
cancelled = true;
};
}, [result.identifier, showToast]);
const runScan = useCallback(async () => {
setScanning(true);
setTab("scan");
try {
const s = await api.scanSkillFromHub(result.identifier);
setScan(s);
} catch (e) {
showToast(`Scan failed: ${e}`, "error");
} finally {
setScanning(false);
}
}, [result.identifier, showToast]);
return (
<Dialog open onOpenChange={(o: boolean) => !o && onClose()}>
<DialogContent className="max-w-3xl rounded-none">
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2 text-sm">
<Package className="h-4 w-4" />
{result.name}
<Badge tone={trust.tone} className="text-xs">
{trust.label}
</Badge>
<Badge tone="secondary" className="text-xs">
{result.source}
</Badge>
{installed && (
<Badge tone="success" className="text-xs">
installed
</Badge>
)}
</DialogTitle>
<DialogDescription className="sr-only">
Preview the SKILL.md source and run a security scan for {result.name}{" "}
before installing.
</DialogDescription>
</DialogHeader>
<p className="text-xs text-text-secondary -mt-1">{result.description}</p>
<p className="text-xs font-mono text-text-tertiary truncate">
{result.identifier}
</p>
{/* Action row */}
<div className="flex flex-wrap items-center gap-2 border-y border-border py-2">
<Button
size="sm"
outlined={tab !== "readme"}
onClick={() => setTab("readme")}
prefix={<FileText className="h-3.5 w-3.5" />}
>
Read SKILL.md
</Button>
<Button
size="sm"
outlined={tab !== "scan"}
onClick={() => void runScan()}
disabled={scanning}
prefix={
scanning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Shield className="h-3.5 w-3.5" />
)
}
>
{scan ? "Re-scan" : "Security scan"}
</Button>
{result.repo && (
<a
href={`https://github.com/${result.repo}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ExternalLink className="h-3.5 w-3.5" />
{result.repo}
</a>
)}
<div className="ml-auto">
{installed ? (
<Button size="sm" ghost disabled prefix={<CheckCircle2 className="h-3.5 w-3.5" />}>
Installed
</Button>
) : (
<Button
size="sm"
onClick={onInstall}
prefix={<Download className="h-3.5 w-3.5" />}
>
Install
</Button>
)}
</div>
</div>
{/* Body */}
<div className="max-h-[55vh] overflow-auto">
{tab === "readme" ? (
previewLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="text-xl text-primary" />
</div>
) : preview ? (
<div className="flex flex-col gap-3">
{preview.tags.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{preview.tags.map((tag) => (
<span
key={tag}
className="text-[0.65rem] font-mono text-text-tertiary border border-border px-1 py-px"
>
{tag}
</span>
))}
</div>
)}
{preview.files.length > 0 && (
<div className="text-xs text-text-tertiary">
<span className="font-mondwest tracking-[0.1em] uppercase">
Files:{" "}
</span>
<span className="font-mono">{preview.files.join(" ")}</span>
</div>
)}
<pre className="whitespace-pre-wrap break-words bg-background/50 border border-border p-3 text-xs font-mono text-text-secondary leading-relaxed">
{preview.skill_md || "(SKILL.md is empty)"}
</pre>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-10">
Couldn't load the skill source.
</p>
)
) : (
<ScanPanel scan={scan} scanning={scanning} />
)}
</div>
</DialogContent>
</Dialog>
);
}
/* ---- Visual security-scan result ---- */
function ScanPanel({
scan,
scanning,
}: {
scan: SkillHubScan | null;
scanning: boolean;
}) {
if (scanning && !scan) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="text-xs text-muted-foreground">
Fetching, quarantining, and scanning
</span>
</div>
);
}
if (!scan) {
return (
<p className="text-sm text-muted-foreground text-center py-10">
Run a security scan to inspect this skill for risky patterns before
installing.
</p>
);
}
const v = verdictVisual(scan.verdict);
const policyTone =
scan.policy === "allow"
? "success"
: scan.policy === "ask"
? "warning"
: "destructive";
const policyLabel =
scan.policy === "allow"
? "Install allowed"
: scan.policy === "ask"
? "Needs confirmation"
: "Install blocked";
return (
<div className="flex flex-col gap-3">
{/* Verdict header */}
<div className="flex flex-wrap items-center gap-2 border border-border p-3">
<v.Icon
className={cn(
"h-6 w-6",
scan.verdict === "safe"
? "text-emerald-400"
: scan.verdict === "dangerous"
? "text-red-400"
: "text-amber-400",
)}
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Verdict: {v.label}</span>
<Badge tone={v.tone} className="text-xs">
{scan.verdict}
</Badge>
</div>
<span className="text-xs text-text-tertiary">
{scan.trust_level} source · {scan.findings.length} finding
{scan.findings.length !== 1 ? "s" : ""}
</span>
</div>
<Badge tone={policyTone} className="ml-auto text-xs">
{policyLabel}
</Badge>
</div>
{/* Severity tally */}
<div className="flex flex-wrap items-center gap-1.5">
{(["critical", "high", "medium", "low"] as const).map((sev) => {
const n = scan.severity_counts[sev] || 0;
if (n === 0) return null;
return (
<Badge key={sev} tone={SEVERITY_TONE[sev]} className="text-xs">
{n} {sev}
</Badge>
);
})}
{scan.findings.length === 0 && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
No risky patterns detected
</span>
)}
</div>
<p className="text-xs text-text-tertiary">{scan.policy_reason}</p>
{/* Findings */}
{scan.findings.length > 0 && (
<div className="flex flex-col border border-border divide-y divide-border">
{scan.findings.map((f, i) => (
<div key={i} className="flex items-start gap-2 p-2">
<Badge tone={SEVERITY_TONE[f.severity] || "outline"} className="text-xs shrink-0">
{f.severity}
</Badge>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium">{f.category}</span>
<span className="text-xs font-mono text-text-tertiary truncate">
{f.file}:{f.line}
</span>
</div>
<p className="text-xs text-text-secondary">{f.description}</p>
</div>
</div>
))}
</div>
)}
</div>
);
}