The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog: - search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with whether each is already installed in the default profile. Read-only. - install: opens the same connection operation manage_connections opens, with rows of kind plugin / skill. Nothing installs until the user approves a row. An approved row installs into `default` (or the profile the Advanced modal named) through dashboard_install_plugin / the hub's headless install, so the catalog pin, kill list, security scan and live activation (#119644) are the host's. The row settles with the live MCP tool names and the plugin's skill. The model sends catalog ids and an action only; every other key is refused before anything runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the result is the `hermes plugins install` / `hermes skills install` pointer. - contract: plugin/skill targets follow the MCP transitions. - run.apply_answer / reissue route a card answer to the module that owns the operation. - tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never deferred behind tool_search. - docs: tools reference, toolsets reference, plugin catalog page. Linear NS-964.
184 lines
8.4 KiB
Python
184 lines
8.4 KiB
Python
"""``manage_catalog``: the setup agent's catalog search and install-through-the-card.
|
|
|
|
``search`` reads the plugin catalog (the Plugins tab's resolver) and the skills hub and says what is
|
|
already installed in ``default``. ``install`` opens the same connection operation
|
|
``manage_connections`` opens, with rows of kind ``plugin`` / ``skill``; the host installs each row
|
|
the user approves (``tools/connectors/catalog.py``). The model sends catalog ids and an action,
|
|
nothing else: the pin, scan, target profile and activation are the host's.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from tools.registry import tool_error
|
|
|
|
ACTIONS = ("search", "install")
|
|
ITEM_KINDS = ("plugin", "skill")
|
|
_TOP_KEYS = frozenset({"action", "kind", "query", "items", "reason"})
|
|
_ITEM_KEYS = frozenset({"kind", "id"})
|
|
SEARCH_LIMIT = 10
|
|
|
|
NOT_HERE = (
|
|
"manage_catalog is available only in the Hermes desktop app, where the approval card can be drawn. "
|
|
"Tell the user to install from a terminal instead: `hermes plugins install <id>` for a plugin, "
|
|
"`hermes skills install <id>` for a skill."
|
|
)
|
|
|
|
NOTE = (
|
|
"Settled once; do not re-offer a row the user skipped. Each installed row names target_profile: "
|
|
"its tools and skills are live now in that profile's open chats and in every new chat of it; "
|
|
"they are callable in this chat only when this chat runs in that profile. A failed row carries "
|
|
"the reason in detail."
|
|
)
|
|
|
|
MANAGE_CATALOG_SCHEMA = {
|
|
"name": "manage_catalog",
|
|
"description": (
|
|
"Find and install Hermes catalog plugins and hub skills for the user. 'search' lists matches "
|
|
"(id, kind, display, tier, platforms, installed) and changes nothing. 'install' shows the user "
|
|
"one approval card with a row per item and blocks until every row is installed, skipped, or "
|
|
"the card is closed; the host installs each approved row into the user's default profile at "
|
|
"the catalog's reviewed version. Pass only catalog ids exactly as 'search' returns them; the "
|
|
"host decides the source, version, profile and settings, and the user can change them on the "
|
|
"card. Offer an install only for something the user asked for or agreed to."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {"type": "string", "enum": list(ACTIONS)},
|
|
"kind": {"type": "string", "enum": list(ITEM_KINDS),
|
|
"description": "search only: limit results to plugins or skills."},
|
|
"query": {"type": "string", "description": "search only: words to match; empty lists plugins."},
|
|
"items": {
|
|
"type": "array",
|
|
"description": "install only: the catalog items to offer, one card row each.",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"kind": {"type": "string", "enum": list(ITEM_KINDS)},
|
|
"id": {"type": "string", "description": "Catalog id as 'search' returns it."},
|
|
},
|
|
"required": ["kind", "id"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
"reason": {"type": "string", "description": "install only: one line on why these are offered."},
|
|
},
|
|
"required": ["action"],
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
|
|
|
|
def _check_args(args: Dict[str, Any]) -> Optional[str]:
|
|
unknown = sorted(set(args) - _TOP_KEYS)
|
|
if unknown:
|
|
return (f"unknown parameter(s) {', '.join(unknown)}: manage_catalog takes action, kind, query, "
|
|
"items and reason. The host decides source, version, profile and settings.")
|
|
if args.get("action") not in ACTIONS:
|
|
return f"action must be one of {', '.join(ACTIONS)}."
|
|
if args.get("kind") not in (None, *ITEM_KINDS):
|
|
return f"kind must be one of {', '.join(ITEM_KINDS)}."
|
|
if args["action"] == "search":
|
|
return None
|
|
items = args.get("items")
|
|
if not isinstance(items, list) or not items:
|
|
return "install needs 'items': [{\"kind\": \"plugin\", \"id\": \"<catalog id>\"}, ...]."
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
return "every item is an object {kind, id}."
|
|
extra = sorted(set(item) - _ITEM_KEYS)
|
|
if extra:
|
|
return (f"unknown item field(s) {', '.join(extra)}: an item is {{kind, id}}. The host decides "
|
|
"source, version, profile and settings.")
|
|
if item.get("kind") not in ITEM_KINDS or not str(item.get("id") or "").strip():
|
|
return "every item needs kind (plugin or skill) and a non-empty id."
|
|
return None
|
|
|
|
|
|
def manage_catalog(
|
|
args: Dict[str, Any],
|
|
*,
|
|
session_id: Optional[str] = None,
|
|
tool_call_id: Optional[str] = None,
|
|
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
|
|
card_surface: bool = False,
|
|
installer: Any = None,
|
|
) -> str:
|
|
"""``card_surface``: the session is a desktop chat, the one surface that draws catalog rows."""
|
|
error = _check_args(args)
|
|
if error:
|
|
return tool_error(error)
|
|
if connection_callback is None or not card_surface:
|
|
return tool_error(NOT_HERE)
|
|
if args["action"] == "search":
|
|
return json.dumps(search(str(args.get("query") or ""), args.get("kind"), installer=installer),
|
|
ensure_ascii=False)
|
|
return install(args["items"], session_id=session_id, tool_call_id=tool_call_id,
|
|
connection_callback=connection_callback, installer=installer)
|
|
|
|
|
|
def install(items: List[Dict[str, str]], *, session_id: Optional[str], tool_call_id: Optional[str],
|
|
connection_callback: Callable[[Dict[str, Any]], Optional[str]], installer: Any = None) -> str:
|
|
from tools.connectors.catalog import open_runner
|
|
from tools.connectors.gateway.config import operation_session_key
|
|
from tools.connectors.operation import Target
|
|
from tools.connectors.run import Kind, run_operation
|
|
|
|
seen, targets = set(), []
|
|
for item in items:
|
|
key = (item["kind"], str(item["id"]).strip())
|
|
if key not in seen:
|
|
seen.add(key)
|
|
targets.append(Target(name=key[1], kind=key[0], action="install"))
|
|
runner = open_runner(installer)
|
|
try:
|
|
return run_operation(
|
|
targets, Kind(prepare=runner.prepare, observe=runner.observe, note=NOTE),
|
|
session_key=operation_session_key(session_id), tool_call_id=tool_call_id,
|
|
connection_callback=connection_callback, with_urls_in_result=False,
|
|
)
|
|
finally:
|
|
runner.close()
|
|
|
|
|
|
def search(query: str, kind: Optional[str], *, installer: Any = None) -> Dict[str, Any]:
|
|
from tools.connectors.catalog import DEFAULT_PROFILE, target_scope
|
|
|
|
rows: List[Dict[str, Any]] = []
|
|
with target_scope(DEFAULT_PROFILE):
|
|
if kind in (None, "plugin"):
|
|
rows += _plugin_rows(query)
|
|
if kind in (None, "skill") and query.strip():
|
|
rows += _skill_rows(query)
|
|
return {"results": rows, "installed_in": DEFAULT_PROFILE}
|
|
|
|
|
|
def _plugin_rows(query: str) -> List[Dict[str, Any]]:
|
|
from hermes_cli.plugin_catalog import filter_entries, load_catalog_live
|
|
from hermes_cli.plugins_cmd import PluginOperationError, _read_install_metadata
|
|
from tools.connectors.catalog import _display
|
|
|
|
try:
|
|
installed = {str(rec["catalog"]["name"]) for rec in _read_install_metadata().values()
|
|
if isinstance(rec, dict) and isinstance(rec.get("catalog"), dict) and rec["catalog"].get("name")}
|
|
except PluginOperationError:
|
|
installed = set()
|
|
return [{"id": e.name, "kind": "plugin", "display": _display(e.name), "tier": e.tier,
|
|
"platforms": list(e.platforms), "installed": e.name in installed}
|
|
for e in filter_entries(load_catalog_live(), query)[:SEARCH_LIMIT]]
|
|
|
|
|
|
def _skill_rows(query: str) -> List[Dict[str, Any]]:
|
|
from tools.skills_hub import HubLockFile
|
|
from tools.skills_hub_github import GitHubAuth
|
|
from tools.skills_hub_search import create_source_router, unified_search
|
|
|
|
installed = {str(e.get("identifier")) for e in HubLockFile().list_installed()}
|
|
return [{"id": r.identifier, "kind": "skill", "display": r.name,
|
|
"tier": "official" if r.source == "official" else "community", "platforms": [],
|
|
"installed": r.identifier in installed}
|
|
for r in unified_search(query, create_source_router(GitHubAuth()), limit=SEARCH_LIMIT)]
|