diff --git a/apps/desktop/src/app/skills/catalog-browser.tsx b/apps/desktop/src/app/skills/catalog-browser.tsx index d67b7da119..d93cb21bf2 100644 --- a/apps/desktop/src/app/skills/catalog-browser.tsx +++ b/apps/desktop/src/app/skills/catalog-browser.tsx @@ -146,6 +146,17 @@ export const CatalogBrowser = memo(function CatalogBrowser({ const details = selected ? ( <>
+ {selected.imageUrl && ( + { e.currentTarget.style.display = 'none' }} + referrerPolicy="no-referrer" + src={selected.imageUrl} + /> + )}
diff --git a/apps/desktop/src/app/skills/catalog-data.test.ts b/apps/desktop/src/app/skills/catalog-data.test.ts index f46ec9f995..4062999cf4 100644 --- a/apps/desktop/src/app/skills/catalog-data.test.ts +++ b/apps/desktop/src/app/skills/catalog-data.test.ts @@ -116,6 +116,20 @@ describe('public catalog data', () => { ) }) + it('only renders plugin images hosted on GitHub so the browser never fans out to third-party hosts', () => { + const base = { name: 'x', tier: 'community', repo: 'https://github.com/o/r', sha: 'a'.repeat(40), version: '1.4.0' } + const [github, offhost, http] = parseCatalog('plugins', [ + { ...base, name: 'github', image: 'https://raw.githubusercontent.com/o/r/abc/banner.png' }, + { ...base, name: 'offhost', image: 'https://cdn.example.com/banner.png' }, + { ...base, name: 'http', image: 'http://github.com/o/r/banner.png' } + ]) + + expect(github.imageUrl).toBe('https://raw.githubusercontent.com/o/r/abc/banner.png') + expect(github.version).toBe('1.4.0') + expect(offhost.imageUrl).toBeNull() + expect(http.imageUrl).toBeNull() + }) + it('rejects a non-catalog response instead of treating an error payload as an empty catalog', () => { expect(() => parseCatalog('skills', { error: 'Service unavailable' })).toThrow('Invalid catalog response') }) diff --git a/apps/desktop/src/app/skills/catalog-data.ts b/apps/desktop/src/app/skills/catalog-data.ts index 624e44c8c2..e8774431e3 100644 --- a/apps/desktop/src/app/skills/catalog-data.ts +++ b/apps/desktop/src/app/skills/catalog-data.ts @@ -26,6 +26,8 @@ export interface CatalogEntry { hooks: string[] sourceUrl: string | null docsUrl: string | null + /** GitHub-hosted banner for plugin entries; the only third-party fetch the browser makes. */ + imageUrl: string | null stars: number | null search: string } @@ -37,6 +39,20 @@ const CATALOG_BASE = 'https://nousresearch.github.io/hermes-agent/docs/api' const text = (value: unknown): string => typeof value === 'string' ? value : '' const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(v => typeof v === 'string') : [] +const IMAGE_HOSTS = new Set(['raw.githubusercontent.com', 'github.com']) + +/** Mirrors scripts/validate_plugin_catalog.py: https on a GitHub host, else no image. */ +export function catalogImageUrl(value: unknown): string | null { + try { + const url = new URL(text(value)) + const host = url.hostname.toLowerCase() + + return url.protocol === 'https:' && (IMAGE_HOSTS.has(host) || host.endsWith('.githubusercontent.com')) ? url.href : null + } catch { + return null + } +} + function webUrl(value: unknown): string | null { try { const url = new URL(text(value)) @@ -98,6 +114,7 @@ export function parseCatalog(kind: CatalogKind, data: unknown): CatalogEntry[] { docsUrl: webUrl(row.docsUrl) || (text(row.docsPath) ? `${DOCS_ORIGIN}/docs/user-guide/skills/${text(row.docsPath)}` : null), + imageUrl: kind === 'plugins' ? catalogImageUrl(row.image) : null, stars: typeof row.stars === 'number' && Number.isFinite(row.stars) ? row.stars : null, search: [name, description, author, category, row.categoryLabel, source, ...tags, ...tools, ...hooks] .filter(Boolean).join(' ').toLowerCase() diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx index 390b4b272f..d77f711cec 100644 --- a/apps/desktop/src/app/skills/plugins-tab.tsx +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -269,7 +269,7 @@ function PackageRow({ size="xs" variant="outline" > - {p.updateToPin(agent.catalog_sha?.slice(0, 8) ?? '')} + {p.updateToPin(agent.catalog_version ?? agent.catalog_sha?.slice(0, 8) ?? '')} )} {busy && } diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 8262e5c128..1e740fafd6 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -3681,6 +3681,7 @@ export interface AgentPluginRow { catalog_tier?: string | null installed_sha?: string | null catalog_sha?: string | null + catalog_version?: string | null update_available?: boolean | null pinned_sha?: string | null } diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index c4480ec5fa..11b21bb518 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -4145,6 +4145,18 @@ "default": null, "title": "Catalog Sha" }, + "catalog_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Catalog Version" + }, "update_available": { "anyOf": [ { diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index abc8a5ab52..ee57b843d1 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -36,6 +36,18 @@ _REQUEST_TIMEOUT = 5.0 _MAX_LIVE_BYTES = 2 * 1024 * 1024 _SHA_RE = re.compile(r"^[0-9a-f]{40}$") +_VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$") +# Catalog images may only come from GitHub: the Desktop catalog browser never fans out to +# third-party hosts, and a raw URL pinned to the entry's commit is as immutable as the sha. +IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com") +IMAGE_HOST_SUFFIX = ".githubusercontent.com" + + +def is_allowed_image_url(url: str) -> bool: + from urllib.parse import urlsplit + parts = urlsplit(url) + host = (parts.hostname or "").lower() + return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX)) _NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$") @@ -67,6 +79,8 @@ class PluginCatalogEntry: requires_hermes: str = "" subdir: str = "" docs_url: str = "" + version: str = "" # human label for the pinned sha ("1.4.0"); cosmetic, never parsed + image: str = "" # https image URL on a GitHub host; shown on catalog cards platforms: List[str] = field(default_factory=list) # empty = all OSes capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities) @@ -81,7 +95,8 @@ class PluginCatalogEntry: "name": self.name, "repo": self.repo, "sha": self.sha, "description": self.description, "maintainer": self.maintainer, "tier": self.tier, "category": self.category, "requires_hermes": self.requires_hermes, - "subdir": self.subdir, "docs_url": self.docs_url, "platforms": list(self.platforms), + "subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image, + "platforms": list(self.platforms), "capabilities": { "provides_tools": list(caps.provides_tools), "provides_hooks": list(caps.provides_hooks), "provides_middleware": list(caps.provides_middleware), "requires_env": list(caps.requires_env), @@ -123,12 +138,21 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]: return None caps_raw = data.get("capabilities") caps: Dict[str, Any] = caps_raw if isinstance(caps_raw, dict) else {} + version = str(data.get("version") or "").strip() + if version and not _VERSION_RE.match(version): + logger.warning("Plugin catalog: %s: ignoring version %r (max 32 chars of [A-Za-z0-9._+-])", label, version) + version = "" + image = str(data.get("image") or "").strip() + if image and not is_allowed_image_url(image): + logger.warning("Plugin catalog: %s: ignoring image %r (must be https on a GitHub host)", label, image) + image = "" return PluginCatalogEntry( name=name, repo=repo, sha=sha, description=str(data.get("description") or "").strip(), maintainer=str(data.get("maintainer") or "").strip(), tier=tier, category=category, requires_hermes=str(data.get("requires_hermes") or "").strip(), subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(), + version=version, image=image, platforms=_str_list(data.get("platforms")), capabilities=CatalogCapabilities( provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")), diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index ec660642d6..456f24a43d 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -160,6 +160,11 @@ def _capability_counts(entry: PluginCatalogEntry) -> str: return ", ".join(parts) or "—" +def pin_label(entry: PluginCatalogEntry) -> str: + """``1.4.0 @ abcd1234`` when the entry carries a version label, else the short sha.""" + return f"{entry.version} @ {entry.sha[:8]}" if entry.version else entry.sha[:8] + + def _render_entries(entries: List[PluginCatalogEntry], console) -> None: from hermes_cli.plugins_cmd import _table table = _table(((("Name", "bold")), ("Category", None), ("Tier", None), ("Description", None), @@ -167,7 +172,7 @@ def _render_entries(entries: List[PluginCatalogEntry], console) -> None: for e in sorted(entries, key=lambda e: (e.category, e.tier != "official", e.name)): tier = "[cyan]official[/cyan]" if e.tier == "official" else "[magenta]community[/magenta]" desc = e.description if len(e.description) <= 60 else e.description[:57] + "..." - table.add_row(e.name, e.category, tier, desc, e.sha[:8], _capability_counts(e)) + table.add_row(e.name, e.category, tier, desc, pin_label(e), _capability_counts(e)) console.print() console.print(table) console.print() @@ -203,7 +208,8 @@ def cmd_info(name: str) -> None: if entry.description: console.print(entry.description) console.print() - rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Pinned SHA", entry.sha), + rows = [("Repo", entry.repo), ("Subdir", entry.subdir), ("Version", entry.version), ("Pinned SHA", entry.sha), + ("Image", entry.image), ("Maintainer", entry.maintainer), ("Requires", f"hermes {entry.requires_hermes}" if entry.requires_hermes else ""), ("Platforms", ", ".join(entry.platforms)), ("Docs", entry.docs_url)] for label, value in rows: @@ -274,9 +280,11 @@ def installed_catalog_state(installed: Dict[str, Dict[str, Any]]) -> Dict[str, A } -def catalog_row_fields(dir_path, pins: Dict[str, str]) -> Dict[str, Any]: +def catalog_row_fields(dir_path, pins: Dict[str, str], versions: Optional[Dict[str, str]] = None) -> Dict[str, Any]: """Provenance fields for one installed-plugin row (TUI/desktop ``plugins.manage list``): catalog - name/tier/installed SHA and, when *pins* has the entry, the current pin + ``update_available``.""" + name/tier/installed SHA and, when *pins* has the entry, the current pin (+ its version label from + *versions*) and ``update_available``.""" + versions = versions or {} sidecar = read_catalog_sidecar(dir_path) if not sidecar: return {} @@ -287,6 +295,7 @@ def catalog_row_fields(dir_path, pins: Dict[str, str]) -> Dict[str, Any]: pin = pins.get(str(sidecar["catalog_name"])) if pin: row["catalog_sha"] = pin + row["catalog_version"] = versions.get(str(sidecar["catalog_name"])) or None row["update_available"] = bool(installed_sha) and installed_sha != pin return row @@ -297,3 +306,11 @@ def catalog_pins() -> Dict[str, str]: return {e.name: e.sha for e in load_catalog_live()} except Exception: return {} + + +def catalog_versions() -> Dict[str, str]: + """``{catalog_name: version_label}`` for entries that carry one; empty on failure (best effort).""" + try: + return {e.name: e.version for e in load_catalog_live() if e.version} + except Exception: + return {} diff --git a/plugin-catalog/README.md b/plugin-catalog/README.md index 38d76e58f5..11ed4ee89d 100644 --- a/plugin-catalog/README.md +++ b/plugin-catalog/README.md @@ -54,6 +54,9 @@ category: memory # desktop | memory | platform | web | tools | voice # (default desktop) — the shelf the entry sits on at /docs/plugins requires_hermes: ">=0.19" # optional docs_url: "" # optional +version: "1.4.0" # optional human label for the sha (quote it); shown as "1.4.0 @ abcd1234" +image: "" # optional https image on a GitHub host, e.g. + # https://raw.githubusercontent.com/owner/repo//docs/banner.png platforms: [] # optional, e.g. [linux, macos]; empty = all capabilities: provides_tools: [] @@ -62,6 +65,13 @@ capabilities: requires_env: [] ``` +`version` and `image` are cosmetic: neither is parsed or used to pick what +installs. The sha stays the release; bump `version` in the same PR that bumps +`sha` so the label on the card matches the code. Images must live on +`raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com` so +the Desktop catalog never fetches from third-party hosts; pin the raw URL to +the entry's commit and the picture is as immutable as the code. + ## removed.yaml — the blocklist When an entry is pulled from the catalog for security or policy reasons, it diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py index 82cafb920b..0ced5791b6 100644 --- a/scripts/validate_plugin_catalog.py +++ b/scripts/validate_plugin_catalog.py @@ -29,6 +29,7 @@ import json import re import sys from pathlib import Path +from urllib.parse import urlsplit try: import yaml @@ -63,15 +64,29 @@ KNOWN_KEYS = { "category", "requires_hermes", "docs_url", + "version", + "image", "platforms", "capabilities", } +# Cosmetic labels attached to the pin. ``version`` is never parsed; ``image`` may only point +# at GitHub so the Desktop catalog browser never fetches from third-party hosts and a raw URL +# pinned to the entry's commit stays as immutable as the sha. +VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$") +IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com") +IMAGE_HOST_SUFFIX = ".githubusercontent.com" REQUIRED_KEYS = ("name", "repo", "sha", "description", "maintainer") # One comparator clause of a requires_hermes spec, e.g. ">=0.19" or "!=1.2.3". _COMPARATOR_RE = re.compile(r"^(>=|<=|==|!=|>|<)\s*\d+(\.\d+)*$") +def _is_allowed_image_url(url: str) -> bool: + parts = urlsplit(url) + host = (parts.hostname or "").lower() + return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX)) + + def _is_nonempty_str(value: object) -> bool: return isinstance(value, str) and value.strip() != "" @@ -134,6 +149,14 @@ def validate_entry(data: object) -> tuple[list[str], list[str]]: if "requires_hermes" in data: _check_requires_hermes(data["requires_hermes"], errors) + version = data.get("version") + if version is not None and (not isinstance(version, str) or not VERSION_RE.match(version)): + errors.append(f"version {version!r} must be 1-32 chars of [A-Za-z0-9._+-] (quote it in YAML)") + + image = data.get("image") + if image is not None and (not isinstance(image, str) or not _is_allowed_image_url(image)): + errors.append(f"image {image!r} must be an https URL on {list(IMAGE_HOSTS)} or *{IMAGE_HOST_SUFFIX}") + platforms = data.get("platforms", []) if platforms is None: platforms = [] diff --git a/tests/hermes_cli/test_plugin_catalog.py b/tests/hermes_cli/test_plugin_catalog.py index 8d438cd294..6224893ca3 100644 --- a/tests/hermes_cli/test_plugin_catalog.py +++ b/tests/hermes_cli/test_plugin_catalog.py @@ -42,6 +42,19 @@ def test_category_defaults_to_other_and_unknown_category_is_rejected(tmp_path): assert entries["mem"].to_dict()["category"] == "memory" +def test_version_and_image_are_cosmetic_and_offhost_images_are_dropped(tmp_path): + """``version``/``image`` label the pin, they never gate it: a bad value is dropped with a warning and + the entry survives; an image off GitHub is dropped because the Desktop browser must never fetch + from third-party hosts.""" + (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"))) + (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("offhost", version="1.4.0 beta", image="https://evil.example/x.png"))) + entries = {e.name: e for e in pc.load_catalog(tmp_path)} + assert set(entries) == {"labelled", "offhost"} + assert entries["labelled"].version == "1.4.0" and entries["labelled"].image == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png" + assert entries["offhost"].version == "" and entries["offhost"].image == "" + assert entries["labelled"].to_dict()["version"] == "1.4.0" + + def test_invalid_entries_are_skipped_not_raised(tmp_path): (tmp_path / "a.yaml").write_text(yaml.safe_dump(_entry("ok"))) (tmp_path / "b.yaml").write_text(yaml.safe_dump(_entry("short-sha", sha="abc123"))) diff --git a/tests/scripts/test_validate_plugin_catalog.py b/tests/scripts/test_validate_plugin_catalog.py index 52bbd80c3e..057a2c45f8 100644 --- a/tests/scripts/test_validate_plugin_catalog.py +++ b/tests/scripts/test_validate_plugin_catalog.py @@ -97,6 +97,15 @@ def test_unknown_category_fails(tmp_path): assert run_validator(str(path)).returncode == 0 +def test_version_and_image_are_validated_when_present(tmp_path): + """Admission rejects a malformed label or an off-GitHub image so the site and CLI never have to + coerce one; a well-formed pair passes.""" + _expect_error(tmp_path, {"image": "https://cdn.example.com/banner.png"}, "image") + _expect_error(tmp_path, {"version": "1.4.0 beta"}, "version") + path = write_entry(tmp_path, {**VALID_ENTRY, "version": "1.4.0", "image": "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png"}, "ok.yaml") + assert run_validator(str(path)).returncode == 0 + + def test_bad_name_fails(tmp_path): _expect_error(tmp_path, {"name": "Bad Name!"}, "name") diff --git a/tests/website/test_extract_plugins.py b/tests/website/test_extract_plugins.py index 94921aa702..b3c884379f 100644 --- a/tests/website/test_extract_plugins.py +++ b/tests/website/test_extract_plugins.py @@ -133,6 +133,17 @@ def test_unknown_tier_normalizes_to_community(mod, tmp_path): assert entries[0]["tier"] == "community" +def test_version_and_image_are_emitted_and_offhost_image_is_dropped_not_fatal(mod, tmp_path): + catalog = tmp_path / "plugin-catalog" + catalog.mkdir() + _write_entry(catalog, "labelled", version="1.4.0", image="https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png") + _write_entry(catalog, "offhost", version="1.4.0", image="https://cdn.example.com/banner.png") + + entries = {e["name"]: e for e in mod.load_catalog_entries(catalog)} + assert entries["labelled"]["version"] == "1.4.0" and entries["labelled"]["image"] == "https://raw.githubusercontent.com/owner/repo/38fe0fb53eff98d477f807432e965429e665ca33/banner.png" + assert entries["offhost"]["image"] == "" and entries["offhost"]["version"] == "1.4.0" + + # -------------------------------------------------------------------------- # Full run: outputs + graceful degradation # -------------------------------------------------------------------------- diff --git a/tui_gateway/contracts/tools_mcp_plugins.py b/tui_gateway/contracts/tools_mcp_plugins.py index 071b592927..2d59cc9e33 100644 --- a/tui_gateway/contracts/tools_mcp_plugins.py +++ b/tui_gateway/contracts/tools_mcp_plugins.py @@ -604,6 +604,7 @@ class AgentPluginRow(Result): catalog_tier: str | None = None installed_sha: str | None = None catalog_sha: str | None = None + catalog_version: str | None = None update_available: bool | None = None pinned_sha: str | None = None diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 9f72effe5d..88c78668e9 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1380,6 +1380,7 @@ def _plugin_rows() -> list[dict]: cat = _tools_mod("hermes_cli.plugins_cmd_catalog") enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set() pins = cat.catalog_pins() # powers the desktop's "Update to " affordance + versions = cat.catalog_versions() ref_pins = pc._read_install_metadata() # ``--ref`` installs: pinned_sha so the desktop can show the pin out = [] for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()): @@ -1397,7 +1398,7 @@ def _plugin_rows() -> list[dict]: "source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir), "install_dir": str(_dir_path) if _dir_path else "", "has_desktop_half": bool(_dir_path and (_dir_path / "desktop" / "plugin.js").is_file()), - **cat.catalog_row_fields(_dir, pins), + **cat.catalog_row_fields(_dir, pins, versions), **({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})}) return out diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md index 4322886bce..cd2b424dd4 100644 --- a/website/docs/user-guide/features/plugin-catalog.md +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -65,6 +65,8 @@ directory of the hermes-agent repository, declaring: | `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) | | `platforms` | OS restrictions, empty = all (optional) | | `docs_url` | External documentation link (optional) | +| `version` | Human-readable label for the pinned sha, e.g. `"1.4.0"`; shown as `1.4.0 @ abcd1234` in the CLI, on the catalog card and on the Desktop **Update to** button (optional, cosmetic) | +| `image` | Banner image for the catalog card; an `https` URL on `raw.githubusercontent.com`, `github.com` or `*.githubusercontent.com` (optional). Pin it to the entry's commit (`raw.githubusercontent.com/owner/repo//...`) so it never changes under the review | ## Trust model @@ -201,7 +203,11 @@ in short, an entry must be: `hermes plugins update `). Pin updates (bumping `sha` to a newer commit) follow the same PR + review -process. +process; bump `version` in the same PR so the label users see matches the +code. Installed plugins compare their recorded sha against the live pin: +`hermes plugins list --json` reports `update_available`, the Desktop Plugins +tab shows an **Update to 1.4.0** button, and `hermes plugins update ` +checks out exactly the new pin. ## See also diff --git a/website/scripts/extract-plugins.py b/website/scripts/extract-plugins.py index 590d5a1b24..1b394be0d6 100644 --- a/website/scripts/extract-plugins.py +++ b/website/scripts/extract-plugins.py @@ -32,6 +32,7 @@ import sys from collections import Counter from datetime import datetime, timezone from pathlib import Path +from urllib.parse import urlsplit import yaml @@ -45,12 +46,31 @@ _GITHUB_REPO_RE = re.compile(r"^https://github\.com/([^/\s]+)/([^/\s#?]+?)(?:\.g CATALOG_TIERS = ("official", "community") CATALOG_CATEGORIES = ("desktop", "memory", "platform", "web", "tools", "voice", "automation", "models", "general") SHA_RE = re.compile(r"^[0-9a-f]{40}$") +# Keep in sync with scripts/validate_plugin_catalog.py (cosmetic fields attached to the pin). +VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$") +IMAGE_HOSTS = ("raw.githubusercontent.com", "github.com") +IMAGE_HOST_SUFFIX = ".githubusercontent.com" def _log(msg: str) -> None: print(f"[extract-plugins] {msg}", file=sys.stderr) +def _is_allowed_image_url(url: str) -> bool: + parts = urlsplit(url) + host = (parts.hostname or "").lower() + return parts.scheme == "https" and bool(host) and (host in IMAGE_HOSTS or host.endswith(IMAGE_HOST_SUFFIX)) + + +def _cosmetic(value, accept, file_name: str, entry: str, key: str) -> str: + """A cosmetic field is dropped, never fatal: the site must not lose an entry a reviewer merged.""" + text = str(value or "").strip() + if text and not accept(text): + _log(f"{file_name} ({entry}): dropping invalid {key} {text!r}") + return "" + return text + + def _str_list(value) -> list[str]: if isinstance(value, str): return [value] if value.strip() else [] @@ -145,6 +165,8 @@ def load_catalog_entries(catalog_dir: Path, stars: dict[str, int] | None = None) "platforms": _str_list(raw.get("platforms")), "capabilities": _normalize_capabilities(raw.get("capabilities")), "docsUrl": str(raw.get("docs_url") or "").strip(), + "version": _cosmetic(raw.get("version"), VERSION_RE.match, path.name, name, "version"), + "image": _cosmetic(raw.get("image"), _is_allowed_image_url, path.name, name, "image"), "installCommand": f"hermes plugins install {name}", "stars": _repo_stars(repo, stars), }) diff --git a/website/src/pages/plugins/index.tsx b/website/src/pages/plugins/index.tsx index 94540b2d89..d55bedcf87 100644 --- a/website/src/pages/plugins/index.tsx +++ b/website/src/pages/plugins/index.tsx @@ -25,6 +25,10 @@ interface CatalogPlugin { platforms?: string[]; capabilities?: PluginCapabilities; docsUrl?: string; + /** Human label for the pin ("1.4.0"); cosmetic, shown beside the sha. */ + version?: string; + /** Card banner image (GitHub-hosted https URL enforced by the extractor). */ + image?: string; installCommand: string; /** GitHub stargazers at the last daily probe; null when the repo is not on GitHub or unprobed. */ stars?: number | null; @@ -197,6 +201,18 @@ function PluginCard({ >
+ {plugin.image && ( + { e.currentTarget.style.display = "none"; }} + /> + )} +
{category.icon} @@ -212,6 +228,11 @@ function PluginCard({ > {tier.icon} {tier.label} + {plugin.version && ( + + v{plugin.version.replace(/^v/i, "")} + + )} {typeof plugin.stars === "number" && ( - {plugin.shaShort} ↗ + {plugin.version ? `${plugin.version} @ ${plugin.shaShort}` : plugin.shaShort} ↗
diff --git a/website/src/pages/plugins/styles.module.css b/website/src/pages/plugins/styles.module.css index 37e65af6af..e94db2240a 100644 --- a/website/src/pages/plugins/styles.module.css +++ b/website/src/pages/plugins/styles.module.css @@ -464,6 +464,15 @@ opacity: 1; } +.cardImage { + display: block; + width: 100%; + aspect-ratio: 16 / 7; + object-fit: cover; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); +} + .cardInner { padding: 1rem 1rem 0.85rem 1.15rem; } @@ -516,6 +525,18 @@ margin-top: 0.1rem; } +.versionPill { + font-family: "JetBrains Mono", monospace; + font-size: 0.62rem; + padding: 0.15rem 0.4rem; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--ifm-font-color-secondary); + white-space: nowrap; + flex-shrink: 0; + margin-top: 0.1rem; +} + .cardDesc { font-size: 0.82rem; line-height: 1.55;