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:
@@ -0,0 +1,34 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
title: "Cron Recipes Catalog"
|
||||
description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
|
||||
---
|
||||
|
||||
import CronRecipesCatalog from '@site/src/components/CronRecipesCatalog';
|
||||
|
||||
# Cron Recipes
|
||||
|
||||
Cron Recipes are ready-to-run automation templates. Pick one, fill in a couple
|
||||
of fields, and Hermes schedules it as a cron job — no cron syntax required.
|
||||
|
||||
Every recipe works from **every surface**:
|
||||
|
||||
- **Dashboard / desktop app** — open the Cron page, switch to the **Recipes**
|
||||
tab, fill the form, and click *Schedule it*.
|
||||
- **CLI, TUI, and messengers** — copy a recipe's `/cron-recipe` command below,
|
||||
edit the values, and send it. Hermes fills in anything you leave out and
|
||||
asks if something's ambiguous.
|
||||
- **Desktop app** — click **Send to App** on any recipe and it opens with the
|
||||
command pre-loaded in your composer.
|
||||
|
||||
Recipes never schedule anything silently — you always confirm before the job
|
||||
is created. Manage created jobs anytime with `/cron`.
|
||||
|
||||
<CronRecipesCatalog />
|
||||
|
||||
## Writing your own
|
||||
|
||||
A recipe is just a skill with a `metadata.hermes.recipe` block in its
|
||||
`SKILL.md` frontmatter. See
|
||||
[Creating Skills → Cron Recipes](../developer-guide/creating-skills.md) for the
|
||||
slot schema and how to publish one.
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Cron Recipes catalog JSON for the docs site.
|
||||
|
||||
Mirrors ``extract-skills.py``: imports the single-source-of-truth recipe
|
||||
definitions from ``cron/recipe_catalog.py`` and emits a flat JSON array the
|
||||
docs page renders into cards (description, schedule, copy-paste slash command,
|
||||
and a ``hermes://`` "Send to App" deep-link).
|
||||
|
||||
Output: ``website/static/api/cron-recipes-index.json`` (served at
|
||||
``/docs/api/cron-recipes-index.json``). Run automatically by
|
||||
``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Repo root = two levels up from website/scripts/.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
OUTPUT = REPO_ROOT / "website" / "static" / "api" / "cron-recipes-index.json"
|
||||
|
||||
|
||||
def build_index() -> list:
|
||||
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
|
||||
|
||||
return [recipe_catalog_entry(r) for r in CATALOG]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
index = build_index()
|
||||
except Exception as e: # pragma: no cover - import/build failure
|
||||
# Match extract-skills.py's resilience: write an empty array so the
|
||||
# docs build never hard-fails on a generator hiccup.
|
||||
sys.stderr.write(f"extract-cron-recipes: {e}; writing empty index\n")
|
||||
index = []
|
||||
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(OUTPUT, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"))
|
||||
sys.stderr.write(f"extract-cron-recipes: wrote {len(index)} recipes -> {OUTPUT}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -31,6 +31,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const websiteDir = resolve(scriptDir, "..");
|
||||
const extractScript = join(scriptDir, "extract-skills.py");
|
||||
const llmsScript = join(scriptDir, "generate-llms-txt.py");
|
||||
const cronRecipesScript = join(scriptDir, "extract-cron-recipes.py");
|
||||
const outputFile = join(websiteDir, "static", "api", "skills.json");
|
||||
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
|
||||
const UNIFIED_INDEX_URL =
|
||||
@@ -138,3 +139,7 @@ if (!existsSync(extractScript)) {
|
||||
|
||||
// 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal.
|
||||
runPython(llmsScript, "generate-llms-txt.py");
|
||||
|
||||
// 3) cron-recipes-index.json — Cron Recipes catalog page. Non-fatal; the page
|
||||
// renders an empty state if the generator can't run.
|
||||
runPython(cronRecipesScript, "extract-cron-recipes.py");
|
||||
|
||||
@@ -78,6 +78,7 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Automation',
|
||||
items: [
|
||||
'user-guide/features/cron',
|
||||
'reference/cron-recipes-catalog',
|
||||
'user-guide/features/delegation',
|
||||
'user-guide/features/kanban',
|
||||
'user-guide/features/codex-app-server-runtime',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user