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.
This commit is contained in:
teknium1 2026-06-07 18:49:09 -07:00 committed by Teknium
parent 9a09ea69fb
commit 1593ca5406
25 changed files with 1975 additions and 0 deletions

View file

@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import styles from "./styles.module.css";
interface RecipeField {
name: string;
type: string;
label: string;
default: string | null;
options: string[];
optional: boolean;
help: string;
}
interface Recipe {
key: string;
title: string;
description: string;
category: string;
tags: string[];
fields: RecipeField[];
scheduleHuman: string;
command: string;
appUrl: string;
}
const INDEX_URL = "/docs/api/cron-recipes-index.json";
function CopyButton({ text }: { text: string }): JSX.Element {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className={styles.copyBtn}
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
aria-label="Copy command"
>
{copied ? "Copied" : "Copy"}
</button>
);
}
function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
return (
<div className={styles.card}>
<div className={styles.cardHead}>
<h3 className={styles.title}>{recipe.title}</h3>
<span className={styles.schedule}>{recipe.scheduleHuman}</span>
</div>
<p className={styles.desc}>{recipe.description}</p>
<div className={styles.tags}>
{recipe.tags.map((t) => (
<span key={t} className={styles.tag}>
{t}
</span>
))}
</div>
<div className={styles.cmdRow}>
<code className={styles.cmd}>{recipe.command}</code>
<CopyButton text={recipe.command} />
</div>
<div className={styles.actions}>
<a className={styles.appBtn} href={recipe.appUrl}>
Send to App
</a>
<span className={styles.hint}>
or paste the command into the CLI, TUI, or any messenger
</span>
</div>
</div>
);
}
export default function CronRecipesCatalog(): JSX.Element {
const [recipes, setRecipes] = useState<Recipe[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch(INDEX_URL)
.then((r) => r.json())
.then((data: Recipe[]) => {
if (!cancelled) setRecipes(data);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, []);
if (error) {
return <p>Couldn't load the recipe catalog: {error}</p>;
}
if (recipes === null) {
return <p>Loading recipes</p>;
}
if (recipes.length === 0) {
return <p>No cron recipes are available.</p>;
}
return (
<div className={styles.grid}>
{recipes.map((r) => (
<RecipeCard key={r.key} recipe={r} />
))}
</div>
);
}

View file

@ -0,0 +1,114 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.card {
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
padding: 1.1rem 1.2rem;
background: var(--ifm-card-background-color, var(--ifm-background-surface-color));
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.cardHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.title {
margin: 0;
font-size: 1.1rem;
}
.schedule {
font-size: 0.8rem;
color: var(--ifm-color-emphasis-700);
white-space: nowrap;
}
.desc {
margin: 0;
color: var(--ifm-color-emphasis-800);
font-size: 0.92rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-800);
}
.cmdRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cmd {
flex: 1;
overflow-x: auto;
white-space: nowrap;
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
border-radius: 6px;
background: var(--ifm-color-emphasis-100);
}
.copyBtn {
flex-shrink: 0;
border: 1px solid var(--ifm-color-emphasis-300);
background: transparent;
color: var(--ifm-color-emphasis-800);
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.copyBtn:hover {
background: var(--ifm-color-emphasis-200);
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.appBtn {
display: inline-block;
padding: 0.4rem 0.85rem;
border-radius: 6px;
background: var(--ifm-color-primary);
color: var(--ifm-color-primary-contrast-background, #fff);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
}
.appBtn:hover {
background: var(--ifm-color-primary-dark);
text-decoration: none;
color: var(--ifm-color-primary-contrast-background, #fff);
}
.hint {
font-size: 0.78rem;
color: var(--ifm-color-emphasis-600);
}