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:
parent
5af899c7ca
commit
56236b16e3
@ -6721,44 +6721,311 @@ async def update_skills_hub():
|
|||||||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
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")
|
@app.get("/api/skills/hub/search")
|
||||||
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
|
async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20):
|
||||||
"""Search the skill hub across all configured sources.
|
"""Search the skill hub across all configured sources.
|
||||||
|
|
||||||
Network-bound (parallel source search); runs in a thread so the FastAPI
|
Network-bound (parallel source search); runs in a thread so the FastAPI
|
||||||
loop isn't blocked. Returns structured results the UI installs by
|
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()
|
query = (q or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
return {"results": []}
|
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
|
||||||
|
|
||||||
def _run():
|
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()
|
sources = create_source_router()
|
||||||
metas = unified_search(
|
capped = min(max(limit, 1), 50)
|
||||||
query, sources, source_filter=source or "all", limit=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 [
|
|
||||||
{
|
# Dedupe by identifier, preferring higher trust (mirrors unified_search).
|
||||||
"name": m.name,
|
_rank = {"builtin": 2, "trusted": 1, "community": 0}
|
||||||
"description": m.description,
|
seen = {}
|
||||||
"source": m.source,
|
for r in all_results:
|
||||||
"identifier": m.identifier,
|
if r.identifier not in seen:
|
||||||
"trust_level": m.trust_level,
|
seen[r.identifier] = r
|
||||||
"repo": m.repo,
|
elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0):
|
||||||
"tags": list(m.tags or []),
|
seen[r.identifier] = r
|
||||||
}
|
deduped = list(seen.values())[:capped]
|
||||||
for m in metas
|
|
||||||
]
|
return {
|
||||||
|
"results": [_skill_meta_to_payload(m) for m in deduped],
|
||||||
|
"source_counts": source_counts,
|
||||||
|
"timed_out": timed_out,
|
||||||
|
"installed": _installed_hub_identifiers(),
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = await asyncio.to_thread(_run)
|
return await asyncio.to_thread(_run)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("skills hub search failed")
|
_log.exception("skills hub search failed")
|
||||||
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -352,9 +352,212 @@ class TestSkillsHubSearchEndpoint:
|
|||||||
self.client, _ = _client()
|
self.client, _ = _client()
|
||||||
|
|
||||||
def test_empty_query_returns_empty(self):
|
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=")
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -916,9 +916,19 @@ export const api = {
|
|||||||
updateSkillsFromHub: () =>
|
updateSkillsFromHub: () =>
|
||||||
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
|
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
|
||||||
searchSkillsHub: (q: string, source = "all", limit = 20) =>
|
searchSkillsHub: (q: string, source = "all", limit = 20) =>
|
||||||
fetchJSON<{ results: SkillHubResult[] }>(
|
fetchJSON<SkillHubSearchResponse>(
|
||||||
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
|
`/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).
|
/** Identity payload returned by ``GET /api/auth/me`` (Phase 7).
|
||||||
@ -975,6 +985,77 @@ export interface SkillHubResult {
|
|||||||
tags: string[];
|
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 ───────────────────────────────────────────────────────
|
// ── Admin types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface McpServer {
|
export interface McpServer {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
|
import { useEffect, useLayoutEffect, useState, useMemo, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Search,
|
Search,
|
||||||
@ -7,6 +7,9 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
Globe,
|
Globe,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldQuestion,
|
||||||
Eye,
|
Eye,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
Brain,
|
Brain,
|
||||||
@ -16,9 +19,23 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
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 { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
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 { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||||
import { Switch } from "@nous-research/ui/ui/components/switch";
|
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 { cn } from "@/lib/utils";
|
||||||
import { Input } from "@nous-research/ui/ui/components/input";
|
import { Input } from "@nous-research/ui/ui/components/input";
|
||||||
import { useI18n } from "@/i18n";
|
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({
|
function HubBrowser({
|
||||||
showToast,
|
showToast,
|
||||||
}: {
|
}: {
|
||||||
@ -580,28 +646,73 @@ function HubBrowser({
|
|||||||
const [results, setResults] = useState<SkillHubResult[]>([]);
|
const [results, setResults] = useState<SkillHubResult[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [searched, setSearched] = 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 [action, setAction] = useState<string | null>(null);
|
||||||
const [actionLog, setActionLog] = useState<string[]>([]);
|
const [actionLog, setActionLog] = useState<string[]>([]);
|
||||||
const [actionRunning, setActionRunning] = useState(false);
|
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();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
const r = await api.searchSkillsHub(q);
|
const r = await api.searchSkillsHub(q);
|
||||||
setResults(r.results);
|
setResults(r.results);
|
||||||
|
setSourceCounts(r.source_counts || {});
|
||||||
|
setTimedOut(r.timed_out || []);
|
||||||
|
setInstalled((prev) => ({ ...prev, ...(r.installed || {}) }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Hub search failed: ${e}`, "error");
|
showToast(`Hub search failed: ${e}`, "error");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setSourceCounts({});
|
||||||
|
setTimedOut([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
setSearchMs(Math.round(performance.now() - t0));
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
};
|
}, [query, showToast]);
|
||||||
|
|
||||||
// Poll a spawned action's log until it exits.
|
/* ---- Poll a spawned action's log until it exits ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -612,7 +723,15 @@ function HubBrowser({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setActionLog(st.lines);
|
setActionLog(st.lines);
|
||||||
setActionRunning(st.running);
|
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 {
|
} catch {
|
||||||
if (!cancelled) setActionRunning(false);
|
if (!cancelled) setActionRunning(false);
|
||||||
}
|
}
|
||||||
@ -624,19 +743,23 @@ function HubBrowser({
|
|||||||
};
|
};
|
||||||
}, [action]);
|
}, [action]);
|
||||||
|
|
||||||
const install = async (identifier: string) => {
|
const install = useCallback(
|
||||||
try {
|
async (identifier: string) => {
|
||||||
const res = await api.installSkillFromHub(identifier);
|
try {
|
||||||
showToast(`Installing ${identifier}…`, "success");
|
const res = await api.installSkillFromHub(identifier);
|
||||||
setActionLog([]);
|
showToast(`Installing ${identifier}…`, "success");
|
||||||
setActionRunning(true);
|
setActionLog([]);
|
||||||
setAction(res.name);
|
setActionRunning(true);
|
||||||
} catch (e) {
|
setAction(res.name);
|
||||||
showToast(`Install failed: ${e}`, "error");
|
setDetail(null);
|
||||||
}
|
} catch (e) {
|
||||||
};
|
showToast(`Install failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const updateAll = async () => {
|
const updateAll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.updateSkillsFromHub();
|
const res = await api.updateSkillsFromHub();
|
||||||
showToast("Updating installed skills…", "success");
|
showToast("Updating installed skills…", "success");
|
||||||
@ -646,10 +769,18 @@ function HubBrowser({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Update failed: ${e}`, "error");
|
showToast(`Update failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
};
|
}, [showToast]);
|
||||||
|
|
||||||
|
const isInstalled = useCallback(
|
||||||
|
(identifier: string) => Boolean(installed[identifier]),
|
||||||
|
[installed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLanding = !searched && !searching;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* ── Search bar ── */}
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
<CardContent className="py-4 flex flex-col gap-3">
|
<CardContent className="py-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -682,13 +813,13 @@ function HubBrowser({
|
|||||||
Update all
|
Update all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Results come from the same sources as <span className="font-mono">hermes skills search</span>.
|
{/* Connected hubs strip — proves the tab is wired up. */}
|
||||||
Installs run in the background; the log streams below.
|
<ConnectedHubs sources={sources} loading={sourcesLoading} />
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Install/update action log ── */}
|
||||||
{action && (
|
{action && (
|
||||||
<Card className="rounded-none">
|
<Card className="rounded-none">
|
||||||
<CardContent className="py-3">
|
<CardContent className="py-3">
|
||||||
@ -700,6 +831,17 @@ function HubBrowser({
|
|||||||
) : (
|
) : (
|
||||||
<Badge tone="success">done</Badge>
|
<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>
|
</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">
|
<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…"}
|
{actionLog.length ? actionLog.join("\n") : "Starting…"}
|
||||||
@ -708,46 +850,562 @@ function HubBrowser({
|
|||||||
</Card>
|
</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 && (
|
{searching && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Spinner className="text-xl text-primary" />
|
<Spinner className="text-xl text-primary" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!searching && searched && results.length === 0 && (
|
{/* ── Search results ── */}
|
||||||
<Card className="rounded-none">
|
{!searching && searched && (
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
<>
|
||||||
No matching skills found in the hub.
|
<SearchMeta
|
||||||
</CardContent>
|
count={results.length}
|
||||||
</Card>
|
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) => (
|
{/* ── Detail dialog: preview + scan ── */}
|
||||||
<Card key={r.identifier} className="rounded-none">
|
{detail && (
|
||||||
<CardContent className="py-3 flex items-start gap-3">
|
<SkillDetailDialog
|
||||||
<div className="flex-1 min-w-0">
|
result={detail}
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
installed={isInstalled(detail.identifier)}
|
||||||
<span className="font-mono-ui text-sm">{r.name}</span>
|
onClose={() => setDetail(null)}
|
||||||
<Badge tone="secondary" className="text-xs">{r.source}</Badge>
|
onInstall={() => void install(detail.identifier)}
|
||||||
<Badge tone="outline" className="text-xs">{r.trust_level}</Badge>
|
showToast={showToast}
|
||||||
</div>
|
/>
|
||||||
<p className="text-xs text-text-secondary">{r.description}</p>
|
)}
|
||||||
<p className="text-xs font-mono text-text-tertiary truncate mt-0.5">
|
</div>
|
||||||
{r.identifier}
|
);
|
||||||
</p>
|
}
|
||||||
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
outlined
|
onClick={onInstall}
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => void install(r.identifier)}
|
|
||||||
prefix={<Download className="h-3.5 w-3.5" />}
|
prefix={<Download className="h-3.5 w-3.5" />}
|
||||||
>
|
>
|
||||||
Install
|
Install
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user