hermes-agent/hermes_cli/cron_recipe_cmd.py
teknium1 1593ca5406 feat(cron): Cron Recipes — parameterized automation templates across every surface
A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.

Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
  recipe's typed slots as a form (time-picker, enum dropdown, free-text);
  submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
  fields, or fills + creates from a pasted 'key slot=val' command. The shared
  handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
  the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
  shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
  setAsDefaultProtocolClient + open-url/second-instance) routes
  hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.

Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.

Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
  recipe_form_schema / recipe_slash_command / recipe_deeplink /
  recipe_catalog_entry renderers, fill_recipe (validate + translate to
  create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
  gateway never drift). CommandDef + dispatch in commands.py / cli.py /
  gateway/run.py.

Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.

Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).

Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.

Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
2026-06-11 10:49:47 -07:00

147 lines
5.1 KiB
Python

"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway.
The conversational counterpart to the dashboard's Cron Recipes form. Where a
surface has a screen, the user fills a form (dashboard / GUI app) and the API
calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a
chat line, the user pastes a pre-filled slash command and this handler
parses it; any missing or invalid slot is reported so the agent can ask.
Subcommand shapes:
/cron-recipe list the catalog (numbered + copy commands)
/cron-recipe <key> show that recipe's slots + a ready command
/cron-recipe <key> slot=val … fill + create the cron job
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
survive. On a fill error the message names the slot, which is exactly what the
agent needs to ask a targeted follow-up rather than re-prompting everything.
"""
from __future__ import annotations
import logging
import shlex
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if explicit is not None:
return explicit
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if platform and chat_id:
return {
"platform": platform,
"chat_id": chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
}
except Exception:
pass
return None
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
values: Dict[str, str] = {}
leftovers = []
for tok in tokens:
if "=" in tok:
k, _, v = tok.partition("=")
k = k.strip()
if k:
values[k] = v.strip()
continue
leftovers.append(tok)
return values, leftovers
def _fmt_catalog() -> str:
from cron.recipe_catalog import CATALOG, recipe_slash_command
lines = ["Cron Recipes — `/cron-recipe <name>` to set one up:\n"]
for r in CATALOG:
lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}")
lines.append(f"{recipe_slash_command(r)}")
lines.append("\nEdit the values then send, or just send to use the defaults.")
return "\n".join(lines)
def _fmt_recipe(recipe) -> str:
from cron.recipe_catalog import recipe_slash_command
lines = [f"{recipe.title}{recipe.description}\n", "Fields:"]
for s in recipe.slots:
opts = f" (one of: {', '.join(map(str, s.options))})" if s.options else ""
dflt = f" [default: {s.default}]" if s.default not in (None, "") else ""
opt = " (optional)" if s.optional else ""
lines.append(f"{s.name}: {s.label}{opts}{dflt}{opt}")
lines.append("\nReady-to-edit command:")
lines.append(f" {recipe_slash_command(recipe)}")
return "\n".join(lines)
def handle_cron_recipe_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
) -> str:
"""Dispatch a ``/cron-recipe`` invocation. Returns text to show the user.
``args`` is everything after ``/cron-recipe``. ``origin`` lets an accepted
recipe's job deliver back to the chat it was created from; resolved from
session env when omitted.
"""
try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
except Exception as e: # pragma: no cover - import guard
logger.debug("recipe catalog import failed: %s", e)
return "Cron Recipes are unavailable in this build."
try:
tokens = shlex.split(args or "")
except ValueError:
tokens = (args or "").split()
# Bare -> list catalog.
if not tokens:
return _fmt_catalog()
key = tokens[0]
recipe = get_recipe(key)
if recipe is None:
return (
f"No cron recipe named '{key}'. Run /cron-recipe to see the catalog."
)
values, _leftover = _parse_kv(tokens[1:])
# `<key>` with no slot args -> show the recipe's fields + a ready command.
if not values:
return _fmt_recipe(recipe)
# `<key> slot=val …` -> fill + create.
try:
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
except RecipeFillError as e:
return f"Can't set up '{recipe.title}': {e}\nRun /cron-recipe {key} to see its fields."
try:
from cron.jobs import create_job
job = create_job(**spec)
except Exception as e:
logger.debug("cron-recipe create_job failed: %s", e)
return f"Failed to create the job: {e}"
sched = job.get("schedule_display") or spec.get("schedule", "")
return (
f"Scheduled '{recipe.title}'"
+ (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
)