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
+82 -1
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 {
+709 -51
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>
);
}