diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index b86a03a392..43ff82de6b 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -3896,7 +3896,7 @@ export interface PluginsManageParams { accept_capabilities?: boolean | null values?: Record | null } -export type PluginsAction = 'list' | 'toggle' | 'install' | 'update' | 'remove' | 'settings' +export type PluginsAction = 'list' | 'toggle' | 'install' | 'update' | 'remove' | 'settings' | 'onboarding' /** ``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s ok payload; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the running gateway picked it up and re-wired its handlers) and ``activation`` — the honest split of what is live now vs deferred, so ``restart_required`` is True only when no gateway answered; ``update`` → ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the ``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds — nothing changed until the client retries with ``accept_capabilities``; ``remove`` → ``ok``/``name`` plus ``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``. */ export interface PluginsManageResult { plugins?: AgentPluginRow[] | null @@ -3922,6 +3922,7 @@ export interface PluginsManageResult { delta_lines?: string[] | null error?: string | null written?: string[] | null + onboarding?: OnboardingCatalogPlugin[] | null } /** ``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance. */ export interface AgentPluginRow { @@ -3988,6 +3989,16 @@ export interface PluginLiveSkill { name: string description?: string } +/** A catalog plugin curated for the onboarding card (``onboarding: true``) that this OS runs. ``app_state`` is the pinned ``plugin.json`` declaration judged on this host; ``sentence`` names what is missing (empty when present or unknown). */ +export interface OnboardingCatalogPlugin { + name: string + title: string + description: string + tier: CatalogTier + platforms: string[] + app_state: CatalogAppState + sentence: string +} /** Single question: ``question`` / ``choices`` (/ ``multi_select``); batch: ``questions``. ``answers`` rides only on a reconnect replay (locks the server already accepted). */ export interface ClarifyRequestParams { session_id: string diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 87065298e9..a505d9c96a 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -17602,6 +17602,52 @@ "title": "OnboardingAnswers", "type": "object" }, + "OnboardingCatalogPlugin": { + "additionalProperties": false, + "description": "A catalog plugin curated for the onboarding card (``onboarding: true``) that this OS runs.\n``app_state`` is the pinned ``plugin.json`` declaration judged on this host; ``sentence`` names what\nis missing (empty when present or unknown).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "description": { + "title": "Description", + "type": "string" + }, + "tier": { + "$ref": "#/components/schemas/CatalogTier" + }, + "platforms": { + "items": { + "type": "string" + }, + "title": "Platforms", + "type": "array" + }, + "app_state": { + "$ref": "#/components/schemas/CatalogAppState" + }, + "sentence": { + "title": "Sentence", + "type": "string" + } + }, + "required": [ + "name", + "title", + "description", + "tier", + "platforms", + "app_state", + "sentence" + ], + "title": "OnboardingCatalogPlugin", + "type": "object" + }, "OnboardingEnsureSetupProfileResult": { "additionalProperties": false, "description": "``created`` is false when an existing setup profile was found (and returned untouched).", @@ -20019,7 +20065,8 @@ "install", "update", "remove", - "settings" + "settings", + "onboarding" ], "title": "PluginsAction", "type": "string" @@ -20493,6 +20540,21 @@ ], "default": null, "title": "Written" + }, + "onboarding": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/OnboardingCatalogPlugin" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Onboarding" } }, "title": "PluginsManageResult", diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index 6a0d15a4cf..e9a89cb8ba 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -88,6 +88,8 @@ class PluginCatalogEntry: screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/ readme: bool = False # docs site renders the README from the pinned commit on the entry's page platforms: List[str] = field(default_factory=list) # empty = all OSes + title: str = "" # human name ("NVIDIA App"); empty = derived from ``name`` + onboarding: bool = False # curated: offered on the desktop onboarding card capabilities: CatalogCapabilities = field(default_factory=CatalogCapabilities) @property @@ -103,7 +105,7 @@ class PluginCatalogEntry: "requires_hermes": self.requires_hermes, "subdir": self.subdir, "docs_url": self.docs_url, "version": self.version, "image": self.image, "screenshots": list(self.screenshots), "readme": self.readme, - "platforms": list(self.platforms), + "platforms": list(self.platforms), "title": self.title, "onboarding": self.onboarding, "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), @@ -164,6 +166,7 @@ def entry_from_mapping(data: Any, label: str) -> Optional[PluginCatalogEntry]: subdir=str(data.get("subdir") or "").strip(), docs_url=str(data.get("docs_url") or "").strip(), version=version, image=image, screenshots=screenshots, readme=data.get("readme") is not False, platforms=_str_list(data.get("platforms")), + title=str(data.get("title") or "").strip(), onboarding=data.get("onboarding") is True, capabilities=CatalogCapabilities( provides_tools=_str_list(caps.get("provides_tools")), provides_hooks=_str_list(caps.get("provides_hooks")), provides_middleware=_str_list(caps.get("provides_middleware")), @@ -403,11 +406,12 @@ def _live_generated_time(data: Dict[str, Any]) -> Optional[float]: def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tree_is_newer: Optional[bool]) -> bool: - """For one entry present in both sources with a different pin: the newer catalog wins. Newer is + """For one entry present in both sources that differs (a new pin, or new metadata such as ``title`` or + ``onboarding`` at the same pin): the newer catalog wins. Newer is decided by the checkout's catalog commit time vs the doc's ``generated_at`` when both resolve; otherwise by the entries' ``version`` labels when both parse; otherwise the live doc wins (a release install's in-tree copy is frozen at release time).""" - if tree.sha == live.sha: + if tree == live: return False if tree_is_newer is not None: return tree_is_newer @@ -421,8 +425,8 @@ def _prefer_in_tree_entry(tree: PluginCatalogEntry, live: PluginCatalogEntry, tr def load_catalog_live() -> List[PluginCatalogEntry]: - """Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry at - different pins the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin, + """Entries from the live (or cached) catalog, else the in-tree catalog. When both name an entry and + disagree the NEWER source supplies it — right after ``hermes update`` bumps an in-tree pin, a cache fetched before the bump must not re-install the old one (see :func:`_prefer_in_tree_entry`).""" data = fetch_live_catalog() if data is None: diff --git a/hermes_cli/plugin_catalog_presence.py b/hermes_cli/plugin_catalog_presence.py new file mode 100644 index 0000000000..509dd2e693 --- /dev/null +++ b/hermes_cli/plugin_catalog_presence.py @@ -0,0 +1,151 @@ +"""Whether the desktop app a catalog plugin drives is on this machine, before the plugin is installed. + +A portable plugin declares its app in ``plugin.json`` (``extensions.com.nousresearch.hermes.servers``). +The catalog pins the commit, so the declaration is read once per pin from the repo at that commit and +judged by the same ``hermes_platform`` resolver the installer and the Plugins-tab pill use. Nothing is +installed, spawned or connected. Any failure to read the declaration is ``unknown``, never a guess. +""" + +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +_GITHUB = re.compile(r"^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$") +_TIMEOUT = 5.0 +_MAX_BYTES = 256 * 1024 +# (repo, sha, subdir) -> manifest dict, or None when the pin has no readable plugin.json. A pin is +# immutable, so the process keeps the answer. +_manifests: Dict[Tuple[str, str, str], Optional[Dict[str, Any]]] = {} + + +@dataclass(frozen=True) +class Presence: + """``state`` uses the card's vocabulary (``CatalogAppState``); ``sentence`` is empty when present or unknown.""" + + state: str + sentence: str = "" + + +UNKNOWN = Presence("unknown") + + +def _raw_manifest_url(repo: str, sha: str, subdir: str) -> Optional[str]: + match = _GITHUB.match(repo.strip()) + if not match: + return None + path = "/".join(p for p in (subdir.strip("/"), "plugin.json") if p) + return f"https://raw.githubusercontent.com/{match.group(1)}/{match.group(2)}/{sha}/{path}" + + +def _pinned_manifest(repo: str, sha: str, subdir: str) -> Optional[Dict[str, Any]]: + key = (repo, sha, subdir) + if key in _manifests: + return _manifests[key] + url = _raw_manifest_url(repo, sha, subdir) + manifest: Optional[Dict[str, Any]] = None + if url: + try: + import httpx + + resp = httpx.get(url, timeout=_TIMEOUT, follow_redirects=True) + if resp.status_code == 200 and len(resp.content) <= _MAX_BYTES: + data = json.loads(resp.content) + manifest = data if isinstance(data, dict) else None + elif resp.status_code != 404: + return None # transient: do not remember + except Exception as exc: + logger.debug("plugin catalog presence: %s unreadable: %s", url, exc) + return None + _manifests[key] = manifest + return manifest + + +def _server_presence(decl: Any, liveness_raw: Any, title: str) -> Presence: + from hermes_platform.host import facts + from hermes_platform.resolver.app import AppResolver + from hermes_platform.resolver.availability import availability + from hermes_platform.resolver.base import Effort + from tools.mcp_liveness import parse_liveness + + available = availability(decl) + if available.state == "no_requirements": + return UNKNOWN + if available.state in ("missing_app", "unsupported_os"): + return Presence("missing_app", f"needs {title}") + if available.state == "version_too_old": + found = f" (found {available.version})" if available.version else "" + return Presence("missing_app", f"needs {title} {available.min_version} or newer{found}") + try: + live = parse_liveness(liveness_raw) if liveness_raw is not None else None + except ValueError: + live = None + if live is None or live.kind == "static": + return Presence("present") + if live.kind == "interactive_session": + return Presence("present") if facts.interactive_session() else Presence( + "app_not_running", f"{title} needs an interactive desktop session") + definition = decl.app_for(facts.os_family()) + if definition is None: + return Presence("missing_app", f"needs {title}") + resolver = AppResolver(live.app_definition(definition)) + probe = resolver.probe(resolver.locate(), effort=Effort.LOCAL) + return Presence("present") if probe.running.value is True else Presence( + "app_not_running", f"{title} is not running") + + +def presence(entry: Any) -> Presence: + """The app state of one catalog entry on this host. The worst server wins (missing, then not running).""" + from hermes_cli.agent_plugins import _server_declarations + + manifest = _pinned_manifest(entry.repo, entry.sha, entry.subdir) + if not manifest: + return UNKNOWN + raw_servers = (manifest.get("extensions") or {}).get("com.nousresearch.hermes", {}).get("servers") or {} + if not isinstance(raw_servers, dict) or not raw_servers: + return UNKNOWN + try: + declarations = _server_declarations(manifest, {name: {} for name in raw_servers}) + except Exception as exc: + logger.debug("plugin catalog presence: %s declaration invalid: %s", entry.name, exc) + return UNKNOWN + title = getattr(entry, "title", "") or entry.name + rank = {"missing_app": 0, "app_not_running": 1, "present": 2, "unknown": 3} + found = [_server_presence(d.declaration, d.liveness, title) for d in declarations.values()] + return min(found, key=lambda p: rank[p.state]) if found else UNKNOWN + + +def onboarding_entries() -> list[Dict[str, Any]]: + """Catalog entries curated for the onboarding card (``onboarding: true``) that this OS can run, + each with its app state. Platform mismatch is the only exclusion; a missing app is reported.""" + from hermes_cli.plugin_catalog import load_catalog_live + from hermes_cli.plugins_cmd_catalog import normalized_platforms + from hermes_platform.host.facts import os_family + + here = os_family() + rows = [] + for entry in load_catalog_live(): + if not entry.onboarding or (entry.platforms and here not in normalized_platforms(entry.platforms)): + continue + found = presence(entry) + rows.append({ + "name": entry.name, + "title": entry.title or entry.name, + "description": _first_sentence(entry.description), + "tier": entry.tier, + "platforms": list(entry.platforms), + "app_state": found.state, + "sentence": found.sentence, + }) + return rows + + +def _first_sentence(text: str) -> str: + text = " ".join(str(text or "").split()) + head, dot, _rest = text.partition(". ") + return f"{head}." if dot else text diff --git a/plugin-catalog/blender.yaml b/plugin-catalog/blender.yaml index 9fabbc0570..209f38609d 100644 --- a/plugin-catalog/blender.yaml +++ b/plugin-catalog/blender.yaml @@ -9,11 +9,13 @@ description: >- prepare the uv environment before connecting. Disclosure: tools execute arbitrary Python in Blender with the user's privileges. No automatic application/version/liveness gate; server discovery does not prove scene readiness. +title: Blender maintainer: NousResearch tier: official category: tools docs_url: https://github.com/NousResearch/hermes-plugin-blender#readme platforms: [windows, macos, linux] +onboarding: true capabilities: provides_tools: [] provides_hooks: [] diff --git a/plugin-catalog/nvidia-app.yaml b/plugin-catalog/nvidia-app.yaml index 71f78cf046..6a1e3ff832 100644 --- a/plugin-catalog/nvidia-app.yaml +++ b/plugin-catalog/nvidia-app.yaml @@ -8,6 +8,7 @@ description: "NVIDIA App control through its local MCP server: overlay capture a The plugin reads the endpoint and token NVIDIA App publishes in its own McpServer server.json on every connection, so a changed port or token needs no reconfiguration. Disclosure: the tools read and change NVIDIA App settings on this machine; nothing leaves loopback." +title: NVIDIA App maintainer: NousResearch tier: official category: tools @@ -15,6 +16,7 @@ requires_hermes: ">=0.21.4" docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-app version: "0.1.0" platforms: [windows] +onboarding: true capabilities: provides_tools: [] provides_hooks: [] diff --git a/plugin-catalog/nvidia-broadcast.yaml b/plugin-catalog/nvidia-broadcast.yaml index f5be9a6af4..f30eb92831 100644 --- a/plugin-catalog/nvidia-broadcast.yaml +++ b/plugin-catalog/nvidia-broadcast.yaml @@ -8,6 +8,7 @@ description: "NVIDIA Broadcast control through its local MCP gateway: camera, mi refused when the app is absent, and the tools appear while NVIDIA Broadcast is running in the user's desktop session. Disclosure: the tools change live audio and video effects on this machine; nothing leaves loopback." +title: NVIDIA Broadcast maintainer: NousResearch tier: official category: tools @@ -15,6 +16,7 @@ requires_hermes: ">=0.21.4" docs_url: https://github.com/NousResearch/hermes-nvidia/tree/main/nvidia-broadcast version: "0.1.0" platforms: [windows] +onboarding: true capabilities: provides_tools: [] provides_hooks: [] diff --git a/scripts/validate_plugin_catalog.py b/scripts/validate_plugin_catalog.py index cd537e9cc0..992cff2ea8 100644 --- a/scripts/validate_plugin_catalog.py +++ b/scripts/validate_plugin_catalog.py @@ -70,6 +70,8 @@ KNOWN_KEYS = { "readme", "platforms", "capabilities", + "title", + "onboarding", } # Cosmetic labels attached to the pin. ``version`` is never parsed; ``image`` and ``screenshots`` # may only point at GitHub so the Desktop catalog browser and the docs site never fetch from diff --git a/tests/hermes_cli/test_plugin_catalog_presence.py b/tests/hermes_cli/test_plugin_catalog_presence.py new file mode 100644 index 0000000000..b8c2ef2342 --- /dev/null +++ b/tests/hermes_cli/test_plugin_catalog_presence.py @@ -0,0 +1,47 @@ +"""The onboarding card's catalog plugins: curated flag, platform filter, and the pinned app declaration.""" + +import pytest + +from hermes_cli import plugin_catalog as pc +from hermes_cli import plugin_catalog_presence as presence_mod + +SHA = "0" * 40 +NEEDS_APP = {"extensions": {"com.nousresearch.hermes": {"servers": {"srv": { + "app": {"darwin": {"presence": "executable", "location": "/nonexistent/fx-app"}, + "linux": {"presence": "executable", "location": "/nonexistent/fx-app"}, + "win32": {"presence": "executable", "location": "C:/nonexistent/fx-app.exe"}}, + "requires": {"app": True}}}}}} + + +def _entry(name, *, onboarding=True, platforms=(), title=""): + return pc.entry_from_mapping({"name": name, "repo": f"https://github.com/fx/{name}", "sha": SHA, + "description": f"{name} does things. More.", "maintainer": "fx", + "tier": "official", "category": "tools", "platforms": list(platforms), + "onboarding": onboarding, "title": title}, name) + + +@pytest.fixture +def catalog(monkeypatch): + from hermes_platform.host import facts + + here = {"darwin": "macos", "win32": "windows"}.get(facts.os_family(), "linux") + other = "windows" if here != "windows" else "macos" + entries = [_entry("everywhere", title="Everywhere App"), _entry("not-curated", onboarding=False), + _entry("here-only", platforms=[here]), _entry("elsewhere", platforms=[other])] + monkeypatch.setattr(pc, "load_catalog_live", lambda: entries) + manifests = {"everywhere": NEEDS_APP, "here-only": {"name": "here-only"}} + monkeypatch.setattr(presence_mod, "_pinned_manifest", lambda repo, sha, subdir: manifests.get(repo.rsplit("/", 1)[-1])) + return entries + + +def test_onboarding_rows_are_curated_entries_this_os_runs(catalog): + rows = {r["name"]: r for r in presence_mod.onboarding_entries()} + assert set(rows) == {"everywhere", "here-only"} + assert rows["everywhere"]["title"] == "Everywhere App" and rows["here-only"]["title"] == "here-only" + + +def test_app_state_comes_from_the_pinned_declaration(catalog): + rows = {r["name"]: r for r in presence_mod.onboarding_entries()} + # A declared app that is absent greys the row with a reason; no declaration is unknown, never "present". + assert rows["everywhere"]["app_state"] == "missing_app" and "Everywhere App" in rows["everywhere"]["sentence"] + assert rows["here-only"]["app_state"] == "unknown" and rows["here-only"]["sentence"] == "" diff --git a/tools/connectors/catalog.py b/tools/connectors/catalog.py index 8e98cab4c1..8d14fce106 100644 --- a/tools/connectors/catalog.py +++ b/tools/connectors/catalog.py @@ -277,8 +277,10 @@ def target_declared_env(fact: Any) -> List[str]: def _plugin_row(entry: Any) -> Dict[str, Any]: requirements = [f"Hermes {entry.requires_hermes}"] if entry.requires_hermes else [] requirements += [f"{name} environment variable" for name in entry.capabilities.requires_env] + from hermes_cli.plugin_catalog_presence import presence + row: Dict[str, Any] = { - "display": _display(entry.name), + "display": getattr(entry, "title", "") or _display(entry.name), "description": _first_sentence(entry.description), "tier": entry.tier if entry.tier in _TIERS else "community", "repo": entry.repo, @@ -286,6 +288,7 @@ def _plugin_row(entry: Any) -> Dict[str, Any]: "requirements": requirements, "has_desktop_half": False, "target_profile": DEFAULT_PROFILE, + "app_state": presence(entry).state, } if entry.platforms: row["platforms"] = list(entry.platforms) diff --git a/tui_gateway/contracts/tools_mcp_plugins.py b/tui_gateway/contracts/tools_mcp_plugins.py index ff7be024ef..35e268911e 100644 --- a/tui_gateway/contracts/tools_mcp_plugins.py +++ b/tui_gateway/contracts/tools_mcp_plugins.py @@ -12,6 +12,7 @@ from pydantic import Field from .base import JsonValue, Params, Result, WireEnum from .common import OpenModel, ProfileParams, SessionLiveInfo +from .connectors_operation import CatalogAppState, CatalogTier from .registry import method @@ -585,6 +586,7 @@ class PluginsAction(WireEnum): update = "update" remove = "remove" settings = "settings" + onboarding = "onboarding" class PluginsManageParams(ProfileParams): @@ -708,6 +710,20 @@ class PluginActivation(Result): deferred: dict[str, list[str]] = Field(default_factory=dict) +class OnboardingCatalogPlugin(Result): + """A catalog plugin curated for the onboarding card (``onboarding: true``) that this OS runs. + ``app_state`` is the pinned ``plugin.json`` declaration judged on this host; ``sentence`` names what + is missing (empty when present or unknown).""" + + name: str + title: str + description: str + tier: CatalogTier + platforms: list[str] + app_state: CatalogAppState + sentence: str + + class PluginsManageResult(Result): """``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s @@ -742,6 +758,8 @@ class PluginsManageResult(Result): delta_lines: list[str] | None = None error: str | None = None written: list[str] | None = None + # ``onboarding`` → the curated catalog plugins for the onboarding card. + onboarding: list[OnboardingCatalogPlugin] | None = None method("plugins.manage", params=PluginsManageParams, result=PluginsManageResult, diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index fb1720a060..5456de09d4 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1696,7 +1696,12 @@ def _plugins_settings(rid, params): return _ok(rid, {"ok": True, "name": canonical, "written": written, "plugin": row}) -_PLUGINS_ACTIONS = {"list": _plugins_list, "toggle": _plugins_toggle, "install": _plugins_install, +def _plugins_onboarding(rid, params): + """Catalog plugins curated for the onboarding card that this OS runs, each with its app state.""" + return _ok(rid, {"onboarding": _tools_mod("hermes_cli.plugin_catalog_presence").onboarding_entries()}) + + +_PLUGINS_ACTIONS = {"list": _plugins_list, "onboarding": _plugins_onboarding, "toggle": _plugins_toggle, "install": _plugins_install, "update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings} diff --git a/website/docs/user-guide/features/plugin-catalog.md b/website/docs/user-guide/features/plugin-catalog.md index 928e599b76..dc7e0d3caa 100644 --- a/website/docs/user-guide/features/plugin-catalog.md +++ b/website/docs/user-guide/features/plugin-catalog.md @@ -53,6 +53,8 @@ directory of the hermes-agent repository, declaring: | `capabilities` | Declared tools, hooks, middleware, and required env vars | | `requires_hermes` | Minimum Hermes version, e.g. `>=0.19` (optional) | | `platforms` | OS restrictions, empty = all (optional) | +| `title` | Human name shown on cards, e.g. `NVIDIA App` (optional; defaults to `name`) | +| `onboarding` | `true` offers the plugin on the desktop onboarding card, beside the hosted connectors, on the platforms it lists. Curated: official entries only (optional, default `false`) | | `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 and the plugin page hero, shown at 2:1 (1200×600 works; other shapes are centre-cropped); 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 |