From 56236b16e383cc656bb8c88429902f4de83f1faf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:44:50 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20rehaul=20Skills=20hub=20brow?= =?UTF-8?q?ser=20=E2=80=94=20connected=20hubs,=20featured,=20preview=20+?= =?UTF-8?q?=20security=20scan=20(#40384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/web_server.py | 305 ++++++- .../test_dashboard_admin_endpoints.py | 207 ++++- web/src/lib/api.ts | 83 +- web/src/pages/SkillsPage.tsx | 760 ++++++++++++++++-- 4 files changed, 1282 insertions(+), 73 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0fb4fbd9c9c..6a3ed07344b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 1dec745b2fb..04ccc7eb299 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -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 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f4a7588d0f6..0452ee46103 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -916,9 +916,19 @@ export const api = { updateSkillsFromHub: () => fetchJSON("/api/skills/hub/update", { method: "POST" }), searchSkillsHub: (q: string, source = "all", limit = 20) => - fetchJSON<{ results: SkillHubResult[] }>( + fetchJSON( `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`, ), + getSkillHubSources: () => + fetchJSON("/api/skills/hub/sources"), + previewSkillFromHub: (identifier: string) => + fetchJSON( + `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, + ), + scanSkillFromHub: (identifier: string) => + fetchJSON( + `/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; + /** source ids that didn't return within the parallel-search timeout. */ + timed_out: string[]; + /** identifier -> installed lock entry (for "already installed" badges). */ + installed: Record; +} + +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; +} + +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; +} + // ── Admin types ─────────────────────────────────────────────────────── export interface McpServer { diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index e26c807fe2c..5131cc3e877 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -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 = { + critical: "destructive", + high: "destructive", + medium: "warning", + low: "secondary", +}; + function HubBrowser({ showToast, }: { @@ -580,28 +646,73 @@ function HubBrowser({ const [results, setResults] = useState([]); 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>({}); + const [timedOut, setTimedOut] = useState([]); + const [searchMs, setSearchMs] = useState(null); + + // Landing state: which hubs are wired up + featured skills. + const [sources, setSources] = useState([]); + const [featured, setFeatured] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(true); + + // identifier -> installed entry (drives "Installed" badges). + const [installed, setInstalled] = useState>({}); + + // Live action log for the most recent install/update. const [action, setAction] = useState(null); const [actionLog, setActionLog] = useState([]); const [actionRunning, setActionRunning] = useState(false); - const runSearch = async () => { + // Detail dialog (preview + scan for a single skill). + const [detail, setDetail] = useState(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 (
+ {/* ── Search bar ── */}
@@ -682,13 +813,13 @@ function HubBrowser({ Update all
-

- Results come from the same sources as hermes skills search. - Installs run in the background; the log streams below. -

+ + {/* Connected hubs strip — proves the tab is wired up. */} +
+ {/* ── Install/update action log ── */} {action && ( @@ -700,6 +831,17 @@ function HubBrowser({ ) : ( done )} + {!actionRunning && ( + + )}
               {actionLog.length ? actionLog.join("\n") : "Starting…"}
@@ -708,46 +850,562 @@ function HubBrowser({
         
       )}
 
+      {/* ── Landing: featured skills (before any search) ── */}
+      {showLanding && (
+        <>
+          {sourcesLoading ? (
+            
+ +
+ ) : featured.length > 0 ? ( +
+
+ + + Featured skills + + + from the Hermes index — search above for thousands more + +
+ {featured.map((r) => ( + setDetail(r)} + onInstall={() => void install(r.identifier)} + /> + ))} +
+ ) : ( + + + Search the hub above to browse installable skills from the + connected sources. + + + )} + + )} + + {/* ── Searching spinner ── */} {searching && (
)} - {!searching && searched && results.length === 0 && ( - - - No matching skills found in the hub. - - + {/* ── Search results ── */} + {!searching && searched && ( + <> + + {results.length === 0 ? ( + + + No matching skills found in the hub. + + + ) : ( + results.map((r) => ( + setDetail(r)} + onInstall={() => void install(r.identifier)} + /> + )) + )} + )} - {results.map((r) => ( - - -
-
- {r.name} - {r.source} - {r.trust_level} -
-

{r.description}

-

- {r.identifier} -

-
+ {/* ── Detail dialog: preview + scan ── */} + {detail && ( + setDetail(null)} + onInstall={() => void install(detail.identifier)} + showToast={showToast} + /> + )} + + ); +} + +/* ---- Connected hubs strip ---- */ +function ConnectedHubs({ + sources, + loading, +}: { + sources: SkillHubSource[]; + loading: boolean; +}) { + if (loading) { + return ( +

Connecting to skill hubs…

+ ); + } + if (sources.length === 0) { + return ( +

+ Results come from the same sources as{" "} + hermes skills search. +

+ ); + } + return ( +
+ + + Connected hubs: + + {sources.map((s) => { + const down = + (s.id === "hermes-index" && s.available === false) || + (s.id === "github" && s.rate_limited === true); + return ( + + {s.label} + {s.id === "github" && s.rate_limited ? " (rate-limited)" : ""} + + ); + })} +
+ ); +} + +/* ---- Search result-count + per-source breakdown ---- */ +function SearchMeta({ + count, + sourceCounts, + timedOut, + ms, +}: { + count: number; + sourceCounts: Record; + timedOut: string[]; + ms: number | null; +}) { + const entries = Object.entries(sourceCounts).filter(([, n]) => n > 0); + return ( +
+ + {count} result{count !== 1 ? "s" : ""} + + {ms != null && {(ms / 1000).toFixed(1)}s} + {entries.length > 0 && ( + + {entries.map(([sid, n]) => ( + + {sid}:{n} + + ))} + + )} + {timedOut.length > 0 && ( + + + {timedOut.join(", ")} timed out + + )} +
+ ); +} + +/* ---- One result card ---- */ +function HubResultCard({ + result, + installed, + onOpen, + onInstall, +}: { + result: SkillHubResult; + installed: boolean; + onOpen: () => void; + onInstall: () => void; +}) { + const trust = trustVisual(result.trust_level); + return ( + + + +
+ + {installed ? ( + + ) : ( - - - ))} + )} +
+
+
+ ); +} + +/* ---- 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(null); + const [previewLoading, setPreviewLoading] = useState(true); + const [scan, setScan] = useState(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 ( + !o && onClose()}> + + + + + {result.name} + + {trust.label} + + + {result.source} + + {installed && ( + + installed + + )} + + + Preview the SKILL.md source and run a security scan for {result.name}{" "} + before installing. + + + +

{result.description}

+

+ {result.identifier} +

+ + {/* Action row */} +
+ + + {result.repo && ( + + + {result.repo} + + )} +
+ {installed ? ( + + ) : ( + + )} +
+
+ + {/* Body */} +
+ {tab === "readme" ? ( + previewLoading ? ( +
+ +
+ ) : preview ? ( +
+ {preview.tags.length > 0 && ( +
+ {preview.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + {preview.files.length > 0 && ( +
+ + Files:{" "} + + {preview.files.join(" ")} +
+ )} +
+                  {preview.skill_md || "(SKILL.md is empty)"}
+                
+
+ ) : ( +

+ Couldn't load the skill source. +

+ ) + ) : ( + + )} +
+
+
+ ); +} + +/* ---- Visual security-scan result ---- */ +function ScanPanel({ + scan, + scanning, +}: { + scan: SkillHubScan | null; + scanning: boolean; +}) { + if (scanning && !scan) { + return ( +
+ + + Fetching, quarantining, and scanning… + +
+ ); + } + if (!scan) { + return ( +

+ Run a security scan to inspect this skill for risky patterns before + installing. +

+ ); + } + + 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 ( +
+ {/* Verdict header */} +
+ +
+
+ Verdict: {v.label} + + {scan.verdict} + +
+ + {scan.trust_level} source · {scan.findings.length} finding + {scan.findings.length !== 1 ? "s" : ""} + +
+ + {policyLabel} + +
+ + {/* Severity tally */} +
+ {(["critical", "high", "medium", "low"] as const).map((sev) => { + const n = scan.severity_counts[sev] || 0; + if (n === 0) return null; + return ( + + {n} {sev} + + ); + })} + {scan.findings.length === 0 && ( + + + No risky patterns detected + + )} +
+ +

{scan.policy_reason}

+ + {/* Findings */} + {scan.findings.length > 0 && ( +
+ {scan.findings.map((f, i) => ( +
+ + {f.severity} + +
+
+ {f.category} + + {f.file}:{f.line} + +
+

{f.description}

+
+
+ ))} +
+ )}
); }