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:
committed by
Siddharth Balyan
parent
9fe737aef2
commit
5c215ffa21
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
151
hermes_cli/plugin_catalog_presence.py
Normal file
151
hermes_cli/plugin_catalog_presence.py
Normal 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
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
47
tests/hermes_cli/test_plugin_catalog_presence.py
Normal file
47
tests/hermes_cli/test_plugin_catalog_presence.py
Normal 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"] == ""
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user