Files
hermes-agent/tools/connectors/catalog.py
alt-glitch 5c215ffa21 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.
2026-09-23 09:26:07 +05:30

383 lines
17 KiB
Python

"""``manage_catalog`` install targets: catalog plugins and hub skills, installed through the card.
``prepare`` resolves every id against the plugin catalog (the Plugins tab's resolver) or the skills
hub and fills the row the card draws; an id that does not resolve, or a plugin this OS cannot run,
is drawn failed with the reason. Nothing installs until the user approves a row. The install runs on
a worker under the TARGET profile's runtime scope (``default`` unless the Advanced modal named
another), through the same host install the Plugins tab uses, so the catalog pin, the kill list, the
security scan and the live activation of the plugin's MCP servers and skills are the host's.
"""
from __future__ import annotations
import contextlib
import contextvars
import io
import json
import logging
import re
import threading
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from tools.connectors.contract import Actor, SettleReason, TargetState
from tools.connectors.mcp import _fail, _move
from tools.connectors.operation import ConnectionOperation, IllegalTransition, Target
logger = logging.getLogger(__name__)
DEFAULT_PROFILE = "default"
# The Advanced modal's own keys (CATALOG-ROW-CONTRACT.md); every other answer key is a credential.
_OPTION_KEYS = frozenset({"target_profile", "agent_half", "desktop_half", "enable", "force", "ref"})
_COMMIT_SHA = re.compile(r"^[0-9a-fA-F]{40}$")
_TIERS = frozenset({"official", "community"})
def _flag(value: Optional[str], default: bool) -> bool:
return default if value in (None, "") else value == "1"
def _first_sentence(text: str) -> str:
text = " ".join(str(text or "").split())
head, dot, _rest = text.partition(". ")
return f"{head}." if dot else text
def _display(identifier: str) -> str:
leaf = identifier.rstrip("/").rsplit("/", 1)[-1]
return " ".join(part.capitalize() for part in re.split(r"[-_]+", leaf) if part) or identifier
@contextlib.contextmanager
def target_scope(profile: str):
"""Bind the named profile's home, secrets and terminal policy, the way an RPC for that profile
does. The install writes its tree, config and ``.env`` there, never into the setup profile. The
home override is bound for the launch profile too: the calling thread carries the setup
profile's override, and an unbound launch scope would leave it in place."""
from tui_gateway import server
from tui_gateway.launch_profile_policy import launch_profile_runtime_scope
home = server._profile_home(profile) # None = the launch profile; raises for a missing one
scope = (launch_profile_runtime_scope(server._hermes_home) if home is None
else server._session_profile_runtime_scope({"profile_home": str(home)}))
with scope:
yield
class HostInstaller:
"""The host side of a catalog row. Tests replace it; production reads the real catalog, hub and
installer."""
def plugin_entry(self, name: str) -> Any:
from hermes_cli.plugin_catalog import get_live_catalog_entry
return get_live_catalog_entry(name)
def refuse(self, entry: Any) -> None:
"""Raise with the installer's own text when the catalog would refuse this entry here."""
from hermes_cli.plugins_cmd_catalog import _refuse_unsupported_catalog_platform, raise_if_removed
raise_if_removed(entry.name, entry.repo)
_refuse_unsupported_catalog_platform(entry)
def install_plugin(self, name: str, *, force: bool, enable: bool, ref: Optional[str]) -> Dict[str, Any]:
from hermes_cli.plugins_cmd import dashboard_install_plugin
return dashboard_install_plugin("", force=force, enable=enable, catalog_name=name, ref=ref)
def skill_meta(self, identifier: str) -> Optional[Dict[str, Any]]:
"""The first hub source that knows the identifier; metadata only, no bundle download."""
from hermes_cli.skills_hub import _sources
from tools.skills_hub import skills_hub_http_session
with skills_hub_http_session():
for source in _sources():
try:
meta = source.inspect(identifier)
except Exception:
continue
if meta is not None:
return {"name": meta.name, "description": meta.description, "source": meta.source,
"identifier": meta.identifier or identifier}
return None
def install_skill(self, identifier: str, *, force: bool) -> Dict[str, Any]:
"""Install headless; ``{name, already_installed}``. ``do_install`` reports only by printing, so
success is read from the hub lock file and failure from its last line. A skill that is already
installed (force off) is left as it is and reported so."""
from rich.console import Console
from hermes_cli.skills_hub import do_install
from tools.skills_hub import HubLockFile
def entry() -> Optional[Dict[str, Any]]:
return next((e for e in HubLockFile().list_installed() if e.get("identifier") == identifier), None)
before = entry()
if before is not None and not force:
return {"name": str(before["name"]), "already_installed": True}
out = io.StringIO()
do_install(identifier, force=force, skip_confirm=True,
console=Console(file=out, width=200, no_color=True, highlight=False))
after = entry()
if after is None or (before is not None and after.get("updated_at") == before.get("updated_at")):
lines = [line.strip() for line in out.getvalue().splitlines() if line.strip()]
raise RuntimeError(lines[-1] if lines else "the skill was not installed")
return {"name": str(after["name"]), "already_installed": False}
@dataclass
class _Work:
done: threading.Event = field(default_factory=threading.Event)
outcome: Dict[str, Any] = field(default_factory=dict)
error: str = ""
class _Runner:
"""Resolved catalog facts and install work for one operation's rows."""
def __init__(self, installer: HostInstaller):
self.installer = installer
self.op_id: Optional[str] = None
self.facts: Dict[str, Any] = {} # row name -> PluginCatalogEntry | skill meta; never on the wire
self.work: Dict[str, _Work] = {}
# The Advanced values the user approved per row; Try again (env null) reuses them. Values
# are credentials in part, so they stay here, off the target.
self.approved_env: Dict[str, Dict[str, str]] = {}
# -- prepare: resolve each id and draw its row ------------------------------------------------
def prepare(self, operation: ConnectionOperation) -> None:
_RUNNERS[operation.op_id] = self
self.op_id = operation.op_id
for target in operation.targets:
target.extra = {"display": _display(target.name), "target_profile": DEFAULT_PROFILE}
try:
self._resolve(target)
except Exception as exc:
_fail(operation, target, self._detail(exc, target))
def _resolve(self, target: Target) -> None:
if target.kind == "plugin":
entry = self.installer.plugin_entry(target.name)
if entry is None:
raise LookupError(f"'{target.name}' is not in the Hermes plugin catalog")
self.facts[target.name] = entry
target.extra = _plugin_row(entry)
target.required_env = [{"name": name, "required": False, "secret": True, "default": ""}
for name in entry.capabilities.requires_env]
self.installer.refuse(entry)
return
meta = self.installer.skill_meta(target.name)
if not meta:
raise LookupError(f"'{target.name}' was not found in the skills hub")
self.facts[target.name] = meta
target.extra = {
"display": str(meta.get("name") or _display(target.name)),
"description": _first_sentence(meta.get("description") or ""),
"tier": "official" if meta.get("source") == "official" else "community",
"target_profile": DEFAULT_PROFILE,
}
# -- the card's answer ------------------------------------------------------------------------
def approve(self, operation: ConnectionOperation, target: Target, env: Optional[Dict[str, str]]) -> None:
approved = {**self.approved_env.get(target.name, {}), **(env or {})}
actor = Actor.user if target.state == TargetState.failed else Actor.backend_watcher
if target.name not in self.facts: # failed at prepare: Try again resolves once more
try:
self._resolve(target)
except Exception as exc:
_fail(operation, target, self._detail(exc, target))
return
error = self._check_answer(target, approved)
if error:
_fail(operation, target, error)
return
self.approved_env[target.name] = approved
if not _move(operation, target, TargetState.initiated, actor, detail=""):
return
self._spawn(operation, target, approved)
def _check_answer(self, target: Target, env: Dict[str, str]) -> str:
if target.kind == "plugin" and env.get("agent_half") == "0":
return "only the desktop half was selected; install it from Settings, Plugins"
ref = env.get("ref")
if ref and not _COMMIT_SHA.match(ref):
return "the pin must be a full 40-character commit SHA"
declared = set(target_declared_env(self.facts.get(target.name)))
undeclared = sorted(k for k in env if k not in _OPTION_KEYS and k not in declared)
if undeclared:
return f"'{target.name}' does not declare {', '.join(undeclared)}"
return ""
def _spawn(self, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
work = _Work()
self.work[target.name] = work
def body() -> None:
try:
work.outcome = self._install(target, env)
except Exception as exc:
work.error = self._detail(exc, target)
work.done.set()
operation.wake.set()
# A copy of the answering thread's context; the install binds the target profile itself.
threading.Thread(target=contextvars.copy_context().run, args=(body,), daemon=True,
name=f"catalog-install-{target.name}").start()
def _install(self, target: Target, env: Dict[str, str]) -> Dict[str, Any]:
profile = (env.get("target_profile") or DEFAULT_PROFILE).strip()
force = _flag(env.get("force"), False)
with target_scope(profile):
_save_credentials({k: v for k, v in env.items() if k not in _OPTION_KEYS and v})
if target.kind == "skill":
identifier = str(self.facts[target.name].get("identifier") or target.name)
return {"profile": profile, **self.installer.install_skill(identifier, force=force)}
enable = _flag(env.get("enable"), True)
result = self.installer.install_plugin(target.name, force=force, enable=enable, ref=env.get("ref") or None)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "the install failed")
return {"profile": profile, "enabled": enable, **result}
# -- the watcher --------------------------------------------------------------------------------
def observe(self, operation: ConnectionOperation) -> None:
for target in operation.targets:
work = self.work.get(target.name)
if operation.settled or target.state != TargetState.initiated or work is None or not work.done.is_set():
continue
self.work.pop(target.name, None)
if work.error:
_fail(operation, target, work.error)
continue
extra, detail = _installed_row(target, work.outcome)
_move(operation, target, TargetState.connected, Actor.backend_watcher, detail=detail, **extra)
def close(self) -> None:
if self.op_id is not None:
_RUNNERS.pop(self.op_id, None)
def _detail(self, exc: Any, target: Target) -> str:
from agent.redact import redact_sensitive_text
text = str(exc) or exc.__class__.__name__
for value in self.approved_env.get(target.name, {}).values():
if value and len(value) > 3:
text = text.replace(value, "[REDACTED]")
return redact_sensitive_text(text, force=True) or "error"
def target_declared_env(fact: Any) -> List[str]:
caps = getattr(fact, "capabilities", None)
return list(getattr(caps, "requires_env", None) or ())
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": 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,
"sha": entry.sha,
"requirements": requirements,
"has_desktop_half": False,
"target_profile": DEFAULT_PROFILE,
"app_state": presence(entry).state,
}
if entry.platforms:
row["platforms"] = list(entry.platforms)
if entry.subdir:
row["subdir"] = entry.subdir
return row
def _installed_row(target: Target, outcome: Dict[str, Any]) -> tuple:
"""The connected row's fields (the drawn row plus what went live) and its one-line detail."""
extra = {**target.extra, "target_profile": outcome["profile"]}
if target.kind == "skill":
detail = "already installed; left as it is (Advanced, force reinstall replaces it)" \
if outcome.get("already_installed") else ""
return {**extra, "skill": outcome["name"], "tools": []}, detail
live = (outcome.get("activation") or {}).get("live_now") or {}
servers = live.get("mcp_servers") or []
tools = [name for server in servers if server.get("connected") for name in server.get("tools") or ()]
notes = [f"MCP server {s['name']} not connected: {s.get('error') or 'unknown error'}"
for s in servers if not s.get("connected")]
if not outcome.get("enabled", True):
notes.append("installed but not enabled")
if outcome.get("missing_env"):
notes.append(f"set {', '.join(outcome['missing_env'])} to finish setup")
skills = [s["name"] for s in live.get("skills") or () if s.get("name")]
if skills:
extra["skill"] = skills[0]
return {**extra, "tools": tools}, "; ".join(notes)
def _save_credentials(env: Dict[str, str]) -> None:
from hermes_cli.config import save_env_value, validate_env_var_name_for_write
for key, value in env.items():
validate_env_var_name_for_write(key)
save_env_value(key, value)
# op_id -> the runner driving it, so the card's answer (RPC thread) finds the work.
_RUNNERS: Dict[str, _Runner] = {}
def owns(op_id: str) -> bool:
return op_id in _RUNNERS
def apply_answer(operation: ConnectionOperation, raw: str) -> None:
"""The card's ``connection.respond`` for a catalog operation: skip, approve (with the Advanced
values or null for defaults), approve again on a failed row (Try again), and Continue."""
try:
answer = json.loads(raw)
except (TypeError, ValueError):
answer = {}
answer = answer if isinstance(answer, dict) else {}
runner = _RUNNERS.get(operation.op_id)
for entry in answer.get("targets") or ():
if not isinstance(entry, dict):
continue
target = operation.target(str(entry.get("name") or "").strip())
if target is None:
continue
status = str(entry.get("status") or "").lower()
if status == "skipped":
try:
operation.transition(target.name, TargetState.skipped, Actor.user)
except IllegalTransition:
if not target.resolved and not operation.settled:
raise
elif status == "approved" and runner is not None and target.state in (TargetState.pending, TargetState.failed):
env = entry.get("env")
runner.approve(operation, target, {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else None)
if answer.get("settled_by") == SettleReason.continue_.value and not operation.all_resolved:
operation.settle(SettleReason.continue_)
def retry(operation: ConnectionOperation, names: List[str]) -> Optional[str]:
runner = _RUNNERS.get(operation.op_id)
if runner is None or operation.settled:
return "this operation has settled; its result is frozen"
for name in names:
target = operation.target(name)
if target is not None and target.state == TargetState.failed:
runner.approve(operation, target, None)
return None
def open_runner(installer: Optional[HostInstaller] = None) -> _Runner:
return _Runner(installer or HostInstaller())
Callback = Callable[[Dict[str, Any]], Optional[str]]