feat: catalog marks onboarding plugins; catalog rows carry the app's presence

The onboarding card needs to list catalog plugins beside the hosted
connectors (NS-960 D1, D4) and grey a plugin whose app is absent (D5).
The catalog had no curated flag, and the manage_catalog row left
app_state empty.

- `onboarding: true` and `title` on catalog entries (loader, validator,
  docs); set on blender, nvidia-app and nvidia-broadcast.
- hermes_cli/plugin_catalog_presence.py reads the plugin.json at the
  catalog's pinned commit once per pin and judges its app declaration with
  the hermes_platform resolver the installer and the Plugins-tab pill use.
  No declaration or an unreadable one is `unknown`, never `present`.
- `plugins.manage action=onboarding` lists the curated entries this OS
  runs (platform mismatch is the only exclusion) with app_state and the
  sentence the card greys the row with.
- manage_catalog plugin rows now carry app_state and the catalog title.
- A live entry that differs from the in-tree entry at the same pin (new
  metadata) now follows the same newer-catalog rule as a new pin, so a
  checkout that adds `onboarding` is not masked by a published doc that
  predates it.
This commit is contained in:
alt-glitch
2026-09-23 08:19:21 +05:30
committed by Siddharth Balyan
parent 9fe737aef2
commit 5c215ffa21
13 changed files with 320 additions and 9 deletions

View File

@@ -3896,7 +3896,7 @@ export interface PluginsManageParams {
accept_capabilities?: boolean | null
values?: Record<string, unknown> | 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

View File

@@ -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",

View File

@@ -88,6 +88,8 @@ class PluginCatalogEntry:
screenshots: List[str] = field(default_factory=list) # GitHub-hosted https URLs; gallery on /docs/plugins/<name>
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:

View File

@@ -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

View File

@@ -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: []

View File

@@ -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: []

View File

@@ -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: []

View File

@@ -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

View File

@@ -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"] == ""

View File

@@ -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)

View File

@@ -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,

View File

@@ -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}

View File

@@ -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/<sha>/...`) so it never changes under the review |