Plugins can now call ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category, slots, deliver_default, skills, tags). The fields mirror the built-in AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call also crosses the plugin-host boundary. - One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(), merges the built-ins with the active profile's plugin blueprints at call time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the instantiate route all use it. Each dashboard route resolves inside the ?profile= scope, off the event loop. - Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in. A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type or a schedule that does not parse is warned and ignored at registration. - Catalog entries carry source/plugin. Desktop fetches the catalog for the profile it creates jobs in, keyed per profile, and shows the plugin name on the blueprint row and in the "Start from" select. The dashboard shows a plugin badge. - A created job is an ordinary cron job with the prompt and schedule already filled in, so it keeps running if the plugin is later disabled. - The ctx content registrars (register_skill, now joined by register_automation_blueprint) move to hermes_cli/plugins_content.py. The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts. Closes #132373. Requested by @enckequity.
80 lines
4.1 KiB
Python
80 lines
4.1 KiB
Python
"""Content registrars on ``PluginContext``: plugin skills and Automation Blueprints.
|
|
|
|
Bound into the class body (``from hermes_cli.plugins_content import ...``), so they are ordinary
|
|
``ctx.register_*`` methods and get the same abandoned-load guard as every other registrar.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping, Optional
|
|
|
|
from hermes_cli.plugins_ledger import PluginRegistration
|
|
from hermes_cli.plugins_loader import _serialized_replacement
|
|
|
|
# Same logger as the rest of the ctx surface, so plugin-load diagnostics stay in one place.
|
|
logger = logging.getLogger("hermes_cli.plugins")
|
|
|
|
__all__ = ["register_automation_blueprint", "register_skill"]
|
|
|
|
|
|
@_serialized_replacement
|
|
def register_skill(
|
|
self, name: str, path: Path, description: str = "",
|
|
frontmatter: Optional[Mapping[str, Any]] = None,
|
|
) -> PluginRegistration:
|
|
"""Register a read-only skill resolvable as ``'<plugin_name>:<name>'`` via ``skill_view()``
|
|
and listed by ``skills_list``. Not copied into ``~/.hermes/skills/`` and not in the system
|
|
prompt's ``<available_skills>``. Raises ``ValueError`` (``':'``/invalid chars) or
|
|
``FileNotFoundError``."""
|
|
from agent.skill_utils import _NAMESPACE_RE
|
|
if ":" in name:
|
|
raise ValueError(f"Skill name '{name}' must not contain ':' (the namespace is derived from the "
|
|
f"plugin name '{self.manifest.name}' automatically).")
|
|
if not name or not _NAMESPACE_RE.match(name):
|
|
raise ValueError(f"Invalid skill name '{name}'. Must match [a-zA-Z0-9_-]+.")
|
|
# Plugin register() helpers commonly pass the SKILL.md location as str
|
|
# (PluginManifest.path is stored as str); the registry and find_plugin_skill()
|
|
# promise a Path downstream.
|
|
path = Path(path)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"SKILL.md not found at {path}")
|
|
namespace = self.manifest.skill_namespace or self.manifest.name
|
|
qualified = f"{namespace}:{name}"
|
|
if self.manifest.portable and qualified in self._manager._plugin_skills:
|
|
raise ValueError(f"Plugin skill '{qualified}' is already registered")
|
|
entry = {
|
|
"path": path, "plugin": namespace, "plugin_key": self.plugin_id, "bare_name": name,
|
|
"description": description, "frontmatter": dict(frontmatter or {}),
|
|
}
|
|
return self._register_entry("skill", qualified, self._manager._plugin_skills, entry,
|
|
"Plugin %s registered skill: %s", qualified)
|
|
|
|
def register_automation_blueprint(
|
|
self, key: str, *, title: str, description: str, schedule_template: str,
|
|
prompt_template: str, category: str = "general", slots: Iterable[Any] = (),
|
|
deliver_default: str = "origin", skills: Iterable[str] = (), tags: Iterable[str] = (),
|
|
) -> Optional[PluginRegistration]:
|
|
"""Add an Automation Blueprint to this profile's catalog as ``'<plugin_name>:<key>'``
|
|
(``/blueprint``, the dashboard/Desktop gallery). Fields mirror
|
|
:class:`cron.blueprint_catalog.AutomationBlueprint`; ``slots`` are ``BlueprintSlot`` field
|
|
mappings. Malformed input or a duplicate key logs a warning and returns ``None``."""
|
|
from cron.blueprint_plugins import build_plugin_blueprint
|
|
try:
|
|
blueprint = build_plugin_blueprint(
|
|
self.manifest.name, key, title=title, description=description,
|
|
schedule_template=schedule_template, prompt_template=prompt_template, category=category,
|
|
slots=slots, deliver_default=deliver_default, skills=skills, tags=tags,
|
|
)
|
|
except ValueError as exc:
|
|
logger.warning("Plugin '%s' automation blueprint %r rejected: %s", self.manifest.name, key, exc)
|
|
return None
|
|
if blueprint.key in self._manager._automation_blueprints:
|
|
logger.warning("Plugin '%s' automation blueprint %r is already registered",
|
|
self.manifest.name, blueprint.key)
|
|
return None
|
|
return self._register_entry("automation_blueprint", blueprint.key,
|
|
self._manager._automation_blueprints, blueprint,
|
|
"Plugin %s registered automation blueprint: %s", blueprint.key)
|