Files
hermes-agent/hermes_cli/plugins_content.py
Teknium a928a959e3 feat(plugins): let plugins register Automation Blueprints via ctx
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.
2026-10-05 17:34:36 -07:00

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)